diff options
| -rw-r--r-- | CLAUDE.md | 14 | ||||
| -rw-r--r-- | internal/api/changestats.go | 48 | ||||
| -rw-r--r-- | internal/executor/executor.go | 8 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 154 | ||||
| -rw-r--r-- | internal/task/changestats.go | 47 |
5 files changed, 227 insertions, 44 deletions
@@ -105,6 +105,20 @@ Batch files wrap multiple tasks under a `tasks:` key. Two tables: `tasks` (with `config_json`, `retry_json`, `tags_json`, `depends_on_json` as JSON blobs) and `executions` (with paths to log files). Schema is auto-migrated on `storage.Open()`. +## Features + +### Changestats + +After each task execution, Claudomator extracts git diff statistics from the execution's stdout log. If the log contains a git `diff --stat` summary line (e.g. `5 files changed, 127 insertions(+), 43 deletions(-)`), the stats are parsed and stored in the `executions.changestats_json` column via `storage.DB.UpdateExecutionChangestats`. + +**Extraction points:** +- `internal/executor.Pool.handleRunResult` — calls `task.ParseChangestatFromFile(exec.StdoutPath)` after every execution; stores via `Store.UpdateExecutionChangestats`. +- `internal/api.Server.processResult` — also extracts changestats when the API server processes a result (same file, idempotent second write). + +**Parser location:** `internal/task/changestats.go` — exported functions `ParseChangestatFromOutput` and `ParseChangestatFromFile` usable by any package without creating circular imports. + +**Frontend display:** `web/app.js` renders a `.changestats-badge` on COMPLETED/READY task cards and in execution history rows. + ## ADRs See `docs/adr/001-language-and-architecture.md` for the Go + SQLite + WebSocket rationale. diff --git a/internal/api/changestats.go b/internal/api/changestats.go index 0b70105..4f18f7f 100644 --- a/internal/api/changestats.go +++ b/internal/api/changestats.go @@ -1,49 +1,15 @@ package api -import ( - "bufio" - "os" - "regexp" - "strconv" - "strings" +import "github.com/thepeterstone/claudomator/internal/task" - "github.com/thepeterstone/claudomator/internal/task" -) - -// gitDiffStatRe matches git diff --stat summary lines, e.g.: -// -// "3 files changed, 50 insertions(+), 10 deletions(-)" -// "1 file changed, 5 insertions(+)" -// "1 file changed, 3 deletions(-)" -var gitDiffStatRe = regexp.MustCompile(`(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`) - -// parseChangestatFromOutput scans text for git diff --stat summary lines and -// returns the first match as a Changestats value. Returns nil if no match found. +// parseChangestatFromOutput delegates to task.ParseChangestatFromOutput. +// Kept as a package-local wrapper for use within the api package. func parseChangestatFromOutput(output string) *task.Changestats { - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - if m := gitDiffStatRe.FindStringSubmatch(line); m != nil { - cs := &task.Changestats{} - cs.FilesChanged, _ = strconv.Atoi(m[1]) - if m[2] != "" { - cs.LinesAdded, _ = strconv.Atoi(m[2]) - } - if m[3] != "" { - cs.LinesRemoved, _ = strconv.Atoi(m[3]) - } - return cs - } - } - return nil + return task.ParseChangestatFromOutput(output) } -// parseChangestatFromFile reads a log file and extracts the first git diff stat -// summary it finds. Returns nil if the file cannot be read or contains no match. +// parseChangestatFromFile delegates to task.ParseChangestatFromFile. +// Kept as a package-local wrapper for use within the api package. func parseChangestatFromFile(path string) *task.Changestats { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - return parseChangestatFromOutput(string(data)) + return task.ParseChangestatFromFile(path) } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7674fe6..fd37c33 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -29,6 +29,7 @@ type Store interface { UpdateTaskSummary(taskID, summary string) error AppendTaskInteraction(taskID string, interaction task.Interaction) error UpdateTaskAgent(id string, agent task.AgentConfig) error + UpdateExecutionChangestats(execID string, stats *task.Changestats) error } // LogPather is an optional interface runners can implement to provide the log @@ -352,6 +353,13 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. p.logger.Error("failed to update task summary", "taskID", t.ID, "error", summaryErr) } } + if exec.StdoutPath != "" { + if cs := task.ParseChangestatFromFile(exec.StdoutPath); cs != nil { + if csErr := p.store.UpdateExecutionChangestats(exec.ID, cs); csErr != nil { + p.logger.Error("failed to store changestats", "execID", exec.ID, "error", csErr) + } + } + } if updateErr := p.store.UpdateExecution(exec); updateErr != nil { p.logger.Error("failed to update execution", "error", updateErr) } diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index a6c4ad8..610ed3b 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -921,9 +921,13 @@ type minimalMockStore struct { executions map[string]*storage.Execution stateUpdates []struct{ id string; state task.State } questionUpdates []string - subtasksFunc func(parentID string) ([]*task.Task, error) - updateExecErr error - updateStateErr error + changestatCalls []struct { + execID string + stats *task.Changestats + } + subtasksFunc func(parentID string) ([]*task.Task, error) + updateExecErr error + updateStateErr error } func newMinimalMockStore() *minimalMockStore { @@ -977,6 +981,15 @@ func (m *minimalMockStore) AppendTaskInteraction(taskID string, _ task.Interacti return nil } func (m *minimalMockStore) UpdateTaskAgent(id string, agent task.AgentConfig) error { return nil } +func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task.Changestats) error { + m.mu.Lock() + m.changestatCalls = append(m.changestatCalls, struct { + execID string + stats *task.Changestats + }{execID, stats}) + m.mu.Unlock() + return nil +} func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() @@ -1203,3 +1216,138 @@ func TestPool_SpecificAgent_PersistsToDB(t *testing.T) { t.Errorf("expected agent type gemini in DB, got %q", reloaded.Agent.Type) } } + +// TestExecute_ExtractAndStoreChangestats verifies that when the execution stdout +// contains a git diff --stat summary line, the changestats are parsed and stored. +func TestExecute_ExtractAndStoreChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + content := "some output\n5 files changed, 127 insertions(+), 43 deletions(-)\n" + return os.WriteFile(e.StdoutPath, []byte(content), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-extract-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + cs := execs[0].Changestats + if cs == nil { + t.Fatal("expected changestats to be populated, got nil") + } + if cs.FilesChanged != 5 { + t.Errorf("FilesChanged: want 5, got %d", cs.FilesChanged) + } + if cs.LinesAdded != 127 { + t.Errorf("LinesAdded: want 127, got %d", cs.LinesAdded) + } + if cs.LinesRemoved != 43 { + t.Errorf("LinesRemoved: want 43, got %d", cs.LinesRemoved) + } +} + +// TestExecute_NoChangestats verifies that when execution output contains no git +// diff stat line, changestats are not stored (remain nil). +func TestExecute_NoChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + return os.WriteFile(e.StdoutPath, []byte("no git output here\n"), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-none-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + if execs[0].Changestats != nil { + t.Errorf("expected changestats to be nil for output with no git stats, got %+v", execs[0].Changestats) + } +} + +// TestExecute_MalformedChangestats verifies that malformed git-stat-like output +// does not produce changestats (parser returns nil, nothing is stored). +func TestExecute_MalformedChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + // Looks like a git stat line but doesn't match the regex. + return os.WriteFile(e.StdoutPath, []byte("lots of cheese changed, many insertions\n"), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-malformed-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + if execs[0].Changestats != nil { + t.Errorf("expected nil changestats for malformed output, got %+v", execs[0].Changestats) + } +} diff --git a/internal/task/changestats.go b/internal/task/changestats.go new file mode 100644 index 0000000..95be8ec --- /dev/null +++ b/internal/task/changestats.go @@ -0,0 +1,47 @@ +package task + +import ( + "bufio" + "os" + "regexp" + "strconv" + "strings" +) + +// gitDiffStatRe matches git diff --stat summary lines, e.g.: +// +// "3 files changed, 50 insertions(+), 10 deletions(-)" +// "1 file changed, 5 insertions(+)" +// "1 file changed, 3 deletions(-)" +var gitDiffStatRe = regexp.MustCompile(`(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`) + +// ParseChangestatFromOutput scans text for git diff --stat summary lines and +// returns the first match as a Changestats value. Returns nil if no match found. +func ParseChangestatFromOutput(output string) *Changestats { + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + if m := gitDiffStatRe.FindStringSubmatch(line); m != nil { + cs := &Changestats{} + cs.FilesChanged, _ = strconv.Atoi(m[1]) + if m[2] != "" { + cs.LinesAdded, _ = strconv.Atoi(m[2]) + } + if m[3] != "" { + cs.LinesRemoved, _ = strconv.Atoi(m[3]) + } + return cs + } + } + return nil +} + +// ParseChangestatFromFile reads a log file and extracts the first git diff stat +// summary it finds. Returns nil if the file cannot be read or contains no match. +func ParseChangestatFromFile(path string) *Changestats { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return ParseChangestatFromOutput(string(data)) +} |
