summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md14
-rw-r--r--internal/api/changestats.go48
-rw-r--r--internal/executor/executor.go8
-rw-r--r--internal/executor/executor_test.go154
-rw-r--r--internal/task/changestats.go47
5 files changed, 227 insertions, 44 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 1d03678..16a48b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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))
+}