@expo/code-review-cli 0.6.0 → 0.8.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 +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
|
@@ -1,12 +1,214 @@
|
|
|
1
|
+
// @ref LLP 0008#github-reporter-identity — the reporter's core problem: which PR comment is ours; identity is proven by author + marker, never marker alone
|
|
1
2
|
import { writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { run } from "../core/exec.js";
|
|
5
|
+
import { resolveTrustedTool, run } from "../core/exec.js";
|
|
5
6
|
import { parseUnifiedDiff } from "../core/diff.js";
|
|
6
7
|
import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
|
|
7
|
-
import {
|
|
8
|
+
import { matchReplies } from "../core/responses.js";
|
|
9
|
+
import { dropStaleVerdict, feedbackApplied, feedbackNeedsPrAuthor } from "../core/adjudicate.js";
|
|
10
|
+
import { applyPins, collectPins, fingerprintFinding, scopedFingerprint } from "../core/schema.js";
|
|
8
11
|
import { appendStepSummary } from "../core/step-summary.js";
|
|
9
12
|
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
13
|
+
/**
|
|
14
|
+
* PR-author lookups shared across reporter INSTANCES, keyed by the exact PR they
|
|
15
|
+
* describe. Routed CI builds one GitHubReporter per scope comment (plus one for the
|
|
16
|
+
* aggregate comment), and every one of them would otherwise run its own `gh pr view` for
|
|
17
|
+
* the SAME PR. The key carries the checkout, the repo and the PR number, so a lookup can
|
|
18
|
+
* never leak across repos or PRs in one process (the `ecr feedback` crawl walks many
|
|
19
|
+
* PRs). The in-flight promise is stored, not just the result, so concurrent scopes share
|
|
20
|
+
* one call.
|
|
21
|
+
*/
|
|
22
|
+
const prAuthorByPr = new Map();
|
|
23
|
+
/** Cache key for prAuthorByPr. Newline-joined with the free-form part (the checkout
|
|
24
|
+
* path) LAST: a PR number is digits and a repo is `owner/name`, neither of which can
|
|
25
|
+
* contain a newline, so two different PRs can never collide on one key. Exported for
|
|
26
|
+
* tests. */
|
|
27
|
+
export function prAuthorCacheKey(repo, prNumber, cwd) {
|
|
28
|
+
return `${prNumber}\n${repo}\n${cwd ?? ""}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Run `resolve` at most once per PR for the lifetime of the process — but cache only a
|
|
32
|
+
* SUCCESSFUL answer. A `gh pr view` that fails (rate limit, network blip) resolves to
|
|
33
|
+
* null, and caching that null would make one transient error fail-close every later
|
|
34
|
+
* scope of the same run: routed CI builds a reporter per scope, all reading this map,
|
|
35
|
+
* so every reply would be marked `author: false` and the adjudicated clear path could
|
|
36
|
+
* not fire again for the whole process. The entry is therefore removed once the promise
|
|
37
|
+
* settles to null (or rejects, which leaves nothing dangling either), so the next scope
|
|
38
|
+
* retries. Concurrency is unchanged: the in-flight promise is what's stored, so
|
|
39
|
+
* simultaneous scopes still join one call rather than each firing their own. Exported
|
|
40
|
+
* for tests (the reporter's own call path needs `gh`).
|
|
41
|
+
*/
|
|
42
|
+
export function sharedPrAuthor(key, resolve) {
|
|
43
|
+
const cached = prAuthorByPr.get(key);
|
|
44
|
+
if (cached) {
|
|
45
|
+
return cached;
|
|
46
|
+
}
|
|
47
|
+
// Only ever forget OUR entry: a later call may already have started a new lookup.
|
|
48
|
+
const forget = (settled) => {
|
|
49
|
+
if (prAuthorByPr.get(key) === settled) {
|
|
50
|
+
prAuthorByPr.delete(key);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const pending = resolve().then((login) => {
|
|
54
|
+
if (login == null) {
|
|
55
|
+
forget(pending);
|
|
56
|
+
}
|
|
57
|
+
return login;
|
|
58
|
+
}, (error) => {
|
|
59
|
+
forget(pending);
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
prAuthorByPr.set(key, pending);
|
|
63
|
+
return pending;
|
|
64
|
+
}
|
|
65
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged
|
|
66
|
+
/**
|
|
67
|
+
* Carry a decision already recorded in the prior comment state onto the freshly
|
|
68
|
+
* matched records. A **verdict** (and the `applied` it justified) is bound to the words
|
|
69
|
+
* it judged AND to the source it judged: it carries only while the newest reply is the
|
|
70
|
+
* SAME comment it was decided about and `dropStaleVerdict` agrees the head is
|
|
71
|
+
* unchanged. A newer reply answers different words; a newer head means the code the
|
|
72
|
+
* rebuttal relied on may be gone. Either way the reply is re-judged rather than trusted.
|
|
73
|
+
*
|
|
74
|
+
* Fresh records own the reply identity (author, comment id, link); the prior state owns
|
|
75
|
+
* the decision. A maintainer's `/undismiss` pin is NOT merged here — it belongs to the
|
|
76
|
+
* finding, lives in the comment state's own `pins` set, and is stamped back on by
|
|
77
|
+
* `applyPins` (which is what keeps it alive through a run that matches no reply at all).
|
|
78
|
+
*/
|
|
79
|
+
function mergeFeedback(fresh, previous, headSha) {
|
|
80
|
+
const priorByFp = new Map(previous.map((record) => [record.fp, record]));
|
|
81
|
+
return fresh.map((record) => {
|
|
82
|
+
const prior = priorByFp.get(record.fp);
|
|
83
|
+
if (!prior || prior.commentId !== record.commentId) {
|
|
84
|
+
// No prior decision, or different words: nothing decided about the old comment
|
|
85
|
+
// carries.
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
return dropStaleVerdict({
|
|
89
|
+
...record,
|
|
90
|
+
...(prior.verdict !== undefined ? { verdict: prior.verdict } : {}),
|
|
91
|
+
...(prior.reason !== undefined ? { reason: prior.reason } : {}),
|
|
92
|
+
...(prior.sourceSha !== undefined ? { sourceSha: prior.sourceSha } : {}),
|
|
93
|
+
applied: prior.applied,
|
|
94
|
+
}, headSha);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// @ref LLP 0011#deterministic-matching [implements] — the pure core of matchAdjudicationItems: no IO, so it is unit-testable; the caller supplies the fetched state/replies
|
|
98
|
+
/**
|
|
99
|
+
* Pair each matched reply with the finding it answers and its raw reply text, carrying
|
|
100
|
+
* any prior verdict forward. Pure: the caller has already fetched `previousFeedback`
|
|
101
|
+
* (the comment's stored records) and `replies`. `fpOf` MUST key findings the same way
|
|
102
|
+
* that comment stores them (scope-namespaced for an aggregate comment, plain otherwise)
|
|
103
|
+
* or a prior verdict can never carry — its fp would not match the fresh record's.
|
|
104
|
+
* `headSha` is the source revision being reviewed: a stored verdict carries only when
|
|
105
|
+
* it judged that same revision (see mergeFeedback). `pins` is the comment's
|
|
106
|
+
* `/undismiss` set: it is stamped onto the matched records so a pinned finding is never
|
|
107
|
+
* re-judged and never re-cleared (a v3 comment's record-level flags migrate in through
|
|
108
|
+
* collectPins). Exported for tests.
|
|
109
|
+
*/
|
|
110
|
+
export function buildAdjudicationItems(review, previousFeedback, replies, fpOf, match, headSha, pins) {
|
|
111
|
+
const withFp = review.findings.map((finding) => ({ finding, fp: fpOf(finding) }));
|
|
112
|
+
const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match }), previousFeedback, headSha), collectPins(pins, previousFeedback));
|
|
113
|
+
const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
|
|
114
|
+
const bodyById = new Map(replies.map((reply) => [reply.id, reply.body]));
|
|
115
|
+
const items = [];
|
|
116
|
+
for (const record of records) {
|
|
117
|
+
const finding = findingByFp.get(record.fp);
|
|
118
|
+
if (!finding) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
items.push({ finding, record, replyText: bodyById.get(record.commentId) ?? "" });
|
|
122
|
+
}
|
|
123
|
+
return items;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The findings a posted comment holds, keyed by the id that comment renders them under:
|
|
127
|
+
* scope-namespaced on an aggregate (comment:'single') comment, plain otherwise. This is
|
|
128
|
+
* both the valid-id set a `/dismiss` is checked against and the lookup that re-derives
|
|
129
|
+
* each feedback record's `applied` under the current config.
|
|
130
|
+
*/
|
|
131
|
+
function stateFindingsById(state) {
|
|
132
|
+
const scopes = state.scopes;
|
|
133
|
+
if (scopes && scopes.length > 0) {
|
|
134
|
+
return new Map(scopes.flatMap((scope) => scope.review.findings.map((finding) => [scopedFingerprint(scope.isDefault ? null : scope.scope, finding), finding])));
|
|
135
|
+
}
|
|
136
|
+
return new Map(state.review.findings.map((finding) => [fingerprintFinding(finding), finding]));
|
|
137
|
+
}
|
|
138
|
+
// @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — `/undismiss` writes the pin into the state's own pin set (never only onto a reply record), and `/dismiss` is the maintainer action that lifts it
|
|
139
|
+
// @ref LLP 0011#hard-floors-in-code [implements] — a re-render is a render: every kept record's `applied` is re-derived from feedbackApplied under the CURRENT config, never carried over as a stored fact
|
|
140
|
+
/**
|
|
141
|
+
* The pure state transition behind applyDismissal: which findings end up dismissed,
|
|
142
|
+
* which are pinned back to the active list, and what each feedback record's `applied`
|
|
143
|
+
* flag is under the config in force NOW. Exported for tests (the reporter's own path
|
|
144
|
+
* needs `gh`).
|
|
145
|
+
*
|
|
146
|
+
* Three rules meet here:
|
|
147
|
+
* - a `/dismiss` (an fp in `add`) is only recorded for an id this comment actually
|
|
148
|
+
* holds, and it LIFTS any pin on that finding — the same trusted hand deciding the
|
|
149
|
+
* opposite way;
|
|
150
|
+
* - a `/undismiss` (an fp in `remove`) restores a finding a REPLY cleared, not just a
|
|
151
|
+
* manual dismissal: it pins the finding so a later re-review recomputing `applied`
|
|
152
|
+
* from the still-present reply keeps it active;
|
|
153
|
+
* - every record's `applied` is then re-derived with `feedbackApplied` — the same single
|
|
154
|
+
* decision function every other render path uses. Passing a record through untouched
|
|
155
|
+
* would keep honoring the config of the run that stored it, so a repo that has since
|
|
156
|
+
* tightened `dismiss` (or widened `protectedCategories`) would leave an unrelated
|
|
157
|
+
* finding hidden until the next full review. A record whose finding is no longer in
|
|
158
|
+
* the comment keeps its stored flag, exactly as computeFeedback does.
|
|
159
|
+
*/
|
|
160
|
+
export function applyDismissalToState(state, add, remove, config, by, reason) {
|
|
161
|
+
const findingById = stateFindingsById(state);
|
|
162
|
+
const matched = add.filter((fp) => findingById.has(fp));
|
|
163
|
+
const unmatched = add.filter((fp) => !findingById.has(fp));
|
|
164
|
+
const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
|
|
165
|
+
for (const fp of matched) {
|
|
166
|
+
if (!dismissed.some((record) => record.fp === fp)) {
|
|
167
|
+
dismissed.push({ fp, by, reason });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const records = state.feedback ?? [];
|
|
171
|
+
const removeSet = new Set(remove);
|
|
172
|
+
const addSet = new Set(matched);
|
|
173
|
+
const pins = collectPins(state.pins, records).filter((pin) => !addSet.has(pin.fp));
|
|
174
|
+
for (const record of records) {
|
|
175
|
+
if (removeSet.has(record.fp) && !pins.some((pin) => pin.fp === record.fp)) {
|
|
176
|
+
// Pinned against the reply that is current right now: that same reply must not
|
|
177
|
+
// lift the pin later, only a maintainer reply posted after it (see applyPins).
|
|
178
|
+
pins.push({ fp: record.fp, commentId: record.commentId });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const stamped = applyPins(records, pins);
|
|
182
|
+
const feedback = stamped.records.map((record) => {
|
|
183
|
+
const finding = findingById.get(record.fp);
|
|
184
|
+
// No feedback config ⇒ the feature is off for this caller, so nothing is applied;
|
|
185
|
+
// the record itself (who replied, any verdict) is still preserved.
|
|
186
|
+
if (!config) {
|
|
187
|
+
return { ...record, applied: false };
|
|
188
|
+
}
|
|
189
|
+
return finding ? { ...record, applied: feedbackApplied(finding, record, config) } : record;
|
|
190
|
+
});
|
|
191
|
+
return { dismissed, feedback, pins: stamped.pins, matched, unmatched };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
|
|
195
|
+
* by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
|
|
196
|
+
* public literal and is readable in the base-branch config, so anyone who can comment
|
|
197
|
+
* on the PR (the untrusted PR author included) could post a comment carrying it plus a
|
|
198
|
+
* forged embedded review state; a newest-marker-wins lookup would then adopt that
|
|
199
|
+
* state and carry its `dismissed` list forward, silently suppressing real findings.
|
|
200
|
+
* GitHub sets a comment's author from the authenticated identity and it cannot be
|
|
201
|
+
* spoofed, so matching on author closes that. When `ownLogin` is null the author
|
|
202
|
+
* cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
|
|
203
|
+
* tests.
|
|
204
|
+
*/
|
|
205
|
+
// @ref LLP 0008#github-reporter-identity [constrained-by] — marker-only matching would let a forged comment (from the untrusted PR author) carry forged dismissal state forward; author+marker is what makes identity unspoofable
|
|
206
|
+
export function selectOwnComments(comments, marker, ownLogin) {
|
|
207
|
+
if (!ownLogin) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
return comments.filter((comment) => comment.body?.includes(marker) && comment.user?.login === ownLogin);
|
|
211
|
+
}
|
|
10
212
|
/**
|
|
11
213
|
* Maintains exactly one PR comment, updating it in place across re-reviews (and
|
|
12
214
|
* cleaning up duplicates) so the review converges instead of churning. Runs the
|
|
@@ -15,10 +217,87 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
|
15
217
|
export class GitHubReporter {
|
|
16
218
|
options;
|
|
17
219
|
marker;
|
|
220
|
+
/** Memoized login of the account this reporter posts as (see resolveOwnLogin). */
|
|
221
|
+
ownLoginResolution;
|
|
18
222
|
constructor(options) {
|
|
19
223
|
this.options = options;
|
|
20
224
|
this.marker = commentMarker(options.commentTag);
|
|
21
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* The login of the account this reporter comments as, so its own comment is
|
|
228
|
+
* recognized by AUTHOR (see selectOwnComments for why the body marker is not enough).
|
|
229
|
+
* Resolution: `gh api user` (a user/PAT token), else the scaffolded workflow's default
|
|
230
|
+
* GITHUB_TOKEN identity, `github-actions[bot]`, when running under Actions — an
|
|
231
|
+
* installation token can't read `/user`. Null when neither is available, which makes
|
|
232
|
+
* selectOwnComments treat no comment as ours (fail closed). Memoized: the identity is
|
|
233
|
+
* stable for the process, and every reporter method consults it.
|
|
234
|
+
*/
|
|
235
|
+
resolveOwnLogin() {
|
|
236
|
+
if (this.options.ownLogin) {
|
|
237
|
+
return Promise.resolve(this.options.ownLogin);
|
|
238
|
+
}
|
|
239
|
+
this.ownLoginResolution ??= (async () => {
|
|
240
|
+
try {
|
|
241
|
+
const gh = await resolveTrustedTool("gh");
|
|
242
|
+
const { stdout } = await run(gh, ["api", "user", "--jq", ".login"], {
|
|
243
|
+
cwd: this.options.cwd,
|
|
244
|
+
});
|
|
245
|
+
const login = stdout.trim();
|
|
246
|
+
if (login) {
|
|
247
|
+
return login;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// The default GITHUB_TOKEN is an installation token: `/user` returns 403.
|
|
252
|
+
}
|
|
253
|
+
return process.env.GITHUB_ACTIONS ? "github-actions[bot]" : null;
|
|
254
|
+
})();
|
|
255
|
+
return this.ownLoginResolution;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The PR author's login, used to mark a reply as coming from the author (see
|
|
259
|
+
* replyComments) so the adjudicated clear path can gate on it. Resolved via
|
|
260
|
+
* `gh pr view --json author` and fail-soft — a null result marks no reply as the
|
|
261
|
+
* author's, so the adjudicated path clears nothing (fail closed), exactly like an
|
|
262
|
+
* unresolved own-login. Memoized per PR across reporter INSTANCES (see
|
|
263
|
+
* prAuthorByPr), not per instance: routed CI builds one reporter per scope for the
|
|
264
|
+
* same PR, and the author's login is a property of the PR, not of the reporter.
|
|
265
|
+
*/
|
|
266
|
+
resolvePrAuthor() {
|
|
267
|
+
const key = prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd);
|
|
268
|
+
return sharedPrAuthor(key, async () => {
|
|
269
|
+
try {
|
|
270
|
+
const gh = await resolveTrustedTool("gh");
|
|
271
|
+
const { stdout } = await run(gh, [
|
|
272
|
+
"pr",
|
|
273
|
+
"view",
|
|
274
|
+
String(this.options.prNumber),
|
|
275
|
+
"--repo",
|
|
276
|
+
this.options.repo,
|
|
277
|
+
"--json",
|
|
278
|
+
"author",
|
|
279
|
+
"--jq",
|
|
280
|
+
".author.login",
|
|
281
|
+
], { cwd: this.options.cwd });
|
|
282
|
+
const login = stdout.trim();
|
|
283
|
+
return login || null;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// No PR author resolvable (missing PR, API error): fail closed — no reply is
|
|
287
|
+
// treated as the author's, so nothing clears via the adjudicated path.
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** This reporter's own marker comments, author-verified (see selectOwnComments). */
|
|
293
|
+
async ownComments() {
|
|
294
|
+
const [comments, ownLogin] = await Promise.all([
|
|
295
|
+
this.fetchAllComments(),
|
|
296
|
+
this.resolveOwnLogin(),
|
|
297
|
+
]);
|
|
298
|
+
return selectOwnComments(comments, this.marker, ownLogin);
|
|
299
|
+
}
|
|
300
|
+
// @ref LLP 0008#comment-lifecycle [constrained-by] — gates on author_association (OWNER/MEMBER/COLLABORATOR), not on posting the marker string, so only a maintainer can skip review
|
|
22
301
|
async checkBreakGlass() {
|
|
23
302
|
const comments = await this.fetchAllComments();
|
|
24
303
|
return comments.some((comment) => typeof comment.body === "string" &&
|
|
@@ -28,26 +307,208 @@ export class GitHubReporter {
|
|
|
28
307
|
async postSkipNote() {
|
|
29
308
|
await this.upsertComment(`${this.marker}\n🤖 AI review skipped via \`${this.options.breakGlassMarker}\`.`);
|
|
30
309
|
}
|
|
31
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Post/update the single-scope comment. When `feedback` is supplied (the
|
|
312
|
+
* adjudication path already matched + judged the replies) it is rendered as-is;
|
|
313
|
+
* otherwise the reporter matches live replies to this review's findings itself
|
|
314
|
+
* (annotate mode). Either way the feedback path fails soft — it never blocks the
|
|
315
|
+
* comment from being posted.
|
|
316
|
+
*/
|
|
317
|
+
async report(review, feedback) {
|
|
32
318
|
// Carry forward any per-PR dismissals recorded in the existing comment so they
|
|
33
319
|
// survive re-reviews (a dismissed finding stays in the collapsed section).
|
|
34
320
|
const existing = await this.findExistingComment();
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
321
|
+
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
|
|
322
|
+
const dismissed = state?.dismissed ?? [];
|
|
323
|
+
const withFp = review.findings.map((finding) => ({
|
|
324
|
+
finding,
|
|
325
|
+
fp: fingerprintFinding(finding),
|
|
326
|
+
}));
|
|
327
|
+
// The `/undismiss` pins carry on every render, whether or not this run matched a
|
|
328
|
+
// reply for the pinned finding — the records may come and go, the pin does not.
|
|
329
|
+
const priorRecords = state?.feedback ?? [];
|
|
330
|
+
const pinsIn = collectPins(state?.pins, priorRecords);
|
|
331
|
+
const { records, pins } = feedback
|
|
332
|
+
? applyPins(feedback, pinsIn)
|
|
333
|
+
: await this.computeFeedback(withFp, priorRecords, pinsIn);
|
|
38
334
|
const link = await this.linkContextAsync();
|
|
39
|
-
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
|
|
335
|
+
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
|
|
40
336
|
}
|
|
41
337
|
/** Post/update the aggregate multi-scope comment (comment:'single' mode). */
|
|
42
|
-
async reportAggregate(results, unmatchedFiles) {
|
|
338
|
+
async reportAggregate(results, unmatchedFiles, feedback) {
|
|
43
339
|
const existing = await this.findExistingComment();
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
340
|
+
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
|
|
341
|
+
const dismissed = state?.dismissed ?? [];
|
|
342
|
+
// Feedback is keyed by the SAME scope-namespaced id the aggregate comment
|
|
343
|
+
// renders, so a record can never drift across scopes.
|
|
344
|
+
const withFp = results.flatMap((result) => result.review.findings.map((finding) => ({
|
|
345
|
+
finding,
|
|
346
|
+
fp: scopedFingerprint(result.isDefault ? null : result.scope, finding),
|
|
347
|
+
})));
|
|
348
|
+
const priorRecords = state?.feedback ?? [];
|
|
349
|
+
const pinsIn = collectPins(state?.pins, priorRecords);
|
|
350
|
+
const { records, pins } = feedback
|
|
351
|
+
? applyPins(feedback, pinsIn)
|
|
352
|
+
: await this.computeFeedback(withFp, priorRecords, pinsIn);
|
|
47
353
|
const link = await this.linkContextAsync();
|
|
48
|
-
await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, {
|
|
49
|
-
|
|
50
|
-
|
|
354
|
+
await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, { unmatchedFiles }, records, pins));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Pair each matched PR reply with the finding it answers and its raw reply text —
|
|
358
|
+
* the input the adjudication step (core/review.ts) judges against the source. Any
|
|
359
|
+
* verdict already decided in the prior comment state is carried forward (so a reply
|
|
360
|
+
* is not re-judged for the same words). The reply text rides only the transient
|
|
361
|
+
* AdjudicationItem: it feeds the adjudicator prompt and is never stored on a record
|
|
362
|
+
* or rendered (see FeedbackRecordSchema). Gated on `mode !== "off"`. A fetch/parse
|
|
363
|
+
* error THROWS — the caller (runReview's feedback step) catches it and continues
|
|
364
|
+
* without records, which lets report() fall back to computeFeedback and preserve
|
|
365
|
+
* the previously recorded state; returning `[]` here would wipe it instead.
|
|
366
|
+
*
|
|
367
|
+
* `fpOf` MUST key findings the same way the comment this review lands in stores its
|
|
368
|
+
* feedback: scope-namespaced for the aggregate (comment:'single') comment, plain
|
|
369
|
+
* for a single/per-scope one. A mismatch would leave every fresh record keyed
|
|
370
|
+
* differently from the prior state, so mergeFeedback would carry no verdict and the
|
|
371
|
+
* budget would be re-spent judging the same words each run.
|
|
372
|
+
*/
|
|
373
|
+
async matchAdjudicationItems(review, fpOf = fingerprintFinding) {
|
|
374
|
+
const config = this.options.feedback;
|
|
375
|
+
if (!config || config.mode === "off") {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
// Deliberately NOT wrapped in a swallow-all: a fetch error here must propagate so
|
|
379
|
+
// runReview's own catch leaves `feedback` undefined — then report() falls back to
|
|
380
|
+
// computeFeedback, whose error path preserves the previously recorded state. A
|
|
381
|
+
// caught `[]` instead reads downstream as "there are no replies" and would wipe
|
|
382
|
+
// prior annotations (and un-hide reply-cleared findings) on a transient API error.
|
|
383
|
+
const existing = await this.findExistingComment();
|
|
384
|
+
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
|
|
385
|
+
const replies = await this.replyComments();
|
|
386
|
+
return buildAdjudicationItems(review, state?.feedback ?? [], replies, fpOf, config.match, this.options.headSha, state?.pins);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Read what humans pushed back on for THIS PR from the comment already posted:
|
|
390
|
+
* decode its embedded findings, match the non-bot replies, and merge any prior
|
|
391
|
+
* verdict. Works retroactively — the bot comment embeds its own findings, so no
|
|
392
|
+
* re-review is needed. `ecr feedback` crawls history through this. Fails soft:
|
|
393
|
+
* missing/unparseable comment ⇒ empty result, never a throw.
|
|
394
|
+
*/
|
|
395
|
+
async collectFeedback() {
|
|
396
|
+
try {
|
|
397
|
+
const existing = await this.findExistingComment();
|
|
398
|
+
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
|
|
399
|
+
if (!state) {
|
|
400
|
+
return { findings: [], records: [] };
|
|
401
|
+
}
|
|
402
|
+
// An aggregate comment namespaces ids per scope; a single comment does not.
|
|
403
|
+
const withFp = state.scopes && state.scopes.length > 0
|
|
404
|
+
? state.scopes.flatMap((scope) => scope.review.findings.map((finding) => ({
|
|
405
|
+
finding,
|
|
406
|
+
fp: scopedFingerprint(scope.isDefault ? null : scope.scope, finding),
|
|
407
|
+
})))
|
|
408
|
+
: state.review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
|
|
409
|
+
const replies = await this.replyComments();
|
|
410
|
+
// The retroactive crawl reports on any recorded pushback regardless of
|
|
411
|
+
// `mode`; only the match strategy is honored (default "both"). The crawl reviews
|
|
412
|
+
// nothing, so it has no head SHA to compare against: stored verdicts do not carry
|
|
413
|
+
// here. That costs the report nothing — it renders who replied and where, never a
|
|
414
|
+
// verdict — and keeps "unknown source" uniformly non-carrying.
|
|
415
|
+
const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match: this.options.feedback?.match ?? "both" }), state.feedback ?? [], this.options.headSha), collectPins(state.pins, state.feedback ?? []));
|
|
416
|
+
return { findings: withFp, records };
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return { findings: [], records: [] };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// @ref LLP 0011#deterministic-matching [implements] — replies are matched to findings deterministically; the reporter never lets a model pick which finding a reply answers
|
|
423
|
+
/**
|
|
424
|
+
* The matched, merged feedback records to render, or none. Gated on
|
|
425
|
+
* `mode !== "off"` (the annotate/adjudicate switch) and wrapped so a fetch or
|
|
426
|
+
* match error degrades to the records already recorded — a review is never
|
|
427
|
+
* blocked by the feedback path.
|
|
428
|
+
*/
|
|
429
|
+
async computeFeedback(withFp, previous, pinsIn) {
|
|
430
|
+
const config = this.options.feedback;
|
|
431
|
+
// `applied` is a function of the CURRENT config, never a stored fact: a carried
|
|
432
|
+
// record's flag was computed under the config of the run that stored it, so a
|
|
433
|
+
// repo flipping `dismiss` back to "never" (or `mode` to "off") must un-hide the
|
|
434
|
+
// finding on the next render, not keep honoring the old policy.
|
|
435
|
+
const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
|
|
436
|
+
const reapply = (records) => {
|
|
437
|
+
// Stamp the pins first: `feedbackApplied` reads the pin off the record, and the
|
|
438
|
+
// set — not the record — is what says which finding a maintainer restored.
|
|
439
|
+
const stamped = applyPins(records, pinsIn);
|
|
440
|
+
return {
|
|
441
|
+
pins: stamped.pins,
|
|
442
|
+
records: stamped.records.map((record) => {
|
|
443
|
+
const finding = findingByFp.get(record.fp);
|
|
444
|
+
// With no feedback config at all, the feature is disabled, so nothing is
|
|
445
|
+
// applied — but the record itself (who replied, any verdict) is preserved.
|
|
446
|
+
if (!config) {
|
|
447
|
+
return { ...record, applied: false };
|
|
448
|
+
}
|
|
449
|
+
return finding
|
|
450
|
+
? { ...record, applied: feedbackApplied(finding, record, config) }
|
|
451
|
+
: record;
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
// mode "off" (or no config) preserves the previously recorded records and only
|
|
456
|
+
// stops matching NEW replies. Returning [] here would let render's reviewState
|
|
457
|
+
// drop the feedback key entirely, permanently losing every recorded reply, verdict
|
|
458
|
+
// and reply-dismissal decision — mirror applyDismissal, which keeps feedback on a
|
|
459
|
+
// re-render. `applied` is still recomputed (→ false under "off"), so a finding a
|
|
460
|
+
// reply had cleared correctly returns to the active list.
|
|
461
|
+
if (!config || config.mode === "off") {
|
|
462
|
+
return reapply(previous);
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const replies = await this.replyComments();
|
|
466
|
+
return reapply(mergeFeedback(matchReplies(replies, withFp, { match: config.match }), previous, this.options.headSha));
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return reapply(previous);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// @ref LLP 0008#github-reporter-identity [implements] — a reply is any comment NOT authored by us and NOT carrying our marker; author identity (user.login) is what excludes our own, never the forgeable marker alone
|
|
473
|
+
/**
|
|
474
|
+
* The PR's human replies, as the matcher consumes them. Excludes comments we
|
|
475
|
+
* authored (by unspoofable `user.login`) and any comment carrying our marker, so
|
|
476
|
+
* our own footer is never read back as a reply; the `author_association` gives
|
|
477
|
+
* the maintainer flag and the PR author's login gives the `author` flag. When our
|
|
478
|
+
* login can't be resolved, the marker filter still keeps our own comments out.
|
|
479
|
+
*
|
|
480
|
+
* The PR-author lookup is an extra `gh` call, so it only runs when the flag can
|
|
481
|
+
* actually change an outcome — `dismiss: "adjudicated"` (see feedbackNeedsPrAuthor).
|
|
482
|
+
* Skipping it leaves every reply `author: false`, the same fail-closed answer a
|
|
483
|
+
* failed lookup gives, and under any other `dismiss` value nothing reads the flag.
|
|
484
|
+
*/
|
|
485
|
+
async replyComments() {
|
|
486
|
+
const [comments, ownLogin, prAuthor] = await Promise.all([
|
|
487
|
+
this.fetchAllComments(),
|
|
488
|
+
this.resolveOwnLogin(),
|
|
489
|
+
feedbackNeedsPrAuthor(this.options.feedback) ? this.resolvePrAuthor() : null,
|
|
490
|
+
]);
|
|
491
|
+
const out = [];
|
|
492
|
+
for (const comment of comments) {
|
|
493
|
+
const login = comment.user?.login;
|
|
494
|
+
if (!login || (ownLogin && login === ownLogin)) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (comment.body?.includes(this.marker)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
out.push({
|
|
501
|
+
id: comment.id,
|
|
502
|
+
body: comment.body ?? "",
|
|
503
|
+
login,
|
|
504
|
+
maintainer: MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""),
|
|
505
|
+
// Trusted-for-adjudication identity: the reply is from the PR author. Derived
|
|
506
|
+
// from the unspoofable comment author, not the (public) marker or reply text.
|
|
507
|
+
author: prAuthor != null && login === prAuthor,
|
|
508
|
+
...(comment.html_url ? { url: comment.html_url } : {}),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
51
512
|
}
|
|
52
513
|
/**
|
|
53
514
|
* The embedded review state of the existing reviewer comment, or null when no
|
|
@@ -65,8 +526,9 @@ export class GitHubReporter {
|
|
|
65
526
|
* reviewdog #1911 lesson).
|
|
66
527
|
*/
|
|
67
528
|
async clear() {
|
|
68
|
-
|
|
69
|
-
|
|
529
|
+
// Only ever delete comments WE authored — never touch a look-alike posted by
|
|
530
|
+
// someone else (see selectOwnComments).
|
|
531
|
+
for (const comment of await this.ownComments()) {
|
|
70
532
|
await this.deleteComment(comment.id);
|
|
71
533
|
}
|
|
72
534
|
}
|
|
@@ -88,7 +550,8 @@ export class GitHubReporter {
|
|
|
88
550
|
await Promise.all([
|
|
89
551
|
(async () => {
|
|
90
552
|
try {
|
|
91
|
-
const
|
|
553
|
+
const gh = await resolveTrustedTool("gh");
|
|
554
|
+
const { stdout } = await run(gh, ["pr", "diff", ...prArgs], { cwd });
|
|
92
555
|
link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
|
|
93
556
|
}
|
|
94
557
|
catch {
|
|
@@ -97,7 +560,8 @@ export class GitHubReporter {
|
|
|
97
560
|
})(),
|
|
98
561
|
(async () => {
|
|
99
562
|
try {
|
|
100
|
-
const
|
|
563
|
+
const gh = await resolveTrustedTool("gh");
|
|
564
|
+
const { stdout } = await run(gh, ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
|
|
101
565
|
cwd,
|
|
102
566
|
});
|
|
103
567
|
const oid = JSON.parse(stdout).baseRefOid;
|
|
@@ -114,8 +578,13 @@ export class GitHubReporter {
|
|
|
114
578
|
}
|
|
115
579
|
/**
|
|
116
580
|
* Add or remove per-PR finding dismissals in the reviewer's comment and re-render
|
|
117
|
-
* it in place — no re-review needed (the comment embeds the full review state).
|
|
581
|
+
* it in place — no re-review needed (the comment embeds the full review state). The
|
|
582
|
+
* decision itself is pure: see applyDismissalToState, which also re-derives every
|
|
583
|
+
* feedback record's `applied` under the current config and carries the `/undismiss`
|
|
584
|
+
* pin set. This is a render like any other, so it must never leave a record hidden
|
|
585
|
+
* under a policy the repo has since changed.
|
|
118
586
|
*/
|
|
587
|
+
// @ref LLP 0008#comment-lifecycle [implements] — validates added fingerprints against the CURRENT review's valid set, scope-aware (scoped fingerprints for an aggregate comment, plain otherwise), rather than trusting caller-supplied ids
|
|
119
588
|
async applyDismissal(add, remove, by, reason) {
|
|
120
589
|
const existing = await this.findExistingComment();
|
|
121
590
|
if (!existing) {
|
|
@@ -125,36 +594,55 @@ export class GitHubReporter {
|
|
|
125
594
|
if (!state) {
|
|
126
595
|
throw new Error("The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.");
|
|
127
596
|
}
|
|
128
|
-
// Scope-aware validity: on an aggregate comment the ids are scope-namespaced, so
|
|
129
|
-
// validate against every scope's scoped fingerprints; otherwise the plain ones.
|
|
130
597
|
const isAggregate = Array.isArray(state.scopes) && state.scopes.length > 0;
|
|
131
|
-
const
|
|
132
|
-
? new Set(state.scopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding))))
|
|
133
|
-
: new Set(state.review.findings.map(fingerprintFinding));
|
|
134
|
-
const matched = add.filter((fp) => validFps.has(fp));
|
|
135
|
-
const unmatched = add.filter((fp) => !validFps.has(fp));
|
|
136
|
-
const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
|
|
137
|
-
for (const fp of matched) {
|
|
138
|
-
if (!dismissed.some((record) => record.fp === fp)) {
|
|
139
|
-
dismissed.push({ fp, by, reason });
|
|
140
|
-
}
|
|
141
|
-
}
|
|
598
|
+
const next = applyDismissalToState(state, add, remove, this.options.feedback, by, reason);
|
|
142
599
|
const link = await this.linkContextAsync();
|
|
143
600
|
const body = isAggregate
|
|
144
|
-
? renderAggregateMarkdown(state.scopes, this.options.commentTag, dismissed, link)
|
|
145
|
-
: renderMarkdown(state.review, this.options.commentTag, dismissed, link);
|
|
601
|
+
? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
|
|
602
|
+
: renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
|
|
146
603
|
await this.patchComment(existing.id, body);
|
|
147
|
-
return {
|
|
604
|
+
return {
|
|
605
|
+
dismissedCount: next.dismissed.length,
|
|
606
|
+
matched: next.matched,
|
|
607
|
+
unmatched: next.unmatched,
|
|
608
|
+
};
|
|
148
609
|
}
|
|
149
|
-
/** Newest
|
|
610
|
+
/** Newest comment WE authored carrying our marker (id + body), or null if none. */
|
|
150
611
|
async findExistingComment() {
|
|
151
|
-
const
|
|
152
|
-
const keep =
|
|
612
|
+
const own = await this.ownComments();
|
|
613
|
+
const keep = own[own.length - 1];
|
|
153
614
|
return keep ? { id: keep.id, body: keep.body ?? "" } : null;
|
|
154
615
|
}
|
|
155
616
|
// Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
|
|
156
617
|
// pathological PR; virtually every real PR exits far earlier.
|
|
157
618
|
static MAX_COMMENT_PAGES = 30;
|
|
619
|
+
/** How long a fetched comment list may be reused (see fetchAllComments). */
|
|
620
|
+
static COMMENTS_CACHE_TTL_MS = 30_000;
|
|
621
|
+
commentsCache;
|
|
622
|
+
/**
|
|
623
|
+
* Burst-collapsing cache over fetchAllCommentsUncached. One logical operation
|
|
624
|
+
* fans out into several comment reads (readState + collectFeedback in the
|
|
625
|
+
* `ecr feedback` crawl; findExistingComment + replyComments + upsert in a
|
|
626
|
+
* report) that would each re-fetch the identical paginated list. The TTL is
|
|
627
|
+
* deliberately short: across a real gap (break-glass check → 30-minute review →
|
|
628
|
+
* post) a stale list could miss a fresh `/skip-review` or a new duplicate, so
|
|
629
|
+
* only back-to-back calls coalesce. Any comment mutation invalidates it.
|
|
630
|
+
*/
|
|
631
|
+
fetchAllComments() {
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
if (!this.commentsCache || now - this.commentsCache.at > GitHubReporter.COMMENTS_CACHE_TTL_MS) {
|
|
634
|
+
const comments = this.fetchAllCommentsUncached().catch((error) => {
|
|
635
|
+
// Never cache a failure.
|
|
636
|
+
this.commentsCache = undefined;
|
|
637
|
+
throw error;
|
|
638
|
+
});
|
|
639
|
+
this.commentsCache = { at: now, comments };
|
|
640
|
+
}
|
|
641
|
+
return this.commentsCache.comments;
|
|
642
|
+
}
|
|
643
|
+
invalidateComments() {
|
|
644
|
+
this.commentsCache = undefined;
|
|
645
|
+
}
|
|
158
646
|
/**
|
|
159
647
|
* Fetch ALL issue comments, paginating manually (a single page's array is valid
|
|
160
648
|
* JSON; `--paginate` concatenates arrays into invalid JSON). The issue-comments
|
|
@@ -163,10 +651,12 @@ export class GitHubReporter {
|
|
|
163
651
|
* a recent `/skip-review` can otherwise fall outside a single 100-comment window,
|
|
164
652
|
* causing duplicate comments and missed break-glass).
|
|
165
653
|
*/
|
|
166
|
-
|
|
654
|
+
// @ref LLP 0008#comment-lifecycle [constrained-by] — the issue-comments endpoint ignores sort/direction and returns oldest-first; pagination must reach the end or the newest comment (ours, or a recent break-glass) can fall outside the window
|
|
655
|
+
async fetchAllCommentsUncached() {
|
|
167
656
|
const all = [];
|
|
657
|
+
const gh = await resolveTrustedTool("gh");
|
|
168
658
|
for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
|
|
169
|
-
const { stdout } = await run(
|
|
659
|
+
const { stdout } = await run(gh, [
|
|
170
660
|
"api",
|
|
171
661
|
"-X",
|
|
172
662
|
"GET",
|
|
@@ -198,8 +688,12 @@ export class GitHubReporter {
|
|
|
198
688
|
* otherwise create it. Comments come back oldest-first, so the LAST marked one
|
|
199
689
|
* is the newest and is the keeper.
|
|
200
690
|
*/
|
|
691
|
+
// @ref LLP 0008#comment-lifecycle [implements] — converges to one live comment: patches the newest own-marker comment and deletes older duplicates, never touching a look-alike from someone else
|
|
201
692
|
async upsertComment(body) {
|
|
202
|
-
|
|
693
|
+
// Update/clean up only comments WE authored, never a look-alike posted by
|
|
694
|
+
// someone else (see selectOwnComments) — otherwise the newest forged marker
|
|
695
|
+
// comment would be adopted as "ours" and edited/patched in its place.
|
|
696
|
+
const marked = await this.ownComments();
|
|
203
697
|
if (marked.length === 0) {
|
|
204
698
|
await this.createComment(body);
|
|
205
699
|
}
|
|
@@ -227,7 +721,9 @@ export class GitHubReporter {
|
|
|
227
721
|
}
|
|
228
722
|
}
|
|
229
723
|
async createComment(body) {
|
|
230
|
-
|
|
724
|
+
this.invalidateComments();
|
|
725
|
+
const gh = await resolveTrustedTool("gh");
|
|
726
|
+
await this.withBodyFile(body, (jsonPath) => run(gh, [
|
|
231
727
|
"api",
|
|
232
728
|
"-X",
|
|
233
729
|
"POST",
|
|
@@ -237,7 +733,9 @@ export class GitHubReporter {
|
|
|
237
733
|
], { cwd: this.options.cwd }));
|
|
238
734
|
}
|
|
239
735
|
async patchComment(commentId, body) {
|
|
240
|
-
|
|
736
|
+
this.invalidateComments();
|
|
737
|
+
const gh = await resolveTrustedTool("gh");
|
|
738
|
+
await this.withBodyFile(body, (jsonPath) => run(gh, [
|
|
241
739
|
"api",
|
|
242
740
|
"-X",
|
|
243
741
|
"PATCH",
|
|
@@ -247,6 +745,8 @@ export class GitHubReporter {
|
|
|
247
745
|
], { cwd: this.options.cwd }));
|
|
248
746
|
}
|
|
249
747
|
async deleteComment(commentId) {
|
|
250
|
-
|
|
748
|
+
this.invalidateComments();
|
|
749
|
+
const gh = await resolveTrustedTool("gh");
|
|
750
|
+
await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
|
|
251
751
|
}
|
|
252
752
|
}
|