diff --git a/TASKS/review/T03.md b/TASKS/done/T03.md similarity index 100% rename from TASKS/review/T03.md rename to TASKS/done/T03.md diff --git a/TASKS/reports/T05-report.md b/TASKS/reports/T05-report.md new file mode 100644 index 0000000..1e82e59 --- /dev/null +++ b/TASKS/reports/T05-report.md @@ -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 diff --git a/TASKS/review/T05.md b/TASKS/review/T05.md new file mode 100644 index 0000000..3f51c38 --- /dev/null +++ b/TASKS/review/T05.md @@ -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)* diff --git a/code/internal/supervisor/reporter.go b/code/internal/supervisor/reporter.go new file mode 100644 index 0000000..8ed336e --- /dev/null +++ b/code/internal/supervisor/reporter.go @@ -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")) +} diff --git a/code/internal/supervisor/reporter_test.go b/code/internal/supervisor/reporter_test.go new file mode 100644 index 0000000..4663b8d --- /dev/null +++ b/code/internal/supervisor/reporter_test.go @@ -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) + } +}