T05: Reporter — pisanje izveštaja
- WriteReport generiše markdown izveštaj u reports/ - formatDuration, truncateOutput za formatiranje - Kreira folder ako ne postoji, skraćuje output na 50 linija - 10 reporter testova — svi prolaze - T03 premešten u done/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9d2c249ed1
commit
028872be43
45
TASKS/reports/T05-report.md
Normal file
45
TASKS/reports/T05-report.md
Normal file
@ -0,0 +1,45 @@
|
||||
# T05 Izveštaj: Reporter — pisanje izveštaja
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Opus
|
||||
**Datum:** 2026-02-20
|
||||
|
||||
---
|
||||
|
||||
## Šta je urađeno
|
||||
|
||||
Implementiran reporter u `code/internal/supervisor/`:
|
||||
|
||||
### Kreirani fajlovi
|
||||
|
||||
| Fajl | Opis |
|
||||
|------|------|
|
||||
| `reporter.go` | WriteReport, buildReport, formatDuration, truncateOutput |
|
||||
| `reporter_test.go` | 10 testova za sve funkcije |
|
||||
|
||||
### Funkcije
|
||||
|
||||
- **WriteReport** — generiše markdown izveštaj, kreira folder ako ne postoji
|
||||
- **buildReport** — sastavlja sadržaj sa headerom, verifikacijom, agent outputom
|
||||
- **formatDuration** — "Xm Ys" ili "Xs" format
|
||||
- **truncateOutput** — skraćuje na poslednjih N linija
|
||||
|
||||
### Testovi — 10/10 PASS (reporter)
|
||||
|
||||
```
|
||||
TestWriteReport_CreatesFile PASS
|
||||
TestWriteReport_Content PASS
|
||||
TestWriteReport_FailedStatus PASS
|
||||
TestWriteReport_CreatesDirectory PASS
|
||||
TestTruncateOutput_Short PASS
|
||||
TestTruncateOutput_Long PASS
|
||||
TestTruncateOutput_Empty PASS
|
||||
TestFormatDuration_Minutes PASS
|
||||
TestFormatDuration_SecondsOnly PASS
|
||||
TestFormatDuration_Zero PASS
|
||||
```
|
||||
|
||||
### Ukupno projekat: 50 testova, svi prolaze
|
||||
|
||||
- `go vet ./...` — čist
|
||||
- `go build ./...` — prolazi
|
||||
90
TASKS/review/T05.md
Normal file
90
TASKS/review/T05.md
Normal file
@ -0,0 +1,90 @@
|
||||
# T05: Reporter — pisanje izveštaja
|
||||
|
||||
**Kreirao:** planer
|
||||
**Datum:** 2026-02-20
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** T03 ✅, T04 ✅
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Generiše markdown izveštaj posle izvršenog taska.
|
||||
Koristi RunResult iz T03 i VerifyResult iz T04.
|
||||
|
||||
## Fajlovi za kreiranje
|
||||
|
||||
```
|
||||
code/internal/supervisor/
|
||||
├── reporter.go ← WriteReport()
|
||||
└── reporter_test.go
|
||||
```
|
||||
|
||||
## Funkcije
|
||||
|
||||
```go
|
||||
func WriteReport(
|
||||
task Task,
|
||||
run RunResult,
|
||||
verify VerifyResult,
|
||||
reportsDir string,
|
||||
) (string, error) // vraća putanju do izveštaja
|
||||
```
|
||||
|
||||
## Format izveštaja
|
||||
|
||||
```markdown
|
||||
# T{XX}: Naslov
|
||||
|
||||
**Datum:** 2026-02-20 10:15
|
||||
**Trajanje:** 8m 45s
|
||||
**Status:** ✅ Završen / ❌ Neuspešan
|
||||
**Tag:** v0.1.X
|
||||
|
||||
## Verifikacija
|
||||
- ✅/❌ go build (0.3s)
|
||||
- ✅/❌ go vet (0.2s)
|
||||
- ✅/❌ go test (12/12 testova, 2.1s)
|
||||
|
||||
## Agent output
|
||||
(skraćen output Claude Code sesije, max 50 linija)
|
||||
|
||||
## Commit
|
||||
- Hash: a1b2c3d
|
||||
- Branch: main
|
||||
```
|
||||
|
||||
## Pravila
|
||||
|
||||
- Fajl ime: `T{XX}-report.md` (npr. T01-report.md)
|
||||
- Ako reportsDir ne postoji — kreiraj
|
||||
- Output skrati na max 50 linija (poslednje linije, najrelevantnije)
|
||||
- Datum u formatu: YYYY-MM-DD HH:MM
|
||||
- Trajanje: Xm Ys ili Xs ako manje od minuta
|
||||
|
||||
## Testovi
|
||||
|
||||
- WriteReport sa mock podacima → fajl postoji, sadržaj tačan
|
||||
- Proveri format datuma, trajanja
|
||||
- Proveri da folder kreira ako ne postoji
|
||||
- Proveri skraćivanje outputa (>50 linija → samo poslednjih 50)
|
||||
- AllPassed=true → "✅ Završen"
|
||||
- AllPassed=false → "❌ Neuspešan"
|
||||
|
||||
## Očekivani izlaz
|
||||
|
||||
`TASKS/reports/T{XX}-report.md` se generiše sa tačnim sadržajem.
|
||||
`go test ./internal/supervisor/ -v` — svi testovi zeleni.
|
||||
|
||||
---
|
||||
|
||||
## Pitanja
|
||||
|
||||
*(agent piše pitanja ovde, planer odgovara)*
|
||||
|
||||
---
|
||||
|
||||
## Odgovori
|
||||
|
||||
*(planer piše odgovore ovde)*
|
||||
95
code/internal/supervisor/reporter.go
Normal file
95
code/internal/supervisor/reporter.go
Normal file
@ -0,0 +1,95 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WriteReport generates a markdown report for a completed task and writes it
|
||||
// to the reports directory. Returns the path to the generated report file.
|
||||
func WriteReport(task Task, run RunResult, verify VerifyResult, reportsDir string) (string, error) {
|
||||
if err := os.MkdirAll(reportsDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("create reports dir: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s-report.md", task.ID)
|
||||
path := filepath.Join(reportsDir, filename)
|
||||
|
||||
content := buildReport(task, run, verify)
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("write report: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// buildReport generates the markdown content for a task report.
|
||||
func buildReport(task Task, run RunResult, verify VerifyResult) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
fmt.Fprintf(&b, "# %s: %s\n\n", task.ID, task.Title)
|
||||
|
||||
// Metadata
|
||||
fmt.Fprintf(&b, "**Datum:** %s\n", run.StartedAt.Format("2006-01-02 15:04"))
|
||||
fmt.Fprintf(&b, "**Trajanje:** %s\n", formatDuration(run.Duration))
|
||||
|
||||
if verify.AllPassed {
|
||||
b.WriteString("**Status:** ✅ Završen\n")
|
||||
} else {
|
||||
b.WriteString("**Status:** ❌ Neuspešan\n")
|
||||
}
|
||||
|
||||
// Verification
|
||||
b.WriteString("\n## Verifikacija\n")
|
||||
for _, c := range []CheckResult{verify.Build, verify.Vet, verify.Test} {
|
||||
icon := "✅"
|
||||
if c.Status == "fail" {
|
||||
icon = "❌"
|
||||
}
|
||||
line := fmt.Sprintf("- %s %s (%s)", icon, c.Name, c.Duration.Round(time.Millisecond))
|
||||
if c.Name == "test" && c.TestCount > 0 {
|
||||
line = fmt.Sprintf("- %s %s (%d testova, %s)", icon, c.Name, c.TestCount, c.Duration.Round(time.Millisecond))
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
// Agent output
|
||||
b.WriteString("\n## Agent output\n")
|
||||
b.WriteString(truncateOutput(run.Output, 50))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatDuration formats a duration as "Xm Ys" or "Xs" for short durations.
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
minutes := int(d.Minutes())
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
|
||||
// truncateOutput keeps only the last maxLines lines of output.
|
||||
func truncateOutput(output string, maxLines int) string {
|
||||
if output == "" {
|
||||
return "(nema outputa)\n"
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
|
||||
if len(lines) <= maxLines {
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
truncated := lines[len(lines)-maxLines:]
|
||||
return fmt.Sprintf("... (skraćeno, prikazano poslednjih %d od %d linija)\n%s\n",
|
||||
maxLines, len(lines), strings.Join(truncated, "\n"))
|
||||
}
|
||||
209
code/internal/supervisor/reporter_test.go
Normal file
209
code/internal/supervisor/reporter_test.go
Normal file
@ -0,0 +1,209 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestData(allPassed bool) (Task, RunResult, VerifyResult) {
|
||||
task := Task{
|
||||
ID: "T07",
|
||||
Title: "Test feature",
|
||||
Agent: "coder",
|
||||
Model: "Sonnet",
|
||||
Description: "Implement test feature.",
|
||||
}
|
||||
|
||||
run := RunResult{
|
||||
TaskID: "T07",
|
||||
StartedAt: time.Date(2026, 2, 20, 10, 15, 0, 0, time.UTC),
|
||||
FinishedAt: time.Date(2026, 2, 20, 10, 23, 45, 0, time.UTC),
|
||||
Duration: 8*time.Minute + 45*time.Second,
|
||||
ExitCode: 0,
|
||||
Output: "Task completed successfully.\nAll tests passed.",
|
||||
}
|
||||
|
||||
verify := VerifyResult{
|
||||
Build: CheckResult{
|
||||
Name: "build", Status: "pass",
|
||||
Duration: 300 * time.Millisecond,
|
||||
},
|
||||
Vet: CheckResult{
|
||||
Name: "vet", Status: "pass",
|
||||
Duration: 200 * time.Millisecond,
|
||||
},
|
||||
Test: CheckResult{
|
||||
Name: "test", Status: "pass",
|
||||
Duration: 2100 * time.Millisecond,
|
||||
TestCount: 12,
|
||||
},
|
||||
AllPassed: allPassed,
|
||||
}
|
||||
|
||||
if !allPassed {
|
||||
verify.Test.Status = "fail"
|
||||
run.ExitCode = 1
|
||||
}
|
||||
|
||||
return task, run, verify
|
||||
}
|
||||
|
||||
func TestWriteReport_CreatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reportsDir := filepath.Join(dir, "reports")
|
||||
|
||||
task, run, verify := makeTestData(true)
|
||||
|
||||
path, err := WriteReport(task, run, verify, reportsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("report file not found: %v", err)
|
||||
}
|
||||
|
||||
expectedFilename := "T07-report.md"
|
||||
if filepath.Base(path) != expectedFilename {
|
||||
t.Errorf("expected filename %s, got %s", expectedFilename, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReport_Content(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
task, run, verify := makeTestData(true)
|
||||
|
||||
path, err := WriteReport(task, run, verify, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
substr string
|
||||
}{
|
||||
{"header", "# T07: Test feature"},
|
||||
{"date format", "2026-02-20 10:15"},
|
||||
{"duration", "8m 45s"},
|
||||
{"status passed", "✅ Završen"},
|
||||
{"build check", "✅ build"},
|
||||
{"vet check", "✅ vet"},
|
||||
{"test count", "12 testova"},
|
||||
{"agent output section", "## Agent output"},
|
||||
{"output content", "Task completed successfully"},
|
||||
}
|
||||
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(content, c.substr) {
|
||||
t.Errorf("%s: expected %q in report, got:\n%s", c.name, c.substr, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReport_FailedStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
task, run, verify := makeTestData(false)
|
||||
|
||||
path, err := WriteReport(task, run, verify, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "❌ Neuspešan") {
|
||||
t.Errorf("expected '❌ Neuspešan' in report, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "❌ test") {
|
||||
t.Errorf("expected '❌ test' in report, got:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReport_CreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reportsDir := filepath.Join(dir, "deep", "nested", "reports")
|
||||
|
||||
task, run, verify := makeTestData(true)
|
||||
|
||||
path, err := WriteReport(task, run, verify, reportsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("report file not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateOutput_Short(t *testing.T) {
|
||||
output := "line1\nline2\nline3\n"
|
||||
result := truncateOutput(output, 50)
|
||||
|
||||
if strings.Contains(result, "skraćeno") {
|
||||
t.Error("should not truncate short output")
|
||||
}
|
||||
if !strings.Contains(result, "line1") {
|
||||
t.Error("expected all lines present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateOutput_Long(t *testing.T) {
|
||||
var lines []string
|
||||
for i := 1; i <= 80; i++ {
|
||||
lines = append(lines, "line "+string(rune('0'+i/10))+string(rune('0'+i%10)))
|
||||
}
|
||||
output := strings.Join(lines, "\n")
|
||||
|
||||
result := truncateOutput(output, 50)
|
||||
|
||||
if !strings.Contains(result, "skraćeno") {
|
||||
t.Error("expected truncation marker")
|
||||
}
|
||||
if !strings.Contains(result, "poslednjih 50 od 80") {
|
||||
t.Errorf("expected 'poslednjih 50 od 80' in: %s", result)
|
||||
}
|
||||
// First lines should be gone
|
||||
if strings.Contains(result, "line 01") {
|
||||
t.Error("expected first lines to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateOutput_Empty(t *testing.T) {
|
||||
result := truncateOutput("", 50)
|
||||
if !strings.Contains(result, "nema outputa") {
|
||||
t.Errorf("expected 'nema outputa', got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration_Minutes(t *testing.T) {
|
||||
d := 8*time.Minute + 45*time.Second
|
||||
result := formatDuration(d)
|
||||
if result != "8m 45s" {
|
||||
t.Errorf("expected '8m 45s', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration_SecondsOnly(t *testing.T) {
|
||||
d := 45 * time.Second
|
||||
result := formatDuration(d)
|
||||
if result != "45s" {
|
||||
t.Errorf("expected '45s', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration_Zero(t *testing.T) {
|
||||
result := formatDuration(0)
|
||||
if result != "0s" {
|
||||
t.Errorf("expected '0s', got %q", result)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user