@expo/code-review-cli 0.13.0 → 0.14.2

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.
@@ -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
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins, inputHash));
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
- /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
338
- async reportAggregate(results, unmatchedFiles, feedback) {
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
- await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, { unmatchedFiles }, records, pins));
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
- const [comments, ownLogin, prAuthor] = await Promise.all([
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,