@expo/code-review-cli 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -22
- package/build/commands/ci.js +18 -3
- package/build/commands/dismiss.js +2 -0
- package/build/commands/doctor.js +3 -3
- package/build/commands/init.js +6 -6
- package/build/commands/post-review.js +1 -0
- package/build/commands/review.js +3 -0
- package/build/commands/verify-config.js +3 -5
- package/build/config/load.js +44 -9
- package/build/config/schema.js +20 -0
- package/build/core/config-refs.js +12 -4
- package/build/core/deferred-review.js +4 -0
- package/build/core/render.js +96 -0
- package/build/core/responses.js +35 -2
- package/build/core/schema.js +10 -1
- package/build/reporters/github.js +585 -7
- package/build/research-mcp/direct-fetch.js +16 -0
- package/build/research-mcp/providers.js +44 -0
- package/build/research-mcp/remote-search.js +32 -0
- package/build/research-mcp/server.js +2 -2
- package/build/research-mcp/types.js +9 -0
- package/package.json +1 -1
- package/templates/atlantis.yml +3 -1
- package/templates/command.yml +3 -1
- package/templates/config.jsonc +16 -0
- package/templates/workflow.yml +3 -1
|
@@ -3,11 +3,12 @@ import { writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { resolveTrustedTool, run } from "../core/exec.js";
|
|
6
|
+
import { errorMessage } from "../core/util.js";
|
|
6
7
|
import { parseUnifiedDiff } from "../core/diff.js";
|
|
7
|
-
import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
|
|
8
|
+
import { buildDiffLineIndex, commentMarker, inlineStubBody, parseInlineMarkerFp, parseReviewState, renderAggregateMarkdown, renderInlineCommentBody, renderMarkdown, } from "../core/render.js";
|
|
8
9
|
import { matchReplies } from "../core/responses.js";
|
|
9
10
|
import { dropStaleVerdict, feedbackApplied, feedbackNeedsPrAuthor } from "../core/adjudicate.js";
|
|
10
|
-
import { applyPins, collectPins, fingerprintFinding, scopedFingerprint } from "../core/schema.js";
|
|
11
|
+
import { applyPins, collectPins, fingerprintFinding, scopedFingerprint, SEVERITY_RANK, } from "../core/schema.js";
|
|
11
12
|
import { appendStepSummary } from "../core/step-summary.js";
|
|
12
13
|
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
13
14
|
/**
|
|
@@ -62,6 +63,133 @@ export function sharedPrAuthor(key, resolve) {
|
|
|
62
63
|
prAuthorByPr.set(key, pending);
|
|
63
64
|
return pending;
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Burst-collapsing cache over the paginated review-comments listing, shared across
|
|
68
|
+
* reporter INSTANCES per PR (same motivation and key shape as prAuthorByPr): routed
|
|
69
|
+
* single-mode CI builds a fresh reporter per scope for its clear() reconciliation,
|
|
70
|
+
* and per-instance caching would re-fetch the identical list once per scope. The
|
|
71
|
+
* short TTL and mutation invalidation mirror fetchAllComments.
|
|
72
|
+
*/
|
|
73
|
+
const reviewCommentsByPr = new Map();
|
|
74
|
+
// @ref LLP 0011#suppression-is-never-silent [constrained-by] — leaving the inline set is
|
|
75
|
+
// not resolution: teardown only stubs/deletes when the caller proved the target set is
|
|
76
|
+
// complete, and the stub text (see inlineStubBody) never claims resolved/dismissed
|
|
77
|
+
/**
|
|
78
|
+
* Decide the inline mutations for one sync, purely. `targets` are the ACTIVE,
|
|
79
|
+
* diff-anchored findings; `existing` are OUR live inline comments.
|
|
80
|
+
*
|
|
81
|
+
* The cap is STICKY and deterministic: candidates order by severity rank then fp,
|
|
82
|
+
* and fps that already have a live thread fill slots before new ones are admitted —
|
|
83
|
+
* a live thread is never evicted (and its replies destroyed) just to create a
|
|
84
|
+
* different one, and a tie at the boundary cannot flip membership run to run.
|
|
85
|
+
*
|
|
86
|
+
* `teardown: false` (a failed/partial/carried run whose target set may be
|
|
87
|
+
* incomplete) makes the plan additive-only: no stubs, no deletes of live threads —
|
|
88
|
+
* except older DUPLICATES of a kept fp (a crash window between create and list),
|
|
89
|
+
* which are ours and redundant whatever the target set says.
|
|
90
|
+
*/
|
|
91
|
+
export function planInlineSync(opts) {
|
|
92
|
+
const ordered = [...opts.targets].sort((a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] ||
|
|
93
|
+
a.fp.localeCompare(b.fp));
|
|
94
|
+
// Newest comment per fp is the live thread; older ones are duplicates.
|
|
95
|
+
const liveByFp = new Map();
|
|
96
|
+
const duplicates = [];
|
|
97
|
+
for (const comment of [...opts.existing].sort((a, b) => a.id - b.id)) {
|
|
98
|
+
const previous = liveByFp.get(comment.fp);
|
|
99
|
+
if (previous) {
|
|
100
|
+
duplicates.push(previous);
|
|
101
|
+
}
|
|
102
|
+
liveByFp.set(comment.fp, comment);
|
|
103
|
+
}
|
|
104
|
+
const selected = [];
|
|
105
|
+
for (const entry of ordered) {
|
|
106
|
+
if (selected.length >= opts.maxComments) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
if (liveByFp.has(entry.fp)) {
|
|
110
|
+
selected.push(entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const entry of ordered) {
|
|
114
|
+
if (selected.length >= opts.maxComments) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
if (!liveByFp.has(entry.fp)) {
|
|
118
|
+
selected.push(entry);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const plan = { create: [], patch: [], stub: [], remove: [] };
|
|
122
|
+
const selectedFps = new Set(selected.map((entry) => entry.fp));
|
|
123
|
+
for (const entry of selected) {
|
|
124
|
+
const live = liveByFp.get(entry.fp);
|
|
125
|
+
if (live) {
|
|
126
|
+
plan.patch.push({ comment: live, finding: entry.finding });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
plan.create.push(entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const [fp, live] of liveByFp) {
|
|
133
|
+
if (selectedFps.has(fp)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!opts.teardown) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (live.hasReplies) {
|
|
140
|
+
plan.stub.push(live);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
plan.remove.push(live);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// A duplicate with human replies is still a conversation: stub it like any other
|
|
147
|
+
// retired thread rather than deleting what people wrote in.
|
|
148
|
+
for (const duplicate of duplicates) {
|
|
149
|
+
if (duplicate.hasReplies) {
|
|
150
|
+
plan.stub.push(duplicate);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
plan.remove.push(duplicate);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return plan;
|
|
157
|
+
}
|
|
158
|
+
/** `\r\n`-insensitive body comparison: the API returns CRLF-normalized bodies. */
|
|
159
|
+
export function inlineBodiesEqual(a, b) {
|
|
160
|
+
return a.replace(/\r\n/g, "\n") === b.replace(/\r\n/g, "\n");
|
|
161
|
+
}
|
|
162
|
+
/** Does a body's UNQUOTED text carry this marker? Quoted (`> `) lines are the
|
|
163
|
+
* replier quoting US, which is a genuine reply, not our comment. */
|
|
164
|
+
export function hasUnquotedMarker(body, marker) {
|
|
165
|
+
return body.split("\n").some((line) => !/^\s*>/.test(line) && line.includes(marker));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The findings the main comment renders in its ACTIVE list — not dismissed, not
|
|
169
|
+
* cleared by an applied reply, not requalified — which is exactly the inline sync's
|
|
170
|
+
* target set. Mirrors the isDropped/kept split in renderMarkdown and
|
|
171
|
+
* renderAggregateMarkdown (first record per fp wins, like matchedFeedback there).
|
|
172
|
+
* Exported for tests.
|
|
173
|
+
*/
|
|
174
|
+
export function activeFindings(withFp, dismissed, records) {
|
|
175
|
+
const dismissedFps = new Set(dismissed.map((record) => record.fp));
|
|
176
|
+
const appliedFps = new Set();
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const record of records) {
|
|
179
|
+
if (!seen.has(record.fp)) {
|
|
180
|
+
seen.add(record.fp);
|
|
181
|
+
if (record.applied) {
|
|
182
|
+
appliedFps.add(record.fp);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return withFp.filter(({ finding, fp }) => !dismissedFps.has(fp) && !appliedFps.has(fp) && !finding.requalifiedBy);
|
|
187
|
+
}
|
|
188
|
+
/** A per-render COPY of the link context carrying this run's inline thread URLs —
|
|
189
|
+
* the routed ci flow shares one LinkContext across reporters, so never mutate it. */
|
|
190
|
+
function withInlineUrls(link, inlineUrls) {
|
|
191
|
+
return inlineUrls.size > 0 ? { ...link, inlineUrls } : link;
|
|
192
|
+
}
|
|
65
193
|
// @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged
|
|
66
194
|
/**
|
|
67
195
|
* Carry a decision already recorded in the prior comment state onto the freshly
|
|
@@ -332,10 +460,23 @@ export class GitHubReporter {
|
|
|
332
460
|
? applyPins(feedback, pinsIn)
|
|
333
461
|
: await this.computeFeedback(withFp, priorRecords, pinsIn);
|
|
334
462
|
const link = await this.linkContextAsync();
|
|
335
|
-
|
|
463
|
+
// Inline threads converge to the ACTIVE findings before the body renders, so
|
|
464
|
+
// the short form can link each one. A failed run (couldNotComplete) posts an
|
|
465
|
+
// empty findings list that proves nothing — additive-only, no teardown.
|
|
466
|
+
const inlineUrls = await this.syncInline(activeFindings(withFp, dismissed, records), link, {
|
|
467
|
+
teardown: review.couldNotComplete !== true,
|
|
468
|
+
});
|
|
469
|
+
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, withInlineUrls(link, inlineUrls), records, pins, inputHash));
|
|
336
470
|
}
|
|
337
|
-
/**
|
|
338
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Post/update the aggregate multi-scope comment (comment:'single' mode).
|
|
473
|
+
* `opts.inlineTeardown: false` marks a run whose result set is NOT the complete
|
|
474
|
+
* truth about the PR — a `--scopes` partial run carries the other scopes out of
|
|
475
|
+
* (possibly truncated) embedded state — so the inline sync must not stub/delete
|
|
476
|
+
* threads it merely cannot see. Any failed scope forces the same, since its
|
|
477
|
+
* findings list is empty without being clean.
|
|
478
|
+
*/
|
|
479
|
+
async reportAggregate(results, unmatchedFiles, feedback, opts) {
|
|
339
480
|
const existing = await this.findExistingComment();
|
|
340
481
|
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
|
|
341
482
|
const dismissed = state?.dismissed ?? [];
|
|
@@ -351,7 +492,12 @@ export class GitHubReporter {
|
|
|
351
492
|
? applyPins(feedback, pinsIn)
|
|
352
493
|
: await this.computeFeedback(withFp, priorRecords, pinsIn);
|
|
353
494
|
const link = await this.linkContextAsync();
|
|
354
|
-
|
|
495
|
+
const teardown = (opts?.inlineTeardown ?? true) &&
|
|
496
|
+
results.every((result) => result.review.couldNotComplete !== true);
|
|
497
|
+
const inlineUrls = await this.syncInline(activeFindings(withFp, dismissed, records), link, {
|
|
498
|
+
teardown,
|
|
499
|
+
});
|
|
500
|
+
await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, withInlineUrls(link, inlineUrls), { unmatchedFiles }, records, pins));
|
|
355
501
|
}
|
|
356
502
|
/**
|
|
357
503
|
* Pair each matched PR reply with the finding it answers and its raw reply text —
|
|
@@ -483,10 +629,17 @@ export class GitHubReporter {
|
|
|
483
629
|
* failed lookup gives, and under any other `dismiss` value nothing reads the flag.
|
|
484
630
|
*/
|
|
485
631
|
async replyComments() {
|
|
486
|
-
|
|
632
|
+
// Inline replies are read only when the reporter carries an ENABLED inline
|
|
633
|
+
// config: the `ecr feedback` crawl and every non-inline repo keep exactly the
|
|
634
|
+
// API footprint they had.
|
|
635
|
+
const inlineEnabled = this.options.inline?.enabled === true;
|
|
636
|
+
const [comments, ownLogin, prAuthor, reviewComments] = await Promise.all([
|
|
487
637
|
this.fetchAllComments(),
|
|
488
638
|
this.resolveOwnLogin(),
|
|
489
639
|
feedbackNeedsPrAuthor(this.options.feedback) ? this.resolvePrAuthor() : null,
|
|
640
|
+
inlineEnabled
|
|
641
|
+
? this.fetchAllReviewComments().catch(() => [])
|
|
642
|
+
: [],
|
|
490
643
|
]);
|
|
491
644
|
const out = [];
|
|
492
645
|
for (const comment of comments) {
|
|
@@ -508,6 +661,62 @@ export class GitHubReporter {
|
|
|
508
661
|
...(comment.html_url ? { url: comment.html_url } : {}),
|
|
509
662
|
});
|
|
510
663
|
}
|
|
664
|
+
if (inlineEnabled && reviewComments.length > 0) {
|
|
665
|
+
// fp per OUR top-level inline comment (author + anchored marker), so a reply
|
|
666
|
+
// threading to it names its finding structurally.
|
|
667
|
+
const fpByRootId = new Map();
|
|
668
|
+
if (ownLogin) {
|
|
669
|
+
for (const comment of reviewComments) {
|
|
670
|
+
if (comment.in_reply_to_id != null || comment.user?.login !== ownLogin) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const fp = parseInlineMarkerFp(comment.body ?? "", this.options.commentTag);
|
|
674
|
+
if (fp) {
|
|
675
|
+
fpByRootId.set(comment.id, fp);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const byId = new Map(reviewComments.map((comment) => [comment.id, comment]));
|
|
680
|
+
// `in_reply_to_id` points at the thread root, but chase a short chain anyway
|
|
681
|
+
// so an unexpected reply-to-reply shape still resolves.
|
|
682
|
+
const threadFpOf = (comment) => {
|
|
683
|
+
let parentId = comment.in_reply_to_id;
|
|
684
|
+
for (let hop = 0; parentId != null && hop < 5; hop++) {
|
|
685
|
+
const fp = fpByRootId.get(parentId);
|
|
686
|
+
if (fp) {
|
|
687
|
+
return fp;
|
|
688
|
+
}
|
|
689
|
+
parentId = byId.get(parentId)?.in_reply_to_id;
|
|
690
|
+
}
|
|
691
|
+
return undefined;
|
|
692
|
+
};
|
|
693
|
+
for (const comment of reviewComments) {
|
|
694
|
+
const login = comment.user?.login;
|
|
695
|
+
if (!login || (ownLogin && login === ownLogin)) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const body = comment.body ?? "";
|
|
699
|
+
// Unquoted-line marker tests only: a GitHub "Quote reply" copies our marker
|
|
700
|
+
// behind `> ` and is a genuine human reply that must still be matched.
|
|
701
|
+
if (hasUnquotedMarker(body, this.marker) ||
|
|
702
|
+
body.split("\n").some((line) => !/^\s*>/.test(line) && line.includes(":inline:fp="))) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const threadFp = threadFpOf(comment);
|
|
706
|
+
out.push({
|
|
707
|
+
// NEGATED: PR-review-comment ids are an independent sequence from issue
|
|
708
|
+
// comments; negation keeps stored commentIds collision-free across streams
|
|
709
|
+
// (and applyPins refuses cross-stream pin-lift comparisons).
|
|
710
|
+
id: -comment.id,
|
|
711
|
+
body,
|
|
712
|
+
login,
|
|
713
|
+
maintainer: MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""),
|
|
714
|
+
author: prAuthor != null && login === prAuthor,
|
|
715
|
+
...(comment.html_url ? { url: comment.html_url } : {}),
|
|
716
|
+
...(threadFp ? { threadFp } : {}),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
511
720
|
return out;
|
|
512
721
|
}
|
|
513
722
|
/**
|
|
@@ -531,6 +740,36 @@ export class GitHubReporter {
|
|
|
531
740
|
for (const comment of await this.ownComments()) {
|
|
532
741
|
await this.deleteComment(comment.id);
|
|
533
742
|
}
|
|
743
|
+
// Sweep this tag's inline threads too, gated on the config KEY being present
|
|
744
|
+
// (not on `enabled`), so a comment-mode switch or a later disable still cleans
|
|
745
|
+
// up — while callers that never wire `inline` (break-glass reporter, terminal
|
|
746
|
+
// failure notes) keep their zero-review-API footprint. A thread humans replied
|
|
747
|
+
// in is stubbed, never deleted. Fail-soft per thread.
|
|
748
|
+
if (this.options.inline !== undefined) {
|
|
749
|
+
let own = [];
|
|
750
|
+
try {
|
|
751
|
+
own = await this.ownInlineComments();
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return; // fail-soft: the main-comment clear above already happened
|
|
755
|
+
}
|
|
756
|
+
for (const comment of own) {
|
|
757
|
+
try {
|
|
758
|
+
if (comment.hasReplies) {
|
|
759
|
+
const stub = inlineStubBody(this.options.commentTag, comment.fp);
|
|
760
|
+
if (!inlineBodiesEqual(comment.body, stub)) {
|
|
761
|
+
await this.patchReviewComment(comment.id, stub);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
await this.deleteReviewComment(comment.id);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
process.stderr.write(`inline sync: clear failed for one inline comment (skipped): ${errorMessage(error)}\n`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
534
773
|
}
|
|
535
774
|
/**
|
|
536
775
|
* PR context for turning finding locations into links: the set of lines actually
|
|
@@ -576,6 +815,307 @@ export class GitHubReporter {
|
|
|
576
815
|
]);
|
|
577
816
|
return link;
|
|
578
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* Fetch ALL PR review (inline) comments, paginating manually like
|
|
820
|
+
* fetchAllCommentsUncached. Unlike the issue-comments endpoint this one honors
|
|
821
|
+
* sort/direction, but a full scan is needed anyway (we reconcile every own
|
|
822
|
+
* thread), so the same oldest-first walk keeps the two fetchers uniform.
|
|
823
|
+
*/
|
|
824
|
+
async fetchAllReviewCommentsUncached() {
|
|
825
|
+
const all = [];
|
|
826
|
+
const gh = await resolveTrustedTool("gh");
|
|
827
|
+
for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
|
|
828
|
+
const { stdout } = await run(gh, [
|
|
829
|
+
"api",
|
|
830
|
+
"-X",
|
|
831
|
+
"GET",
|
|
832
|
+
`repos/${this.options.repo}/pulls/${this.options.prNumber}/comments`,
|
|
833
|
+
"-f",
|
|
834
|
+
"per_page=100",
|
|
835
|
+
"-f",
|
|
836
|
+
`page=${page}`,
|
|
837
|
+
], { cwd: this.options.cwd });
|
|
838
|
+
let batch;
|
|
839
|
+
try {
|
|
840
|
+
batch = JSON.parse(stdout);
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
all.push(...batch);
|
|
849
|
+
if (batch.length < 100) {
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return all;
|
|
854
|
+
}
|
|
855
|
+
/** Shared-per-PR, TTL'd review-comments listing (see reviewCommentsByPr). */
|
|
856
|
+
fetchAllReviewComments() {
|
|
857
|
+
const key = prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd);
|
|
858
|
+
const now = Date.now();
|
|
859
|
+
const cached = reviewCommentsByPr.get(key);
|
|
860
|
+
if (cached && now - cached.at <= GitHubReporter.COMMENTS_CACHE_TTL_MS) {
|
|
861
|
+
return cached.comments;
|
|
862
|
+
}
|
|
863
|
+
const comments = this.fetchAllReviewCommentsUncached().catch((error) => {
|
|
864
|
+
// Never cache a failure.
|
|
865
|
+
if (reviewCommentsByPr.get(key)?.comments === comments) {
|
|
866
|
+
reviewCommentsByPr.delete(key);
|
|
867
|
+
}
|
|
868
|
+
throw error;
|
|
869
|
+
});
|
|
870
|
+
reviewCommentsByPr.set(key, { at: now, comments });
|
|
871
|
+
return comments;
|
|
872
|
+
}
|
|
873
|
+
invalidateReviewComments() {
|
|
874
|
+
reviewCommentsByPr.delete(prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd));
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* OUR live inline finding comments under THIS tag: top-level, authored by our
|
|
878
|
+
* own login (unspoofable), carrying the anchored byte-0 marker. Identity is
|
|
879
|
+
* author + marker exactly like selectOwnComments; an unresolved login returns
|
|
880
|
+
* nothing (fail closed). `hasReplies` is derived from the same listed snapshot.
|
|
881
|
+
*/
|
|
882
|
+
async ownInlineComments() {
|
|
883
|
+
const [comments, ownLogin] = await Promise.all([
|
|
884
|
+
this.fetchAllReviewComments(),
|
|
885
|
+
this.resolveOwnLogin(),
|
|
886
|
+
]);
|
|
887
|
+
if (!ownLogin) {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
890
|
+
const repliedTo = new Set(comments
|
|
891
|
+
.filter((comment) => comment.in_reply_to_id != null)
|
|
892
|
+
.map((comment) => comment.in_reply_to_id));
|
|
893
|
+
const own = [];
|
|
894
|
+
for (const comment of comments) {
|
|
895
|
+
if (comment.in_reply_to_id != null || comment.user?.login !== ownLogin) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const fp = parseInlineMarkerFp(comment.body ?? "", this.options.commentTag);
|
|
899
|
+
if (!fp) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
own.push({
|
|
903
|
+
id: comment.id,
|
|
904
|
+
fp,
|
|
905
|
+
body: comment.body ?? "",
|
|
906
|
+
...(comment.html_url ? { url: comment.html_url } : {}),
|
|
907
|
+
hasReplies: repliedTo.has(comment.id),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return own;
|
|
911
|
+
}
|
|
912
|
+
/** The PR's CURRENT head OID, fetched live — or null. Used to refuse creates
|
|
913
|
+
* whose commit_id (the head this run reviewed) is no longer the head. */
|
|
914
|
+
async liveHeadOid() {
|
|
915
|
+
try {
|
|
916
|
+
const gh = await resolveTrustedTool("gh");
|
|
917
|
+
const { stdout } = await run(gh, [
|
|
918
|
+
"pr",
|
|
919
|
+
"view",
|
|
920
|
+
String(this.options.prNumber),
|
|
921
|
+
"--repo",
|
|
922
|
+
this.options.repo,
|
|
923
|
+
"--json",
|
|
924
|
+
"headRefOid",
|
|
925
|
+
"--jq",
|
|
926
|
+
".headRefOid",
|
|
927
|
+
], { cwd: this.options.cwd });
|
|
928
|
+
return stdout.trim() || null;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/** Secondary-rate-limit shapes (403/429): abort the rest of the sync, not just
|
|
935
|
+
* this mutation — hammering on would dig the hole deeper. */
|
|
936
|
+
static isRateLimitError(error) {
|
|
937
|
+
return /HTTP 403|HTTP 429|rate limit|secondary rate/i.test(errorMessage(error));
|
|
938
|
+
}
|
|
939
|
+
// @ref LLP 0008#github-reporter-identity [constrained-by] — inline threads are
|
|
940
|
+
// patched/deleted only when author + anchored marker both match; creates also require
|
|
941
|
+
// a resolved own login, or every run would duplicate what it cannot recognize
|
|
942
|
+
/**
|
|
943
|
+
* Converge this reporter's inline comments to `targets` (the ACTIVE, in-diff
|
|
944
|
+
* findings), returning fp → thread permalink for the live full-form threads.
|
|
945
|
+
* Never throws and never blocks the main comment: every failure degrades to
|
|
946
|
+
* "that finding renders full-form in the main comment".
|
|
947
|
+
*
|
|
948
|
+
* `teardown: false` marks a run whose target set may be INCOMPLETE — a failed
|
|
949
|
+
* review, a `--scopes` partial run, carried/truncated aggregate state — and makes
|
|
950
|
+
* the sync additive-only (see planInlineSync): a finding this run cannot see must
|
|
951
|
+
* not have its thread torn down.
|
|
952
|
+
*
|
|
953
|
+
* Creates require the reviewed head (`options.headSha`) to still BE the live head:
|
|
954
|
+
* after a mid-review push the diff we validated lines against no longer matches
|
|
955
|
+
* the commit we'd anchor to — a stale commit_id can silently pin the comment to
|
|
956
|
+
* the wrong code (or 422 wholesale after a force-push) — so creates are skipped
|
|
957
|
+
* and logged; patches/stubs/deletes are body-only and safe either way.
|
|
958
|
+
*/
|
|
959
|
+
async syncInline(targets, link, opts) {
|
|
960
|
+
const urls = new Map();
|
|
961
|
+
const config = this.options.inline;
|
|
962
|
+
if (!config?.enabled || !link.diffLines) {
|
|
963
|
+
return urls;
|
|
964
|
+
}
|
|
965
|
+
const eligible = targets.filter(({ finding }) => finding.line != null && link.diffLines.get(finding.file)?.has(finding.line) === true);
|
|
966
|
+
let existing;
|
|
967
|
+
try {
|
|
968
|
+
existing = await this.ownInlineComments();
|
|
969
|
+
if (eligible.length > 0 && (await this.resolveOwnLogin()) == null) {
|
|
970
|
+
// Without an own login we cannot recognize our previous comments, so a
|
|
971
|
+
// create now would duplicate on every future run. Fail closed.
|
|
972
|
+
process.stderr.write("inline sync: could not resolve the posting identity; skipping inline comments this run.\n");
|
|
973
|
+
return urls;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
process.stderr.write(`inline sync: could not list review comments (findings render in the main comment): ${errorMessage(error)}\n`);
|
|
978
|
+
return urls;
|
|
979
|
+
}
|
|
980
|
+
const plan = planInlineSync({
|
|
981
|
+
targets: eligible,
|
|
982
|
+
existing,
|
|
983
|
+
maxComments: config.maxComments,
|
|
984
|
+
teardown: opts.teardown,
|
|
985
|
+
});
|
|
986
|
+
// One live-head check per sync, only when creating. A mismatch (or an unknown
|
|
987
|
+
// reviewed head) skips creates; everything body-only still runs.
|
|
988
|
+
let creates = plan.create;
|
|
989
|
+
if (creates.length > 0) {
|
|
990
|
+
const reviewedHead = this.options.headSha;
|
|
991
|
+
const live = reviewedHead ? await this.liveHeadOid() : null;
|
|
992
|
+
if (!reviewedHead || live !== reviewedHead) {
|
|
993
|
+
process.stderr.write(`inline sync: PR head ${reviewedHead ? "moved during the review" : "is unknown"}; ` +
|
|
994
|
+
`skipping ${creates.length} new inline comment(s) this run.\n`);
|
|
995
|
+
creates = [];
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const counts = { created: 0, patched: 0, stubbed: 0, deleted: 0, failed: 0 };
|
|
999
|
+
let aborted = false;
|
|
1000
|
+
const attempt = async (fn) => {
|
|
1001
|
+
if (aborted) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await fn();
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
counts.failed++;
|
|
1009
|
+
if (GitHubReporter.isRateLimitError(error)) {
|
|
1010
|
+
aborted = true;
|
|
1011
|
+
process.stderr.write(`inline sync: rate-limited; aborting remaining inline mutations this run: ${errorMessage(error)}\n`);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
process.stderr.write(`inline sync: mutation failed (skipped): ${errorMessage(error)}\n`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
// Sequential on purpose: parallel content creation is what trips GitHub's
|
|
1019
|
+
// secondary rate limit.
|
|
1020
|
+
for (const { comment, finding } of plan.patch) {
|
|
1021
|
+
if (comment.url) {
|
|
1022
|
+
urls.set(comment.fp, comment.url);
|
|
1023
|
+
}
|
|
1024
|
+
const body = renderInlineCommentBody(finding, this.options.commentTag, comment.fp);
|
|
1025
|
+
if (inlineBodiesEqual(comment.body, body)) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
await attempt(async () => {
|
|
1029
|
+
await this.patchReviewComment(comment.id, body);
|
|
1030
|
+
counts.patched++;
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
for (const entry of creates) {
|
|
1034
|
+
await attempt(async () => {
|
|
1035
|
+
const created = await this.createReviewComment(renderInlineCommentBody(entry.finding, this.options.commentTag, entry.fp), entry.finding.file, entry.finding.line);
|
|
1036
|
+
counts.created++;
|
|
1037
|
+
if (created?.html_url) {
|
|
1038
|
+
urls.set(entry.fp, created.html_url);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
for (const comment of plan.stub) {
|
|
1043
|
+
const body = inlineStubBody(this.options.commentTag, comment.fp);
|
|
1044
|
+
if (inlineBodiesEqual(comment.body, body)) {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
await attempt(async () => {
|
|
1048
|
+
await this.patchReviewComment(comment.id, body);
|
|
1049
|
+
counts.stubbed++;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
for (const comment of plan.remove) {
|
|
1053
|
+
await attempt(async () => {
|
|
1054
|
+
await this.deleteReviewComment(comment.id);
|
|
1055
|
+
counts.deleted++;
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const total = counts.created + counts.patched + counts.stubbed + counts.deleted + counts.failed;
|
|
1059
|
+
if (total > 0) {
|
|
1060
|
+
const summary = `inline sync: ${counts.created} created, ${counts.patched} patched, ` +
|
|
1061
|
+
`${counts.stubbed} stubbed, ${counts.deleted} deleted, ${counts.failed} failed`;
|
|
1062
|
+
process.stderr.write(`${summary}\n`);
|
|
1063
|
+
// Inline comments are mutated in place like the main comment, so this line is
|
|
1064
|
+
// the only per-run record of what changed (mirrors upsertComment's mirror).
|
|
1065
|
+
await appendStepSummary(`🤖 AI review — ${summary}`);
|
|
1066
|
+
}
|
|
1067
|
+
return urls;
|
|
1068
|
+
}
|
|
1069
|
+
async createReviewComment(body, path_, line) {
|
|
1070
|
+
this.invalidateReviewComments();
|
|
1071
|
+
const gh = await resolveTrustedTool("gh");
|
|
1072
|
+
const payload = JSON.stringify({
|
|
1073
|
+
body,
|
|
1074
|
+
commit_id: this.options.headSha,
|
|
1075
|
+
path: path_,
|
|
1076
|
+
line,
|
|
1077
|
+
side: "RIGHT",
|
|
1078
|
+
});
|
|
1079
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ecr-"));
|
|
1080
|
+
const jsonPath = path.join(dir, "review-comment.json");
|
|
1081
|
+
try {
|
|
1082
|
+
await writeFile(jsonPath, payload, "utf8");
|
|
1083
|
+
const { stdout } = await run(gh, [
|
|
1084
|
+
"api",
|
|
1085
|
+
"-X",
|
|
1086
|
+
"POST",
|
|
1087
|
+
`repos/${this.options.repo}/pulls/${this.options.prNumber}/comments`,
|
|
1088
|
+
"--input",
|
|
1089
|
+
jsonPath,
|
|
1090
|
+
], { cwd: this.options.cwd });
|
|
1091
|
+
try {
|
|
1092
|
+
return JSON.parse(stdout);
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
await rm(dir, { recursive: true, force: true });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async patchReviewComment(commentId, body) {
|
|
1103
|
+
this.invalidateReviewComments();
|
|
1104
|
+
const gh = await resolveTrustedTool("gh");
|
|
1105
|
+
await this.withBodyFile(body, (jsonPath) => run(gh, [
|
|
1106
|
+
"api",
|
|
1107
|
+
"-X",
|
|
1108
|
+
"PATCH",
|
|
1109
|
+
`repos/${this.options.repo}/pulls/comments/${commentId}`,
|
|
1110
|
+
"--input",
|
|
1111
|
+
jsonPath,
|
|
1112
|
+
], { cwd: this.options.cwd }));
|
|
1113
|
+
}
|
|
1114
|
+
async deleteReviewComment(commentId) {
|
|
1115
|
+
this.invalidateReviewComments();
|
|
1116
|
+
const gh = await resolveTrustedTool("gh");
|
|
1117
|
+
await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/pulls/comments/${commentId}`], { cwd: this.options.cwd });
|
|
1118
|
+
}
|
|
579
1119
|
/**
|
|
580
1120
|
* Add or remove per-PR finding dismissals in the reviewer's comment and re-render
|
|
581
1121
|
* it in place — no re-review needed (the comment embeds the full review state). The
|
|
@@ -601,6 +1141,44 @@ export class GitHubReporter {
|
|
|
601
1141
|
? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
|
|
602
1142
|
: renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins, state.inputHash);
|
|
603
1143
|
await this.patchComment(existing.id, body);
|
|
1144
|
+
// Additive-only inline updates for exactly the fps this action named — never a
|
|
1145
|
+
// full stale sweep: the embedded state an aggregate stores may be TRUNCATED
|
|
1146
|
+
// (renderAggregateMarkdown trims kept findings to fit GitHub's body cap), so
|
|
1147
|
+
// "not in state" here does not mean "gone" and must not tear a thread down.
|
|
1148
|
+
// Fail-soft: a dismissal must never fail on the inline layer.
|
|
1149
|
+
if (this.options.inline?.enabled) {
|
|
1150
|
+
try {
|
|
1151
|
+
const own = await this.ownInlineComments();
|
|
1152
|
+
const byFp = new Map(own.map((comment) => [comment.fp, comment]));
|
|
1153
|
+
const findingById = stateFindingsById(state);
|
|
1154
|
+
for (const fp of next.matched) {
|
|
1155
|
+
const comment = byFp.get(fp);
|
|
1156
|
+
if (!comment) {
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const stub = inlineStubBody(this.options.commentTag, fp);
|
|
1160
|
+
if (!inlineBodiesEqual(comment.body, stub)) {
|
|
1161
|
+
await this.patchReviewComment(comment.id, stub);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
for (const fp of remove) {
|
|
1165
|
+
const comment = byFp.get(fp);
|
|
1166
|
+
const finding = findingById.get(fp);
|
|
1167
|
+
// A finding truncated out of the state (or a thread that was deleted) just
|
|
1168
|
+
// renders full-form in the main comment until the next review re-creates it.
|
|
1169
|
+
if (!comment || !finding) {
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const full = renderInlineCommentBody(finding, this.options.commentTag, fp);
|
|
1173
|
+
if (!inlineBodiesEqual(comment.body, full)) {
|
|
1174
|
+
await this.patchReviewComment(comment.id, full);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
process.stderr.write(`inline sync: dismissal update skipped (main comment already updated): ${errorMessage(error)}\n`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
604
1182
|
return {
|
|
605
1183
|
dismissedCount: next.dismissed.length,
|
|
606
1184
|
matched: next.matched,
|