Skip to content

Commit 8afd57d

Browse files
authored
Merge pull request #7 from ashishxcode/feat/reviews-onboarding-ux
Fork-aware PR/review discovery, guided setup, and worklog UX
2 parents e72cbed + db679a0 commit 8afd57d

20 files changed

Lines changed: 923 additions & 180 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,16 @@ jobs:
1717
- run: go vet ./...
1818
- run: go build ./...
1919
- run: go test -race ./...
20+
21+
security:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: actions/setup-go@v5
26+
with:
27+
go-version: "1.24"
28+
# Scan dependencies and our own code for known Go vulnerabilities.
29+
- name: govulncheck
30+
run: |
31+
go install golang.org/x/vuln/cmd/govulncheck@latest
32+
govulncheck ./...

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Generated reports
22
*.md
33
!README.md
4+
!SECURITY.md
45

56
# Temporary files
67
temp_*

‎README.md‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ Maintainers can produce all platform binaries with `make release` (output in
6363

6464
---
6565

66+
## Quick start
67+
68+
```bash
69+
commit-chronicle
70+
```
71+
72+
On the **first run** with no repos configured, it walks you through a one-time
73+
setup: it scans the usual places (`~/projects`, `~/work`, `~/code`, the current
74+
folder, …), shows how many git repos each holds, lets you pick one, and offers
75+
to remember it. It also checks whether `gh` is authenticated so PRs and reviews
76+
can be included.
77+
78+
After that, just run `commit-chronicle` from anywhere:
79+
80+
```bash
81+
commit-chronicle # pick range → pick items → edit → export
82+
commit-chronicle --since "7 days ago"
83+
commit-chronicle --date today --copy # also: yesterday, "3 days ago", etc.
84+
```
85+
86+
> Re-run setup any time with `commit-chronicle --setup`.
87+
88+
---
89+
6690
## Usage
6791

6892
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.
104128
--all select everything (skip the picker)
105129
--no-edit skip the editor step
106130
--no-pr skip GitHub PR + review discovery (git commits only)
107-
--copy copy the worklog to the clipboard
131+
--copy copy the whole worklog to the clipboard (skips the picker)
108132
-h, --help show help
109133
```
110134

@@ -135,12 +159,41 @@ file format.
135159

136160
---
137161

162+
## Pull requests & reviews
163+
164+
PRs and reviews are **included by default** — there's no flag to turn them on.
165+
All you need is the GitHub CLI, authenticated once:
166+
167+
```bash
168+
gh auth login # one-time
169+
gh auth status # verify
170+
```
171+
172+
With that in place, every run gathers, alongside your commits:
173+
174+
- pull requests **you authored** (tag `PR`)
175+
- pull requests **you reviewed** (tag `review`, dated by your review)
176+
- commits on your PRs that the plain author match might miss
177+
178+
**Fork workflows just work.** If you push to your own `origin` fork but open
179+
PRs and submit reviews against an `upstream` parent, discovery queries *every*
180+
remote — so your reviews on the upstream repo are found automatically.
181+
182+
Pass `--no-pr` if you ever want commits only. No `gh` installed (or not
183+
authenticated) also falls back to git-only, with a one-line note telling you how
184+
to enable PRs/reviews.
185+
186+
---
187+
138188
## How it works
139189

140190
- **Commits** come from `git log --all --author=<you>` across every ref.
141191
- **PRs / reviews** come from `gh` (the GitHub CLI). It lists your PRs in the
142192
window, then fetches commit/review details per-PR — GitHub searches are
143193
date-bounded so it only inspects PRs that could fall in range.
194+
- **Fork-aware:** discovery follows *every* remote of a repo, not just
195+
`origin`. In a fork workflow you push to your `origin` fork but open PRs and
196+
submit reviews against the `upstream` parent, so both are queried.
144197
- Everything is keyed by hash (commits) or repo+number (PRs) and de-duplicated,
145198
so a commit that shows up both in history and on a PR appears once.
146199
- 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.
156209
- **gh**, authenticated (`gh auth login`) — optional, for PR & review discovery
157210
- a clipboard tool for `--copy`: `pbcopy` (macOS), `wl-copy` or `xclip` (Linux)
158211

212+
## Security
213+
214+
All `git`/`gh` calls use an explicit argument vector (no shell), `gh` handles
215+
GitHub auth (no tokens touched here), and commit/PR text is stripped of terminal
216+
escape sequences before display. CI runs `govulncheck` on every push. See
217+
[SECURITY.md](SECURITY.md) for details and how to report issues.
218+
159219
## License
160220

161221
See [LICENSE](LICENSE).

‎SECURITY.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Security
2+
3+
## Reporting a vulnerability
4+
5+
Please report security issues privately via
6+
[GitHub Security Advisories](https://github.com/ashishxcode/commit-chronicle/security/advisories/new)
7+
rather than opening a public issue. We aim to acknowledge reports within a few
8+
days.
9+
10+
## Design notes
11+
12+
commit-chronicle is a local, single-user CLI. It reads your own repositories and
13+
talks to GitHub through the `gh` CLI you have already authenticated. With that in
14+
mind:
15+
16+
- **No shell interpolation.** All `git`/`gh` invocations use `exec.Command` with
17+
an explicit argument vector — never a shell — so repo paths, author names, PR
18+
numbers, and date strings cannot be used for command injection.
19+
- **No credential handling.** GitHub authentication is delegated entirely to
20+
`gh`; this tool never reads, stores, or transmits tokens.
21+
- **Untrusted text is sanitized.** Commit messages and PR titles come from other
22+
people. Before they are shown in the picker, preview, or a worklog printed to
23+
the terminal, `model.CleanText` strips ANSI/terminal escape sequences and
24+
control characters so a crafted message cannot hijack your terminal.
25+
- **Dependency scanning.** CI runs
26+
[`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) on every
27+
push to catch known vulnerabilities in the module and its dependencies.

