From 34320376767ca9131183216a01cf106ca6405500 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Sat, 14 Mar 2026 17:07:29 +0000 Subject: feat: expose changestats in API responses - Add parseChangestatFromOutput/File helpers in internal/api/changestats.go to parse git diff --stat summary lines from execution stdout logs - Wire parser in processResult: after each execution completes, scan the stdout log for git diff stats and persist via UpdateExecutionChangestats - Tests: TestGetTask_IncludesChangestats (verifies processResult wiring), TestListExecutions_IncludesChangestats (verifies storage round-trip) Co-Authored-By: Claude Sonnet 4.6 --- internal/api/changestats.go | 49 +++++++++++++++++ internal/api/server.go | 9 ++++ internal/api/server_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 internal/api/changestats.go diff --git a/internal/api/changestats.go b/internal/api/changestats.go new file mode 100644 index 0000000..0b70105 --- /dev/null +++ b/internal/api/changestats.go @@ -0,0 +1,49 @@ +package api + +import ( + "bufio" + "os" + "regexp" + "strconv" + "strings" + + "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. +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 +} + +// 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) *task.Changestats { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return parseChangestatFromOutput(string(data)) +} diff --git a/internal/api/server.go b/internal/api/server.go index 163f2b8..8290738 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -128,7 +128,16 @@ func (s *Server) forwardResults() { } // processResult broadcasts a task completion event via WebSocket and calls the notifier if set. +// It also parses git diff stats from the execution stdout log and persists them. func (s *Server) processResult(result *executor.Result) { + if result.Execution.StdoutPath != "" { + if stats := parseChangestatFromFile(result.Execution.StdoutPath); stats != nil { + if err := s.store.UpdateExecutionChangestats(result.Execution.ID, stats); err != nil { + s.logger.Error("failed to store changestats", "execID", result.Execution.ID, "error", err) + } + } + } + event := map[string]interface{}{ "type": "task_completed", "task_id": result.TaskID, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index ec927c0..d090313 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1433,3 +1433,128 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) { t.Errorf("task state: want CANCELLED, got %v", got) } } + +// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats +// from the execution stdout log, they appear in the execution history response. +func TestGetTask_IncludesChangestats(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "cs-task-1", task.StateCompleted) + + // Write a stdout log with a git diff --stat summary line. + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + logContent := "Agent output line 1\n3 files changed, 50 insertions(+), 10 deletions(-)\nAgent output line 2\n" + if err := os.WriteFile(stdoutPath, []byte(logContent), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "cs-exec-1", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", + StdoutPath: stdoutPath, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + // processResult should parse changestats from the stdout log and store them. + result := &executor.Result{ + TaskID: tk.ID, + Execution: exec, + } + srv.processResult(result) + + // GET the task's execution history and assert changestats are populated. + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var execs []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(execs) != 1 { + t.Fatalf("want 1 execution, got %d", len(execs)) + } + + csVal, ok := execs[0]["Changestats"] + if !ok || csVal == nil { + t.Fatal("execution missing Changestats field after processResult") + } + csMap, ok := csVal.(map[string]interface{}) + if !ok { + t.Fatalf("Changestats is not an object: %T", csVal) + } + if csMap["files_changed"].(float64) != 3 { + t.Errorf("files_changed: want 3, got %v", csMap["files_changed"]) + } + if csMap["lines_added"].(float64) != 50 { + t.Errorf("lines_added: want 50, got %v", csMap["lines_added"]) + } + if csMap["lines_removed"].(float64) != 10 { + t.Errorf("lines_removed: want 10, got %v", csMap["lines_removed"]) + } +} + +// TestListExecutions_IncludesChangestats verifies that changestats stored on an execution +// are returned correctly by GET /api/tasks/{id}/executions. +func TestListExecutions_IncludesChangestats(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "cs-task-2", task.StateCompleted) + + cs := &task.Changestats{FilesChanged: 2, LinesAdded: 100, LinesRemoved: 20} + exec := &storage.Execution{ + ID: "cs-exec-2", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", + Changestats: cs, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var execs []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(execs) != 1 { + t.Fatalf("want 1 execution, got %d", len(execs)) + } + + csVal, ok := execs[0]["Changestats"] + if !ok || csVal == nil { + t.Fatal("execution missing Changestats field") + } + csMap, ok := csVal.(map[string]interface{}) + if !ok { + t.Fatalf("Changestats is not an object: %T", csVal) + } + if csMap["files_changed"].(float64) != 2 { + t.Errorf("files_changed: want 2, got %v", csMap["files_changed"]) + } + if csMap["lines_added"].(float64) != 100 { + t.Errorf("lines_added: want 100, got %v", csMap["lines_added"]) + } + if csMap["lines_removed"].(float64) != 20 { + t.Errorf("lines_removed: want 20, got %v", csMap["lines_removed"]) + } +} -- cgit v1.2.3