-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathgithub.go
More file actions
1513 lines (1409 loc) · 51.2 KB
/
github.go
File metadata and controls
1513 lines (1409 loc) · 51.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// bodyHashAtPush returns a short stable digest of a comment body. We use
// the first 16 hex chars (64 bits) of SHA-256 — collision risk is
// negligible at single-PR scale (≤50 comments) and keeps review files
// small for downstream agent consumption.
func bodyHashAtPush(body string) string {
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:8]) // 8 bytes → 16 hex chars
}
// ghComment represents a GitHub PR review comment from the API.
type ghComment struct {
ID int64 `json:"id"`
Path string `json:"path"`
Line int `json:"line"` // end line in the diff (RIGHT side = new file line)
StartLine int `json:"start_line"` // start line for multi-line comments (0 if single-line)
Side string `json:"side"` // "RIGHT" or "LEFT"
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
// Note: GitHub's /pulls/.../comments REST endpoint returns only `login`
// per user. We resolve the display name lazily via /users/{login} —
// see userNameCache.
CreatedAt string `json:"created_at"`
InReplyToID int64 `json:"in_reply_to_id"`
}
// errGHAuthFailed signals that a `gh api` call returned HTTP 401. Callers
// in the push loop check this with errors.Is to distinguish a token
// rotation / expiration from generic transport failures: when it appears
// the rest of the push must abort because every subsequent gh call would
// fail the same way, and surface a clear "run gh auth refresh" message
// instead of a vague per-request error spam.
var errGHAuthFailed = errors.New("gh auth failed (HTTP 401)")
// isGHAuthFailure reports whether `gh api` output (stdout+stderr or the
// --include status block) indicates an HTTP 401. gh surfaces 401 in two
// canonical line-anchored shapes: a stderr error line that begins with
// "gh: HTTP 401" and the HTTP status line in the --include block that
// begins with "HTTP/1.1 401" / "HTTP/2 401" / "HTTP/2.0 401".
//
// We deliberately do NOT match bare "HTTP 401" or "Bad credentials"
// substrings: they false-trigger when an echoed request body or quoted
// error text happens to mention either phrase (e.g. a comment body that
// contains "got HTTP 401 from upstream"). Anchoring to line start keeps
// detection tied to gh's own framing.
func isGHAuthFailure(out []byte) bool {
scanner := bufio.NewScanner(bytes.NewReader(out))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "gh: HTTP 401") {
return true
}
if strings.HasPrefix(line, "HTTP/1.1 401") ||
strings.HasPrefix(line, "HTTP/2 401") ||
strings.HasPrefix(line, "HTTP/2.0 401") {
return true
}
}
return false
}
// requireGH checks that the gh CLI is installed and authenticated.
func requireGH() error {
if _, err := exec.LookPath("gh"); err != nil {
return fmt.Errorf("gh CLI not found. Install it: https://cli.github.com")
}
cmd := exec.Command("gh", "auth", "status")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
return fmt.Errorf("gh is not authenticated. Run: gh auth login")
}
return nil
}
// detectPR returns the PR number for the current branch.
// If prFlag is non-zero, it's used directly.
func detectPR(prFlag int) (int, error) {
if prFlag > 0 {
return prFlag, nil
}
out, err := exec.Command("gh", "pr", "view", "--json", "number", "--jq", ".number").Output()
if err != nil {
return 0, fmt.Errorf("no PR found for current branch (try: crit pull <pr-number>)")
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, fmt.Errorf("unexpected PR number: %s", string(out))
}
return n, nil
}
// PRInfo holds metadata about the PR for the current branch.
type PRInfo struct {
URL string `json:"url"`
Number int `json:"number"`
Title string `json:"title"`
IsDraft bool `json:"isDraft"`
State string `json:"state"`
Body string `json:"body"`
BaseRefName string `json:"baseRefName"`
HeadRefName string `json:"headRefName"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changedFiles"`
AuthorLogin string `json:"authorLogin"`
CreatedAt string `json:"createdAt"`
// SHA pins from `gh pr view`. Populated by C5 fetchPRByNumber.
BaseRefOid string `json:"baseRefOid,omitempty"`
HeadRefOid string `json:"headRefOid,omitempty"`
HeadRepoURL string `json:"headRepoURL,omitempty"` // fork URL when IsCrossRepository
IsCrossRepository bool `json:"isCrossRepository,omitempty"` // PR head is on a fork
}
// prAuthor is used to unmarshal the nested author field from gh output.
// Name is GitHub's optional display name; we prefer it over Login when set.
type prAuthor struct {
Login string `json:"login"`
Name string `json:"name"`
}
// prHeadRepo carries the URL of the PR head's source repo. For cross-repo
// PRs (forks), this is the contributor's fork; for same-repo PRs it equals
// the base repo.
type prHeadRepo struct {
URL string `json:"url"`
}
// displayName returns name when set, falling back to login. Used to convert
// GitHub-imported author identities into the friendlier display string we
// pass through to crit-web.
func displayName(login, name string) string {
if strings.TrimSpace(name) != "" {
return name
}
return login
}
// userNameCache memoizes login → display-name lookups for the duration of a
// single `crit pull`. The /pulls/.../comments REST payload only returns
// `login`, so we hit /users/{login} once per unique commenter.
type userNameCache map[string]string
// fetchGHUserName is the seam used by userNameCache.lookup to resolve a login
// to a display name via the GitHub API. Tests override this; production code
// uses the default that shells out to `gh api`.
var fetchGHUserName = ghAPIUserName
// ghAPIUserName shells out to `gh api users/<login>` and returns the user's
// display name (empty string if unset).
func ghAPIUserName(login string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "gh", "api", "users/"+login, "--jq", ".name // \"\"").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// lookup returns the display name for a login, fetching from GitHub on cache
// miss. On any error or missing name, returns login (always a valid fallback)
// and caches that fallback so the warning is logged at most once per login.
func (c userNameCache) lookup(login string) string {
if login == "" {
return ""
}
if cached, ok := c[login]; ok {
return cached
}
name, err := fetchGHUserName(login)
if err != nil {
log.Printf("warning: gh api users/%s: %v", login, err)
c[login] = login
return login
}
resolved := displayName(login, name)
c[login] = resolved
return resolved
}
// prInfoRaw mirrors the gh JSON output shape (author is nested).
type prInfoRaw struct {
URL string `json:"url"`
Number int `json:"number"`
Title string `json:"title"`
IsDraft bool `json:"isDraft"`
State string `json:"state"`
Body string `json:"body"`
BaseRefName string `json:"baseRefName"`
HeadRefName string `json:"headRefName"`
BaseRefOid string `json:"baseRefOid"`
HeadRefOid string `json:"headRefOid"`
IsCrossRepository bool `json:"isCrossRepository"`
HeadRepository prHeadRepo `json:"headRepository"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changedFiles"`
Author prAuthor `json:"author"`
CreatedAt string `json:"createdAt"`
}
// prJSONFields is the comma-separated `--json` field list passed to
// `gh pr view`. Shared between detectPRInfo and fetchPRByNumber so we extract
// the same shape regardless of which entry point we used.
const prJSONFields = "number,url,title,isDraft,state,body,baseRefName,headRefName,baseRefOid,headRefOid,isCrossRepository,headRepository,additions,deletions,changedFiles,author,createdAt"
// detectPRInfo returns PR metadata for the current branch.
// Returns nil if gh is unavailable, no PR exists, or the PR is merged/closed
// (to avoid associating a new local branch with a stale PR that had the same name).
func detectPRInfo() *PRInfo {
if err := requireGH(); err != nil {
return nil
}
out, err := exec.Command("gh", "pr", "view", "--json", prJSONFields).Output()
if err != nil {
return nil
}
info, err := parsePRViewJSON(out)
if err != nil {
return nil
}
if info.URL == "" || info.State == "MERGED" || info.State == "CLOSED" {
return nil
}
return info
}
// parsePRViewJSON decodes `gh pr view --json` output into a PRInfo. Factored
// out so tests can drive it with fixture bytes without invoking gh.
func parsePRViewJSON(b []byte) (*PRInfo, error) {
var raw prInfoRaw
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("parsing gh pr view: %w", err)
}
return &PRInfo{
URL: raw.URL,
Number: raw.Number,
Title: raw.Title,
IsDraft: raw.IsDraft,
State: raw.State,
Body: raw.Body,
BaseRefName: raw.BaseRefName,
HeadRefName: raw.HeadRefName,
BaseRefOid: raw.BaseRefOid,
HeadRefOid: raw.HeadRefOid,
HeadRepoURL: raw.HeadRepository.URL,
IsCrossRepository: raw.IsCrossRepository,
Additions: raw.Additions,
Deletions: raw.Deletions,
ChangedFiles: raw.ChangedFiles,
AuthorLogin: displayName(raw.Author.Login, raw.Author.Name),
CreatedAt: raw.CreatedAt,
}, nil
}
// fetchPRByNumberFn is the live function that hits `gh`. Indirected through a
// package var so tests can stub it without a real gh dependency. Tests that
// swap this should also reset prMetaCache (see withFetchPRByNumber) or the
// new stub will be shadowed by a previous test's cached PRInfo.
var fetchPRByNumberFn = fetchPRByNumberReal
// fetchPRByNumber resolves a PR by explicit number using `gh pr view <num>`,
// memoized for the daemon's lifetime via prMetaCache so repeated focus
// switches return instantly. Unlike detectPRInfo, this does not filter
// MERGED/CLOSED — a user explicitly asking to review --pr <num> can review a
// merged PR (the comment-anchoring rules still apply because the head SHA is
// fixed). The cache is invalidated by invalidatePRCache after force-push
// detection (Session.SetFocus) and `crit pull`.
func fetchPRByNumber(num int) (*PRInfo, error) {
return prMetaCache.get(num)
}
func fetchPRByNumberReal(num int) (*PRInfo, error) {
if err := requireGH(); err != nil {
return nil, err
}
out, err := exec.Command("gh", "pr", "view", strconv.Itoa(num), "--json", prJSONFields).Output()
if err != nil {
return nil, fmt.Errorf("gh pr view %d: %w", num, err)
}
return parsePRViewJSON(out)
}
// PRSummary is the lightweight shape returned for the picker's "Other PRs"
// section. Distinct from PRInfo so we don't pay the cost of fetching body etc.
type PRSummary struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
HeadRefName string `json:"headRefName"`
HeadRefOid string `json:"headRefOid"`
BaseRefName string `json:"baseRefName"`
IsDraft bool `json:"isDraft"`
}
// fetchOpenPRs lists open PRs visible to the current gh auth. Capped at 100
// (gh's max page size).
func fetchOpenPRs() ([]PRSummary, error) {
return fetchOpenPRsCtx(context.Background())
}
// fetchOpenPRsCtx is the context-aware variant — the warm-prime path
// passes the daemon's shutdown ctx so a Ctrl+C during boot terminates
// the in-flight gh subprocess instead of orphaning it.
func fetchOpenPRsCtx(ctx context.Context) ([]PRSummary, error) {
if err := requireGH(); err != nil {
return nil, err
}
out, err := exec.CommandContext(ctx, "gh", "pr", "list",
"--state", "open",
"--limit", "100",
"--json", "number,title,url,headRefName,headRefOid,baseRefName,isDraft",
).Output()
if err != nil {
return nil, fmt.Errorf("gh pr list: %w", err)
}
return parsePRListJSON(out)
}
// parsePRListJSON decodes `gh pr list --json` output. Factored for testing.
func parsePRListJSON(b []byte) ([]PRSummary, error) {
var prs []PRSummary
if err := json.Unmarshal(b, &prs); err != nil {
return nil, fmt.Errorf("parsing gh pr list: %w", err)
}
return prs, nil
}
// prListCache caches `gh pr list` results for 60 seconds. The picker may be
// opened/closed multiple times; gh on big orgs can take 3-5s.
type prListCache struct {
mu sync.Mutex
fetched time.Time
data []PRSummary
}
// get returns cached PR data, refreshing if older than 60s.
func (c *prListCache) get() ([]PRSummary, error) {
return c.getCtx(context.Background())
}
// getCtx is the context-aware variant. The warm-prime path on daemon
// boot passes the daemon's shutdown context so the in-flight gh
// subprocess gets killed on Ctrl+C rather than orphaned.
func (c *prListCache) getCtx(ctx context.Context) ([]PRSummary, error) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.fetched) < 60*time.Second && c.data != nil {
return c.data, nil
}
data, err := fetchOpenPRsCtx(ctx)
if err != nil {
return nil, err
}
c.data = data
c.fetched = time.Now()
return data, nil
}
// IsStackedPR reports whether the PR's base branch is something other than
// the repo's default branch — the heuristic for "is this stacked?".
//
// Returns false when defaultBranch is empty (e.g. detached HEAD, missing
// remote): without a known default we can't classify, so degrade safely
// to "not stacked" rather than reporting every PR as stacked.
func IsStackedPR(info *PRInfo, vcs VCS) bool {
if info == nil || vcs == nil {
return false
}
def := vcs.DefaultBranch()
if def == "" {
return false
}
return info.BaseRefName != def
}
// ensureSHAFetched ensures sha is reachable in the local object store,
// attempting auto-fetch from origin (and forkURL when set) when missing.
// forkURL may be "" — same-repo PRs and CLI --range pass through with empty.
func ensureSHAFetched(vcs VCS, sha, repoRoot, forkURL string) error {
if vcs == nil {
return nil
}
if vcs.HasObject(sha, repoRoot) {
return nil
}
if vcs.Name() == "sl" {
return ensureSHAFetchedSapling(vcs, sha, repoRoot, forkURL)
}
if vcs.Name() == "jj" {
return ensureSHAFetchedJJ(vcs, sha, repoRoot, forkURL)
}
if vcs.Name() != "git" {
return fmt.Errorf("commit %s not present locally (auto-fetch not supported for vcs=%q)", sha, vcs.Name())
}
// First attempt: origin. Suffices for same-repo PRs.
if err := tryGitFetch(repoRoot, "origin", sha); err == nil &&
vcs.HasObject(sha, repoRoot) {
return nil
}
// Second attempt: fork URL, if known.
if forkURL != "" {
if err := tryGitFetch(repoRoot, forkURL, sha); err == nil &&
vcs.HasObject(sha, repoRoot) {
return nil
}
return fmt.Errorf("commit %s not present locally; tried origin and fork %s — manual fetch required", sha, forkURL)
}
return fmt.Errorf("commit %s not present locally; manual fetch required (run `git fetch <remote> %s`)", sha, sha)
}
// ensureSHAFetchedJJ falls back to git fetch for colocated JJ/Git repos. Pure
// JJ repos do not expose a stable command for fetching an arbitrary commit id
// from an arbitrary URL, so range/PR focus reports a clear manual-fetch error.
func ensureSHAFetchedJJ(vcs VCS, sha, repoRoot, forkURL string) error {
if hasGitDirAt(repoRoot) {
if err := tryGitFetch(repoRoot, "origin", sha); err == nil && vcs.HasObject(sha, repoRoot) {
return nil
}
if forkURL != "" {
if err := tryGitFetch(repoRoot, forkURL, sha); err == nil && vcs.HasObject(sha, repoRoot) {
return nil
}
return fmt.Errorf("commit %s not present locally; tried `git fetch origin %s` and `git fetch %s %s` for colocated JJ repo — manual fetch required", sha, sha, forkURL, sha)
}
return fmt.Errorf("commit %s not present locally; manual fetch required (run `git fetch <remote> %s` in the colocated JJ repo)", sha, sha)
}
if forkURL != "" {
return fmt.Errorf("commit %s not present locally; PR head is on fork %s. Pure JJ auto-fetch is not supported — fetch it manually and re-run", sha, forkURL)
}
return fmt.Errorf("commit %s not present locally; pure JJ auto-fetch is not supported — fetch it manually and re-run", sha)
}
// ensureSHAFetchedSapling tries `sl pull -r <sha>` first, then falls back to
// `git fetch origin <sha>` when the repo has both .sl and .git
// (sapling-on-git). Sapling-on-git stores objects in the underlying git repo,
// so a git fetch populates them and `sl` will see them on the next HasObject.
//
// forkURL is the cross-repo HEAD repository URL when the PR comes from a
// fork. For sapling-on-git we attempt `git fetch <forkURL> <sha>` as a third
// step. Pure sapling has no clean cross-repo fetch primitive that takes an
// arbitrary URL on the command line — `sl pull <url>` only works when the
// remote is configured — so we surface a clear error telling the user to
// configure the fork as a path/remote and re-run.
func ensureSHAFetchedSapling(vcs VCS, sha, repoRoot, forkURL string) error {
if err := trySLPull(repoRoot, sha); err == nil &&
vcs.HasObject(sha, repoRoot) {
return nil
}
if hasGitDirAt(repoRoot) {
if err := tryGitFetch(repoRoot, "origin", sha); err == nil &&
vcs.HasObject(sha, repoRoot) {
return nil
}
if forkURL != "" {
if err := tryGitFetch(repoRoot, forkURL, sha); err == nil &&
vcs.HasObject(sha, repoRoot) {
return nil
}
return fmt.Errorf("commit %s not present locally; tried `sl pull -r %s`, `git fetch origin %s`, and `git fetch %s %s` — manual fetch required", sha, sha, sha, forkURL, sha)
}
}
if forkURL != "" {
// Pure sapling, fork PR. We can't drive a cross-repo fetch by URL,
// so explain what the user needs to do.
return fmt.Errorf("commit %s not present locally; PR head is on fork %s. Pure sapling can't fetch by URL — run `sl pull %s` (configure the fork as a path first if needed) and re-run", sha, forkURL, forkURL)
}
return fmt.Errorf("commit %s not present locally; tried `sl pull -r %s` and `git fetch origin %s` — run `sl pull` manually with the right source", sha, sha, sha)
}
// tryGitFetch shells `git fetch <remote> <sha>` with a 30s timeout. Local git
// ops are normally context-free in this codebase; `git fetch` is the network
// path and warrants a timeout.
func tryGitFetch(repoRoot, remote, sha string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "fetch", remote, sha)
cmd.Dir = repoRoot
return cmd.Run()
}
// trySLPull shells `sl pull -r <sha>` with a 30s timeout.
func trySLPull(repoRoot, sha string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sl", "pull", "-r", sha)
cmd.Dir = repoRoot
return cmd.Run()
}
// hasGitDirAt reports whether repoRoot has a .git/ directory. Cheap stat.
func hasGitDirAt(repoRoot string) bool {
info, err := os.Stat(filepath.Join(repoRoot, ".git"))
return err == nil && info.IsDir()
}
// fetchPRComments fetches all review comments for a PR.
func fetchPRComments(prNumber int) ([]ghComment, error) {
if ghSupportsAPISlurp() {
return fetchPRCommentsWithSlurp(prNumber)
}
// TODO: Remove this compatibility path once Crit requires gh v2.48.0+,
// which added `gh api --slurp`.
return fetchPRCommentsWithoutSlurp(prNumber)
}
func ghSupportsAPISlurp() bool {
out, err := exec.Command("gh", "version").Output()
if err != nil {
return false
}
return ghVersionSupportsSlurp(string(out))
}
func ghVersionSupportsSlurp(versionOutput string) bool {
fields := strings.Fields(versionOutput)
if len(fields) < 3 || fields[0] != "gh" || fields[1] != "version" {
return false
}
return versionAtLeast(fields[2], 2, 48, 0)
}
func versionAtLeast(version string, wantMajor, wantMinor, wantPatch int) bool {
core := strings.SplitN(strings.TrimPrefix(version, "v"), "-", 2)[0]
parts := strings.Split(core, ".")
if len(parts) < 3 {
return false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return false
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return false
}
if major != wantMajor {
return major > wantMajor
}
if minor != wantMinor {
return minor > wantMinor
}
return patch >= wantPatch
}
func fetchPRCommentsWithSlurp(prNumber int) ([]ghComment, error) {
out, err := exec.Command("gh", "api",
fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments", prNumber),
"--paginate",
"--slurp",
).Output()
if err != nil {
return nil, fmt.Errorf("fetching PR comments: %w", err)
}
var pages [][]ghComment
if err := json.Unmarshal(out, &pages); err != nil {
return nil, fmt.Errorf("parsing PR comments: %w", err)
}
var comments []ghComment
for _, page := range pages {
comments = append(comments, page...)
}
return comments, nil
}
func fetchPRCommentsWithoutSlurp(prNumber int) ([]ghComment, error) {
out, err := exec.Command("gh", "api",
fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments", prNumber),
"--paginate",
"--jq",
".[]",
).Output()
if err != nil {
return nil, fmt.Errorf("fetching PR comments: %w", err)
}
dec := json.NewDecoder(bytes.NewReader(out))
var comments []ghComment
for {
var c ghComment
if err := dec.Decode(&c); err != nil {
if errors.Is(err, io.EOF) {
return comments, nil
}
return nil, fmt.Errorf("parsing PR comments: %w", err)
}
comments = append(comments, c)
}
}
// fetchCurrentRepoOwnerName returns (owner, repo) for the current working
// directory's GitHub repo via `gh repo view`. Used by GraphQL queries which,
// unlike REST, don't honor the `{owner}/{repo}` placeholder substitution
// that gh applies to REST endpoints.
func fetchCurrentRepoOwnerName() (string, string, error) {
out, err := exec.Command("gh", "repo", "view", "--json", "owner,name").Output()
if err != nil {
return "", "", fmt.Errorf("resolving current repo: %w", err)
}
var resp struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
}
if err := json.Unmarshal(out, &resp); err != nil {
return "", "", fmt.Errorf("parsing current repo: %w", err)
}
if resp.Owner.Login == "" || resp.Name == "" {
return "", "", fmt.Errorf("gh repo view returned empty owner/name: %s", string(out))
}
return resp.Owner.Login, resp.Name, nil
}
// fetchPRThreadResolved returns a map databaseID -> isResolved for every
// review-thread comment on the PR, gathered from the reviewThreads GraphQL
// edge. Threads carry the resolved bit only at the thread level, not on the
// individual REST /pulls/{n}/comments rows — so this is the only path that
// surfaces "reviewer clicked Resolve on github.com" to `crit pull`. See #453.
//
// Pagination: GitHub caps reviewThreads at 100 nodes and comments-per-thread
// at 100. We page on the outer connection; very large discussions
// (>100 threads or >100 replies in one thread) are still rare enough at
// crit's scale that the inner cap is acceptable. If we ever hit it, the
// merge logic degrades gracefully — only the unseen replies miss the
// resolved bit, the root still gets it.
func fetchPRThreadResolved(prNumber int) (map[int64]bool, error) {
owner, name, err := fetchCurrentRepoOwnerName()
if err != nil {
return nil, err
}
resolved := make(map[int64]bool)
cursor := ""
for {
page, nextCursor, err := fetchPRThreadResolvedPage(owner, name, prNumber, cursor)
if err != nil {
return nil, err
}
for id, r := range page {
resolved[id] = r
}
if nextCursor == "" {
return resolved, nil
}
cursor = nextCursor
}
}
// graphqlThreadResp is the typed shape of the reviewThreads GraphQL response.
type graphqlThreadResp struct {
Data struct {
Repository struct {
PullRequest struct {
ReviewThreads struct {
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
Nodes []struct {
IsResolved bool `json:"isResolved"`
Comments struct {
Nodes []struct {
DatabaseID int64 `json:"databaseId"`
} `json:"nodes"`
} `json:"comments"`
} `json:"nodes"`
} `json:"reviewThreads"`
} `json:"pullRequest"`
} `json:"repository"`
} `json:"data"`
}
// fetchPRThreadResolvedPage fetches one page of review threads and returns
// (databaseID -> isResolved, nextCursor, error). nextCursor is "" when no
// more pages are available.
func fetchPRThreadResolvedPage(owner, name string, prNumber int, cursor string) (map[int64]bool, string, error) {
const query = `query($owner:String!,$name:String!,$pr:Int!,$after:String){
repository(owner:$owner, name:$name){
pullRequest(number:$pr){
reviewThreads(first:100, after:$after){
pageInfo{ hasNextPage endCursor }
nodes{
isResolved
comments(first:100){ nodes{ databaseId } }
}
}
}
}
}`
args := []string{"api", "graphql",
"-f", "owner=" + owner,
"-f", "name=" + name,
"-F", fmt.Sprintf("pr=%d", prNumber),
"-f", "query=" + query,
}
if cursor != "" {
args = append(args, "-f", "after="+cursor)
}
out, err := exec.Command("gh", args...).Output()
if err != nil {
return nil, "", fmt.Errorf("fetching PR review threads: %w", err)
}
var resp graphqlThreadResp
if err := json.Unmarshal(out, &resp); err != nil {
return nil, "", fmt.Errorf("parsing PR review threads: %w", err)
}
page := make(map[int64]bool)
for _, n := range resp.Data.Repository.PullRequest.ReviewThreads.Nodes {
for _, c := range n.Comments.Nodes {
if c.DatabaseID == 0 {
continue
}
page[c.DatabaseID] = n.IsResolved
}
}
next := ""
if resp.Data.Repository.PullRequest.ReviewThreads.PageInfo.HasNextPage {
next = resp.Data.Repository.PullRequest.ReviewThreads.PageInfo.EndCursor
}
return page, next, nil
}
// isDuplicateGHComment checks if a GitHub comment already exists in the comment list.
// If ghID is non-zero, matches by GitHubID. Otherwise falls back to author+lines+body.
func isDuplicateGHComment(comments []Comment, ghID int64, author string, startLine, endLine int, body string) bool {
for _, c := range comments {
if c.DOMAnchor != nil {
continue // live pins never participate in GH dedup
}
if ghID != 0 && c.GitHubID == ghID {
return true
}
if c.Author == author && c.StartLine == startLine && c.EndLine == endLine && c.Body == body {
return true
}
}
return false
}
// isDuplicateGHReply checks if a GitHub reply already exists in the reply list by GitHubID.
func isDuplicateGHReply(replies []Reply, ghID int64) bool {
for _, r := range replies {
if r.GitHubID == ghID {
return true
}
}
return false
}
// findCommentByGitHubID searches all files in a CritJSON for a comment with the given GitHubID.
// Returns the file path, comment index, and true if found.
func findCommentByGitHubID(cj *CritJSON, ghID int64) (string, int, bool) {
for path, cf := range cj.Files {
for i, c := range cf.Comments {
if c.GitHubID == ghID {
return path, i, true
}
}
}
return "", 0, false
}
// separateRootsAndReplies filters and categorizes ghComments into root comments
// and replies, grouped by parent ID.
func separateRootsAndReplies(ghComments []ghComment) ([]ghComment, map[int64][]ghComment) {
var roots []ghComment
replyMap := make(map[int64][]ghComment)
for _, gc := range ghComments {
if gc.Line == 0 || gc.Side == "LEFT" {
continue
}
if gc.InReplyToID == 0 {
roots = append(roots, gc)
} else {
replyMap[gc.InReplyToID] = append(replyMap[gc.InReplyToID], gc)
}
}
for parentID := range replyMap {
sort.Slice(replyMap[parentID], func(i, j int) bool {
return replyMap[parentID][i].CreatedAt < replyMap[parentID][j].CreatedAt
})
}
return roots, replyMap
}
// appendNewGHReplies adds non-duplicate replies to an existing comment, returning how many were added.
// Replies whose GitHubID is in pendingDeletes are skipped — the user has
// already deleted them locally and the next push will issue DELETE; importing
// them now would resurrect the row under a fresh ID and mask the pending intent.
func appendNewGHReplies(comments []Comment, ci int, childReplies []ghComment, names userNameCache, pendingDeletes map[int64]bool) int {
added := 0
for _, r := range childReplies {
if isDuplicateGHReply(comments[ci].Replies, r.ID) {
continue
}
if pendingDeletes[r.ID] {
continue
}
comments[ci].Replies = append(comments[ci].Replies, Reply{
ID: randomReplyID(),
Body: r.Body,
Author: names.lookup(r.User.Login),
CreatedAt: r.CreatedAt,
GitHubID: r.ID,
LastPushedBodyHash: bodyHashAtPush(r.Body),
})
added++
}
return added
}
// updateDuplicateRoot applies thread-resolved updates and merges new replies
// onto a root comment that already exists locally (matched by GitHubID).
// Returns the number of meaningful changes (new replies + at most one for a
// resolution flip) so runPull's added==0 short-circuit correctly persists.
func updateDuplicateRoot(
cj *CritJSON, cf *CritJSONFile, gc ghComment, replyMap map[int64][]ghComment,
names userNameCache, pendingDeletes map[int64]bool,
hasRemote, remoteResolved bool, now string,
) int {
added := 0
for ci, c := range cf.Comments {
if c.GitHubID != gc.ID {
continue
}
if hasRemote && remoteResolved && !c.Resolved {
cf.Comments[ci].Resolved = true
cf.Comments[ci].ResolvedRound = cj.ReviewRound
cf.Comments[ci].UpdatedAt = now
added++
}
if childReplies, hasReplies := replyMap[gc.ID]; hasReplies {
added += appendNewGHReplies(cf.Comments, ci, childReplies, names, pendingDeletes)
}
break
}
return added
}
// mergeRootComment handles a single root ghComment: either deduplicates or creates it.
// scope stamps the imported comment's HeadSHA + DiffScope when called from a range-mode
// pull. Empty scope leaves the legacy working-tree fields unset. threadResolved
// is the databaseID -> isResolved map from the reviewThreads GraphQL edge;
// when present, the root's Resolved bit is updated asymmetrically:
//
// - Remote resolved + local unresolved -> set Resolved=true, ResolvedRound=cj.ReviewRound
// - Remote unresolved + local resolved -> KEEP local resolved (don't clobber)
// - Both same -> no-op
//
// The asymmetry mirrors the existing pull semantics for Body (dedup matches
// existing comments and never overwrites local edits): a local user may have
// resolved a thread via crit / crit-web independently of github.com, and
// `crit pull` should not undo that.
func mergeRootComment(cj *CritJSON, gc ghComment, replyMap map[int64][]ghComment, now string, names userNameCache, scope inheritedScope, pendingDeletes map[int64]bool, threadResolved map[int64]bool) int {
cf, ok := cj.Files[gc.Path]
if !ok {
cf = CritJSONFile{Status: "modified", Comments: []Comment{}}
}
startLine := gc.StartLine
if startLine == 0 {
startLine = gc.Line
}
authorName := names.lookup(gc.User.Login)
// Pending-delete-locally takes priority over import: the user has signaled
// intent to DELETE this remote comment on the next push. Re-importing
// would resurrect it under a fresh local ID and mask the pending intent.
if pendingDeletes[gc.ID] {
return 0
}
remoteResolved, hasRemote := threadResolvedForRoot(gc.ID, replyMap[gc.ID], threadResolved)
if isDuplicateGHComment(cf.Comments, gc.ID, authorName, startLine, gc.Line, gc.Body) {
added := updateDuplicateRoot(cj, &cf, gc, replyMap, names, pendingDeletes, hasRemote, remoteResolved, now)
cj.Files[gc.Path] = cf
return added
}
commentID := randomCommentID()
comment := stampWithFocus(Comment{
ID: commentID, StartLine: startLine, EndLine: gc.Line,
Body: gc.Body, Author: authorName, CreatedAt: gc.CreatedAt,
UpdatedAt: now, GitHubID: gc.ID, LastPushedBodyHash: bodyHashAtPush(gc.Body),
}, scope.asFocus())
if hasRemote && remoteResolved {
comment.Resolved = true
comment.ResolvedRound = cj.ReviewRound
}
added := 0
if childReplies, hasReplies := replyMap[gc.ID]; hasReplies {
for _, r := range childReplies {
if pendingDeletes[r.ID] {
continue
}
comment.Replies = append(comment.Replies, Reply{
ID: randomReplyID(),
Body: r.Body,
Author: names.lookup(r.User.Login),
CreatedAt: r.CreatedAt,
GitHubID: r.ID,
LastPushedBodyHash: bodyHashAtPush(r.Body),
})
added++
}
}
cf.Comments = append(cf.Comments, comment)
cj.Files[gc.Path] = cf
return added + 1 // +1 for the root
}
// mergeOrphanReplies processes replies whose parent was already in cj from a previous pull.
func mergeOrphanReplies(cj *CritJSON, roots []ghComment, replyMap map[int64][]ghComment, names userNameCache, pendingDeletes map[int64]bool) int {
rootIDs := make(map[int64]struct{}, len(roots))
for _, gc := range roots {
rootIDs[gc.ID] = struct{}{}
}
added := 0
for parentID, childReplies := range replyMap {
if _, handled := rootIDs[parentID]; handled {
continue
}
filePath, ci, found := findCommentByGitHubID(cj, parentID)
if !found {
continue
}
cf := cj.Files[filePath]
added += appendNewGHReplies(cf.Comments, ci, childReplies, names, pendingDeletes)
cj.Files[filePath] = cf
}
return added
}
// mergeGHComments appends GitHub PR comments into an existing CritJSON.
// Only includes RIGHT-side comments (comments on the new version of the file).
// Handles threading: root comments become top-level Comments, replies become Reply entries.
// Deduplicates by GitHubID (preferred) or author+lines+body to prevent duplicates from repeated pulls.
func mergeGHComments(cj *CritJSON, ghComments []ghComment) int {
return mergeGHCommentsScoped(cj, ghComments, inheritedScope{}, nil)
}
// mergeGHCommentsScoped is mergeGHComments with optional HeadSHA + DiffScope
// stamping for range-mode pulls. scope.DiffScope == "" matches legacy
// working-tree behavior. threadResolved (databaseID -> isResolved) is
// applied to root comments to mirror github.com thread-resolution state.
// See spec §E "Write path — `crit pull` import path".
func mergeGHCommentsScoped(cj *CritJSON, ghComments []ghComment, scope inheritedScope, threadResolved map[int64]bool) int {
return mergeGHCommentsWithNames(cj, ghComments, make(userNameCache), scope, threadResolved)
}
// mergeGHCommentsWithNames is the form of mergeGHComments that lets callers
// supply a pre-populated cache. Production uses mergeGHComments (fresh
// cache, lazy /users/{login} lookups). Tests can pre-populate to assert on
// resolved display names without going to the network. scope stamps
// HeadSHA + DiffScope on newly imported root comments (no-op when empty).
// threadResolved propagates the thread-level isResolved bit from the
// GraphQL reviewThreads edge onto the local root comment's Resolved /
// ResolvedRound — see threadResolvedForRoot for the asymmetric merge.
func mergeGHCommentsWithNames(cj *CritJSON, ghComments []ghComment, names userNameCache, scope inheritedScope, threadResolved map[int64]bool) int {
now := time.Now().UTC().Format(time.RFC3339)