diff --git a/cmd/bd/main.go b/cmd/bd/main.go index d2ae7764e6..9668fd1b27 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -107,6 +107,7 @@ var readOnlyCommands = map[string]bool{ "current": true, // bd sync mode current "backup": true, // reads from Dolt, writes only to .beads/backup/ "export": true, // reads from Dolt, writes JSONL to file/stdout + "summary": true, // summarizes completed work (read-only) } // isReadOnlyCommand returns true if the command only reads from the database. diff --git a/cmd/bd/summary.go b/cmd/bd/summary.go new file mode 100644 index 0000000000..0c382b05ab --- /dev/null +++ b/cmd/bd/summary.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage/dolt" + "github.com/steveyegge/beads/internal/types" +) + +// SummaryChild represents a child issue in summary output. +type SummaryChild struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + CloseReason string `json:"close_reason,omitempty"` +} + +// EpicSummaryResult holds the result of an epic summary. +type EpicSummaryResult struct { + EpicID string `json:"epic_id"` + EpicTitle string `json:"epic_title"` + Status string `json:"status"` + Children []SummaryChild `json:"children"` + TotalCount int `json:"total_count"` + ClosedCount int `json:"closed_count"` + Decisions []string `json:"decisions,omitempty"` +} + +// SinceSummaryResult holds the result of a date-range summary. +type SinceSummaryResult struct { + Since time.Time `json:"since"` + Closed []SummaryChild `json:"closed"` + TotalClosed int `json:"total_closed"` +} + +// SessionSummaryResult holds the result of a session summary. +type SessionSummaryResult struct { + SessionID string `json:"session_id"` + Closed []SummaryChild `json:"closed"` +} + +func buildEpicSummary(ctx context.Context, s *dolt.DoltStore, epicID string) (*EpicSummaryResult, error) { + epic, err := s.GetIssue(ctx, epicID) + if err != nil { + return nil, fmt.Errorf("issue %s not found: %w", epicID, err) + } + filter := types.IssueFilter{ParentID: &epicID} + children, err := s.SearchIssues(ctx, "", filter) + if err != nil { + return nil, fmt.Errorf("searching children: %w", err) + } + result := &EpicSummaryResult{ + EpicID: epic.ID, + EpicTitle: epic.Title, + Status: string(epic.Status), + } + for _, child := range children { + sc := SummaryChild{ + ID: child.ID, Title: child.Title, Status: string(child.Status), + ClosedAt: child.ClosedAt, CloseReason: child.CloseReason, + } + result.Children = append(result.Children, sc) + if child.Status == types.StatusClosed { + result.ClosedCount++ + } + } + result.TotalCount = len(children) + // Find decision comments by checking DECISION: text prefix (legacy format). + comments, err := s.GetIssueComments(ctx, epicID) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not fetch comments for %s: %v\n", epicID, err) + } else { + for _, c := range comments { + if len(c.Text) > 9 && c.Text[:9] == "DECISION:" { + result.Decisions = append(result.Decisions, c.Text) + } + } + } + return result, nil +} + +func buildSinceSummary(ctx context.Context, s *dolt.DoltStore, since time.Time) (*SinceSummaryResult, error) { + closedStatus := types.StatusClosed + filter := types.IssueFilter{Status: &closedStatus, ClosedAfter: &since} + issues, err := s.SearchIssues(ctx, "", filter) + if err != nil { + return nil, fmt.Errorf("searching issues: %w", err) + } + result := &SinceSummaryResult{Since: since} + for _, issue := range issues { + result.Closed = append(result.Closed, SummaryChild{ + ID: issue.ID, Title: issue.Title, Status: string(issue.Status), + ClosedAt: issue.ClosedAt, CloseReason: issue.CloseReason, + }) + } + result.TotalClosed = len(result.Closed) + return result, nil +} + +func buildSessionSummary(ctx context.Context, s *dolt.DoltStore, sessionID string) (*SessionSummaryResult, error) { + if sessionID == "" { + return nil, fmt.Errorf("no active session. Set CLAUDE_SESSION_ID or use --since=DATE instead") + } + closedStatus := types.StatusClosed + filter := types.IssueFilter{Status: &closedStatus} + issues, err := s.SearchIssues(ctx, "", filter) + if err != nil { + return nil, fmt.Errorf("searching issues: %w", err) + } + result := &SessionSummaryResult{SessionID: sessionID} + for _, issue := range issues { + if issue.ClosedBySession == sessionID { + result.Closed = append(result.Closed, SummaryChild{ + ID: issue.ID, Title: issue.Title, Status: string(issue.Status), + ClosedAt: issue.ClosedAt, CloseReason: issue.CloseReason, + }) + } + } + return result, nil +} + +var summaryCmd = &cobra.Command{ + Use: "summary [epic-id]", + GroupID: "views", + Short: "Summarize completed work", + Long: `Show a summary of completed work. + +Modes: + bd summary Timeline of epic's children + bd summary --since=2026-03-01 All work closed since date + bd summary --session Current session's closed work`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + sinceStr, _ := cmd.Flags().GetString("since") + sessionFlag, _ := cmd.Flags().GetBool("session") + if err := withStorage(rootCtx, store, dbPath, func(s *dolt.DoltStore) error { + ctx := rootCtx + if len(args) == 1 { + result, err := buildEpicSummary(ctx, s, args[0]) + if err != nil { + FatalErrorRespectJSON("%v", err) + } + if jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + printEpicSummary(result) + return nil + } + if sessionFlag { + sessionID := os.Getenv("CLAUDE_SESSION_ID") + result, err := buildSessionSummary(ctx, s, sessionID) + if err != nil { + FatalErrorRespectJSON("%v", err) + } + if jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + printSessionSummary(result) + return nil + } + if sinceStr != "" { + since, err := time.Parse("2006-01-02", sinceStr) + if err != nil { + FatalErrorRespectJSON("invalid date format: %v (use YYYY-MM-DD)", err) + } + result, err := buildSinceSummary(ctx, s, since) + if err != nil { + FatalErrorRespectJSON("%v", err) + } + if jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + printSinceSummary(result) + return nil + } + FatalErrorRespectJSON("specify an epic ID, --since=DATE, or --session") + return nil + }); err != nil { + FatalErrorRespectJSON("%v", err) + } + }, +} + +func printEpicSummary(r *EpicSummaryResult) { + fmt.Printf("Epic: %s %q\n", r.EpicID, r.EpicTitle) + fmt.Printf("Status: %s\n\n", r.Status) + fmt.Printf("Tasks (%d/%d complete):\n", r.ClosedCount, r.TotalCount) + for _, c := range r.Children { + check := " " + if c.Status == string(types.StatusClosed) { + check = "x" + } + line := fmt.Sprintf(" [%s] %s %q", check, c.ID, c.Title) + if c.ClosedAt != nil { + line += fmt.Sprintf(" closed %s", c.ClosedAt.Format("2006-01-02")) + } + if c.CloseReason != "" { + line += fmt.Sprintf(" %q", c.CloseReason) + } + fmt.Println(line) + } + if len(r.Decisions) > 0 { + fmt.Printf("\nDecisions:\n") + for _, d := range r.Decisions { + fmt.Printf(" %s\n", d) + } + } +} + +func printSinceSummary(r *SinceSummaryResult) { + fmt.Printf("Work completed since %s:\n\n", r.Since.Format("2006-01-02")) + for _, c := range r.Closed { + line := fmt.Sprintf(" [x] %s %q", c.ID, c.Title) + if c.ClosedAt != nil { + line += fmt.Sprintf(" closed %s", c.ClosedAt.Format("2006-01-02")) + } + fmt.Println(line) + } + fmt.Printf("\nTotal: %d tasks closed\n", r.TotalClosed) +} + +func printSessionSummary(r *SessionSummaryResult) { + fmt.Printf("Session: %s\n\n", r.SessionID) + fmt.Printf("Closed (%d):\n", len(r.Closed)) + for _, c := range r.Closed { + line := fmt.Sprintf(" [x] %s %q", c.ID, c.Title) + if c.CloseReason != "" { + line += fmt.Sprintf(" closed %q", c.CloseReason) + } + fmt.Println(line) + } +} + +func init() { + rootCmd.AddCommand(summaryCmd) + summaryCmd.Flags().String("since", "", "Show work closed since date (YYYY-MM-DD, midnight UTC)") + summaryCmd.Flags().Bool("session", false, "Show current session's closed work") +} diff --git a/cmd/bd/summary_test.go b/cmd/bd/summary_test.go new file mode 100644 index 0000000000..c09e143cb8 --- /dev/null +++ b/cmd/bd/summary_test.go @@ -0,0 +1,170 @@ +//go:build cgo + +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +func TestSummaryEpicMode(t *testing.T) { + t.Parallel() + ctx := context.Background() + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + epic := &types.Issue{Title: "Auth System", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, CreatedAt: time.Now().Add(-5 * 24 * time.Hour)} + if err := s.CreateIssue(ctx, epic, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + child1 := &types.Issue{Title: "DB schema", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-4 * 24 * time.Hour)} + child2 := &types.Issue{Title: "API endpoints", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-3 * 24 * time.Hour)} + for _, child := range []*types.Issue{child1, child2} { + if err := s.CreateIssue(ctx, child, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if err := s.AddDependency(ctx, &types.Dependency{IssueID: child.ID, DependsOnID: epic.ID, Type: types.DepParentChild}, "test"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + } + for _, child := range []*types.Issue{child1, child2} { + if err := s.CloseIssue(ctx, child.ID, "done", "test", "sess-1"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + } + t.Run("EpicSummaryShowsChildren", func(t *testing.T) { + result, err := buildEpicSummary(ctx, s, epic.ID) + if err != nil { + t.Fatalf("buildEpicSummary: %v", err) + } + if result.EpicID != epic.ID { + t.Errorf("EpicID = %q, want %q", result.EpicID, epic.ID) + } + if len(result.Children) != 2 { + t.Errorf("len(Children) = %d, want 2", len(result.Children)) + } + if result.ClosedCount != 2 { + t.Errorf("ClosedCount = %d, want 2", result.ClosedCount) + } + }) +} + +func TestSummarySinceMode(t *testing.T) { + t.Parallel() + ctx := context.Background() + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + issue := &types.Issue{Title: "Fix login bug", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now()} + if err := s.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if err := s.CloseIssue(ctx, issue.ID, "done", "test", "sess-1"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + t.Run("SinceFilterFindsClosedIssue", func(t *testing.T) { + since := time.Now().Add(-1 * time.Hour) + result, err := buildSinceSummary(ctx, s, since) + if err != nil { + t.Fatalf("buildSinceSummary: %v", err) + } + if result.TotalClosed != 1 { + t.Errorf("TotalClosed = %d, want 1", result.TotalClosed) + } + }) +} + +func TestSummarySessionMode(t *testing.T) { + t.Parallel() + ctx := context.Background() + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + sessionID := "test-session-abc" + issue := &types.Issue{Title: "Session work", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now()} + if err := s.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if err := s.CloseIssue(ctx, issue.ID, "done", "test", sessionID); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + t.Run("SessionFindsClosedBySession", func(t *testing.T) { + result, err := buildSessionSummary(ctx, s, sessionID) + if err != nil { + t.Fatalf("buildSessionSummary: %v", err) + } + if len(result.Closed) != 1 { + t.Errorf("len(Closed) = %d, want 1", len(result.Closed)) + } + }) + t.Run("SessionErrorsWithoutSessionID", func(t *testing.T) { + os.Unsetenv("CLAUDE_SESSION_ID") + _, err := buildSessionSummary(ctx, s, "") + if err == nil { + t.Error("expected error for empty session ID") + } + }) +} + +func TestSummaryEpicDecisionComments(t *testing.T) { + t.Parallel() + ctx := context.Background() + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + + epic := &types.Issue{Title: "Auth System", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, CreatedAt: time.Now()} + if err := s.CreateIssue(ctx, epic, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if _, err := s.AddIssueComment(ctx, epic.ID, "test", "DECISION: Use JWT for auth"); err != nil { + t.Fatalf("AddIssueComment: %v", err) + } + + result, err := buildEpicSummary(ctx, s, epic.ID) + if err != nil { + t.Fatalf("buildEpicSummary: %v", err) + } + if len(result.Decisions) != 1 { + t.Errorf("len(Decisions) = %d, want 1", len(result.Decisions)) + } + if len(result.Decisions) > 0 && result.Decisions[0] != "DECISION: Use JWT for auth" { + t.Errorf("Decisions[0] = %q, want %q", result.Decisions[0], "DECISION: Use JWT for auth") + } +} + +func TestSummaryJSONOutput(t *testing.T) { + t.Parallel() + ctx := context.Background() + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + issue := &types.Issue{Title: "JSON test", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now()} + if err := s.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if err := s.CloseIssue(ctx, issue.ID, "done", "test", "sess-json"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + result, err := buildSessionSummary(ctx, s, "sess-json") + if err != nil { + t.Fatalf("buildSessionSummary: %v", err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var parsed SessionSummaryResult + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if parsed.SessionID != "sess-json" { + t.Errorf("SessionID = %q, want %q", parsed.SessionID, "sess-json") + } +} diff --git a/internal/storage/dolt/issue_scan.go b/internal/storage/dolt/issue_scan.go index eeb5fdf277..001bb514a5 100644 --- a/internal/storage/dolt/issue_scan.go +++ b/internal/storage/dolt/issue_scan.go @@ -14,6 +14,7 @@ const issueSelectColumns = `id, content_hash, title, description, design, accept status, priority, issue_type, assignee, estimated_minutes, created_at, created_by, owner, updated_at, closed_at, external_ref, spec_id, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, + closed_by_session, sender, ephemeral, wisp_type, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type, @@ -37,7 +38,7 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { var estimatedMinutes, originalSize, timeoutNs sql.NullInt64 var createdBy sql.NullString var assignee, externalRef, specID, compactedAtCommit, owner sql.NullString - var contentHash, sourceRepo, closeReason sql.NullString + var contentHash, sourceRepo, closeReason, closedBySession sql.NullString var workType, sourceSystem sql.NullString var sender, wispType, molType, eventKind, actor, target, payload sql.NullString var awaitType, awaitID, waiters sql.NullString @@ -52,6 +53,7 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &createdAtStr, &createdBy, &owner, &updatedAtStr, &closedAt, &externalRef, &specID, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, + &closedBySession, &sender, &ephemeral, &wispType, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, &hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType, @@ -111,6 +113,9 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { if closeReason.Valid { issue.CloseReason = closeReason.String } + if closedBySession.Valid { + issue.ClosedBySession = closedBySession.String + } if sender.Valid { issue.Sender = sender.String }