diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d09062c..e492c47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,16 @@ jobs: - run: go vet ./... - run: go build ./... - run: go test -race ./... + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + # Scan dependencies and our own code for known Go vulnerabilities. + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... diff --git a/.gitignore b/.gitignore index e38f6a1..7b89367 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated reports *.md !README.md +!SECURITY.md # Temporary files temp_* diff --git a/README.md b/README.md index 13e2707..7a54e3e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,30 @@ Maintainers can produce all platform binaries with `make release` (output in --- +## Quick start + +```bash +commit-chronicle +``` + +On the **first run** with no repos configured, it walks you through a one-time +setup: it scans the usual places (`~/projects`, `~/work`, `~/code`, the current +folder, …), shows how many git repos each holds, lets you pick one, and offers +to remember it. It also checks whether `gh` is authenticated so PRs and reviews +can be included. + +After that, just run `commit-chronicle` from anywhere: + +```bash +commit-chronicle # pick range → pick items → edit → export +commit-chronicle --since "7 days ago" +commit-chronicle --date today --copy # also: yesterday, "3 days ago", etc. +``` + +> Re-run setup any time with `commit-chronicle --setup`. + +--- + ## Usage Run it inside a git repo, or configure repos/roots (below) to scan many at once: @@ -104,7 +128,7 @@ In the editor: `ctrl+s` save · `esc` cancel. --all select everything (skip the picker) --no-edit skip the editor step --no-pr skip GitHub PR + review discovery (git commits only) ---copy copy the worklog to the clipboard +--copy copy the whole worklog to the clipboard (skips the picker) -h, --help show help ``` @@ -135,12 +159,41 @@ file format. --- +## Pull requests & reviews + +PRs and reviews are **included by default** — there's no flag to turn them on. +All you need is the GitHub CLI, authenticated once: + +```bash +gh auth login # one-time +gh auth status # verify +``` + +With that in place, every run gathers, alongside your commits: + +- pull requests **you authored** (tag `PR`) +- pull requests **you reviewed** (tag `review`, dated by your review) +- commits on your PRs that the plain author match might miss + +**Fork workflows just work.** If you push to your own `origin` fork but open +PRs and submit reviews against an `upstream` parent, discovery queries *every* +remote — so your reviews on the upstream repo are found automatically. + +Pass `--no-pr` if you ever want commits only. No `gh` installed (or not +authenticated) also falls back to git-only, with a one-line note telling you how +to enable PRs/reviews. + +--- + ## How it works - **Commits** come from `git log --all --author=` across every ref. - **PRs / reviews** come from `gh` (the GitHub CLI). It lists your PRs in the window, then fetches commit/review details per-PR — GitHub searches are date-bounded so it only inspects PRs that could fall in range. +- **Fork-aware:** discovery follows *every* remote of a repo, not just + `origin`. In a fork workflow you push to your `origin` fork but open PRs and + submit reviews against the `upstream` parent, so both are queried. - Everything is keyed by hash (commits) or repo+number (PRs) and de-duplicated, so a commit that shows up both in history and on a PR appears once. - Output is grouped by date; commits, PRs and reviews each render as distinct, @@ -156,6 +209,13 @@ No `gh`, or pass `--no-pr`, and it runs git-only. - **gh**, authenticated (`gh auth login`) — optional, for PR & review discovery - a clipboard tool for `--copy`: `pbcopy` (macOS), `wl-copy` or `xclip` (Linux) +## Security + +All `git`/`gh` calls use an explicit argument vector (no shell), `gh` handles +GitHub auth (no tokens touched here), and commit/PR text is stripped of terminal +escape sequences before display. CI runs `govulncheck` on every push. See +[SECURITY.md](SECURITY.md) for details and how to report issues. + ## License See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..91377a3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security + +## Reporting a vulnerability + +Please report security issues privately via +[GitHub Security Advisories](https://github.com/ashishxcode/commit-chronicle/security/advisories/new) +rather than opening a public issue. We aim to acknowledge reports within a few +days. + +## Design notes + +commit-chronicle is a local, single-user CLI. It reads your own repositories and +talks to GitHub through the `gh` CLI you have already authenticated. With that in +mind: + +- **No shell interpolation.** All `git`/`gh` invocations use `exec.Command` with + an explicit argument vector — never a shell — so repo paths, author names, PR + numbers, and date strings cannot be used for command injection. +- **No credential handling.** GitHub authentication is delegated entirely to + `gh`; this tool never reads, stores, or transmits tokens. +- **Untrusted text is sanitized.** Commit messages and PR titles come from other + people. Before they are shown in the picker, preview, or a worklog printed to + the terminal, `model.CleanText` strips ANSI/terminal escape sequences and + control characters so a crafted message cannot hijack your terminal. +- **Dependency scanning.** CI runs + [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) on every + push to catch known vulnerabilities in the module and its dependencies. diff --git a/cmd/commit-chronicle/main.go b/cmd/commit-chronicle/main.go index 767ec35..32bc6bc 100644 --- a/cmd/commit-chronicle/main.go +++ b/cmd/commit-chronicle/main.go @@ -53,7 +53,8 @@ func parseFlags() (*app.Config, error) { fs.BoolVar(&c.NoEdit, "no-edit", false, "skip the editor step") fs.BoolVar(&c.All, "all", false, "select everything (skip the picker)") fs.BoolVar(&c.NoPR, "no-pr", false, "skip GitHub PR + review discovery") - fs.BoolVar(&c.Copy, "copy", false, "copy the worklog to the clipboard") + fs.BoolVar(&c.Copy, "copy", false, "copy the whole worklog to the clipboard (skips the picker)") + fs.BoolVar(&c.Setup, "setup", false, "re-run the guided repo setup") fs.Usage = usage if err := fs.Parse(os.Args[1:]); err != nil { return nil, err @@ -94,7 +95,8 @@ OPTIONS: --all select everything (skip the picker) --no-edit skip the editor step --no-pr skip GitHub PR + review discovery (git commits only) - --copy copy the worklog to the clipboard + --copy copy the whole worklog to the clipboard (skips the picker) + --setup re-run the guided repo setup (runs automatically on first use) -h, --help show this help REPO CONFIG (unioned): --repos · --root · ./.commit-chronicle · diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..06d28eb --- /dev/null +++ b/cspell.json @@ -0,0 +1,29 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "ashishxcode", + "bubbletea", + "charmbracelet", + "fzf", + "GOBIN", + "GOPATH", + "gum", + "isatty", + "lipgloss", + "mattn", + "pbcopy", + "unioned", + "usr", + "worklog", + "xclip", + "xdg", + "Culture" + ], + "ignorePaths": [ + "go.sum", + "go.mod", + "dist/", + "bin/" + ] +} diff --git a/internal/app/app.go b/internal/app/app.go index f2aaa41..ab5757d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( + "errors" "fmt" "os" "os/exec" @@ -23,6 +24,7 @@ type Config struct { Author, User, Repos, Root string Out, Format string NoEdit, All, Copy, NoPR bool + Setup bool // force the guided first-run setup } // Run executes the whole pipeline. @@ -31,7 +33,31 @@ func Run(c Config) error { return fmt.Errorf("git is required but was not found on PATH") } - repos, err := config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root)) + interactive := isTerminal() + + // --setup forces the guided flow regardless of existing config. + if c.Setup && !interactive { + return fmt.Errorf("--setup needs an interactive terminal") + } + + ranSetup := false + var repos []string + var err error + if c.Setup { + repos, err = firstRunSetup() + ranSetup = true + } else { + repos, err = config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root)) + if errors.Is(err, config.ErrNoRepos) { + if !interactive { + return fmt.Errorf("no repositories configured.\n" + + " Point at repos with --repos or --root , or configure\n" + + " ~/.config/commit-chronicle/{repos,roots}. Run in a terminal for guided setup.") + } + repos, err = firstRunSetup() + ranSetup = true + } + } if err != nil { return err } @@ -44,8 +70,6 @@ func Run(c Config) error { return fmt.Errorf("could not determine author; pass --author \"Your Name\"") } - interactive := isTerminal() - rng, err := resolveRange(c, interactive) if err != nil { return err @@ -56,6 +80,15 @@ func Run(c Config) error { if ghUser == "" && !c.NoPR && collect.HasGH() { ghUser = ghLogin() } + // If PRs/reviews were wanted but gh can't provide them, say so once (setup + // already reports gh status, so skip the note right after first-run setup). + if !c.NoPR && !ranSetup && ghUser == "" { + if !collect.HasGH() { + fmt.Fprintln(os.Stderr, "ℹ️ gh not found — commits only (install gh + `gh auth login` for PRs & reviews).") + } else { + fmt.Fprintln(os.Stderr, "ℹ️ gh not authenticated — commits only (run `gh auth login` for PRs & reviews).") + } + } opts := collect.Options{ Repos: repos, @@ -85,12 +118,25 @@ func Run(c Config) error { return err } if len(items) == 0 { - return fmt.Errorf("nothing found for \"%s\" in range (%s)", author, rng.Label) + var b strings.Builder + fmt.Fprintf(&b, "nothing found for \"%s\" in range (%s)\n", author, rng.Label) + b.WriteString(" • try a wider range, e.g. --since \"30 days ago\"\n") + b.WriteString(" • check the name matches your commits: --author \"Your Name\"\n") + switch { + case c.NoPR: + b.WriteString(" • drop --no-pr to include PRs & reviews") + case ghUser == "": + b.WriteString(" • authenticate gh (`gh auth login`) to include PRs & reviews") + default: + b.WriteString(" • PR/review activity may live on a different remote — those are scanned automatically") + } + return fmt.Errorf("%s", b.String()) } - // Pick + // Pick. --all and --copy both take everything; --copy is meant to be a + // one-shot "scan and copy the lot", so it skips the picker entirely. selected := items - if !c.All { + if !c.All && !c.Copy { if !interactive { return fmt.Errorf("no TTY for the picker; re-run with --all or in a terminal") } @@ -112,7 +158,7 @@ func Run(c Config) error { content = render.JSON(selected, meta) } else { content = render.Markdown(selected, meta) - if !c.NoEdit && interactive { + if !c.NoEdit && interactive && !c.Copy { edited, canceled, err := tui.Edit(content) if err != nil { return err diff --git a/internal/app/setup.go b/internal/app/setup.go new file mode 100644 index 0000000..bf15969 --- /dev/null +++ b/internal/app/setup.go @@ -0,0 +1,99 @@ +package app + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/ashishxcode/commit-chronicle/internal/collect" + "github.com/ashishxcode/commit-chronicle/internal/config" + "github.com/ashishxcode/commit-chronicle/internal/tui" +) + +const manualLabel = "📁 Enter a path manually…" + +// firstRunSetup is invoked when no repos are configured and we have a TTY. It +// finds likely repo locations, lets the user pick one, optionally remembers it +// for next time, and returns the resolved repo paths to scan now. +func firstRunSetup() ([]string, error) { + fmt.Fprintln(os.Stderr, "👋 Setup — let's find your git repositories.") + fmt.Fprintln(os.Stderr, " (runs automatically on first use; re-run any time with `commit-chronicle --setup`)") + fmt.Fprintln(os.Stderr) + + cands := config.ScanCommonRoots() + + var options []string + for _, c := range cands { + options = append(options, fmt.Sprintf("%s (%d repo%s)", c.Path, c.Count, plural(c.Count))) + } + options = append(options, manualLabel) + + idx, canceled, err := tui.Choose("📂 Where are your repositories?", options) + if err != nil { + return nil, err + } + if canceled { + return nil, fmt.Errorf("setup canceled") + } + + var root string + if idx < len(cands) { + root = cands[idx].Path + } else { + root, err = promptPath("Path to a folder containing your repos (e.g. ~/work): ") + if err != nil { + return nil, err + } + if root == "" { + return nil, fmt.Errorf("setup canceled") + } + if n := config.CountRepos(root); n == 0 { + fmt.Fprintf(os.Stderr, "⚠️ no git repos found under %s — continuing anyway.\n", root) + } + } + + // Resolve the repos under the chosen root now. + repos, err := config.ResolveRepos(nil, []string{root}) + if err != nil { + return nil, fmt.Errorf("scanning %s: %w", root, err) + } + + // Offer to remember the choice. + saveIdx, canceled, err := tui.Choose( + fmt.Sprintf("💾 Remember %s for next time?", root), + []string{"Yes — save it", "No — just this once"}) + if err == nil && !canceled && saveIdx == 0 { + if path, err := config.SaveRoot(root); err == nil { + fmt.Fprintf(os.Stderr, "✅ saved to %s\n", path) + } else { + fmt.Fprintf(os.Stderr, "⚠️ could not save config: %v\n", err) + } + } + + reportGHStatus() + fmt.Fprintln(os.Stderr) + return repos, nil +} + +// promptPath reads a single line of input, trimmed. Empty means cancel. +func promptPath(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", nil + } + return strings.TrimSpace(line), nil +} + +// reportGHStatus prints whether PR/review discovery is available via gh. +func reportGHStatus() { + switch { + case !collect.HasGH(): + fmt.Fprintln(os.Stderr, "ℹ️ gh (GitHub CLI) not found — commits only. Install it + run `gh auth login` to include PRs & reviews.") + case ghLogin() == "": + fmt.Fprintln(os.Stderr, "ℹ️ gh is installed but not authenticated — run `gh auth login` to include PRs & reviews.") + default: + fmt.Fprintln(os.Stderr, "✅ gh authenticated — PRs & reviews will be included.") + } +} diff --git a/internal/collect/collect.go b/internal/collect/collect.go index 5c6a725..d18e015 100644 --- a/internal/collect/collect.go +++ b/internal/collect/collect.go @@ -39,22 +39,29 @@ func Gather(o Options, p Progress) ([]model.Item, error) { } } + // Each phase is announced (count 0) before it runs so the UI can show what + // is currently being scanned — the GitHub phases, reviews especially, can + // take a while — then reported again with its result count when it finishes. + report("git history", 0) items := gitCommits(o.Repos, o.Author, o.Range) - report("git commits", len(items)) + report("git history", len(items)) useGH := o.User != "" && hasGH() if useGH && o.IncludePRs { + report("commits on your PRs", 0) pc := prCommits(o.Repos, o.User, o.Range) - report("PR commits", len(pc)) + report("commits on your PRs", len(pc)) items = append(items, pc...) + report("PRs you authored", 0) ap := authoredPRs(o.Repos, o.User, o.Range) - report("authored PRs", len(ap)) + report("PRs you authored", len(ap)) items = append(items, ap...) } if useGH && o.IncludeReviews { + report("PRs you reviewed", 0) rp := reviewedPRs(o.Repos, o.User, o.Range) - report("reviewed PRs", len(rp)) + report("PRs you reviewed", len(rp)) items = append(items, rp...) } diff --git a/internal/collect/git.go b/internal/collect/git.go index d2c2464..45ff7bc 100644 --- a/internal/collect/git.go +++ b/internal/collect/git.go @@ -4,22 +4,46 @@ import ( "os/exec" "path/filepath" "strings" - "time" "github.com/ashishxcode/commit-chronicle/internal/model" ) const fieldSep = "\x1f" // ASCII unit separator -// anchorMidnight pins a bare YYYY-MM-DD to local midnight. git's --since/--until -// parse a bare date via approxidate, which fills the missing time with the -// *current* time of day — skewing every window by the wall clock. Relative -// strings (e.g. "7 days ago") are passed through untouched. +// isNoiseSubject reports whether a commit subject is mechanical noise that +// shouldn't appear in a worklog: merge commits and git-stash entries. (Plain +// git history is already filtered with --no-merges; this also covers commits +// pulled in from PRs, and stash refs that slip in via `git log --all`.) +func isNoiseSubject(s string) bool { + s = strings.TrimSpace(s) + for _, p := range []string{ + "Merge branch ", "Merge remote-tracking ", "Merge pull request ", + "index on ", "WIP on ", + } { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + +// anchorMidnight pins a date or relative day-phrase to local midnight. git's +// --since/--until parse their argument via approxidate, which fills a missing +// time-of-day with the *current* wall-clock time — so a bare YYYY-MM-DD, +// "today", "yesterday", or "7 days ago" all silently skew the window by the +// time of day (e.g. "--since today" excludes everything committed earlier +// today). Appending an explicit 00:00:00 anchors to the start of the day, +// matching the --date=short granularity we report. Strings that already carry +// a time component (a ":" or the "midnight"/"noon" keywords) are left as-is. func anchorMidnight(when string) string { - if _, err := time.Parse("2006-01-02", when); err == nil { - return when + " 00:00:00" + if when == "" { + return when } - return when + w := strings.ToLower(when) + if strings.Contains(w, ":") || strings.Contains(w, "midnight") || strings.Contains(w, "noon") { + return when + } + return when + " 00:00:00" } // gitCommits returns de-duplicated commits authored by `author` in the range, @@ -55,6 +79,9 @@ func gitCommits(repos []string, author string, r model.Range) []model.Item { if len(p) != 4 { continue } + if isNoiseSubject(p[3]) { + continue + } url := "" if base != "" { url = base + "/commit/" + p[1] @@ -67,7 +94,7 @@ func gitCommits(repos []string, author string, r model.Range) []model.Item { URL: url, Hash: p[1], ShortHash: p[0], - Title: p[3], + Title: model.CleanText(p[3]), }) } } diff --git a/internal/collect/git_test.go b/internal/collect/git_test.go index 40073ed..565f06b 100644 --- a/internal/collect/git_test.go +++ b/internal/collect/git_test.go @@ -2,6 +2,32 @@ package collect import "testing" +func TestIsNoiseSubject(t *testing.T) { + noise := []string{ + "Merge branch 'development' into feature", + "Merge remote-tracking branch 'origin/main'", + "Merge pull request #42 from foo/bar", + "index on main: abc123 do thing", + "WIP on feature: abc123 in progress", + } + for _, s := range noise { + if !isNoiseSubject(s) { + t.Errorf("isNoiseSubject(%q) = false, want true", s) + } + } + real := []string{ + "fix: redirect external LinkTracker URLs", + "feat: add createMutation factory", + "Mergesort optimization", // not a merge commit + "index page layout fix", // not a stash + } + for _, s := range real { + if isNoiseSubject(s) { + t.Errorf("isNoiseSubject(%q) = true, want false", s) + } + } +} + func TestAnchorMidnight(t *testing.T) { tests := []struct { name string @@ -10,10 +36,13 @@ func TestAnchorMidnight(t *testing.T) { }{ {"bare ISO date gets midnight", "2026-05-24", "2026-05-24 00:00:00"}, {"month rollover date", "2026-02-01", "2026-02-01 00:00:00"}, - {"relative string untouched", "7 days ago", "7 days ago"}, - {"yesterday keyword untouched", "yesterday", "yesterday"}, + {"relative string anchored", "7 days ago", "7 days ago 00:00:00"}, + {"today keyword anchored", "today", "today 00:00:00"}, + {"yesterday keyword anchored", "yesterday", "yesterday 00:00:00"}, {"empty untouched", "", ""}, {"date with time untouched", "2026-05-24 12:00:00", "2026-05-24 12:00:00"}, + {"midnight keyword untouched", "midnight", "midnight"}, + {"noon keyword untouched", "noon", "noon"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/collect/github.go b/internal/collect/github.go index 4870700..8a921c8 100644 --- a/internal/collect/github.go +++ b/internal/collect/github.go @@ -3,6 +3,7 @@ package collect import ( "encoding/json" "os/exec" + "sort" "strconv" "strings" "time" @@ -49,36 +50,33 @@ func prCommits(repos []string, user string, r model.Range) []model.Item { since, until := parseDay(r.Since), parseDay(r.Until) var items []model.Item for _, repo := range repos { - slug := repoSlug(repo) - if slug == "" { - continue - } - name := slug[strings.Index(slug, "/")+1:] - base := originURL(repo) - for _, n := range listPRNumbers(slug, "author:"+user+dateQualifier(r)) { - for _, c := range viewPRCommits(slug, n) { - day, ok := isoDay(c.AuthoredDate) - if !ok || !inRange(day, since, until) { - continue + for _, slug := range repoSlugs(repo) { + name := slug[strings.Index(slug, "/")+1:] + base := "https://github.com/" + slug + for _, n := range listPRNumbers(slug, "author:"+user+updatedSinceQualifier(r)) { + for _, c := range viewPRCommits(slug, n) { + day, ok := isoDay(c.AuthoredDate) + if !ok || !inRange(day, since, until) { + continue + } + if isNoiseSubject(c.MessageHeadline) { + continue + } + short := c.OID + if len(short) > 8 { + short = short[:8] + } + items = append(items, model.Item{ + Kind: model.KindCommit, + Date: day.Format("2006-01-02"), + RepoName: name, + RepoPath: repo, + URL: base + "/commit/" + c.OID, + Hash: c.OID, + ShortHash: short, + Title: model.CleanText(c.MessageHeadline), + }) } - short := c.OID - if len(short) > 8 { - short = short[:8] - } - url := "" - if base != "" { - url = base + "/commit/" + c.OID - } - items = append(items, model.Item{ - Kind: model.KindCommit, - Date: day.Format("2006-01-02"), - RepoName: name, - RepoPath: repo, - URL: url, - Hash: c.OID, - ShortHash: short, - Title: c.MessageHeadline, - }) } } } @@ -90,36 +88,34 @@ func authoredPRs(repos []string, user string, r model.Range) []model.Item { since, until := parseDay(r.Since), parseDay(r.Until) var items []model.Item for _, repo := range repos { - slug := repoSlug(repo) - if slug == "" { - continue - } - name := slug[strings.Index(slug, "/")+1:] - raw, err := exec.Command("gh", "pr", "list", "-R", slug, - "--search", "author:"+user+dateQualifier(r), "--state", "all", "--limit", "200", - "--json", "number,title,state,url,createdAt").Output() - if err != nil { - continue - } - var prs []ghNum - if json.Unmarshal(raw, &prs) != nil { - continue - } - for _, pr := range prs { - day, ok := isoDay(pr.CreatedAt) - if !ok || !inRange(day, since, until) { + for _, slug := range repoSlugs(repo) { + name := slug[strings.Index(slug, "/")+1:] + raw, err := exec.Command("gh", "pr", "list", "-R", slug, + "--search", "author:"+user+createdQualifier(r), "--state", "all", "--limit", "200", + "--json", "number,title,state,url,createdAt").Output() + if err != nil { continue } - items = append(items, model.Item{ - Kind: model.KindPR, - Date: day.Format("2006-01-02"), - RepoName: name, - RepoPath: repo, - URL: pr.URL, - Number: pr.Number, - State: pr.State, - Title: pr.Title, - }) + var prs []ghNum + if json.Unmarshal(raw, &prs) != nil { + continue + } + for _, pr := range prs { + day, ok := isoDay(pr.CreatedAt) + if !ok || !inRange(day, since, until) { + continue + } + items = append(items, model.Item{ + Kind: model.KindPR, + Date: day.Format("2006-01-02"), + RepoName: name, + RepoPath: repo, + URL: pr.URL, + Number: pr.Number, + State: pr.State, + Title: model.CleanText(pr.Title), + }) + } } } return items @@ -131,41 +127,40 @@ func reviewedPRs(repos []string, user string, r model.Range) []model.Item { since, until := parseDay(r.Since), parseDay(r.Until) var items []model.Item for _, repo := range repos { - slug := repoSlug(repo) - if slug == "" { - continue - } - name := slug[strings.Index(slug, "/")+1:] + for _, slug := range repoSlugs(repo) { + name := slug[strings.Index(slug, "/")+1:] - // List PRs the user reviewed (numbers + meta only — cheap). - raw, err := exec.Command("gh", "pr", "list", "-R", slug, - "--search", "reviewed-by:"+user+dateQualifier(r), "--state", "all", "--limit", "200", - "--json", "number,title,state,url").Output() - if err != nil { - continue - } - var prs []ghNum - if json.Unmarshal(raw, &prs) != nil { - continue - } - if len(prs) > maxPRs { - prs = prs[:maxPRs] - } - for _, pr := range prs { - day, ok := reviewDayInRange(slug, pr.Number, user, since, until) - if !ok { + // List PRs the user reviewed (numbers + meta only — cheap). + raw, err := exec.Command("gh", "pr", "list", "-R", slug, + "--search", "reviewed-by:"+user+updatedSinceQualifier(r), "--state", "all", "--limit", "200", + "--json", "number,title,state,url").Output() + if err != nil { continue } - items = append(items, model.Item{ - Kind: model.KindReview, - Date: day, - RepoName: name, - RepoPath: repo, - URL: pr.URL, - Number: pr.Number, - State: pr.State, - Title: pr.Title, - }) + var prs []ghNum + if json.Unmarshal(raw, &prs) != nil { + continue + } + if len(prs) > maxPRs { + prs = prs[:maxPRs] + } + for _, pr := range prs { + day, verdict, ok := reviewDayInRange(slug, pr.Number, user, since, until) + if !ok { + continue + } + items = append(items, model.Item{ + Kind: model.KindReview, + Date: day, + RepoName: name, + RepoPath: repo, + URL: pr.URL, + Number: pr.Number, + State: pr.State, + ReviewState: verdict, + Title: model.CleanText(pr.Title), + }) + } } } return items @@ -209,20 +204,23 @@ func viewPRCommits(slug string, number int) []ghCommit { return v.Commits } -// reviewDayInRange returns the date of the user's earliest in-range review on a PR. -func reviewDayInRange(slug string, number int, user string, since, until time.Time) (string, bool) { +// reviewDayInRange returns the date and verdict (APPROVED, CHANGES_REQUESTED, +// COMMENTED, …) of the user's earliest in-range review on a PR. Any review +// state counts — a "changes requested" review is as much a review as an +// approval — and the verdict reflects the dated (earliest in-range) review. +func reviewDayInRange(slug string, number int, user string, since, until time.Time) (string, string, bool) { raw, err := exec.Command("gh", "pr", "view", strconv.Itoa(number), "-R", slug, "--json", "reviews").Output() if err != nil { - return "", false + return "", "", false } var v struct { Reviews []ghReview `json:"reviews"` } if json.Unmarshal(raw, &v) != nil { - return "", false + return "", "", false } - best := "" + best, verdict := "", "" for _, rv := range v.Reviews { if !strings.EqualFold(rv.Author.Login, user) { continue @@ -233,20 +231,15 @@ func reviewDayInRange(slug string, number int, user string, since, until time.Ti } d := day.Format("2006-01-02") if best == "" || d < best { - best = d + best, verdict = d, rv.State } } - return best, best != "" + return best, verdict, best != "" } -// repoSlug returns "owner/repo" for a repo's GitHub origin, or "". -func repoSlug(repoPath string) string { - out, err := exec.Command("git", "-C", repoPath, "remote", "get-url", "origin").Output() - if err != nil { - return "" - } - url := strings.TrimSpace(string(out)) - url = strings.TrimSuffix(url, ".git") +// slugFromURL extracts a GitHub "owner/repo" from a remote URL, or "". +func slugFromURL(url string) string { + url = strings.TrimSuffix(strings.TrimSpace(url), ".git") switch { case strings.HasPrefix(url, "git@github.com:"): url = strings.TrimPrefix(url, "git@github.com:") @@ -261,21 +254,72 @@ func repoSlug(repoPath string) string { return "" } +// repoSlugs returns the distinct GitHub "owner/repo" slugs across all of a +// repo's remotes, origin first. Fork workflows push to a personal "origin" but +// open PRs and submit reviews against the "upstream" parent, so PR and review +// discovery must consider every remote — querying origin alone misses them. +func repoSlugs(repoPath string) []string { + out, err := exec.Command("git", "-C", repoPath, "remote").Output() + if err != nil { + return nil + } + remotes := strings.Fields(string(out)) + // Query origin first so its repo name drives display naming. + sort.SliceStable(remotes, func(i, j int) bool { + return remotes[i] == "origin" && remotes[j] != "origin" + }) + var slugs []string + seen := map[string]struct{}{} + for _, name := range remotes { + u, err := exec.Command("git", "-C", repoPath, "remote", "get-url", name).Output() + if err != nil { + continue + } + s := slugFromURL(string(u)) + if s == "" { + continue + } + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + slugs = append(slugs, s) + } + return slugs +} + // --- date helpers --------------------------------------------------------- -// dateQualifier returns a GitHub search suffix bounding PRs to the window by -// last-updated date, so we don't fan out gh calls over out-of-range PRs. Empty -// when Since isn't a concrete date (e.g. a relative "7 days ago"). -func dateQualifier(r model.Range) string { +// updatedSinceQualifier bounds a PR search to those touched on/after Since, to +// trim fan-out without dropping in-range items. Any PR carrying an in-range +// commit or review has updatedAt >= Since, so the lower bound is safe. We +// deliberately omit an upper bound: a PR reviewed (or committed to) within the +// window can be updated again afterward, and "updated:<=Until" would then +// wrongly hide it before the precise per-item date filtering runs. Empty when +// Since isn't a concrete date (e.g. a relative "7 days ago"). +func updatedSinceQualifier(r model.Range) string { if parseDay(r.Since).IsZero() { return "" } - if !parseDay(r.Until).IsZero() { - return " updated:" + r.Since + ".." + r.Until - } return " updated:>=" + r.Since } +// createdQualifier bounds an authored-PR search by creation date — exactly the +// field authoredPRs dates its items by, so both bounds are precise. Until is +// exclusive (next-day midnight); GitHub's created:A..B is inclusive, so the +// upper bound is the day before Until. Empty for a non-concrete Since. +func createdQualifier(r model.Range) string { + since, until := parseDay(r.Since), parseDay(r.Until) + if since.IsZero() { + return "" + } + if !until.IsZero() { + hi := until.AddDate(0, 0, -1).Format("2006-01-02") + return " created:" + r.Since + ".." + hi + } + return " created:>=" + r.Since +} + func parseDay(s string) time.Time { if s == "" || s == "now" { return time.Time{} diff --git a/internal/collect/github_test.go b/internal/collect/github_test.go new file mode 100644 index 0000000..8ea8545 --- /dev/null +++ b/internal/collect/github_test.go @@ -0,0 +1,67 @@ +package collect + +import ( + "testing" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +func TestSlugFromURL(t *testing.T) { + tests := []struct { + in, want string + }{ + {"git@github.com:ashishxcode/cx-saas-dashboard.git", "ashishxcode/cx-saas-dashboard"}, + {"https://github.com/CultureX-art/cx-saas-dashboard.git", "CultureX-art/cx-saas-dashboard"}, + {"https://github.com/owner/repo", "owner/repo"}, + {" git@github.com:owner/repo.git\n", "owner/repo"}, + {"git@gitlab.com:owner/repo.git", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := slugFromURL(tt.in); got != tt.want { + t.Errorf("slugFromURL(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestUpdatedSinceQualifier(t *testing.T) { + tests := []struct { + name string + r model.Range + want string + }{ + {"single day uses open lower bound", model.Range{Since: "2026-05-25", Until: "2026-05-26"}, " updated:>=2026-05-25"}, + {"open-ended range", model.Range{Since: "2026-05-01"}, " updated:>=2026-05-01"}, + {"relative since is empty", model.Range{Since: "7 days ago"}, ""}, + {"empty since is empty", model.Range{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updatedSinceQualifier(tt.r); got != tt.want { + t.Errorf("updatedSinceQualifier(%+v) = %q, want %q", tt.r, got, tt.want) + } + }) + } +} + +func TestCreatedQualifier(t *testing.T) { + tests := []struct { + name string + r model.Range + want string + }{ + // Until is exclusive (next-day midnight); the inclusive upper bound is Until-1. + {"single day is inclusive both ends", model.Range{Since: "2026-05-25", Until: "2026-05-26"}, " created:2026-05-25..2026-05-25"}, + {"multi-day window", model.Range{Since: "2026-05-01", Until: "2026-06-01"}, " created:2026-05-01..2026-05-31"}, + {"open-ended range", model.Range{Since: "2026-05-01"}, " created:>=2026-05-01"}, + {"relative since is empty", model.Range{Since: "7 days ago"}, ""}, + {"empty since is empty", model.Range{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := createdQualifier(tt.r); got != tt.want { + t.Errorf("createdQualifier(%+v) = %q, want %q", tt.r, got, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 173b130..1d34f50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "bufio" + "errors" "fmt" "io/fs" "os" @@ -12,6 +13,10 @@ import ( "strings" ) +// ErrNoRepos is returned by ResolveRepos when no repositories could be found +// from any source. Callers can detect it (errors.Is) to offer interactive setup. +var ErrNoRepos = errors.New("no repositories configured") + // skipDirs are never descended into during root discovery. var skipDirs = map[string]bool{ "node_modules": true, "vendor": true, ".Trash": true, @@ -59,9 +64,7 @@ func ResolveRepos(explicit, roots []string) ([]string, error) { } } if len(candidates) == 0 { - return nil, fmt.Errorf("no repos found\n" + - " configure via --root ~/work, --repos, ./.commit-chronicle,\n" + - " or ~/.config/commit-chronicle/{repos,roots}") + return nil, ErrNoRepos } // Validate + de-duplicate (preserve discovery order). @@ -112,6 +115,78 @@ func discoverRepos(root string) []string { return repos } +// RootCandidate is a directory found to contain git repositories during setup. +type RootCandidate struct { + Path string // absolute path + Count int // number of git repos discovered beneath it +} + +// commonRoots are the usual places developers keep their repos, probed during +// first-run setup. The current working directory's parent is added separately. +var commonRoots = []string{ + "~/projects", "~/work", "~/code", "~/dev", "~/src", "~/repos", + "~/Documents/GitHub", "~/github", "~/go/src", +} + +// ScanCommonRoots probes the usual repo locations (plus the current directory) +// and returns those that actually contain git repos, most-repos first. It's the +// menu source for interactive first-run setup. +func ScanCommonRoots() []RootCandidate { + roots := append([]string{}, commonRoots...) + if cwd, err := os.Getwd(); err == nil { + roots = append(roots, cwd, filepath.Dir(cwd)) + } + var out []RootCandidate + seen := map[string]bool{} + for _, r := range roots { + p := expand(r) + if seen[p] { + continue + } + seen[p] = true + if fi, err := os.Stat(p); err != nil || !fi.IsDir() { + continue + } + if n := len(discoverRepos(p)); n > 0 { + out = append(out, RootCandidate{Path: p, Count: n}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Count > out[j].Count }) + return out +} + +// CountRepos returns how many git repos live under dir (0 if none/invalid). +func CountRepos(dir string) int { return len(discoverRepos(expand(dir))) } + +// RootsConfigPath is where the persisted list of root directories lives. +func RootsConfigPath() string { return xdgPath("roots") } + +// SaveRoot appends a root directory to the roots config file (creating it and +// its parent dir as needed), skipping a duplicate entry. Returns the file path. +func SaveRoot(root string) (string, error) { + p := RootsConfigPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return "", err + } + root = expand(root) + if existing, ok := linesFromFile(p); ok { + for _, e := range existing { + if expand(e) == root { + return p, nil // already present + } + } + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return "", err + } + defer f.Close() + if _, err := fmt.Fprintln(f, root); err != nil { + return "", err + } + return p, nil +} + func xdgPath(name string) string { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e0aab72 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// mkRepo creates dir/.git so isGitRepo treats dir as a repo. +func mkRepo(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestCountRepos(t *testing.T) { + root := t.TempDir() + mkRepo(t, filepath.Join(root, "a")) + mkRepo(t, filepath.Join(root, "b")) + if err := os.MkdirAll(filepath.Join(root, "notarepo"), 0o755); err != nil { + t.Fatal(err) + } + if got := CountRepos(root); got != 2 { + t.Errorf("CountRepos = %d, want 2", got) + } + if got := CountRepos(filepath.Join(root, "does-not-exist")); got != 0 { + t.Errorf("CountRepos(missing) = %d, want 0", got) + } +} + +func TestSaveRootDedupes(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + root := filepath.Join(dir, "work") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + + p1, err := SaveRoot(root) + if err != nil { + t.Fatalf("SaveRoot: %v", err) + } + if _, err := SaveRoot(root); err != nil { // second save should be a no-op + t.Fatalf("SaveRoot (dup): %v", err) + } + + lines, ok := linesFromFile(p1) + if !ok || len(lines) != 1 { + t.Fatalf("roots file = %v (ok=%v), want exactly one entry", lines, ok) + } + if filepath.Clean(lines[0]) != filepath.Clean(root) { + t.Errorf("saved %q, want %q", lines[0], root) + } +} diff --git a/internal/model/item.go b/internal/model/item.go index 663d59e..bad7fe2 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -2,7 +2,18 @@ // the unified worklog Item and the date Range. package model -import "fmt" +import ( + "fmt" + "regexp" + "strings" +) + +// ansiEscape matches terminal escape sequences: CSI (ESC [ … final byte), +// OSC (ESC ] … terminated by BEL or ST), and simple two-byte escapes. These +// are stripped whole so no visible residue (e.g. "[31m") is left behind. +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]` + // CSI + `|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?` + // OSC … BEL or ST + `|\x1b[@-Z\\-_]`) // other single-char escapes // Kind distinguishes the sources that feed a worklog. type Kind int @@ -30,6 +41,10 @@ type Item struct { Number int State string // OPEN | MERGED | CLOSED + // Review-only: your verdict on the PR (APPROVED | CHANGES_REQUESTED | + // COMMENTED | DISMISSED), from the review we date this entry by. + ReviewState string + // Common payload: commit subject or PR title Title string } @@ -62,3 +77,24 @@ func (i Item) Ref() string { } return fmt.Sprintf("#%d", i.Number) } + +// CleanText strips control and escape characters from externally-sourced text +// (commit subjects, PR titles). Without this, a crafted commit message or PR +// title could inject terminal escape sequences — moving the cursor, hiding +// output, or rewriting the screen — when shown in the picker, the preview pane, +// or a worklog printed to the terminal. Tabs become spaces; all C0/C1 control +// characters (including ESC, CR and LF) are dropped; printable text is kept. +func CleanText(s string) string { + s = ansiEscape.ReplaceAllString(s, "") + cleaned := strings.Map(func(r rune) rune { + switch { + case r == '\t': + return ' ' + case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f: + return -1 + default: + return r + } + }, s) + return strings.TrimSpace(cleaned) +} diff --git a/internal/model/item_test.go b/internal/model/item_test.go new file mode 100644 index 0000000..1039627 --- /dev/null +++ b/internal/model/item_test.go @@ -0,0 +1,28 @@ +package model + +import "testing" + +func TestCleanText(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain text unchanged", "fix: add column button", "fix: add column button"}, + {"strips ANSI color sequence", "title\x1b[31mRED\x1b[0m", "titleRED"}, + {"strips cursor-move escape whole", "\x1b[2Joops", "oops"}, + {"strips OSC title-set sequence", "x\x1b]0;pwned\x07y", "xy"}, + {"drops newlines and CR", "line1\r\nline2", "line1line2"}, + {"tab becomes space", "a\tb", "a b"}, + {"trims surrounding space", " hi ", "hi"}, + {"drops DEL and C1", "x\x7f›y", "xy"}, + {"keeps unicode", "résumé ✓ 日本語", "résumé ✓ 日本語"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CleanText(tt.in); got != tt.want { + t.Errorf("CleanText(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index a52aae1..dc4392e 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -16,6 +16,22 @@ type Meta struct { RangeLabel string } +// reviewVerdict maps a review state to a display icon and label. +func reviewVerdict(state string) (icon, label string) { + switch state { + case "APPROVED": + return "✅", "approved" + case "CHANGES_REQUESTED": + return "🔴", "changes requested" + case "COMMENTED": + return "💬", "commented" + case "DISMISSED": + return "⊘", "dismissed" + default: + return "•", "reviewed" + } +} + func stateIcon(state string) string { switch state { case "MERGED": @@ -29,21 +45,25 @@ func stateIcon(state string) string { } } -// Markdown renders items grouped by date. Commits, PRs and reviews each get a -// distinct, link-bearing line so nothing is ambiguous. +// Markdown renders items grouped by date, then by kind within each day, so a +// reader sees a clean "commits / pull requests / reviews" breakdown per day. func Markdown(items []model.Item, m Meta) string { var b strings.Builder fmt.Fprintf(&b, "# 📓 Worklog — %s\n\n", m.RangeLabel) - fmt.Fprintf(&b, "> **Author:** %s \n", m.Author) - fmt.Fprintf(&b, "> **Generated:** %s \n", time.Now().Format("2006-01-02 15:04")) - fmt.Fprintf(&b, "> **Entries:** %d (%s)\n\n", len(items), counts(items)) - b.WriteString("\n") + fmt.Fprintf(&b, "**%s** · generated %s \n", m.Author, time.Now().Format("2006-01-02 15:04")) + fmt.Fprintf(&b, "%s\n", counts(items)) - cur := "" + // Walk the (already date-sorted, then kind-sorted) items, opening a new day + // section on date change and a kind subsection on kind change within a day. + curDate, curKind := "", model.Kind(-1) for _, it := range items { - if it.Date != cur { - cur = it.Date - fmt.Fprintf(&b, "\n## %s\n\n", it.Date) + if it.Date != curDate { + curDate, curKind = it.Date, model.Kind(-1) + fmt.Fprintf(&b, "\n## %s\n", it.Date) + } + if it.Kind != curKind { + curKind = it.Kind + fmt.Fprintf(&b, "\n### %s\n\n", kindHeading(it.Kind)) } b.WriteString(line(it)) } @@ -51,6 +71,17 @@ func Markdown(items []model.Item, m Meta) string { return b.String() } +func kindHeading(k model.Kind) string { + switch k { + case model.KindPR: + return "Pull requests" + case model.KindReview: + return "Reviews" + default: + return "Commits" + } +} + func line(it model.Item) string { link := func(text string) string { if it.URL != "" { @@ -60,11 +91,12 @@ func line(it model.Item) string { } switch it.Kind { case model.KindPR: - return fmt.Sprintf("- **PR %s** %s %s _(%s)_ — %s\n", - link(it.Ref()), stateIcon(it.State), it.Title, it.State, it.RepoName) + return fmt.Sprintf("- %s **%s** %s — %s · %s\n", + stateIcon(it.State), link(it.Ref()), it.Title, strings.ToLower(it.State), it.RepoName) case model.KindReview: - return fmt.Sprintf("- **Reviewed PR %s** %s %s _(%s)_ — %s\n", - link(it.Ref()), stateIcon(it.State), it.Title, it.State, it.RepoName) + icon, verdict := reviewVerdict(it.ReviewState) + return fmt.Sprintf("- %s **%s** %s — %s (PR %s) · %s\n", + icon, link(it.Ref()), it.Title, verdict, strings.ToLower(it.State), it.RepoName) default: // commit return fmt.Sprintf("- %s _(%s)_\n", it.Title, link(it.RepoName+"@"+it.ShortHash)) @@ -83,18 +115,19 @@ func counts(items []model.Item) string { r++ } } - return fmt.Sprintf("%d commits, %d PRs, %d reviews", c, p, r) + return fmt.Sprintf("%d commits · %d PRs · %d reviews · %d total", c, p, r, len(items)) } type jsonItem struct { - Kind string `json:"kind"` - Date string `json:"date"` - Repo string `json:"repo"` - Title string `json:"title"` - URL string `json:"url"` - Hash string `json:"hash,omitempty"` - Number int `json:"number,omitempty"` - State string `json:"state,omitempty"` + Kind string `json:"kind"` + Date string `json:"date"` + Repo string `json:"repo"` + Title string `json:"title"` + URL string `json:"url"` + Hash string `json:"hash,omitempty"` + Number int `json:"number,omitempty"` + State string `json:"state,omitempty"` + ReviewState string `json:"reviewState,omitempty"` } // JSON renders items as a JSON array. @@ -104,7 +137,7 @@ func JSON(items []model.Item, _ Meta) string { out = append(out, jsonItem{ Kind: it.Tag(), Date: it.Date, Repo: it.RepoName, Title: it.Title, URL: it.URL, Hash: it.Hash, - Number: it.Number, State: it.State, + Number: it.Number, State: it.State, ReviewState: it.ReviewState, }) } data, err := json.MarshalIndent(out, "", " ") diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..f3aee15 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,47 @@ +package render + +import ( + "strings" + "testing" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +func TestReviewVerdict(t *testing.T) { + tests := []struct { + state, wantLabel string + }{ + {"APPROVED", "approved"}, + {"CHANGES_REQUESTED", "changes requested"}, + {"COMMENTED", "commented"}, + {"DISMISSED", "dismissed"}, + {"", "reviewed"}, + {"WEIRD_STATE", "reviewed"}, + } + for _, tt := range tests { + if _, got := reviewVerdict(tt.state); got != tt.wantLabel { + t.Errorf("reviewVerdict(%q) label = %q, want %q", tt.state, got, tt.wantLabel) + } + } +} + +// A changes-requested review must render with its verdict (not the PR's +// merge state) so it is clearly included and distinguishable from approvals. +func TestReviewLineShowsVerdict(t *testing.T) { + it := model.Item{ + Kind: model.KindReview, + Date: "2026-05-22", + RepoName: "cx-saas-dashboard", + Number: 2319, + State: "OPEN", + ReviewState: "CHANGES_REQUESTED", + Title: "Fix: bucket group image removal", + } + got := line(it) + if !strings.Contains(got, "changes requested") { + t.Errorf("review line missing verdict: %q", got) + } + if !strings.Contains(got, "PR open") { + t.Errorf("review line missing PR state: %q", got) + } +} diff --git a/internal/tui/loading.go b/internal/tui/loading.go index acb6d29..29224bc 100644 --- a/internal/tui/loading.go +++ b/internal/tui/loading.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" "github.com/ashishxcode/commit-chronicle/internal/model" "github.com/charmbracelet/bubbles/spinner" @@ -23,13 +24,15 @@ type doneMsg struct { } type loadingModel struct { - sp spinner.Model - label string - stages []string - total int - done bool - items []model.Item - err error + sp spinner.Model + label string + done []string // completed phases, e.g. "git history: 87" + active string // phase currently running, e.g. "PRs you reviewed" + total int + start time.Time + finished bool + items []model.Item + err error } // RunWithSpinner shows an animated spinner with live progress while work runs @@ -39,7 +42,7 @@ func RunWithSpinner(label string, work WorkFn) ([]model.Item, error) { sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(cBlue) - m := loadingModel{sp: sp, label: label} + m := loadingModel{sp: sp, label: label, start: time.Now()} p := tea.NewProgram(m) go func() { @@ -62,13 +65,18 @@ func (m loadingModel) Init() tea.Cmd { return m.sp.Tick } func (m loadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case statusMsg: - if msg.n > 0 { - m.stages = append(m.stages, fmt.Sprintf("%s: %d", msg.stage, msg.n)) + if msg.n == 0 { + m.active = msg.stage // phase started + } else { + m.done = append(m.done, fmt.Sprintf("%s: %d", msg.stage, msg.n)) m.total += msg.n + if m.active == msg.stage { + m.active = "" + } } return m, nil case doneMsg: - m.done = true + m.finished = true m.items = msg.items m.err = msg.err return m, tea.Quit @@ -84,12 +92,21 @@ func (m loadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m loadingModel) View() string { - if m.done { + if m.finished { return "" // cleared once the picker takes over } - head := m.sp.View() + titleSty.Render(m.label) - if len(m.stages) == 0 { - return head + dimSty.Render(" …") + elapsed := time.Since(m.start).Truncate(time.Second) + var b strings.Builder + fmt.Fprintf(&b, "%s%s %s\n", m.sp.View(), titleSty.Render(m.label), + dimSty.Render(fmt.Sprintf("%s · %d found", elapsed, m.total))) + + // Completed phases as a check-list. + for _, d := range m.done { + b.WriteString(selSty.Render(" ✓ ") + dimSty.Render(d) + "\n") + } + // The phase currently running. + if m.active != "" { + b.WriteString(curSty.Render(" → scanning "+m.active) + dimSty.Render(" …")) } - return head + "\n" + dimSty.Render(" "+strings.Join(m.stages, " · ")) + return b.String() }