diff --git a/cmd/bw/close.go b/cmd/bw/close.go index e37a3e25..fd4dbc33 100644 --- a/cmd/bw/close.go +++ b/cmd/bw/close.go @@ -1,12 +1,14 @@ package main import ( + "errors" "fmt" "github.com/jallum/beadwork/internal/config" "github.com/jallum/beadwork/internal/issue" "github.com/jallum/beadwork/internal/md" + "github.com/jallum/beadwork/internal/treefs" ) type CloseArgs struct { @@ -30,28 +32,53 @@ func parseCloseArgs(raw []string) (CloseArgs, error) { }, nil } +const closeMaxRetries = 3 + func cmdClose(store *issue.Store, args []string, w Writer, _ *config.Config) (*config.Config, error) { ca, err := parseCloseArgs(args) if err != nil { return nil, err } - iss, err := store.Close(ca.ID, ca.Reason) - if err != nil { - return nil, err - } + var iss *issue.Issue + var unblocked []*issue.Issue - unblocked, err := store.NewlyUnblocked(iss.ID) - if err != nil { - return nil, err - } + for attempt := range closeMaxRetries { + if attempt > 0 { + store.ClearCache() + if err := store.Refresh(); err != nil { + return nil, fmt.Errorf("refresh after conflict: %w", err) + } + } + + iss, err = store.Close(ca.ID, ca.Reason) + if err != nil { + return nil, err + } - intent := fmt.Sprintf("close %s", iss.ID) - if ca.Reason != "" { - intent += fmt.Sprintf(" reason=%q", ca.Reason) + unblocked, err = store.NewlyUnblocked(iss.ID) + if err != nil { + return nil, err + } + + intent := fmt.Sprintf("close %s", iss.ID) + if ca.Reason != "" { + intent += fmt.Sprintf(" reason=%q", ca.Reason) + } + for _, u := range unblocked { + intent += fmt.Sprintf("\nunblocked %s", u.ID) + } + + err = store.Commit(intent) + if err == nil { + break + } + if !errors.Is(err, treefs.ErrRefMoved) { + return nil, fmt.Errorf("commit failed: %w", err) + } } - if err := store.Commit(intent); err != nil { - return nil, fmt.Errorf("commit failed: %w", err) + if err != nil { + return nil, fmt.Errorf("commit failed after %d attempts: %w", closeMaxRetries, err) } if ca.JSON { diff --git a/cmd/bw/command.go b/cmd/bw/command.go index 03b39fcd..23dddc0d 100644 --- a/cmd/bw/command.go +++ b/cmd/bw/command.go @@ -473,6 +473,31 @@ var commands = []Command{ NeedsStore: true, Run: cmdPrime, }, + { + Name: "recap", + Summary: "Show recent activity across issues", + Description: "Summarize beadwork activity in this repo (or --all for every registered repo).\nBy default, shows activity since the last recap — first-time recaps show the last 24 hours.\nOutput is condensed (one line per issue). Use --verbose for per-event detail.\n\nWindow tokens:\n today, yesterday, week\n durations: 15m, 1h, 3h30m, 24h, 2d, 7d, 2w\nUse --since for an explicit start (RFC3339 or YYYY-MM-DD).\n--dry-run shows activity without advancing the cursor.", + Flags: []Flag{ + {Long: "--since", Value: "DATE", Help: "Start time (RFC3339 or YYYY-MM-DD)"}, + {Long: "--dry-run", Help: "Show activity without advancing the cursor"}, + {Long: "--all", Help: "Recap every registered repository"}, + {Long: "--verbose", Short: "-v", Help: "Per-event detail tree (default is condensed)"}, + {Long: "--json", Help: "Output as JSON"}, + {Long: "--ascii", Help: "Use plain ASCII tree characters (with --verbose)"}, + }, + Examples: []Example{ + {Cmd: "bw recap", Help: "Activity since last recap (or 24h if first-time)"}, + {Cmd: "bw recap 15m", Help: "Last 15 minutes"}, + {Cmd: "bw recap 1h"}, + {Cmd: "bw recap today"}, + {Cmd: "bw recap 7d --verbose", Help: "Full per-event tree"}, + {Cmd: "bw recap week --json"}, + {Cmd: "bw recap --since 2026-01-01"}, + {Cmd: "bw recap --all", Help: "Across all registered repos"}, + {Cmd: "bw recap --dry-run", Help: "Preview without advancing cursor"}, + }, + Run: cmdRecap, + }, { Name: "registry", Summary: "Manage the repository registry", @@ -519,7 +544,7 @@ var commandGroups = []struct { {"Finding Work", []string{"ready", "blocked"}}, {"Dependencies", []string{"dep"}}, {"Sync & Data", []string{"sync", "export", "import"}}, - {"Cross-Repo & Activity", []string{"registry"}}, + {"Cross-Repo & Activity", []string{"recap", "registry"}}, {"Setup & Config", []string{"init", "config", "upgrade", "onboard", "prime"}}, } diff --git a/cmd/bw/history.go b/cmd/bw/history.go index dd9adb94..a2f029e2 100644 --- a/cmd/bw/history.go +++ b/cmd/bw/history.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "regexp" "strings" "github.com/jallum/beadwork/internal/config" @@ -38,11 +39,22 @@ func parseHistoryArgs(raw []string) (HistoryArgs, error) { return ha, nil } +// unblockedRe matches "unblocked " lines in commit messages (line >= 2). +var unblockedRe = regexp.MustCompile(`^unblocked\s+(\S+)$`) + type commitEntry struct { - Hash string `json:"hash"` - Timestamp string `json:"timestamp"` - Author string `json:"author"` - Intent string `json:"intent"` + Hash string `json:"hash"` + Timestamp string `json:"timestamp"` + Author string `json:"author"` + Intent string `json:"intent"` + Unblocked []string `json:"unblocked,omitempty"` +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s } func cmdHistory(store *issue.Store, args []string, w Writer, _ *config.Config) (*config.Config, error) { @@ -68,12 +80,19 @@ func cmdHistory(store *issue.Store, args []string, w Writer, _ *config.Config) ( for i := len(commits) - 1; i >= 0; i-- { c := commits[i] if strings.Contains(c.Message, iss.ID) { - matched = append(matched, commitEntry{ + entry := commitEntry{ Hash: c.Hash, Timestamp: c.Time.UTC().Format("2006-01-02 15:04"), Author: c.Author, - Intent: c.Message, - }) + Intent: firstLine(c.Message), + } + // Parse unblocked lines from the commit message (line >= 2 only). + for _, line := range strings.Split(c.Message, "\n")[1:] { + if m := unblockedRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + entry.Unblocked = append(entry.Unblocked, m[1]) + } + } + matched = append(matched, entry) } } @@ -94,6 +113,9 @@ func cmdHistory(store *issue.Store, args []string, w Writer, _ *config.Config) ( for _, e := range matched { fmt.Fprintf(w, "%s %s %s\n", e.Timestamp, e.Author, e.Intent) + for _, uid := range e.Unblocked { + fmt.Fprintf(w, " → unblocked %s\n", uid) + } } return nil, nil } diff --git a/cmd/bw/main.go b/cmd/bw/main.go index 028d9fc0..0f2cf6f5 100644 --- a/cmd/bw/main.go +++ b/cmd/bw/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/jallum/beadwork/internal/config" "github.com/jallum/beadwork/internal/issue" @@ -14,6 +15,15 @@ import ( const version = "0.12.3" +// globalNoColor is set by commands (e.g. recap --no-color) to force +// non-colored output even when stdout is a TTY. Consulted at render setup. +var globalNoColor bool + +// globalDryRun mirrors the --dry-run flag. For commands with NeedsStore +// it drives store.DryRun; for commands without a store (e.g. recap) it +// suppresses side-effects like advancing the registry cursor. +var globalDryRun bool + func resolveRenderMode(args []string) string { if mode, ok := flagValue(args, "--x-render-as"); ok && mode != "" { return mode @@ -21,6 +31,9 @@ func resolveRenderMode(args []string) string { if hasFlag(args, "--x-raw") { return "raw" } + if hasFlag(args, "--no-color") { + return "markdown" + } if term.IsTerminal(int(os.Stdout.Fd())) && os.Getenv("NO_COLOR") == "" { return "tty" } @@ -55,10 +68,15 @@ func main() { args = removeFlag(args, "--x-raw") args, _ = removeFlagValue(args, "--x-render-as") + if hasFlag(args, "--no-color") { + globalNoColor = true + args = removeFlag(args, "--no-color") + } dryRun := hasFlag(args, "--dry-run") if dryRun { args = removeFlag(args, "--dry-run") + globalDryRun = true } switch cmd { @@ -118,6 +136,20 @@ func main() { } } +// bwNow returns the current time respecting BW_CLOCK. +// The returned Time preserves its original location (local time when no +// BW_CLOCK is set; whatever offset BW_CLOCK carries otherwise) so that +// day-boundary math ("today", "yesterday") uses the user's local zone. +// Callers that need UTC for storage should .UTC() themselves. +func bwNow() time.Time { + if v := os.Getenv("BW_CLOCK"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + } + return time.Now() +} + // extractDirFlag removes all -C pairs from args and sets repoDir. func extractDirFlag(args []string) []string { out := make([]string, 0, len(args)) diff --git a/cmd/bw/recap.go b/cmd/bw/recap.go new file mode 100644 index 00000000..56e9452d --- /dev/null +++ b/cmd/bw/recap.go @@ -0,0 +1,275 @@ +package main + +import ( + "fmt" + "os" + "sort" + "time" + + "github.com/jallum/beadwork/internal/config" + "github.com/jallum/beadwork/internal/issue" + "github.com/jallum/beadwork/internal/recap" + "github.com/jallum/beadwork/internal/registry" + "github.com/jallum/beadwork/internal/repo" + "github.com/jallum/beadwork/internal/treefs" +) + +// storeLookup adapts an *issue.Store to the recap.IssueLookup interface. +type storeLookup struct { + store *issue.Store +} + +func (s *storeLookup) Title(id string) string { + if s.store == nil { + return "" + } + iss, err := s.store.Get(id) + if err != nil { + return "" + } + return iss.Title +} + +type recapArgs struct { + Tokens []string + Since string + JSON bool + ASCII bool + DryRun bool + All bool + Verbose bool +} + +func parseRecapArgs(raw []string) (recapArgs, error) { + expanded := make([]string, len(raw)) + for i, tok := range raw { + if tok == "-v" { + expanded[i] = "--verbose" + } else { + expanded[i] = tok + } + } + a, err := ParseArgs(expanded, + []string{"--since"}, + []string{"--json", "--ascii", "--dry-run", "--all", "--verbose"}, + ) + if err != nil { + return recapArgs{}, err + } + return recapArgs{ + Tokens: a.Pos(), + Since: a.String("--since"), + JSON: a.Bool("--json"), + ASCII: a.Bool("--ascii") || globalNoColor, + DryRun: a.Bool("--dry-run") || globalDryRun, + All: a.Bool("--all"), + Verbose: a.Bool("--verbose"), + }, nil +} + +func cmdRecap(_ *issue.Store, args []string, w Writer, cfg *config.Config) (*config.Config, error) { + ra, err := parseRecapArgs(args) + if err != nil { + return nil, err + } + + if ra.All { + return nil, cmdRecapAll(ra, w, cfg) + } + + return nil, runRecapSingle(ra, w, repoDir) +} + +func runRecapSingle(ra recapArgs, w Writer, dir string) error { + r, err := repo.FindRepoAt(dir) + if err != nil { + return fmt.Errorf("not a git repository (run bw init first)") + } + if !r.IsInitialized() { + return fmt.Errorf("beadwork not initialized. Run: bw init") + } + + store := issue.NewStore(r.TreeFS(), r.Prefix) + store.Committer = r + + now := bwNow() + window, commits, err := resolveRecapWindow(ra, r, now) + if err != nil { + return err + } + + rcp := recap.Build(commits, window, &storeLookup{store: store}) + + if err := renderRecap(w, rcp, ra); err != nil { + return err + } + + if !ra.DryRun { + explicit := ra.Since != "" || len(ra.Tokens) > 0 + cursor := r.RecapCursor() + if explicit && cursor != "" { + gap, cursorKnown := countGap(commits, cursor, window.Start) + if cursorKnown && gap > 0 { + fmt.Fprint(os.Stderr, gapNoticeLine("", gap)) + return nil + } + } + switch { + case len(commits) > 0: + _ = r.SetRecapCursor(commits[0].Hash) + case cursor != "": + _ = r.TouchRecapCursor() + } + } + + return nil +} + +// resolveRecapWindow determines the time window and commit set for a recap. +// Three cases: +// 1. Explicit window (--since or positional tokens) → filter AllCommits by time. +// 2. No cursor yet (first recap) → last 24h backfill. +// 3. Cursor present → use CommitsSince(cursor). +func resolveRecapWindow(ra recapArgs, r *repo.Repo, now time.Time) (recap.Window, []treefs.CommitInfo, error) { + explicit := ra.Since != "" || len(ra.Tokens) > 0 + cursor := r.RecapCursor() + + switch { + case explicit: + window, err := recap.ParseWindow(ra.Tokens, ra.Since, now) + if err != nil { + return recap.Window{}, nil, err + } + commits, err := r.AllCommits() + if err != nil { + return recap.Window{}, nil, fmt.Errorf("read commits: %w", err) + } + return window, commits, nil + + case cursor == "": + window := recap.Window{ + Start: now.Add(-24 * time.Hour), + End: now, + Label: "last 24h (first recap)", + } + commits, err := r.AllCommits() + if err != nil { + return recap.Window{}, nil, fmt.Errorf("read commits: %w", err) + } + return window, commits, nil + + default: + commits, err := r.TreeFS().CommitsSince(cursor) + if err != nil { + return recap.Window{}, nil, fmt.Errorf("read commits: %w", err) + } + start := now + if len(commits) > 0 { + start = commits[len(commits)-1].Time + } + label := "since last recap" + if lastAt := r.LastRecapAt(); !lastAt.IsZero() { + label = fmt.Sprintf("since last recap (%s)", relativeTimeSince(lastAt, now)) + } + return recap.Window{Start: start, End: now, Label: label}, commits, nil + } +} + +func gapNoticeLine(prefix string, gap int) string { + noun := "commits" + if gap == 1 { + noun = "commit" + } + lead := "" + if prefix != "" { + lead = prefix + ": " + } + return fmt.Sprintf("%s%d %s older than this window and newer than your last recap were not shown. Run 'bw recap' to see them.\n", + lead, gap, noun) +} + +func countGap(commits []treefs.CommitInfo, cursor string, windowStart time.Time) (int, bool) { + var cursorTime time.Time + found := false + for _, c := range commits { + if c.Hash == cursor { + cursorTime = c.Time + found = true + break + } + } + if !found { + return 0, false + } + gap := 0 + for _, c := range commits { + if c.Time.After(cursorTime) && c.Time.Before(windowStart) { + gap++ + } + } + return gap, true +} + +func cmdRecapAll(ra recapArgs, w Writer, cfg *config.Config) error { + if repoDir != "" { + fmt.Fprintln(os.Stderr, "warning: -C is ignored with --all") + } + + paths := registry.Paths(cfg) + if len(paths) == 0 { + fmt.Fprintln(w, "no registered repositories") + return nil + } + + now := bwNow() + var all []repoRecap + + for _, p := range paths { + if _, err := os.Stat(p); err != nil { + fmt.Fprintf(os.Stderr, "skipping %s: %v\n", p, err) + continue + } + r, err := repo.FindRepoAt(p) + if err != nil { + fmt.Fprintf(os.Stderr, "skipping %s: %v\n", p, err) + continue + } + if !r.IsInitialized() { + fmt.Fprintf(os.Stderr, "skipping %s: beadwork not initialized\n", p) + continue + } + + store := issue.NewStore(r.TreeFS(), r.Prefix) + store.Committer = r + + window, commits, err := resolveRecapWindow(ra, r, now) + if err != nil { + fmt.Fprintf(os.Stderr, "skipping %s: %v\n", p, err) + continue + } + + rcp := recap.Build(commits, window, &storeLookup{store: store}) + all = append(all, repoRecap{Path: p, Recap: rcp}) + + if !ra.DryRun { + explicit := ra.Since != "" || len(ra.Tokens) > 0 + cursor := r.RecapCursor() + if explicit && cursor != "" { + gap, cursorKnown := countGap(commits, cursor, window.Start) + if cursorKnown && gap > 0 { + fmt.Fprint(os.Stderr, gapNoticeLine(p, gap)) + continue + } + } + switch { + case len(commits) > 0: + _ = r.SetRecapCursor(commits[0].Hash) + case cursor != "": + _ = r.TouchRecapCursor() + } + } + } + + sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path }) + return renderCrossRepo(w, all, ra) +} diff --git a/cmd/bw/recap_render.go b/cmd/bw/recap_render.go new file mode 100644 index 00000000..e5f9efe6 --- /dev/null +++ b/cmd/bw/recap_render.go @@ -0,0 +1,372 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/jallum/beadwork/internal/recap" +) + +// renderRecap renders a single-repo recap to w. +func renderRecap(w Writer, r recap.Recap, ra recapArgs) error { + if ra.JSON { + return renderRecapJSON(w, r) + } + if ra.Verbose { + return renderRecapTree(w, r, ra.ASCII) + } + return renderRecapCondensed(w, r) +} + +func renderRecapJSON(w Writer, r recap.Recap) error { + out := struct { + Scope string `json:"scope"` + Recap recap.Recap `json:"recap"` + }{Scope: "single", Recap: r} + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Fprintln(w, string(data)) + return nil +} + +// sectionSummary rolls up a section into a compact summary string and the +// marker character (open/in_progress/closed). +// +// Buckets: +// - "state" events that matter for a headline: close, reopen, start, create +// - "notes": comment, label +// - "edits": update, link, unlink, defer, undefer +// - "tangential": unblocked, delete +// +// The summary leads with the most-significant state change within the window, +// then appends quiet counts for everything else ("+ 2 comments, 3 edits"). +func sectionSummary(s recap.Section) (marker, summary string, latest time.Time) { + var closed, reopened, started, created bool + var comments, edits, labels, unblocked int + + for _, l := range s.Leaves { + t, _ := time.Parse(time.RFC3339, l.Time) + if t.After(latest) { + latest = t + } + switch l.Type { + case "close": + closed = true + case "reopen": + reopened = true + case "start": + started = true + case "create": + created = true + case "comment": + comments++ + case "label": + labels++ + case "unblocked": + unblocked++ + case "update", "link", "unlink", "defer", "undefer": + edits++ + } + } + + // Headline: the most significant state change. + var parts []string + switch { + case closed: + parts = append(parts, "closed") + marker = "●" + case started: + parts = append(parts, "started") + marker = "◐" + case reopened: + parts = append(parts, "reopened") + marker = "◐" + case created: + parts = append(parts, "created") + marker = "○" + default: + marker = "·" + } + + // Quiet counts. + if comments > 0 { + parts = append(parts, pluralize(comments, "comment")) + } + if edits > 0 { + parts = append(parts, pluralize(edits, "edit")) + } + if labels > 0 { + parts = append(parts, pluralize(labels, "label change")) + } + if unblocked > 0 { + parts = append(parts, pluralize(unblocked, "unblocked")) + } + + if len(parts) == 0 { + summary = fmt.Sprintf("%d event(s)", len(s.Leaves)) + } else { + summary = strings.Join(parts, ", ") + } + return marker, summary, latest +} + +func pluralize(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("1 %s", noun) + } + return fmt.Sprintf("%d %ss", n, noun) +} + +// markerStyle maps a summary marker to the ANSI style used to color it. +func markerStyle(marker string) Style { + switch marker { + case "●": + return Green // closed + case "◐": + return Yellow // started / reopened + case "○": + return Cyan // created + default: + return Dim + } +} + +// colorizeSummary applies per-keyword color to the summary phrase so state +// changes jump out from counts. +func colorizeSummary(w Writer, summary string) string { + // Split by ", " and re-assemble with styling. + parts := strings.Split(summary, ", ") + for i, p := range parts { + switch { + case p == "closed": + parts[i] = w.Style(p, Green, Bold) + case p == "started": + parts[i] = w.Style(p, Yellow, Bold) + case p == "reopened": + parts[i] = w.Style(p, Yellow, Bold) + case p == "created": + parts[i] = w.Style(p, Cyan, Bold) + default: + parts[i] = w.Style(p, Dim) + } + } + return strings.Join(parts, w.Style(", ", Dim)) +} + +func renderRecapCondensed(w Writer, r recap.Recap) error { + if len(r.Sections) == 0 { + fmt.Fprintf(w, "Recap: %s — %s\n", + w.Style(r.Window.Label, Cyan), + w.Style("nothing to report", Dim)) + return nil + } + + // Build rows then sort by latest activity, most recent first. + type row struct { + marker, id, title, summary string + latest time.Time + } + rows := make([]row, 0, len(r.Sections)) + for _, s := range r.Sections { + m, summ, lat := sectionSummary(s) + title := s.Title + if title == "" { + title = "(deleted)" + } + rows = append(rows, row{ + marker: m, id: s.ID, title: title, + summary: summ, latest: lat, + }) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].latest.After(rows[j].latest) + }) + + fmt.Fprintf(w, "Recap: %s %s %s\n", + w.Style(r.Window.Label, Cyan), + w.Style("·", Dim), + w.Style(fmt.Sprintf("%d issue(s)", len(rows)), Dim)) + + // Compute id column width for alignment (pre-style, so ANSI codes + // don't bloat the padding). + idWidth := 0 + for _, r := range rows { + if len(r.id) > idWidth { + idWidth = len(r.id) + } + } + + // Compute available width for the title column so long titles don't + // force the Writer to hard-wrap mid-line. Budget per row: + // " " (2) + marker (1) + " " (1) + id (idWidth) + " " (2) + + // TITLE + " — " (4) + summary + " " (1) + "(age)" + // Reserve a small cushion so we never hit the right edge. + width := w.Width() + now := bwNow() + for _, r := range rows { + age := relativeTimeSince(r.latest, now) + title := r.title + if width > 0 { + fixed := 2 + 1 + 1 + idWidth + 2 + 4 + visibleLen(r.summary) + 1 + len("("+age+")") + budget := width - fixed - 1 // 1-char cushion + if budget < 10 { + budget = 10 + } + if len(title) > budget { + title = title[:budget-1] + "…" + } + } + marker := w.Style(r.marker, markerStyle(r.marker)) + // Pad the id BEFORE styling so alignment is based on visible width. + paddedID := fmt.Sprintf("%-*s", idWidth, r.id) + styledID := w.Style(paddedID, Bold) + summary := colorizeSummary(w, r.summary) + fmt.Fprintf(w, " %s %s %s %s %s %s\n", + marker, + styledID, + title, + w.Style("—", Dim), + summary, + w.Style("("+age+")", Dim), + ) + } + // Only print the interactive hint when output is going to a TTY. + // Keep piped / LLM consumption free of chatty prompts. + if w.IsTTY() { + fmt.Fprintln(w, w.Style("\n (use --verbose for per-event detail)", Dim)) + } + return nil +} + +// visibleLen returns the rune count of s, ignoring nothing — used for +// budget math on strings that have NOT been ANSI-styled yet. +func visibleLen(s string) int { + n := 0 + for range s { + n++ + } + return n +} + +// eventTypeStyle returns the color styling for an event type in the verbose +// tree. State changes stand out; low-signal events are dim. +func eventTypeStyle(t string) []Style { + switch t { + case "close": + return []Style{Green, Bold} + case "start", "reopen": + return []Style{Yellow, Bold} + case "create": + return []Style{Cyan, Bold} + case "unblocked": + return []Style{Cyan} + case "delete": + return []Style{Red, Bold} + case "comment", "label": + return []Style{} // default color + case "update", "link", "unlink", "defer", "undefer": + return []Style{Dim} + default: + return []Style{Dim} + } +} + +func renderRecapTree(w Writer, r recap.Recap, ascii bool) error { + fmt.Fprintf(w, "Recap: %s\n", w.Style(r.Window.Label, Cyan)) + if len(r.Sections) == 0 { + fmt.Fprintln(w, w.Style(" (nothing to report — you're caught up)", Dim)) + return nil + } + + branch := "├─" + last := "└─" + vbar := "│" + if ascii { + branch = "|-" + last = "`-" + vbar = "|" + } + + for i, s := range r.Sections { + isLast := i == len(r.Sections)-1 + marker := branch + if isLast { + marker = last + } + title := s.Title + if title == "" { + title = w.Style("(deleted)", Dim) + } + fmt.Fprintf(w, "%s %s %s\n", w.Style(marker, Dim), w.Style(s.ID, Bold), title) + + // Indent prefix for leaves. + indent := vbar + " " + if isLast { + indent = " " + } + + for j, leaf := range s.Leaves { + leafMarker := branch + if j == len(s.Leaves)-1 { + leafMarker = last + } + + styles := eventTypeStyle(leaf.Type) + styledType := w.Style(leaf.Type, styles...) + line := styledType + if leaf.Detail != "" { + line += " " + w.Style(leaf.Detail, Dim) + } + fmt.Fprintf(w, "%s%s %s %s\n", + w.Style(indent, Dim), + w.Style(leafMarker, Dim), + w.Style(leaf.Time, Dim), + line, + ) + } + } + return nil +} + +// repoRecap pairs a repository path with its computed recap. +// Used for cross-repo fan-out rendering. +type repoRecap struct { + Path string `json:"path"` + Recap recap.Recap `json:"recap"` +} + +// renderCrossRepo renders the output for `bw recap --all`. +func renderCrossRepo(w Writer, all []repoRecap, ra recapArgs) error { + if ra.JSON { + out := struct { + Scope string `json:"scope"` + Repos []repoRecap `json:"repos"` + }{Scope: "cross", Repos: all} + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Fprintln(w, string(data)) + return nil + } + + totalSections := 0 + for _, r := range all { + totalSections += len(r.Recap.Sections) + } + fmt.Fprintf(w, "Cross-repo recap: %d repo(s), %d active issue(s)\n\n", len(all), totalSections) + + for _, rr := range all { + fmt.Fprintf(w, "=== %s ===\n", rr.Path) + if err := renderRecap(w, rr.Recap, ra); err != nil { + return err + } + fmt.Fprintln(w) + } + return nil +} diff --git a/internal/issue/issue.go b/internal/issue/issue.go index c1a78847..6ce1f00a 100644 --- a/internal/issue/issue.go +++ b/internal/issue/issue.go @@ -84,6 +84,13 @@ func (s *Store) ClearCache() { s.idSet = nil } +// Refresh reloads the underlying TreeFS from the current ref and clears +// all caches. Use this after a CAS conflict to pick up the latest state. +func (s *Store) Refresh() error { + s.ClearCache() + return s.FS.Refresh() +} + // Now returns the current time in UTC. If the BW_CLOCK environment variable // is set to an RFC3339 value, that fixed time is used instead of the real // clock. This enables deterministic timestamps for testing and migration. diff --git a/internal/recap/parse.go b/internal/recap/parse.go new file mode 100644 index 00000000..af96ff5f --- /dev/null +++ b/internal/recap/parse.go @@ -0,0 +1,91 @@ +package recap + +import ( + "regexp" + "strings" + "time" +) + +// Intent patterns — anchored to the start of a line. +var ( + createRe = regexp.MustCompile(`^create\s+(\S+)`) + closeRe = regexp.MustCompile(`^close\s+(\S+)`) + startRe = regexp.MustCompile(`^start\s+(\S+)`) + updateRe = regexp.MustCompile(`^update\s+(\S+)`) + reopenRe = regexp.MustCompile(`^reopen\s+(\S+)`) + deferRe = regexp.MustCompile(`^defer\s+(\S+)`) + undeferRe = regexp.MustCompile(`^undefer\s+(\S+)`) + commentRe = regexp.MustCompile(`^comment\s+(\S+)`) + linkRe = regexp.MustCompile(`^link\s+(\S+)\s+blocks\s+(\S+)`) + unlinkRe = regexp.MustCompile(`^unlink\s+(\S+)\s+blocks\s+(\S+)`) + deleteRe = regexp.MustCompile(`^delete\s+(\S+)`) + labelRe = regexp.MustCompile(`^label\s+(\S+)`) + unblockedRe = regexp.MustCompile(`^unblocked\s+(\S+)$`) +) + +// ParseIntent extracts events from a beadwork commit message. +// The first line is the primary intent; subsequent lines may contain +// secondary events (e.g., "unblocked "). +func ParseIntent(message string, ts time.Time) []Event { + lines := strings.Split(strings.TrimSpace(message), "\n") + if len(lines) == 0 { + return nil + } + + var events []Event + first := strings.TrimSpace(lines[0]) + + switch { + case createRe.MatchString(first): + m := createRe.FindStringSubmatch(first) + detail := strings.TrimSpace(first[len(m[0]):]) + events = append(events, Event{Type: "create", ID: m[1], Time: ts, Detail: detail}) + case closeRe.MatchString(first): + m := closeRe.FindStringSubmatch(first) + detail := strings.TrimSpace(first[len(m[0]):]) + events = append(events, Event{Type: "close", ID: m[1], Time: ts, Detail: detail}) + case startRe.MatchString(first): + m := startRe.FindStringSubmatch(first) + events = append(events, Event{Type: "start", ID: m[1], Time: ts}) + case updateRe.MatchString(first): + m := updateRe.FindStringSubmatch(first) + detail := strings.TrimSpace(first[len(m[0]):]) + events = append(events, Event{Type: "update", ID: m[1], Time: ts, Detail: detail}) + case reopenRe.MatchString(first): + m := reopenRe.FindStringSubmatch(first) + events = append(events, Event{Type: "reopen", ID: m[1], Time: ts}) + case deferRe.MatchString(first): + m := deferRe.FindStringSubmatch(first) + detail := strings.TrimSpace(first[len(m[0]):]) + events = append(events, Event{Type: "defer", ID: m[1], Time: ts, Detail: detail}) + case undeferRe.MatchString(first): + m := undeferRe.FindStringSubmatch(first) + events = append(events, Event{Type: "undefer", ID: m[1], Time: ts}) + case commentRe.MatchString(first): + m := commentRe.FindStringSubmatch(first) + events = append(events, Event{Type: "comment", ID: m[1], Time: ts}) + case linkRe.MatchString(first): + m := linkRe.FindStringSubmatch(first) + events = append(events, Event{Type: "link", ID: m[1], Time: ts, Detail: "blocks " + m[2]}) + case unlinkRe.MatchString(first): + m := unlinkRe.FindStringSubmatch(first) + events = append(events, Event{Type: "unlink", ID: m[1], Time: ts, Detail: "blocks " + m[2]}) + case deleteRe.MatchString(first): + m := deleteRe.FindStringSubmatch(first) + events = append(events, Event{Type: "delete", ID: m[1], Time: ts}) + case labelRe.MatchString(first): + m := labelRe.FindStringSubmatch(first) + detail := strings.TrimSpace(first[len(m[0]):]) + events = append(events, Event{Type: "label", ID: m[1], Time: ts, Detail: detail}) + } + + // Parse secondary events from lines >= 2 (e.g., "unblocked "). + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if m := unblockedRe.FindStringSubmatch(line); m != nil { + events = append(events, Event{Type: "unblocked", ID: m[1], Time: ts}) + } + } + + return events +} diff --git a/internal/recap/parse_test.go b/internal/recap/parse_test.go new file mode 100644 index 00000000..709b4d31 --- /dev/null +++ b/internal/recap/parse_test.go @@ -0,0 +1,165 @@ +package recap + +import ( + "testing" + "time" +) + +var testTime = time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + +func TestParseIntentCreate(t *testing.T) { + events := ParseIntent(`create bw-abc p2 task "Fix login"`, testTime) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + e := events[0] + if e.Type != "create" || e.ID != "bw-abc" { + t.Errorf("type=%q id=%q", e.Type, e.ID) + } + if e.Detail == "" { + t.Error("expected detail for create") + } +} + +func TestParseIntentClose(t *testing.T) { + events := ParseIntent("close bw-xyz", testTime) + if len(events) != 1 || events[0].Type != "close" || events[0].ID != "bw-xyz" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentCloseWithReason(t *testing.T) { + events := ParseIntent(`close bw-xyz reason="duplicate"`, testTime) + if len(events) != 1 || events[0].Type != "close" { + t.Fatalf("got %v", events) + } + if events[0].Detail == "" { + t.Error("expected detail with reason") + } +} + +func TestParseIntentStart(t *testing.T) { + events := ParseIntent(`start bw-1 assignee="alice"`, testTime) + if len(events) != 1 || events[0].Type != "start" || events[0].ID != "bw-1" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentUpdate(t *testing.T) { + events := ParseIntent(`update bw-1 priority=1`, testTime) + if len(events) != 1 || events[0].Type != "update" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentReopen(t *testing.T) { + events := ParseIntent("reopen bw-1", testTime) + if len(events) != 1 || events[0].Type != "reopen" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentDefer(t *testing.T) { + events := ParseIntent("defer bw-1 until 2026-06-01", testTime) + if len(events) != 1 || events[0].Type != "defer" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentUndefer(t *testing.T) { + events := ParseIntent("undefer bw-1", testTime) + if len(events) != 1 || events[0].Type != "undefer" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentComment(t *testing.T) { + events := ParseIntent(`comment bw-1 "Fixed it"`, testTime) + if len(events) != 1 || events[0].Type != "comment" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentLink(t *testing.T) { + events := ParseIntent("link bw-1 blocks bw-2", testTime) + if len(events) != 1 || events[0].Type != "link" || events[0].Detail != "blocks bw-2" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentUnlink(t *testing.T) { + events := ParseIntent("unlink bw-1 blocks bw-2", testTime) + if len(events) != 1 || events[0].Type != "unlink" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentDelete(t *testing.T) { + events := ParseIntent("delete bw-1", testTime) + if len(events) != 1 || events[0].Type != "delete" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentLabel(t *testing.T) { + events := ParseIntent("label bw-1 +bug +urgent", testTime) + if len(events) != 1 || events[0].Type != "label" { + t.Errorf("got %v", events) + } +} + +func TestParseIntentUnblockedSecondary(t *testing.T) { + msg := "close bw-1\nunblocked bw-2\nunblocked bw-3" + events := ParseIntent(msg, testTime) + if len(events) != 3 { + t.Fatalf("got %d events, want 3", len(events)) + } + if events[0].Type != "close" || events[0].ID != "bw-1" { + t.Errorf("event[0] = %v", events[0]) + } + if events[1].Type != "unblocked" || events[1].ID != "bw-2" { + t.Errorf("event[1] = %v", events[1]) + } + if events[2].Type != "unblocked" || events[2].ID != "bw-3" { + t.Errorf("event[2] = %v", events[2]) + } +} + +func TestParseIntentReasonContainingUnblocked(t *testing.T) { + // A close reason that contains "unblocked" as a word should NOT + // produce a spurious unblocked event. + msg := `close bw-1 reason="unblocked by external team"` + events := ParseIntent(msg, testTime) + for _, e := range events { + if e.Type == "unblocked" { + t.Errorf("spurious unblocked event from reason text: %v", e) + } + } +} + +func TestParseIntentEmpty(t *testing.T) { + events := ParseIntent("", testTime) + if len(events) != 0 { + t.Errorf("got %d events for empty message", len(events)) + } +} + +func TestParseIntentUnknown(t *testing.T) { + events := ParseIntent("init beadwork", testTime) + if len(events) != 0 { + t.Errorf("got %d events for unknown intent", len(events)) + } +} + +func FuzzParseIntent(f *testing.F) { + f.Add("create bw-1 p2 task \"test\"") + f.Add("close bw-1\nunblocked bw-2") + f.Add("start bw-1 assignee=\"alice\"") + f.Add("update bw-1 priority=0") + f.Add("") + f.Add("garbage input!!!") + f.Fuzz(func(t *testing.T, msg string) { + // Should never panic. + ParseIntent(msg, testTime) + }) +} diff --git a/internal/recap/recap.go b/internal/recap/recap.go new file mode 100644 index 00000000..e07e083d --- /dev/null +++ b/internal/recap/recap.go @@ -0,0 +1,120 @@ +// Package recap builds structured activity summaries from beadwork commit +// history. The data model (Recap/Section/Leaf) is renderer-agnostic: a single +// Build produces one model that both tree and JSON renderers consume. +package recap + +import ( + "sort" + "time" + + "github.com/jallum/beadwork/internal/treefs" +) + +// IssueLookup resolves an issue ID to its title. Implementations may return +// "" if the issue has been deleted or is otherwise unavailable. +type IssueLookup interface { + Title(id string) string +} + +// Event represents a single parsed activity from a commit message. +type Event struct { + Type string // "create", "close", "start", "update", "reopen", "defer", "undefer", "comment", "link", "unlink", "unblocked", "delete", "label" + ID string // primary issue ID + Time time.Time // commit timestamp + Detail string // additional context (title, reason, etc.) +} + +// Leaf is a single event in the recap tree. +type Leaf struct { + Type string `json:"type"` + ID string `json:"id"` + Time string `json:"time"` + Detail string `json:"detail,omitempty"` +} + +// Section groups events for a single issue. +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Leaves []Leaf `json:"events"` +} + +// Recap is the top-level activity summary. +type Recap struct { + Window Window `json:"window"` + Sections []Section `json:"sections"` +} + +// Build constructs a Recap from commits within the given window. +// It parses each commit's intent, deduplicates events, groups by issue, +// and resolves titles via the lookup. +func Build(commits []treefs.CommitInfo, w Window, lookup IssueLookup) Recap { + var events []Event + seen := make(map[string]bool) // dedup key: "type:id:time" + + for _, c := range commits { + // Window is [Start, End] inclusive on both ends so that events + // at "now" are captured. + if c.Time.Before(w.Start) || c.Time.After(w.End) { + continue + } + parsed := ParseIntent(c.Message, c.Time) + for _, e := range parsed { + key := e.Type + ":" + e.ID + ":" + e.Time.Format(time.RFC3339) + if seen[key] { + continue + } + seen[key] = true + events = append(events, e) + } + } + + // Group by issue ID. + grouped := make(map[string][]Event) + var order []string + for _, e := range events { + if _, exists := grouped[e.ID]; !exists { + order = append(order, e.ID) + } + grouped[e.ID] = append(grouped[e.ID], e) + } + + // Build sections. + sections := make([]Section, 0, len(order)) + for _, id := range order { + evts := grouped[id] + title := "" + if lookup != nil { + title = lookup.Title(id) + } + + leaves := make([]Leaf, 0, len(evts)) + for _, e := range evts { + leaves = append(leaves, Leaf{ + Type: e.Type, + ID: e.ID, + Time: e.Time.UTC().Format(time.RFC3339), + Detail: e.Detail, + }) + } + + sections = append(sections, Section{ + ID: id, + Title: title, + Leaves: leaves, + }) + } + + // Sort sections by first event time. + sort.Slice(sections, func(i, j int) bool { + if len(sections[i].Leaves) == 0 || len(sections[j].Leaves) == 0 { + return false + } + return sections[i].Leaves[0].Time < sections[j].Leaves[0].Time + }) + + return Recap{ + Window: w, + Sections: sections, + } +} diff --git a/internal/recap/recap_test.go b/internal/recap/recap_test.go new file mode 100644 index 00000000..3fbb49b6 --- /dev/null +++ b/internal/recap/recap_test.go @@ -0,0 +1,116 @@ +package recap + +import ( + "testing" + "time" + + "github.com/jallum/beadwork/internal/treefs" +) + +type fakeLookup struct { + titles map[string]string +} + +func (f *fakeLookup) Title(id string) string { + return f.titles[id] +} + +func TestBuildEmpty(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w := Window{Start: now.Add(-24 * time.Hour), End: now, Label: "test"} + r := Build(nil, w, nil) + if len(r.Sections) != 0 { + t.Errorf("expected empty recap, got %d sections", len(r.Sections)) + } +} + +func TestBuildGroupsByIssue(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w := Window{Start: now.Add(-24 * time.Hour), End: now, Label: "test"} + + commits := []treefs.CommitInfo{ + {Message: `create bw-1 p2 task "First"`, Time: now.Add(-2 * time.Hour)}, + {Message: `create bw-2 p2 task "Second"`, Time: now.Add(-1 * time.Hour)}, + {Message: "start bw-1", Time: now.Add(-30 * time.Minute)}, + } + + lookup := &fakeLookup{titles: map[string]string{ + "bw-1": "First", + "bw-2": "Second", + }} + + r := Build(commits, w, lookup) + if len(r.Sections) != 2 { + t.Fatalf("sections = %d, want 2", len(r.Sections)) + } + + // bw-1 should have 2 events (create + start) + s1 := findSection(r, "bw-1") + if s1 == nil { + t.Fatal("section bw-1 not found") + } + if len(s1.Leaves) != 2 { + t.Errorf("bw-1 events = %d, want 2", len(s1.Leaves)) + } + if s1.Title != "First" { + t.Errorf("bw-1 title = %q, want 'First'", s1.Title) + } +} + +func TestBuildFiltersWindow(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w := Window{Start: now.Add(-1 * time.Hour), End: now, Label: "test"} + + commits := []treefs.CommitInfo{ + {Message: "create bw-old", Time: now.Add(-2 * time.Hour)}, // outside window + {Message: "create bw-new", Time: now.Add(-30 * time.Minute)}, + } + + r := Build(commits, w, nil) + if len(r.Sections) != 1 { + t.Fatalf("sections = %d, want 1", len(r.Sections)) + } + if r.Sections[0].ID != "bw-new" { + t.Errorf("section ID = %q, want bw-new", r.Sections[0].ID) + } +} + +func TestBuildDedup(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w := Window{Start: now.Add(-24 * time.Hour), End: now} + ts := now.Add(-1 * time.Hour) + + // Same event appearing in two commits (e.g., replay after sync) + commits := []treefs.CommitInfo{ + {Message: "create bw-1", Time: ts}, + {Message: "create bw-1", Time: ts}, + } + + r := Build(commits, w, nil) + if len(r.Sections) != 1 || len(r.Sections[0].Leaves) != 1 { + t.Errorf("expected 1 section with 1 event (deduped), got %v", r.Sections) + } +} + +func TestBuildUnblockedSecondary(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w := Window{Start: now.Add(-24 * time.Hour), End: now} + + commits := []treefs.CommitInfo{ + {Message: "close bw-1\nunblocked bw-2", Time: now.Add(-1 * time.Hour)}, + } + + r := Build(commits, w, nil) + if len(r.Sections) != 2 { + t.Fatalf("sections = %d, want 2 (bw-1 close + bw-2 unblocked)", len(r.Sections)) + } +} + +func findSection(r Recap, id string) *Section { + for i := range r.Sections { + if r.Sections[i].ID == id { + return &r.Sections[i] + } + } + return nil +} diff --git a/internal/recap/window.go b/internal/recap/window.go new file mode 100644 index 00000000..df362f8e --- /dev/null +++ b/internal/recap/window.go @@ -0,0 +1,177 @@ +package recap + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// Window represents a time range for filtering events. +type Window struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Label string `json:"label"` +} + +// ParseWindow parses a time window from tokens. Accepted tokens: +// - "today" — start of today in local TZ to now +// - "yesterday" — start of yesterday to start of today (local TZ) +// - "week" — start of this week (Monday) to now +// - "24h" — 24 hours ago to now +// - "7d" — 7 days ago to now +// +// The --since flag overrides with an explicit RFC3339 or date start. +// now is passed explicitly so tests can be deterministic. +func ParseWindow(tokens []string, since string, now time.Time) (Window, error) { + loc := now.Location() + + if since != "" { + start, err := parseSinceDate(since, loc) + if err != nil { + return Window{}, fmt.Errorf("invalid --since: %w", err) + } + return Window{Start: start, End: now, Label: "since " + since}, nil + } + + token := "24h" + if len(tokens) > 0 { + token = strings.ToLower(strings.Join(tokens, " ")) + } + + switch token { + case "today": + start := startOfDay(now, loc) + return Window{Start: start, End: now, Label: "today"}, nil + case "yesterday": + todayStart := startOfDay(now, loc) + yesterdayStart := todayStart.AddDate(0, 0, -1) + return Window{Start: yesterdayStart, End: todayStart, Label: "yesterday"}, nil + case "week", "this week": + start := startOfWeek(now, loc) + return Window{Start: start, End: now, Label: "this week"}, nil + } + + if d, ok := parseDurationToken(token); ok { + return Window{Start: now.Add(-d), End: now, Label: "last " + formatDuration(d)}, nil + } + + return Window{}, fmt.Errorf("unknown window %q (expected: today, yesterday, week, or duration like 15m, 1h, 24h, 7d, 2w)", token) +} + +// durationPartRe matches a single duration component like "15m", "3h", "2d", "1w". +var durationPartRe = regexp.MustCompile(`^(\d+)(m|h|d|w)$`) + +// parseDurationToken parses tokens like "15m", "1h", "3h30m", "24h", "7d", "2w". +// Returns (duration, true) on success. Accepts Go's time.ParseDuration syntax +// for minute/hour combinations (so "3h30m" works) and extends with d/w for +// days and weeks. +func parseDurationToken(s string) (time.Duration, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + + // First try Go's built-in parser (handles combinations like 3h30m). + if d, err := time.ParseDuration(s); err == nil && d > 0 { + return d, true + } + + // Fall back to our extended parser that supports d (day) and w (week). + // Split into consecutive chunks. + var total time.Duration + rest := s + for rest != "" { + m := durationPartRe.FindStringSubmatch(rest) + if m == nil { + // Try a multi-part match by scanning. + i := 0 + for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' { + i++ + } + if i == 0 || i == len(rest) { + return 0, false + } + n, err := strconv.Atoi(rest[:i]) + if err != nil { + return 0, false + } + unit := rest[i] + rest = rest[i+1:] + switch unit { + case 'm': + total += time.Duration(n) * time.Minute + case 'h': + total += time.Duration(n) * time.Hour + case 'd': + total += time.Duration(n) * 24 * time.Hour + case 'w': + total += time.Duration(n) * 7 * 24 * time.Hour + default: + return 0, false + } + continue + } + n, _ := strconv.Atoi(m[1]) + switch m[2] { + case "m": + total += time.Duration(n) * time.Minute + case "h": + total += time.Duration(n) * time.Hour + case "d": + total += time.Duration(n) * 24 * time.Hour + case "w": + total += time.Duration(n) * 7 * 24 * time.Hour + } + rest = "" + } + + if total <= 0 { + return 0, false + } + return total, true +} + +// formatDuration renders a Duration as a compact label ("15m", "3h", "2d", "1w"). +func formatDuration(d time.Duration) string { + switch { + case d%(7*24*time.Hour) == 0 && d >= 7*24*time.Hour: + return fmt.Sprintf("%dw", d/(7*24*time.Hour)) + case d%(24*time.Hour) == 0 && d >= 24*time.Hour: + return fmt.Sprintf("%dd", d/(24*time.Hour)) + case d%time.Hour == 0 && d >= time.Hour: + return fmt.Sprintf("%dh", d/time.Hour) + case d%time.Minute == 0 && d >= time.Minute: + return fmt.Sprintf("%dm", d/time.Minute) + } + return d.String() +} + +func startOfDay(t time.Time, loc *time.Location) time.Time { + y, m, d := t.In(loc).Date() + return time.Date(y, m, d, 0, 0, 0, 0, loc) +} + +func startOfWeek(t time.Time, loc *time.Location) time.Time { + local := t.In(loc) + weekday := local.Weekday() + if weekday == time.Sunday { + weekday = 7 + } + daysBack := int(weekday) - int(time.Monday) + y, m, d := local.AddDate(0, 0, -daysBack).Date() + return time.Date(y, m, d, 0, 0, 0, 0, loc) +} + +func parseSinceDate(s string, loc *time.Location) (time.Time, error) { + // Try RFC3339 first. + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + // Try YYYY-MM-DD in local TZ. + if t, err := time.ParseInLocation("2006-01-02", s, loc); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("expected RFC3339 or YYYY-MM-DD, got %q", s) +} diff --git a/internal/recap/window_test.go b/internal/recap/window_test.go new file mode 100644 index 00000000..4ae45543 --- /dev/null +++ b/internal/recap/window_test.go @@ -0,0 +1,214 @@ +package recap + +import ( + "testing" + "time" +) + +func TestWindowToday(t *testing.T) { + // Wednesday 2026-01-15 at 10:00 UTC + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow([]string{"today"}, "", now) + if err != nil { + t.Fatal(err) + } + if w.Label != "today" { + t.Errorf("label = %q", w.Label) + } + wantStart := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } + if !w.End.Equal(now) { + t.Errorf("end = %v, want %v", w.End, now) + } +} + +func TestWindowYesterday(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow([]string{"yesterday"}, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } + if !w.End.Equal(wantEnd) { + t.Errorf("end = %v, want %v", w.End, wantEnd) + } +} + +func TestWindowWeek(t *testing.T) { + // Wednesday 2026-01-15 → Monday 2026-01-12 + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow([]string{"week"}, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindow24h(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow([]string{"24h"}, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := now.Add(-24 * time.Hour) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindow7d(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow([]string{"7d"}, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := now.AddDate(0, 0, -7) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindowDefault24h(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow(nil, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := now.Add(-24 * time.Hour) + if !w.Start.Equal(wantStart) { + t.Errorf("default window start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindowSinceRFC3339(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow(nil, "2026-01-10T00:00:00Z", now) + if err != nil { + t.Fatal(err) + } + wantStart := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindowSinceDate(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + w, err := ParseWindow(nil, "2026-01-10", now) + if err != nil { + t.Fatal(err) + } + if w.Start.Year() != 2026 || w.Start.Month() != 1 || w.Start.Day() != 10 { + t.Errorf("start = %v", w.Start) + } +} + +func TestWindowSinceInvalid(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + _, err := ParseWindow(nil, "not-a-date", now) + if err == nil { + t.Error("expected error for invalid --since") + } +} + +func TestWindowUnknownToken(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + _, err := ParseWindow([]string{"fortnight"}, "", now) + if err == nil { + t.Error("expected error for unknown token") + } +} + +func TestWindowTodayLocalTZ(t *testing.T) { + // Ensure "today" uses the local timezone of now, not UTC. + eastern := time.FixedZone("EST", -5*3600) + // 2026-01-15 01:00 EST = 2026-01-15 06:00 UTC + now := time.Date(2026, 1, 15, 1, 0, 0, 0, eastern) + w, err := ParseWindow([]string{"today"}, "", now) + if err != nil { + t.Fatal(err) + } + // Start of today in EST should be 2026-01-15 00:00 EST + wantStart := time.Date(2026, 1, 15, 0, 0, 0, 0, eastern) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } +} + +func TestWindowYesterdayLocalTZ(t *testing.T) { + // At 2026-01-15 01:00 EST, yesterday starts at 2026-01-14 00:00 EST + eastern := time.FixedZone("EST", -5*3600) + now := time.Date(2026, 1, 15, 1, 0, 0, 0, eastern) + w, err := ParseWindow([]string{"yesterday"}, "", now) + if err != nil { + t.Fatal(err) + } + wantStart := time.Date(2026, 1, 14, 0, 0, 0, 0, eastern) + wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, eastern) + if !w.Start.Equal(wantStart) { + t.Errorf("start = %v, want %v", w.Start, wantStart) + } + if !w.End.Equal(wantEnd) { + t.Errorf("end = %v, want %v", w.End, wantEnd) + } +} + +func TestWindowDurationTokens(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + cases := []struct { + token string + want time.Duration + }{ + {"15m", 15 * time.Minute}, + {"30m", 30 * time.Minute}, + {"1h", 1 * time.Hour}, + {"3h30m", 3*time.Hour + 30*time.Minute}, + {"24h", 24 * time.Hour}, + {"2d", 48 * time.Hour}, + {"1w", 7 * 24 * time.Hour}, + {"2w", 14 * 24 * time.Hour}, + } + for _, tc := range cases { + w, err := ParseWindow([]string{tc.token}, "", now) + if err != nil { + t.Errorf("ParseWindow(%q): %v", tc.token, err) + continue + } + wantStart := now.Add(-tc.want) + if !w.Start.Equal(wantStart) { + t.Errorf("token %q: start = %v, want %v", tc.token, w.Start, wantStart) + } + } +} + +func TestWindowDurationRejects(t *testing.T) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + bad := []string{"abc", "1x", ""} + for _, token := range bad { + if _, err := ParseWindow([]string{token}, "", now); err == nil { + t.Errorf("expected error for token %q", token) + } + } +} + +func FuzzParseWindow(f *testing.F) { + f.Add("today") + f.Add("yesterday") + f.Add("week") + f.Add("24h") + f.Add("7d") + f.Add("garbage") + f.Fuzz(func(t *testing.T, token string) { + now := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + ParseWindow([]string{token}, "", now) + }) +} diff --git a/internal/repo/recap_cursor.go b/internal/repo/recap_cursor.go new file mode 100644 index 00000000..342f7dc5 --- /dev/null +++ b/internal/repo/recap_cursor.go @@ -0,0 +1,55 @@ +package repo + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +const recapCursorRef = "refs/beadwork/recap-cursor" + +// RecapCursor returns the commit hash stored in the recap cursor ref, +// or "" if no cursor has been set. The ref is local-only (never pushed). +func (r *Repo) RecapCursor() string { + path := filepath.Join(r.GitDir, recapCursorRef) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// LastRecapAt returns the mtime of the recap cursor ref file, which +// records when recap last ran successfully (advancing the cursor or +// touching the ref on a no-event run). Returns the zero time if the +// ref doesn't exist. +func (r *Repo) LastRecapAt() time.Time { + path := filepath.Join(r.GitDir, recapCursorRef) + info, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +// SetRecapCursor writes a commit hash to the recap cursor ref. +// Creates parent directories as needed. +func (r *Repo) SetRecapCursor(hash string) error { + path := filepath.Join(r.GitDir, recapCursorRef) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(hash+"\n"), 0644) +} + +// TouchRecapCursor bumps the mtime of the recap cursor ref to the +// current time without changing its content. Used to record that recap +// ran successfully even when no new events advanced the cursor, so that +// the "since last recap" header reflects the most recent run rather +// than the last cursor advance. +func (r *Repo) TouchRecapCursor() error { + path := filepath.Join(r.GitDir, recapCursorRef) + now := time.Now() + return os.Chtimes(path, now, now) +} diff --git a/internal/treefs/treefs.go b/internal/treefs/treefs.go index ba12460f..dc897415 100644 --- a/internal/treefs/treefs.go +++ b/internal/treefs/treefs.go @@ -480,8 +480,8 @@ func (t *TreeFS) casUpdateRef(newHash plumbing.Hash) error { // CAS check if currentRef.Hash() != t.baseRef { - return fmt.Errorf("conflict: ref %s has moved (expected %s, got %s)", - t.ref, t.baseRef.String()[:8], currentRef.Hash().String()[:8]) + return fmt.Errorf("%w: ref %s (expected %s, got %s)", + ErrRefMoved, t.ref, t.baseRef.String()[:8], currentRef.Hash().String()[:8]) } // Update ref @@ -793,6 +793,37 @@ func (t *TreeFS) AllCommits() ([]CommitInfo, error) { return commits, nil } +// CommitsSince returns all commits on the tracked ref newer than the given +// hash, newest-first. If sinceHash is zero, returns all commits. +func (t *TreeFS) CommitsSince(sinceHash string) ([]CommitInfo, error) { + if t.baseRef.IsZero() { + return nil, nil + } + var since plumbing.Hash + if sinceHash != "" { + since = plumbing.NewHash(sinceHash) + } + + var commits []CommitInfo + iter, err := t.repo.Log(&git.LogOptions{From: t.baseRef}) + if err != nil { + return nil, fmt.Errorf("walk commits: %w", err) + } + iter.ForEach(func(c *object.Commit) error { + if !since.IsZero() && c.Hash == since { + return storer.ErrStop + } + commits = append(commits, CommitInfo{ + Hash: c.Hash.String(), + Message: strings.TrimSpace(c.Message), + Time: c.Author.When, + Author: c.Author.Name, + }) + return nil + }) + return commits, nil +} + // RefHash returns the current hash of the tracked ref. func (t *TreeFS) RefHash() plumbing.Hash { return t.baseRef @@ -837,6 +868,10 @@ func (t *TreeFS) DeleteRef(name string) error { return t.repo.Storer.RemoveReference(plumbing.ReferenceName(name)) } +// ErrRefMoved is returned by Commit when the ref has moved since the TreeFS +// was opened, indicating a CAS conflict. Callers can check for this to retry. +var ErrRefMoved = fmt.Errorf("ref moved") + // CommitInfo holds a commit hash and message. type CommitInfo struct { Hash string diff --git a/internal/treefs/treefs_test.go b/internal/treefs/treefs_test.go index a6727899..81ae9fda 100644 --- a/internal/treefs/treefs_test.go +++ b/internal/treefs/treefs_test.go @@ -1,6 +1,7 @@ package treefs import ( + "errors" "os" "os/exec" "path/filepath" @@ -383,8 +384,8 @@ func TestCASConflict(t *testing.T) { if err == nil { t.Fatal("expected CAS conflict error") } - if !containsStr(err.Error(), "conflict") { - t.Fatalf("expected conflict error, got: %v", err) + if !errors.Is(err, ErrRefMoved) { + t.Fatalf("expected ErrRefMoved, got: %v", err) } } @@ -527,16 +528,3 @@ func TestCommitRespectsBWClockEnv(t *testing.T) { t.Errorf("commit time = %v, want %v", commits[0].Time, expected) } } - -func containsStr(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && contains(s, substr)) -} - -func contains(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/test/acceptance_test.go b/test/acceptance_test.go index 18e84be6..5cb2e58b 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/jallum/beadwork/internal/config" ) @@ -106,6 +107,22 @@ func (e *bwEnv) bw(args ...string) string { return stdout.String() } +// bwCapture runs a bw command and returns stdout + stderr separately. +func (e *bwEnv) bwCapture(args ...string) (string, string) { + e.t.Helper() + cmd := exec.Command(bwBin, args...) + cmd.Dir = e.dir + cmd.Env = e.env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + e.t.Fatalf("bw %s:\nstdout: %s\nstderr: %s\nerr: %v", + strings.Join(args, " "), stdout.String(), stderr.String(), err) + } + return stdout.String(), stderr.String() +} + // bwFail runs a bw command that is expected to fail. // Returns combined stdout+stderr. Fatals if the command succeeds. func (e *bwEnv) bwFail(args ...string) string { @@ -123,6 +140,44 @@ func (e *bwEnv) bwFail(args ...string) string { return stdout.String() + stderr.String() } +// bwAtClock runs bw with BW_CLOCK (and git commit-date envs) overridden. +// Returns stdout; fatals on non-zero exit. +func (e *bwEnv) bwAtClock(clock string, args ...string) string { + out, _ := e.bwAtClockCapture(clock, args...) + return out +} + +// bwAtClockCapture is bwAtClock that also returns stderr. +func (e *bwEnv) bwAtClockCapture(clock string, args ...string) (string, string) { + e.t.Helper() + cmd := exec.Command(bwBin, args...) + cmd.Dir = e.dir + overridden := make([]string, 0, len(e.env)+3) + for _, kv := range e.env { + switch { + case strings.HasPrefix(kv, "BW_CLOCK="), + strings.HasPrefix(kv, "GIT_AUTHOR_DATE="), + strings.HasPrefix(kv, "GIT_COMMITTER_DATE="): + continue + } + overridden = append(overridden, kv) + } + overridden = append(overridden, + "BW_CLOCK="+clock, + "GIT_AUTHOR_DATE="+clock, + "GIT_COMMITTER_DATE="+clock, + ) + cmd.Env = overridden + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + e.t.Fatalf("bw %s (clock=%s):\nstdout: %s\nstderr: %s\nerr: %v", + strings.Join(args, " "), clock, stdout.String(), stderr.String(), err) + } + return stdout.String(), stderr.String() +} + // bwAt runs bw from a custom directory instead of the default e.dir. func (e *bwEnv) bwAt(dir string, args ...string) string { e.t.Helper() @@ -244,6 +299,25 @@ func (e *bwEnv) registryPaths() []string { return cfg.StringSlice("registry.repos") } +// recapCursor reads the recap cursor ref from the git dir. +func (e *bwEnv) recapCursor() string { + e.t.Helper() + path := filepath.Join(e.dir, ".git", "refs", "beadwork", "recap-cursor") + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// recapCursorExists returns true if the recap cursor ref file exists. +func (e *bwEnv) recapCursorExists() bool { + e.t.Helper() + path := filepath.Join(e.dir, ".git", "refs", "beadwork", "recap-cursor") + _, err := os.Stat(path) + return err == nil +} + // TestScaffoldingHelpers verifies that the test scaffolding helpers work correctly. func TestScaffoldingHelpers(t *testing.T) { env := newBwEnv(t) @@ -422,6 +496,520 @@ func TestWorktreeRegistersSameAsMain(t *testing.T) { } } +// TestRecapEmpty verifies recap output when there's no activity. +func TestRecapEmpty(t *testing.T) { + env := newBwEnv(t) + out := env.bw("recap", "today") + if !strings.Contains(out, "nothing to report") { + t.Errorf("expected 'nothing to report' message:\n%s", out) + } +} + +// TestRecapWithEvents verifies recap groups events by issue. +func TestRecapWithEvents(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "Task Alpha", "--id", "re-1") + env.bw("start", "re-1") + env.bw("close", "re-1") + + out := env.bw("recap", "today") + if !strings.Contains(out, "re-1") { + t.Errorf("recap missing re-1:\n%s", out) + } + if !strings.Contains(out, "Task Alpha") { + t.Errorf("recap missing title 'Task Alpha':\n%s", out) + } +} + +// TestRecapJSON verifies JSON output format and scope field. +func TestRecapJSON(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "json target", "--id", "rj-1") + + out := env.bw("recap", "today", "--json") + if !strings.Contains(out, `"scope": "single"`) { + t.Errorf("recap --json missing scope=single:\n%s", out) + } + if !strings.Contains(out, `"rj-1"`) { + t.Errorf("recap --json missing issue id:\n%s", out) + } +} + +// TestRecapDryRun verifies --dry-run doesn't advance the cursor. +func TestRecapDryRun(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "dry", "--id", "dr-1") + + env.bw("recap", "today", "--dry-run") + // Cursor lives in refs/beadwork/recap-cursor — should not exist after --dry-run. + cmd := exec.Command("git", "show-ref", "--verify", "refs/beadwork/recap-cursor") + cmd.Dir = env.dir + if err := cmd.Run(); err == nil { + t.Error("--dry-run should not have set the recap cursor ref") + } +} + +// TestRecapCursorIsIncremental verifies that after a first recap, a second +// recap with no window flag only shows NEW events, not everything again. +func TestRecapCursorIsIncremental(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "first", "--id", "ic-1") + + // First recap advances the cursor. + out1 := env.bw("recap") + if !strings.Contains(out1, "ic-1") { + t.Fatalf("first recap missing ic-1:\n%s", out1) + } + + // Second recap with no new activity should report nothing. + out2 := env.bw("recap") + if strings.Contains(out2, "ic-1") { + t.Errorf("second recap should not re-report ic-1:\n%s", out2) + } + if !strings.Contains(out2, "nothing to report") { + t.Errorf("second recap should show 'nothing to report':\n%s", out2) + } + + // Create new activity — it must show up on the next recap. + env.bw("create", "second", "--id", "ic-2") + out3 := env.bw("recap") + if !strings.Contains(out3, "ic-2") { + t.Errorf("third recap missing new ic-2:\n%s", out3) + } + if strings.Contains(out3, "ic-1") { + t.Errorf("third recap should not re-show ic-1:\n%s", out3) + } +} + +// TestRecapStampsLastRecapAtWithNoCommits verifies that running recap with +// nothing new bumps the cursor ref's mtime so the "since last recap (Xh ago)" +// header reflects the most recent run, not the last cursor advance. Without +// this, a quiet stretch (no commits between runs) leaves the header growing +// unboundedly even though the user just ran recap. +func TestRecapStampsLastRecapAtWithNoCommits(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "x", "--id", "lr-1") + env.bw("recap") + + if !env.recapCursorExists() { + t.Fatalf("first recap did not set cursor ref") + } + cursor1 := env.recapCursor() + + // Simulate a quiet stretch: backdate the cursor file's mtime as if the + // last recap ran two hours ago. Using a real-time delta (not BW_CLOCK) + // because mtime is wall-clock filesystem state. + cursorPath := filepath.Join(env.dir, ".git", "refs", "beadwork", "recap-cursor") + backdate := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(cursorPath, backdate, backdate); err != nil { + t.Fatalf("chtimes backdate: %v", err) + } + info, err := os.Stat(cursorPath) + if err != nil { + t.Fatalf("stat after backdate: %v", err) + } + if time.Since(info.ModTime()) < time.Hour { + t.Fatalf("backdate did not stick: mtime %v is only %v ago", info.ModTime(), time.Since(info.ModTime())) + } + backdated := info.ModTime() + + // Run again with nothing new. Cursor value must not change, mtime must + // advance beyond the backdated time. + env.bw("recap") + if !env.recapCursorExists() { + t.Errorf("second recap lost cursor ref") + } + if env.recapCursor() != cursor1 { + t.Errorf("cursor changed with no new commits") + } + info2, err := os.Stat(cursorPath) + if err != nil { + t.Fatalf("stat after second recap: %v", err) + } + if !info2.ModTime().After(backdated) { + t.Errorf("cursor mtime did not advance on no-event recap: was %v, still %v", backdated, info2.ModTime()) + } + if time.Since(info2.ModTime()) > time.Minute { + t.Errorf("cursor mtime not recent after recap: %v ago", time.Since(info2.ModTime())) + } +} + +// TestRecapDryRunDoesNotStamp verifies --dry-run leaves the cursor ref alone. +func TestRecapDryRunDoesNotStamp(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "x", "--id", "dr-2") + + env.bw("recap", "--dry-run") + if env.recapCursorExists() { + t.Errorf("--dry-run should not create cursor ref") + } +} + +// TestRecapAdvancesCursor verifies that a non-dry-run recap advances the cursor. +func TestRecapAdvancesCursor(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "normal", "--id", "cr-1") + + env.bw("recap", "today") + if env.recapCursor() == "" { + t.Errorf("recap should advance cursor") + } +} + +// TestRecapSince verifies the --since flag. +func TestRecapSince(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "src", "--id", "sn-1") + + out := env.bw("recap", "--since", "2026-01-01") + if !strings.Contains(out, "sn-1") { + t.Errorf("recap --since missing event:\n%s", out) + } +} + +// TestRecapSinceInvalid verifies rejection of a bad --since value. +func TestRecapSinceInvalid(t *testing.T) { + env := newBwEnv(t) + out := env.bwFail("recap", "--since", "not-a-date") + if !strings.Contains(out, "invalid") { + t.Errorf("expected error for invalid --since:\n%s", out) + } +} + +// TestRecapASCII verifies that --ascii uses plain tree characters +// (only affects --verbose tree output). +func TestRecapASCII(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "ascii test", "--id", "as-1") + + out := env.bw("recap", "today", "--ascii", "--verbose") + // ASCII tree should use | and ` and -, not ├ └ │ + if strings.ContainsRune(out, '├') || strings.ContainsRune(out, '│') { + t.Errorf("--ascii output contains unicode box chars:\n%s", out) + } +} + +// TestRecapTodayLocalTimezone verifies that "today" honors the caller's +// local timezone, not UTC. Simulates a user at 1am US/Eastern (which is +// 5am UTC): work done the previous local evening (e.g. 10pm ET = 2am UTC +// "today" UTC) should fall into "today" local. +func TestRecapTodayLocalTimezone(t *testing.T) { + // Local wall clock: 2026-01-15 01:00:00 -0500 (EST). + // That's 2026-01-15 06:00:00 UTC — safely inside UTC "today" as well, + // but the start of local "today" is 2026-01-15 00:00:00 -0500 + // (= 2026-01-15 05:00:00 UTC), while start of UTC "today" would be + // 2026-01-15 00:00:00 UTC — a 5-hour difference. Seed an event at + // 2026-01-15 00:30:00 -0500 (= 05:30 UTC): inside local today, + // inside UTC today too. Then seed 2026-01-14 23:30:00 -0500 + // (= 2026-01-15 04:30:00 UTC): inside local *yesterday*, but inside + // UTC *today*. A TZ-correct "today" must EXCLUDE the second event. + envEarlyLocal := "2026-01-15T00:30:00-05:00" // inside local today + envLateYesterdayLocal := "2026-01-14T23:30:00-05:00" + + dir := t.TempDir() + cfgPathTZ := filepath.Join(t.TempDir(), ".bw") + baseEnv := append(os.Environ(), + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@test.com", + "NO_COLOR=1", + "BW_CONFIG="+cfgPathTZ, + ) + + run := func(clock string, args ...string) string { + cmd := exec.Command(bwBin, args...) + cmd.Dir = dir + cmd.Env = append(baseEnv, + "BW_CLOCK="+clock, + "GIT_AUTHOR_DATE="+clock, + "GIT_COMMITTER_DATE="+clock, + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("bw %s:\nstderr: %s\nerr: %v", + strings.Join(args, " "), stderr.String(), err) + } + return stdout.String() + } + gitRun := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = baseEnv + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %s: %v", strings.Join(args, " "), out, err) + } + } + + // Setup + gitRun("init") + gitRun("config", "user.email", "test@test.com") + gitRun("config", "user.name", "Test") + os.WriteFile(filepath.Join(dir, "README"), []byte("t"), 0644) + gitRun("add", ".") + gitRun("commit", "-m", "initial") + run("2026-01-15T00:00:00-05:00", "init", "--prefix", "tz") + + // Seed one issue "today local" and one "yesterday local, today UTC". + run(envLateYesterdayLocal, "create", "yday-local", "--id", "ytd-1") + run(envEarlyLocal, "create", "today-local", "--id", "tdy-1") + + // Now ask for today at 1am local on 2026-01-15. + out := run("2026-01-15T01:00:00-05:00", "recap", "today", "--dry-run") + + if !strings.Contains(out, "tdy-1") { + t.Errorf("today-local event missing from 'today' recap:\n%s", out) + } + if strings.Contains(out, "ytd-1") { + t.Errorf("yesterday-local event leaked into 'today' recap (UTC bug):\n%s", out) + } +} + +// TestRecapDurationToken verifies support for duration tokens like 1h, 15m. +func TestRecapDurationToken(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "recent", "--id", "dt-1") + + for _, token := range []string{"1h", "15m", "2d", "1w", "3h30m"} { + out := env.bw("recap", token) + if !strings.Contains(out, "dt-1") { + t.Errorf("recap %s missing event:\n%s", token, out) + } + } +} + +// TestRecapNoANSIWhenPiped verifies that piped output (non-TTY) has no +// ANSI escape sequences. LLM consumers (Claude Code, etc.) rely on this. +// Same treatment as `bw prime`. +func TestRecapNoANSIWhenPiped(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "noansi", "--id", "na-1") + env.bw("start", "na-1") + env.bw("close", "na-1") + + // env.bw() executes bw with stdout captured to a buffer → non-TTY. + out := env.bw("recap", "today") + if strings.ContainsRune(out, '\x1b') { + t.Errorf("recap piped output contains ANSI escape (\\x1b):\n%q", out) + } + if strings.Contains(out, "use --verbose") { + t.Errorf("recap piped output leaks TTY-only hint:\n%s", out) + } + + // Verbose must also be ANSI-free when piped. + vOut := env.bw("recap", "today", "--verbose") + if strings.ContainsRune(vOut, '\x1b') { + t.Errorf("recap --verbose piped output contains ANSI escape:\n%q", vOut) + } +} + +// TestRecapCondensedDefault verifies default output is condensed. +func TestRecapCondensedDefault(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "Task One", "--id", "co-1") + env.bw("start", "co-1") + env.bw("close", "co-1") + env.bw("comment", "co-1", "done") + + out := env.bw("recap", "today") + // Default should be one-line-per-issue (not full tree). + // So it should NOT contain unicode box chars or per-leaf timestamps. + if strings.ContainsRune(out, '├') { + t.Errorf("default output should not be a tree:\n%s", out) + } + // Should contain the issue, title, and a state hint ("closed"). + if !strings.Contains(out, "co-1") || !strings.Contains(out, "Task One") { + t.Errorf("condensed output missing id/title:\n%s", out) + } + if !strings.Contains(out, "closed") { + t.Errorf("condensed output should show 'closed' state:\n%s", out) + } + // Count lines — should be much shorter than verbose. + lines := strings.Count(out, "\n") + if lines > 5 { + t.Errorf("condensed output too long (%d lines):\n%s", lines, out) + } +} + +// TestRecapVerbose verifies --verbose gives the full tree. +func TestRecapVerbose(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "Task One", "--id", "vb-1") + env.bw("start", "vb-1") + env.bw("close", "vb-1") + + out := env.bw("recap", "today", "--verbose") + // Verbose should be a tree — one leaf per event. + if !strings.ContainsRune(out, '├') && !strings.ContainsRune(out, '└') { + t.Errorf("--verbose should render a tree:\n%s", out) + } + // Should contain each event type. + for _, ev := range []string{"create", "start", "close"} { + if !strings.Contains(out, ev) { + t.Errorf("--verbose missing event %q:\n%s", ev, out) + } + } +} + +// TestRecapVerboseShortFlag verifies -v is a shorthand for --verbose. +func TestRecapVerboseShortFlag(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "v test", "--id", "sv-1") + + out := env.bw("recap", "today", "-v") + if !strings.ContainsRune(out, '├') && !strings.ContainsRune(out, '└') { + t.Errorf("-v should render a tree:\n%s", out) + } +} + +// TestRecapFirstRecap24h verifies first-recap uses 24h backfill when no cursor. +func TestRecapFirstRecap24h(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "recent", "--id", "fr-1") + + out := env.bw("recap") + if !strings.Contains(out, "first recap") { + t.Errorf("first recap should show backfill label:\n%s", out) + } +} + +// TestRecapFromSubdir verifies recap walks up to find the repo. +func TestRecapFromSubdir(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "from sub", "--id", "sb-1") + + sub := filepath.Join(env.dir, "a", "b") + os.MkdirAll(sub, 0755) + + out := env.bwAt(sub, "recap", "today") + if !strings.Contains(out, "sb-1") { + t.Errorf("recap from subdir missing event:\n%s", out) + } +} + +// TestRecapNotInRepo verifies error when not in a git repo. +func TestRecapNotInRepo(t *testing.T) { + dir := t.TempDir() + env := &bwEnv{ + t: t, + dir: dir, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "BW_CONFIG="+filepath.Join(t.TempDir(), ".bw"), + "NO_COLOR=1", + ), + } + out := env.bwFail("recap") + if !strings.Contains(out, "not a git repository") { + t.Errorf("expected 'not a git repository' error:\n%s", out) + } +} + +// TestRecapHelp verifies the recap help output. +func TestRecapHelp(t *testing.T) { + env := newBwEnv(t) + out := env.bw("recap", "--help") + for _, flag := range []string{"--since", "--dry-run", "--all", "--json", "--ascii"} { + if !strings.Contains(out, flag) { + t.Errorf("recap help missing %s:\n%s", flag, out) + } + } +} + +// TestRecapInBwHelp verifies recap appears in top-level help. +func TestRecapInBwHelp(t *testing.T) { + env := newBwEnv(t) + out := env.bw("--help") + if !strings.Contains(out, "recap") { + t.Errorf("bw --help missing recap:\n%s", out) + } +} + +// TestRecapAllThreeHealthy verifies cross-repo recap over 3 healthy repos. +func TestRecapAllThreeHealthy(t *testing.T) { + envs := newMultiRepoEnv(t, 3) + + // Create activity in each repo. + envs[0].bw("create", "Alpha", "--id", "a-1") + envs[1].bw("create", "Beta", "--id", "b-1") + envs[2].bw("create", "Gamma", "--id", "g-1") + + out := envs[0].bw("recap", "today", "--all") + for _, id := range []string{"a-1", "b-1", "g-1"} { + if !strings.Contains(out, id) { + t.Errorf("cross-repo recap missing %s:\n%s", id, out) + } + } + if !strings.Contains(out, "3 repo") { + t.Errorf("expected '3 repo(s)' summary:\n%s", out) + } +} + +// TestRecapAllWarnsOnMissing verifies that missing repos warn on stderr +// and get skipped rather than failing. +func TestRecapAllWarnsOnMissing(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("create", "Real", "--id", "re-1") + + // Inject a nonexistent path into the registry. + envs[0].seedRegistry("/nonexistent/path") + + stdout, stderr := envs[0].bwCapture("recap", "today", "--all") + if !strings.Contains(stderr, "skipping") || !strings.Contains(stderr, "/nonexistent/path") { + t.Errorf("expected 'skipping' warning for missing repo on stderr:\n%s", stderr) + } + if !strings.Contains(stdout, "re-1") { + t.Errorf("healthy repo activity missing from stdout:\n%s", stdout) + } +} + +// TestRecapAllWarnsOnCFlag verifies that -C is warned about with --all. +func TestRecapAllWarnsOnCFlag(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("create", "A", "--id", "c-1") + + _, stderr := envs[0].bwCapture("-C", envs[0].dir, "recap", "today", "--all") + if !strings.Contains(stderr, "-C is ignored with --all") { + t.Errorf("expected '-C ignored' warning:\n%s", stderr) + } +} + +// TestRecapAllJSONScope verifies the --json shape has scope=cross. +func TestRecapAllJSONScope(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("create", "J", "--id", "j-1") + + out := envs[0].bw("recap", "today", "--all", "--json") + if !strings.Contains(out, `"scope": "cross"`) { + t.Errorf("cross-repo --json missing scope=cross:\n%s", out) + } + if !strings.Contains(out, `"repos"`) { + t.Errorf("cross-repo --json missing repos array:\n%s", out) + } +} + +// TestRecapAllAdvancesPerRepoCursors verifies each repo gets its own cursor advance. +func TestRecapAllAdvancesPerRepoCursors(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("create", "A", "--id", "ca-1") + envs[1].bw("create", "B", "--id", "cb-1") + + envs[0].bw("recap", "--all") + + // Both repos should now have a cursor ref. + cursors := 0 + for _, e := range envs { + if e.recapCursor() != "" { + cursors++ + } + } + if cursors != 2 { + t.Errorf("expected 2 cursors after recap --all, got %d", cursors) + } +} + // TestRegistryList verifies the registry list command shows registered repos. func TestRegistryList(t *testing.T) { env := newBwEnv(t) @@ -527,6 +1115,73 @@ func TestRegistryInBwHelp(t *testing.T) { } } +// TestCloseStampsUnblockedEvents verifies that closing an issue stamps +// "unblocked " lines into the commit message for each newly unblocked issue. +func TestCloseStampsUnblockedEvents(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "blocker", "--id", "bl-1") + env.bw("create", "blocked", "--id", "bl-2") + env.bw("dep", "add", "bl-1", "blocks", "bl-2") + + out := env.bw("close", "bl-1") + if !strings.Contains(out, "unblocked") { + t.Fatalf("close output missing unblocked info:\n%s", out) + } + + // Check the commit message on the beadwork branch. + log := env.git("log", "-1", "--format=%B", "beadwork") + if !strings.Contains(log, "unblocked bl-2") { + t.Errorf("close commit missing 'unblocked bl-2':\n%s", log) + } +} + +// TestCloseChainStampsOnlyRemainingDep verifies that when an issue has +// multiple blockers, closing one does NOT stamp an unblocked event for +// the still-blocked dependent. +func TestCloseChainStampsOnlyRemainingDep(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "blocker A", "--id", "ca-1") + env.bw("create", "blocker B", "--id", "ca-2") + env.bw("create", "dependent", "--id", "ca-3") + env.bw("dep", "add", "ca-1", "blocks", "ca-3") + env.bw("dep", "add", "ca-2", "blocks", "ca-3") + + // Close only ca-1; ca-3 still blocked by ca-2. + env.bw("close", "ca-1") + log := env.git("log", "-1", "--format=%B", "beadwork") + if strings.Contains(log, "unblocked ca-3") { + t.Errorf("close commit should NOT contain 'unblocked ca-3' (still blocked by ca-2):\n%s", log) + } + + // Now close ca-2; ca-3 should be unblocked. + env.bw("close", "ca-2") + log = env.git("log", "-1", "--format=%B", "beadwork") + if !strings.Contains(log, "unblocked ca-3") { + t.Errorf("close commit should contain 'unblocked ca-3':\n%s", log) + } +} + +// TestCloseReasonContainingUnblockedWord verifies that a close reason +// containing the word "unblocked" does not create a spurious unblocked event. +func TestCloseReasonContainingUnblockedWord(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "solo issue", "--id", "cu-1") + + env.bw("close", "cu-1", "--reason", "unblocked by external team") + log := env.git("log", "-1", "--format=%B", "beadwork") + // The reason appears in the first line (close intent), but there should be + // no second line matching "unblocked ". + lines := strings.Split(strings.TrimSpace(log), "\n") + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "unblocked ") && !strings.Contains(line, "=") { + // This looks like a stamped event, but it's just the reason text + // on the first line. Additional lines should not match. + t.Errorf("spurious unblocked line in commit: %q", line) + } + } +} + // TestWorktreeRefWrites verifies that bw operations run from inside a git // worktree write refs to the shared git dir, so tickets are visible from // the main checkout. @@ -572,3 +1227,74 @@ func TestWorktreeRefWrites(t *testing.T) { t.Fatalf("worktree commit not visible in beadwork log from main:\n%s", log) } } + +// TestRecapExplicitWindowGapSkipsAdvance verifies that an explicit window +// that starts AFTER the current cursor leaves the cursor untouched, and +// prints a gap notice on stderr naming the unrendered count. +func TestRecapExplicitWindowGapSkipsAdvance(t *testing.T) { + env := newBwEnv(t) + + const ( + day1 = "2026-04-10T09:00:00Z" + day2 = "2026-04-12T09:00:00Z" + day3 = "2026-04-14T12:00:00Z" + ) + + env.bwAtClock(day1, "create", "gap-origin", "--id", "gp-1") + env.bwAtClock(day1, "recap") + cursor1 := env.recapCursor() + if cursor1 == "" { + t.Fatalf("day1 bare recap did not stamp cursor") + } + + env.bwAtClock(day2, "create", "gap-middle", "--id", "gp-2") + + stdout, stderr := env.bwAtClockCapture(day3, "recap", "today") + + if strings.Contains(stdout, "gp-2") { + t.Errorf("explicit window should not render gap commit gp-2:\n%s", stdout) + } + if !strings.Contains(stderr, "1 commit older than this window") { + t.Errorf("expected gap notice on stderr, got:\n%s", stderr) + } + if !strings.Contains(stderr, "bw recap") { + t.Errorf("gap notice should reference 'bw recap':\n%s", stderr) + } + + cursor3 := env.recapCursor() + if cursor3 != cursor1 { + t.Errorf("gapped explicit run advanced cursor\n was: %s\n now: %s", cursor1, cursor3) + } +} + +// TestRecapExplicitWindowNoGapAdvances verifies that an explicit window that +// covers the full unseen range (window.Start <= cursor_time) advances the +// cursor to HEAD exactly like a bare recap. +func TestRecapExplicitWindowNoGapAdvances(t *testing.T) { + env := newBwEnv(t) + + const ( + day1 = "2026-04-14T03:00:00Z" + day2 = "2026-04-14T05:00:00Z" + now = "2026-04-14T12:00:00Z" + ) + + env.bwAtClock(day1, "create", "first", "--id", "ng-1") + env.bwAtClock(day1, "recap") + cursorBefore := env.recapCursor() + + env.bwAtClock(day2, "create", "second", "--id", "ng-2") + + stdout, stderr := env.bwAtClockCapture(now, "recap", "today") + if !strings.Contains(stdout, "ng-2") { + t.Errorf("no-gap explicit recap missing ng-2:\n%s", stdout) + } + if strings.Contains(stderr, "older than this window") { + t.Errorf("no-gap explicit recap should not emit gap notice:\n%s", stderr) + } + + cursorAfter := env.recapCursor() + if cursorAfter == cursorBefore { + t.Errorf("no-gap explicit recap should advance cursor") + } +}