‎cmd/commit-chronicle/main.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ func parseFlags() (*app.Config, error) {
5353
fs.BoolVar(&c.NoEdit, "no-edit", false, "skip the editor step")
5454
fs.BoolVar(&c.All, "all", false, "select everything (skip the picker)")
5555
fs.BoolVar(&c.NoPR, "no-pr", false, "skip GitHub PR + review discovery")
56-
fs.BoolVar(&c.Copy, "copy", false, "copy the worklog to the clipboard")
56+
fs.BoolVar(&c.Copy, "copy", false, "copy the whole worklog to the clipboard (skips the picker)")
57+
fs.BoolVar(&c.Setup, "setup", false, "re-run the guided repo setup")
5758
fs.Usage = usage
5859
if err := fs.Parse(os.Args[1:]); err != nil {
5960
return nil, err
@@ -94,7 +95,8 @@ OPTIONS:
9495
--all select everything (skip the picker)
9596
--no-edit skip the editor step
9697
--no-pr skip GitHub PR + review discovery (git commits only)
97-
--copy copy the worklog to the clipboard
98+
--copy copy the whole worklog to the clipboard (skips the picker)
99+
--setup re-run the guided repo setup (runs automatically on first use)
98100
-h, --help show this help
99101
100102
REPO CONFIG (unioned): --repos · --root · ./.commit-chronicle ·

‎cspell.json‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"version": "0.2",
3+
"language": "en",
4+
"words": [
5+
"ashishxcode",
6+
"bubbletea",
7+
"charmbracelet",
8+
"fzf",
9+
"GOBIN",
10+
"GOPATH",
11+
"gum",
12+
"isatty",
13+
"lipgloss",
14+
"mattn",
15+
"pbcopy",
16+
"unioned",
17+
"usr",
18+
"worklog",
19+
"xclip",
20+
"xdg",
21+
"Culture"
22+
],
23+
"ignorePaths": [
24+
"go.sum",
25+
"go.mod",
26+
"dist/",
27+
"bin/"
28+
]
29+
}

‎internal/app/app.go‎

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package app
33

44
import (
5+
"errors"
56
"fmt"
67
"os"
78
"os/exec"
@@ -23,6 +24,7 @@ type Config struct {
2324
Author, User, Repos, Root string
2425
Out, Format string
2526
NoEdit, All, Copy, NoPR bool
27+
Setup bool // force the guided first-run setup
2628
}
2729

2830
// Run executes the whole pipeline.
@@ -31,7 +33,31 @@ func Run(c Config) error {
3133
return fmt.Errorf("git is required but was not found on PATH")
3234
}
3335

34-
repos, err := config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root))
36+
interactive := isTerminal()
37+
38+
// --setup forces the guided flow regardless of existing config.
39+
if c.Setup && !interactive {
40+
return fmt.Errorf("--setup needs an interactive terminal")
41+
}
42+
43+
ranSetup := false
44+
var repos []string
45+
var err error
46+
if c.Setup {
47+
repos, err = firstRunSetup()
48+
ranSetup = true
49+
} else {
50+
repos, err = config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root))
51+
if errors.Is(err, config.ErrNoRepos) {
52+
if !interactive {
53+
return fmt.Errorf("no repositories configured.\n" +
54+
" Point at repos with --repos <path,…> or --root <dir>, or configure\n" +
55+
" ~/.config/commit-chronicle/{repos,roots}. Run in a terminal for guided setup.")
56+
}
57+
repos, err = firstRunSetup()
58+
ranSetup = true
59+
}
60+
}
3561
if err != nil {
3662
return err
3763
}
@@ -44,8 +70,6 @@ func Run(c Config) error {
4470
return fmt.Errorf("could not determine author; pass --author \"Your Name\"")
4571
}
4672

