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:
djuka 2026-02-20 11:48:05 +00:00
parent 9d2c249ed1
commit 028872be43
5 changed files with 439 additions and 0 deletions

View 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
View 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)*

View 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"))
}

View 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)
}
}