From c5c54ac58a574ce0d1e89e9bf266d0eb02f10c91 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:03:32 +0530 Subject: [PATCH 1/6] feat(model): add review verdict field and text sanitizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Item.ReviewState carries the user's verdict on a reviewed PR (APPROVED / CHANGES_REQUESTED / COMMENTED / …). - CleanText strips ANSI/OSC escape sequences and control characters from externally-sourced text (commit subjects, PR titles) so a crafted message cannot inject terminal escapes into the picker, preview, or a worklog printed to the terminal. --- internal/model/item.go | 38 ++++++++++++++++++++++++++++++++++++- internal/model/item_test.go | 28 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 internal/model/item_test.go 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) + } + }) + } +} From 76152878acbb217e8f9ae8c9d4017f336a46f84a Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:04:12 +0530 Subject: [PATCH 2/6] fix(collect): fork-aware discovery, correct date handling, richer reviews GitHub discovery now queries every remote (not just origin), so PRs and reviews opened against an upstream parent in a fork workflow are found. - repoSlugs(): collect distinct owner/repo across all remotes, origin first - createdQualifier / updatedSinceQualifier: date authored PRs by created:, and PR-commits/reviews by a lower-bound updated: so in-range items that were updated again later aren't dropped - reviewDayInRange returns the review verdict; any review state counts - anchorMidnight pins relative --since words (today, yesterday, N days ago) to local midnight so "--since today" no longer misses earlier work - isNoiseSubject drops merge commits and git-stash entries - sanitize commit/PR titles via model.CleanText - announce each scan phase before it runs (count 0) so the UI can show the active phase, then report its result count Adds tests for the date qualifiers, slug parsing, and noise filtering. --- internal/collect/collect.go | 15 +- internal/collect/git.go | 45 ++++-- internal/collect/git_test.go | 33 +++- internal/collect/github.go | 266 +++++++++++++++++++------------- internal/collect/github_test.go | 67 ++++++++ 5 files changed, 300 insertions(+), 126 deletions(-) create mode 100644 internal/collect/github_test.go 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) + } + }) + } +} From 32a5ef595195cefc40f93c42b11668220c60cc68 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:04:36 +0530 Subject: [PATCH 3/6] feat(render,tui): group worklog by kind, show review verdict, livelier spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Markdown is grouped by date then by kind (Commits / Pull requests / Reviews), with a cleaner header and one-line summary. - Review entries show the verdict (approved / changes requested / …) and the PR's state, with a distinct icon per verdict. - Scanning spinner shows a completed-phase checklist, the phase currently running, and elapsed time + running total — so it never looks frozen, and the reviews phase is visible while it runs. Adds render tests for the verdict mapping and review line. --- internal/render/render.go | 81 ++++++++++++++++++++++++---------- internal/render/render_test.go | 47 ++++++++++++++++++++ internal/tui/loading.go | 49 +++++++++++++------- 3 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 internal/render/render_test.go 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() } From 84de9227c55262a49b101f7127b2e9cdb75dc96e Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:04:44 +0530 Subject: [PATCH 4/6] feat(app): guided first-run setup, actionable messages, --copy skips picker - First run with no repos launches a guided setup: scan common roots, show repo counts, pick one, optionally save it, and report gh auth. Re-runnable any time via --setup. - config gains ErrNoRepos, ScanCommonRoots, CountRepos, SaveRoot. - Clearer guidance when no repos are configured, nothing is found in range, or gh is missing/unauthenticated. - --copy now copies the whole worklog and skips the picker and editor, for a one-shot "scan and copy" flow. Adds config tests for CountRepos and SaveRoot. --- cmd/commit-chronicle/main.go | 6 ++- internal/app/app.go | 60 ++++++++++++++++++--- internal/app/setup.go | 99 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 81 ++++++++++++++++++++++++++-- internal/config/config_test.go | 56 +++++++++++++++++++ 5 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 internal/app/setup.go create mode 100644 internal/config/config_test.go 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/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/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) + } +} From aba20ff0ba33a46c4a96d5b174315c6862f04aa3 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:04:54 +0530 Subject: [PATCH 5/6] chore(security): add govulncheck CI job, SECURITY.md, and spell config - CI runs govulncheck on every push to catch known vulnerabilities in the module and its dependencies. - SECURITY.md documents the threat model (no shell interpolation, no credential handling, text sanitization) and how to report issues. - Stop .gitignore's *.md rule from hiding SECURITY.md. - cspell.json whitelists project-specific terms. --- .github/workflows/ci.yml | 13 +++++++++++++ .gitignore | 1 + SECURITY.md | 27 +++++++++++++++++++++++++++ cspell.json | 29 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 SECURITY.md create mode 100644 cspell.json 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/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/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/" + ] +} From db679a0f74b196768be1ad1e2667b53ee704a5bd Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Tue, 26 May 2026 11:05:01 +0530 Subject: [PATCH 6/6] docs(readme): quick start, reviews, fork behavior, and security - Quick start covering the guided first-run setup and --setup. - Pull requests & reviews section: reviews are on by default, fork-aware discovery, and how to enable via gh. - Security summary linking SECURITY.md. - Note --copy skips the picker. --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) 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).