47-
interactive := isTerminal()
48-
4973
rng, err := resolveRange(c, interactive)
5074
if err != nil {
5175
return err
@@ -56,6 +80,15 @@ func Run(c Config) error {
5680
if ghUser == "" && !c.NoPR && collect.HasGH() {
5781
ghUser = ghLogin()
5882
}
83+
// If PRs/reviews were wanted but gh can't provide them, say so once (setup
84+
// already reports gh status, so skip the note right after first-run setup).
85+
if !c.NoPR && !ranSetup && ghUser == "" {
86+
if !collect.HasGH() {
87+
fmt.Fprintln(os.Stderr, "ℹ️ gh not found — commits only (install gh + `gh auth login` for PRs & reviews).")
88+
} else {
89+
fmt.Fprintln(os.Stderr, "ℹ️ gh not authenticated — commits only (run `gh auth login` for PRs & reviews).")
90+
}
91+
}
5992

6093
opts := collect.Options{
6194
Repos: repos,
@@ -85,12 +118,25 @@ func Run(c Config) error {
85118
return err
86119
}
87120
if len(items) == 0 {
88-
return fmt.Errorf("nothing found for \"%s\" in range (%s)", author, rng.Label)
121+
var b strings.Builder
122+
fmt.Fprintf(&b, "nothing found for \"%s\" in range (%s)\n", author, rng.Label)
123+
b.WriteString(" • try a wider range, e.g. --since \"30 days ago\"\n")
124+
b.WriteString(" • check the name matches your commits: --author \"Your Name\"\n")
125+
switch {
126+
case c.NoPR:
127+
b.WriteString(" • drop --no-pr to include PRs & reviews")
128+
case ghUser == "":
129+
b.WriteString(" • authenticate gh (`gh auth login`) to include PRs & reviews")
130+
default:
131+
b.WriteString(" • PR/review activity may live on a different remote — those are scanned automatically")
132+
}
133+
return fmt.Errorf("%s", b.String())
89134
}
90135

91-
// Pick
136+
// Pick. --all and --copy both take everything; --copy is meant to be a
137+
// one-shot "scan and copy the lot", so it skips the picker entirely.
92138
selected := items
93-
if !c.All {
139+
if !c.All && !c.Copy {
94140
if !interactive {
95141
return fmt.Errorf("no TTY for the picker; re-run with --all or in a terminal")
96142
}
@@ -112,7 +158,7 @@ func Run(c Config) error {
112158
content = render.JSON(selected, meta)
113159
} else {
114160
content = render.Markdown(selected, meta)
115-
if !c.NoEdit && interactive {
161+
if !c.NoEdit && interactive && !c.Copy {
116162
edited, canceled, err := tui.Edit(content)
117163
if err != nil {
118164
return err

0 commit comments

Comments
 (0)