@expo/code-review-cli 0.7.0 → 0.9.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 +161 -13
- package/build/cli.js +12 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/ref-check.js +84 -0
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- 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 +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/config-refs.js +772 -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 +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +268 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +307 -15
- package/build/core/schema.js +223 -2
- package/build/core/scrub.js +4 -0
- 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 +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +10 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +6 -1
- package/templates/agents/correctness.md +9 -1
- package/templates/agents/security.md +11 -1
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- 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 +99 -1
- package/templates/workflow.yml +5 -0
package/build/core/render.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
// @ref LLP 0005#comment-rendering — pure Markdown builder; the comment body is the durable state store
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK } from "./schema.js";
|
|
3
|
+
import { collectPins, FeedbackPinSchema, FeedbackRecordSchema, fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK, } from "./schema.js";
|
|
3
4
|
/**
|
|
4
5
|
* Build the file → right-side-line-numbers index from changed files' patch text,
|
|
5
6
|
* by walking each unified-diff hunk (`@@ -a,b +c,d @@`) and collecting the new-tree
|
|
@@ -62,9 +63,44 @@ export function groupBySeverity(findings) {
|
|
|
62
63
|
export function commentMarker(tag) {
|
|
63
64
|
return `<!-- ${tag} -->`;
|
|
64
65
|
}
|
|
66
|
+
// @ref LLP 0011#forged-state-markers [implements] — the first-match parsers make an earlier forged marker win, so untrusted prose never keeps a raw `<!--`
|
|
67
|
+
/**
|
|
68
|
+
* Neutralize anything that could impersonate this reviewer's embedded state
|
|
69
|
+
* comment. `parseReviewState` and `parseEmbeddedFingerprints` match with a
|
|
70
|
+
* non-global RegExp, so they take the FIRST marker in the body while the genuine
|
|
71
|
+
* one is appended LAST: a forged `<!-- tag:state=… -->` rendered earlier — inside
|
|
72
|
+
* a model-written rationale, a path, a dismissal reason — would win over the real
|
|
73
|
+
* state and let PR content dictate the dismissal list. None of that prose ever
|
|
74
|
+
* legitimately needs an HTML comment, so every `<!--` in it is escaped.
|
|
75
|
+
*/
|
|
76
|
+
export function stripStateMarkers(text) {
|
|
77
|
+
return text.replace(/<!--/g, "<!--");
|
|
78
|
+
}
|
|
79
|
+
/** GitHub logins are `[A-Za-z0-9-]`, 39 chars max — anything else is forged. */
|
|
80
|
+
const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
|
|
81
|
+
/** A reply link must be a github.com URL, so a record can't inject an arbitrary
|
|
82
|
+
* target into the body. */
|
|
83
|
+
const REPLY_URL_RE = /^https:\/\/github\.com\/[\w.\-/#]+$/;
|
|
84
|
+
// @ref LLP 0011#never-echo-reply-text [constrained-by] — only the login and the link are rendered, both validated; a login that isn't GitHub-shaped names no one
|
|
85
|
+
/**
|
|
86
|
+
* `@login`, or `an author` when the login isn't GitHub-shaped. The reply's own
|
|
87
|
+
* text is never rendered, so this credit line is the whole of what a reply
|
|
88
|
+
* contributes to the body.
|
|
89
|
+
*/
|
|
90
|
+
function replyAuthor(record) {
|
|
91
|
+
return LOGIN_RE.test(record.by) ? `@${record.by}` : "an author";
|
|
92
|
+
}
|
|
93
|
+
/** Link `text` to the reply comment, or leave it plain when the URL doesn't
|
|
94
|
+
* validate. */
|
|
95
|
+
function replyLink(text, record) {
|
|
96
|
+
return record.url && REPLY_URL_RE.test(record.url) ? `[${text}](${record.url})` : text;
|
|
97
|
+
}
|
|
65
98
|
function locationText(finding) {
|
|
66
|
-
|
|
99
|
+
// A git path may legally hold anything, including a forged state marker.
|
|
100
|
+
const file = stripStateMarkers(finding.file);
|
|
101
|
+
return finding.line != null ? `${file}:${finding.line}` : file;
|
|
67
102
|
}
|
|
103
|
+
// @ref LLP 0005#comment-rendering [implements] — diff anchor only if the line is in the diff; else base-SHA blob (f9fecd5)
|
|
68
104
|
/**
|
|
69
105
|
* Render a finding's location as inline code, linked to the code it points at:
|
|
70
106
|
* - in the diff (file+line shown in a hunk) → the PR's "Files changed" tab at that
|
|
@@ -92,7 +128,7 @@ function location(finding, link) {
|
|
|
92
128
|
}
|
|
93
129
|
if (link.baseSha) {
|
|
94
130
|
const lineAnchor = finding.line != null ? `#L${finding.line}` : "";
|
|
95
|
-
const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${finding.file}${lineAnchor}`;
|
|
131
|
+
const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${stripStateMarkers(finding.file)}${lineAnchor}`;
|
|
96
132
|
return `[\`${text}\`](${url})`;
|
|
97
133
|
}
|
|
98
134
|
return `\`${text}\``;
|
|
@@ -102,29 +138,41 @@ function location(finding, link) {
|
|
|
102
138
|
* per-PR dismissals. Findings whose fingerprint appears in `dismissed` render in a
|
|
103
139
|
* collapsed "Dismissed" section instead of the main list.
|
|
104
140
|
*/
|
|
105
|
-
export function renderMarkdown(review, tag, dismissed = [], link) {
|
|
141
|
+
export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = []) {
|
|
106
142
|
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
107
143
|
const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
|
|
108
|
-
const
|
|
109
|
-
|
|
144
|
+
const feedbackByFp = matchedFeedback(feedback, new Set(withFp.map((entry) => entry.fp)));
|
|
145
|
+
// An applied reply cleared the finding, so it leaves the active list exactly
|
|
146
|
+
// like a dismissal — with its own audit line saying a reply is what did it.
|
|
147
|
+
const isDropped = ({ fp }) => dismissedByFp.has(fp) || feedbackByFp.get(fp)?.applied === true;
|
|
148
|
+
const notDismissed = withFp.filter((entry) => !isDropped(entry));
|
|
149
|
+
const dropped = withFp.filter((entry) => isDropped(entry));
|
|
150
|
+
// A requalified finding is addressed by a stacked PR: shown in its own collapsed
|
|
151
|
+
// section and counted, but never in the main (blocking) severity list.
|
|
152
|
+
const kept = notDismissed.filter(({ finding }) => !finding.requalifiedBy);
|
|
153
|
+
const requalified = notDismissed.filter(({ finding }) => finding.requalifiedBy);
|
|
110
154
|
const lines = [commentMarker(tag), "## 🤖 AI code review", ""];
|
|
111
|
-
lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", review.summary, "");
|
|
155
|
+
lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", stripStateMarkers(review.summary), "");
|
|
112
156
|
if (review.incomplete.length > 0) {
|
|
113
|
-
lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
|
|
157
|
+
lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${stripStateMarkers(note)}`), "");
|
|
114
158
|
}
|
|
159
|
+
lines.push(...setupNote(review.setupNotes));
|
|
160
|
+
lines.push(...requalificationAuditNote(requalified.map((entry) => entry.finding)));
|
|
161
|
+
lines.push(...feedbackAuditNote([...feedbackByFp.values()]));
|
|
115
162
|
if (kept.length === 0) {
|
|
116
163
|
lines.push("No findings.", "");
|
|
117
164
|
}
|
|
118
165
|
else {
|
|
119
|
-
lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link));
|
|
166
|
+
lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link, fingerprintFinding, feedbackByFp));
|
|
167
|
+
}
|
|
168
|
+
if (requalified.length > 0) {
|
|
169
|
+
lines.push("<details>", `<summary>🔁 Addressed in stacked PRs (${requalified.length})</summary>`, "", ...requalified.flatMap(({ finding, fp }) => addressedLines(finding, fp, link)), "</details>", "");
|
|
120
170
|
}
|
|
121
171
|
if (dropped.length > 0) {
|
|
122
172
|
lines.push("<details>", `<summary>🚫 Dismissed on this PR (${dropped.length})</summary>`, "");
|
|
123
173
|
for (const { finding, fp } of dropped) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const why = record.reason ? ` — ${record.reason}` : "";
|
|
127
|
-
lines.push(`- **${finding.title}** — ${location(finding, link)} \`id:${fp}\`${who}${why}`);
|
|
174
|
+
const suffix = droppedSuffix(dismissedByFp.get(fp), feedbackByFp.get(fp));
|
|
175
|
+
lines.push(`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} \`id:${fp}\`${suffix}`);
|
|
128
176
|
}
|
|
129
177
|
lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
|
|
130
178
|
}
|
|
@@ -133,7 +181,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
|
|
|
133
181
|
// and dismissals, so `/dismiss` can re-render this comment without re-running.
|
|
134
182
|
const fingerprints = review.findings.map(fingerprintFinding);
|
|
135
183
|
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
136
|
-
lines.push(`<!-- ${tag}:state=${encodeState({ review, dismissed })} -->`);
|
|
184
|
+
lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed }, feedbackByFp, pins))} -->`);
|
|
137
185
|
return lines.join("\n");
|
|
138
186
|
}
|
|
139
187
|
/**
|
|
@@ -141,7 +189,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
|
|
|
141
189
|
* aggregate renderers. `idFor` supplies each finding's id (default:
|
|
142
190
|
* fingerprintFinding); the aggregate renderer passes a scope-namespaced id.
|
|
143
191
|
*/
|
|
144
|
-
function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
192
|
+
function renderSeveritySections(findings, link, idFor = fingerprintFinding, feedbackById) {
|
|
145
193
|
const out = [];
|
|
146
194
|
const groups = groupBySeverity(sortFindings(findings));
|
|
147
195
|
for (const severity of SEVERITIES) {
|
|
@@ -151,12 +199,14 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
|
151
199
|
}
|
|
152
200
|
out.push(`### ${severityHeading(severity)} (${group.length})`, "");
|
|
153
201
|
for (const finding of group) {
|
|
154
|
-
|
|
202
|
+
const id = idFor(finding);
|
|
203
|
+
out.push(...renderFindingLines(finding, link, id, feedbackById?.get(id)));
|
|
155
204
|
}
|
|
156
205
|
out.push("");
|
|
157
206
|
}
|
|
158
207
|
return out;
|
|
159
208
|
}
|
|
209
|
+
// @ref LLP 0005#comment-rendering [constrained-by] — blank lines must stay truly empty or <details> escapes the list (euxy#45)
|
|
160
210
|
/**
|
|
161
211
|
* Indent every line of a multi-line value to a list item's content column.
|
|
162
212
|
*
|
|
@@ -173,19 +223,116 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
|
173
223
|
function indentContinuation(value, indent = " ") {
|
|
174
224
|
return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
|
|
175
225
|
}
|
|
176
|
-
function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
|
|
226
|
+
function renderFindingLines(finding, link, id = fingerprintFinding(finding), reply) {
|
|
177
227
|
const out = [
|
|
178
|
-
`- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}
|
|
179
|
-
...indentContinuation(finding.rationale),
|
|
228
|
+
`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
|
|
229
|
+
...indentContinuation(stripStateMarkers(finding.rationale)),
|
|
180
230
|
];
|
|
181
231
|
if (finding.suggestion) {
|
|
182
|
-
out.push(...indentContinuation(`_Suggestion:_ ${finding.suggestion}`));
|
|
232
|
+
out.push(...indentContinuation(`_Suggestion:_ ${stripStateMarkers(finding.suggestion)}`));
|
|
183
233
|
}
|
|
184
234
|
// Separator so a rationale ending in `</details>` cannot swallow the next
|
|
185
235
|
// bullet. Findings are already loose list items, so this changes no spacing.
|
|
186
236
|
out.push("");
|
|
187
237
|
return out;
|
|
188
238
|
}
|
|
239
|
+
// @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
|
|
240
|
+
/** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
|
|
241
|
+
function setupNote(notes = []) {
|
|
242
|
+
if (notes.length === 0) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
return ["> 🔗 **Review setup:**", ...notes.map((note) => `> - ${stripStateMarkers(note)}`), ""];
|
|
246
|
+
}
|
|
247
|
+
// @ref LLP 0010#rendering-in-all-three-paths [implements] — the visible audit count is mandatory: requalification's only effect on a real finding is moving it out of the blocking set, so it must never be silent
|
|
248
|
+
/**
|
|
249
|
+
* The visible one-line audit note in the OPEN body, naming the addressing PRs. This
|
|
250
|
+
* is what keeps requalification from being a "collapsed fold nobody reads": the
|
|
251
|
+
* count and PR numbers show above the fold. Empty when nothing was requalified.
|
|
252
|
+
*/
|
|
253
|
+
function requalificationAuditNote(requalified) {
|
|
254
|
+
if (requalified.length === 0) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
const prNumbers = [
|
|
258
|
+
...new Set(requalified
|
|
259
|
+
.map((finding) => finding.requalifiedBy?.prNumber)
|
|
260
|
+
.filter((n) => n != null)),
|
|
261
|
+
].sort((a, b) => a - b);
|
|
262
|
+
const prList = prNumbers.map((n) => `#${n}`).join(", ");
|
|
263
|
+
return [
|
|
264
|
+
`> 🔁 **${requalified.length} finding(s)** marked addressed by stacked PR(s) (${prList}); ` +
|
|
265
|
+
"excluded from the decision but shown below.",
|
|
266
|
+
"",
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
/** One bullet per finding in the "Addressed in stacked PRs" section — names the
|
|
270
|
+
* addressing PR and the exact upstack path relied on. */
|
|
271
|
+
function addressedLines(finding, fp, link) {
|
|
272
|
+
const requalified = finding.requalifiedBy;
|
|
273
|
+
const reason = requalified.reason ? `: ${stripStateMarkers(requalified.reason)}` : "";
|
|
274
|
+
return [
|
|
275
|
+
`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} \`id:${fp}\` — addressed in ` +
|
|
276
|
+
`#${requalified.prNumber} (\`${stripStateMarkers(requalified.file)}\`)${reason}`,
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Index the feedback records that answer a finding actually present in this
|
|
281
|
+
* comment, keyed by the id the comment renders. A record whose finding is gone
|
|
282
|
+
* (the flagged code changed, so the fingerprint moved) is dropped: it can no
|
|
283
|
+
* longer be shown, counted, or audited, so carrying it forward only grows state.
|
|
284
|
+
*/
|
|
285
|
+
function matchedFeedback(feedback, ids) {
|
|
286
|
+
const matched = new Map();
|
|
287
|
+
for (const record of feedback) {
|
|
288
|
+
if (ids.has(record.fp) && !matched.has(record.fp)) {
|
|
289
|
+
matched.set(record.fp, record);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return matched;
|
|
293
|
+
}
|
|
294
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — the count sits above the fold, exactly like the requalification note, so a reply clearing a finding is never a quiet fold nobody opens
|
|
295
|
+
/** The visible one-line audit note for author responses. Empty when no reply
|
|
296
|
+
* matched a finding in this comment. */
|
|
297
|
+
function feedbackAuditNote(records) {
|
|
298
|
+
if (records.length === 0) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const applied = records.filter((record) => record.applied).length;
|
|
302
|
+
return [
|
|
303
|
+
`> 💬 **${records.length} finding(s)** have an author response (${applied} applied).`,
|
|
304
|
+
"",
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
/** ` · 💬 [@login replied](url)` on an active finding — the entire visible trace
|
|
308
|
+
* of a reply. The reply's own text is never part of it. */
|
|
309
|
+
function replyAnnotation(record) {
|
|
310
|
+
return record ? ` · 💬 ${replyLink(`${replyAuthor(record)} replied`, record)}` : "";
|
|
311
|
+
}
|
|
312
|
+
/** The audit tail of a "Dismissed" bullet: an explicit `/dismiss`, or the reply
|
|
313
|
+
* that cleared the finding. */
|
|
314
|
+
function droppedSuffix(dismissal, reply) {
|
|
315
|
+
if (dismissal) {
|
|
316
|
+
const who = dismissal.by ? ` by @${stripStateMarkers(dismissal.by)}` : "";
|
|
317
|
+
const why = dismissal.reason ? ` — ${stripStateMarkers(dismissal.reason)}` : "";
|
|
318
|
+
return `${who}${why}`;
|
|
319
|
+
}
|
|
320
|
+
return reply ? ` — dismissed via reply by ${replyLink(replyAuthor(reply), reply)}` : "";
|
|
321
|
+
}
|
|
322
|
+
// @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin set rides the state on EVERY render, independent of which replies matched this run, so a reply that disappears can never drop a maintainer's restore
|
|
323
|
+
/** Attach the matched feedback to the state blob. It rides the embedded state
|
|
324
|
+
* like dismissals do, so the next run re-reads what was recorded (including a
|
|
325
|
+
* verdict already decided) instead of re-deriving it.
|
|
326
|
+
*
|
|
327
|
+
* `pins` is written whole and unfiltered — NOT indexed by the findings or the records
|
|
328
|
+
* this render happens to show. A pin is a maintainer's decision about a finding, so it
|
|
329
|
+
* must outlive a run where the reply was edited away, the record was dropped, or the
|
|
330
|
+
* finding itself was not re-emitted. */
|
|
331
|
+
function reviewState(state, feedbackByFp, pins = []) {
|
|
332
|
+
const feedback = [...feedbackByFp.values()];
|
|
333
|
+
const withFeedback = feedback.length > 0 ? { ...state, feedback } : state;
|
|
334
|
+
return pins.length > 0 ? { ...withFeedback, pins } : withFeedback;
|
|
335
|
+
}
|
|
189
336
|
/** Parse the fingerprints embedded in a previously-posted comment body. */
|
|
190
337
|
export function parseEmbeddedFingerprints(body, tag) {
|
|
191
338
|
// Escape the (config-controlled) tag so regex metacharacters can't break the match.
|
|
@@ -213,6 +360,7 @@ export function worstDecision(decisions) {
|
|
|
213
360
|
}
|
|
214
361
|
/** GitHub's comment body limit is ~65k chars; keep a margin. */
|
|
215
362
|
const MAX_COMMENT_CHARS = 60_000;
|
|
363
|
+
// @ref LLP 0005#truncation-and-aggregate-state [constrained-by] — truncation trims shown findings only; dismissed findings always kept in state
|
|
216
364
|
/**
|
|
217
365
|
* One aggregated comment under the single existing marker: a scope summary table,
|
|
218
366
|
* an optional coverage block, one <details> per scope (findings rendered with
|
|
@@ -221,19 +369,33 @@ const MAX_COMMENT_CHARS = 60_000;
|
|
|
221
369
|
* carries the real per-scope data. Oversized bodies trim each scope's findings to
|
|
222
370
|
* the most severe N (halving until it fits, floor 3) with a per-scope note.
|
|
223
371
|
*/
|
|
224
|
-
export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
372
|
+
export function renderAggregateMarkdown(results, tag, dismissed, link, opts, feedback = [], pins = []) {
|
|
225
373
|
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
226
374
|
const idOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
|
|
227
|
-
//
|
|
375
|
+
// Feedback is keyed by the SAME scope-namespaced id the comment renders, so a
|
|
376
|
+
// record can never cross scopes.
|
|
377
|
+
const feedbackById = matchedFeedback(feedback, new Set(results.flatMap((result) => result.review.findings.map((f) => idOf(result, f)))));
|
|
378
|
+
// Split each scope's findings into kept/requalified/dropped once (dismissal and
|
|
379
|
+
// requalification are both limit-independent). `kept` is the active/blocking set;
|
|
380
|
+
// requalified findings are addressed by a stacked PR — counted and shown, never
|
|
381
|
+
// in the blocking list.
|
|
228
382
|
const perScope = results.map((result) => {
|
|
229
383
|
const withId = result.review.findings.map((finding) => ({
|
|
230
384
|
finding,
|
|
231
385
|
id: idOf(result, finding),
|
|
232
386
|
}));
|
|
387
|
+
// An applied reply drops a finding out of the active list, like a dismissal.
|
|
388
|
+
const isDropped = (entry) => dismissedByFp.has(entry.id) || feedbackById.get(entry.id)?.applied === true;
|
|
389
|
+
const notDismissed = withId.filter((entry) => !isDropped(entry));
|
|
233
390
|
return {
|
|
234
391
|
result,
|
|
235
|
-
kept:
|
|
236
|
-
|
|
392
|
+
kept: notDismissed.filter((entry) => !entry.finding.requalifiedBy),
|
|
393
|
+
requalified: notDismissed.filter((entry) => entry.finding.requalifiedBy),
|
|
394
|
+
dropped: withId.filter((entry) => isDropped(entry)),
|
|
395
|
+
// The scope's own author responses, for its audit note.
|
|
396
|
+
feedback: withId
|
|
397
|
+
.map((entry) => feedbackById.get(entry.id))
|
|
398
|
+
.filter((record) => record != null),
|
|
237
399
|
};
|
|
238
400
|
});
|
|
239
401
|
const worst = worstDecision(results.map((result) => result.review.decision));
|
|
@@ -249,7 +411,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
249
411
|
"| --- | --- | --- |",
|
|
250
412
|
];
|
|
251
413
|
for (const { result, kept } of perScope) {
|
|
252
|
-
lines.push(`| ${result.scope} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
|
|
414
|
+
lines.push(`| ${stripStateMarkers(result.scope)} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
|
|
253
415
|
}
|
|
254
416
|
lines.push("");
|
|
255
417
|
const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
|
|
@@ -258,54 +420,77 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
258
420
|
if (unmatched.length > 0) {
|
|
259
421
|
const shown = unmatched
|
|
260
422
|
.slice(0, 10)
|
|
261
|
-
.map((file) => `\`${file}\``)
|
|
423
|
+
.map((file) => `\`${stripStateMarkers(file)}\``)
|
|
262
424
|
.join(", ");
|
|
263
425
|
const more = unmatched.length > 10 ? `, …(+${unmatched.length - 10} more)` : "";
|
|
264
426
|
lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
|
|
265
427
|
}
|
|
266
428
|
for (const result of results) {
|
|
267
429
|
for (const note of result.review.incomplete) {
|
|
268
|
-
lines.push(`> - [${result.scope}] ${note}`);
|
|
430
|
+
lines.push(`> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`);
|
|
269
431
|
}
|
|
270
432
|
}
|
|
271
433
|
lines.push("");
|
|
272
434
|
}
|
|
435
|
+
const setupLines = results.flatMap((result) => (result.review.setupNotes ?? []).map((note) => `> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`));
|
|
436
|
+
if (setupLines.length > 0) {
|
|
437
|
+
lines.push("> 🔗 **Review setup:**", ...setupLines, "");
|
|
438
|
+
}
|
|
273
439
|
// Shown = the most-severe N kept findings per scope (N = limitPerScope). The
|
|
274
440
|
// embedded state trims KEPT findings to the same set so a truncated comment
|
|
275
441
|
// still fits GitHub's body limit (the hidden findings are noted, not silently
|
|
276
442
|
// carried) — but dismissed findings are always kept in state (see below).
|
|
277
|
-
const rendered = perScope.map(({ result, kept, dropped }) => ({
|
|
443
|
+
const rendered = perScope.map(({ result, kept, requalified, dropped, feedback: replies }) => ({
|
|
278
444
|
result,
|
|
445
|
+
replies,
|
|
279
446
|
shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
|
|
280
447
|
hidden: Math.max(0, kept.length - limitPerScope),
|
|
448
|
+
// The requalified section is trimmed by the same per-scope limit as shown: it
|
|
449
|
+
// is coordinator-populated (a wide stack can requalify many findings at once),
|
|
450
|
+
// and an untrimmed section would keep the truncation loop below from ever
|
|
451
|
+
// converging under MAX_COMMENT_CHARS. The audit note carries the TOTAL count,
|
|
452
|
+
// so trimming never hides that requalification happened.
|
|
453
|
+
requalified: requalified.slice(0, limitPerScope),
|
|
454
|
+
requalifiedHidden: Math.max(0, requalified.length - limitPerScope),
|
|
455
|
+
requalifiedAll: requalified,
|
|
281
456
|
dropped,
|
|
282
457
|
}));
|
|
283
|
-
for (const { result, shown, hidden } of rendered) {
|
|
284
|
-
|
|
458
|
+
for (const { result, replies, shown, hidden, requalified, requalifiedAll, requalifiedHidden, } of rendered) {
|
|
459
|
+
// @ref LLP 0011#suppression-is-never-silent — a reply-suppressed scope opens
|
|
460
|
+
// its fold so the audit note is visible, matching renderMarkdown and LLP 0010's
|
|
461
|
+
// visible-suppression rule (else a scope replies cleared reads as clean).
|
|
462
|
+
const open = shown.length > 0 || requalifiedAll.length > 0 || replies.length > 0 ? " open" : "";
|
|
285
463
|
const keptCount = shown.length + hidden;
|
|
286
|
-
lines.push(`<details${open}>`, `<summary>${result.scope} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
|
|
464
|
+
lines.push(`<details${open}>`, `<summary>${stripStateMarkers(result.scope)} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
|
|
287
465
|
if (result.review.summary) {
|
|
288
|
-
lines.push(result.review.summary, "");
|
|
466
|
+
lines.push(stripStateMarkers(result.review.summary), "");
|
|
289
467
|
}
|
|
468
|
+
lines.push(...requalificationAuditNote(requalifiedAll.map((entry) => entry.finding)));
|
|
469
|
+
lines.push(...feedbackAuditNote(replies));
|
|
290
470
|
if (shown.length === 0) {
|
|
291
471
|
lines.push("No findings.", "");
|
|
292
472
|
}
|
|
293
473
|
else {
|
|
294
|
-
lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
|
|
474
|
+
lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding), feedbackById));
|
|
295
475
|
}
|
|
296
476
|
if (hidden > 0) {
|
|
297
477
|
lines.push(`_…and ${hidden} more finding(s) — see the workflow log._`, "");
|
|
298
478
|
}
|
|
479
|
+
if (requalifiedAll.length > 0) {
|
|
480
|
+
lines.push(`**🔁 Addressed in stacked PRs (${requalifiedAll.length})**`, "", ...requalified.flatMap((entry) => addressedLines(entry.finding, entry.id, link)));
|
|
481
|
+
if (requalifiedHidden > 0) {
|
|
482
|
+
lines.push(`_…and ${requalifiedHidden} more addressed finding(s) — see the workflow log._`);
|
|
483
|
+
}
|
|
484
|
+
lines.push("");
|
|
485
|
+
}
|
|
299
486
|
lines.push("</details>", "");
|
|
300
487
|
}
|
|
301
488
|
const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
|
|
302
489
|
if (allDropped.length > 0) {
|
|
303
490
|
lines.push("<details>", `<summary>🚫 Dismissed on this PR (${allDropped.length})</summary>`, "");
|
|
304
491
|
for (const { finding, id, scope } of allDropped) {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const why = record.reason ? ` — ${record.reason}` : "";
|
|
308
|
-
lines.push(`- **${finding.title}** — ${location(finding, link)} \`id:${id}\` _(${scope})_${who}${why}`);
|
|
492
|
+
const suffix = droppedSuffix(dismissedByFp.get(id), feedbackById.get(id));
|
|
493
|
+
lines.push(`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} \`id:${id}\` _(${stripStateMarkers(scope)})_${suffix}`);
|
|
309
494
|
}
|
|
310
495
|
lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
|
|
311
496
|
}
|
|
@@ -316,11 +501,30 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
316
501
|
// review) so /undismiss can restore them and the Dismissed section persists
|
|
317
502
|
// across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
|
|
318
503
|
// keep both v2 and v1 consumers working.
|
|
319
|
-
const stateScopes = rendered.map(({ result, shown, dropped }) =>
|
|
320
|
-
scope
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
504
|
+
const stateScopes = rendered.map(({ result, shown, requalified, dropped }) => {
|
|
505
|
+
// @ref LLP 0011#suppression-is-never-silent — strip any per-scope `feedback`
|
|
506
|
+
// a freshly-reviewed scope's ReviewRunResult carries: the top-level feedback
|
|
507
|
+
// array (reviewState below) is the single source of truth. A stale per-scope
|
|
508
|
+
// copy that survived here would be read back on a later carried-over run and
|
|
509
|
+
// could re-apply a record a human /undismiss already overrode at the top level.
|
|
510
|
+
const { feedback: _feedback, ...review } = result.review;
|
|
511
|
+
return {
|
|
512
|
+
scope: result.scope,
|
|
513
|
+
isDefault: result.isDefault,
|
|
514
|
+
// Requalified findings ride the embedded state (like dismissed ones) so a
|
|
515
|
+
// re-render (/dismiss) round-trips them and the addressed section persists.
|
|
516
|
+
// Under truncation they are trimmed exactly like `shown` — state bytes count
|
|
517
|
+
// toward the comment size, so an untrimmed list would defeat the cap loop.
|
|
518
|
+
review: {
|
|
519
|
+
...review,
|
|
520
|
+
findings: [
|
|
521
|
+
...shown,
|
|
522
|
+
...requalified.map((entry) => entry.finding),
|
|
523
|
+
...dropped.map((entry) => entry.finding),
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
});
|
|
324
528
|
const merged = {
|
|
325
529
|
decision: worst,
|
|
326
530
|
findings: stateScopes.flatMap((scope) => scope.review.findings),
|
|
@@ -331,12 +535,17 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
331
535
|
};
|
|
332
536
|
const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
|
|
333
537
|
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
334
|
-
|
|
538
|
+
// Feedback records and `/undismiss` pins ride the state whole, never trimmed by the
|
|
539
|
+
// cap loop: each is a handful of bytes, and losing one would lose a verdict already
|
|
540
|
+
// decided — or a maintainer's restore, which no later run could recover.
|
|
541
|
+
lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review: merged, dismissed, scopes: stateScopes }, feedbackById, pins))} -->`);
|
|
335
542
|
return lines.join("\n");
|
|
336
543
|
};
|
|
337
544
|
let limit = Number.POSITIVE_INFINITY;
|
|
338
545
|
let body = buildBody(limit);
|
|
339
|
-
|
|
546
|
+
// Seed from the largest per-scope section the limit applies to — kept OR
|
|
547
|
+
// requalified — so the halving loop shrinks whichever one is oversized.
|
|
548
|
+
const largestScope = Math.max(0, ...perScope.map((entry) => Math.max(entry.kept.length, entry.requalified.length)));
|
|
340
549
|
while (body.length > MAX_COMMENT_CHARS && limit > 3) {
|
|
341
550
|
limit =
|
|
342
551
|
limit === Number.POSITIVE_INFINITY
|
|
@@ -359,7 +568,21 @@ export function parseReviewState(body, tag) {
|
|
|
359
568
|
try {
|
|
360
569
|
const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
|
|
361
570
|
if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
|
|
362
|
-
|
|
571
|
+
// The v3 `feedback` field is shape-validated rather than trusted: it feeds
|
|
572
|
+
// the blocking decision, so a malformed blob must yield no records, not
|
|
573
|
+
// junk ones. Same for the v4 `pins`.
|
|
574
|
+
const feedback = FeedbackRecordSchema.array().safeParse(parsed.feedback ?? []);
|
|
575
|
+
const records = feedback.success ? feedback.data : [];
|
|
576
|
+
const parsedPins = FeedbackPinSchema.array().safeParse(parsed.pins ?? []);
|
|
577
|
+
// Migration on read: a v3 comment stored its pins on the records themselves, so
|
|
578
|
+
// collectPins lifts those into the set. Without this, the first render by this
|
|
579
|
+
// version would write a state with no pins at all and silently release every
|
|
580
|
+
// `/undismiss` a maintainer had already made.
|
|
581
|
+
return {
|
|
582
|
+
...parsed,
|
|
583
|
+
feedback: records,
|
|
584
|
+
pins: collectPins(parsedPins.success ? parsedPins.data : [], records),
|
|
585
|
+
};
|
|
363
586
|
}
|
|
364
587
|
}
|
|
365
588
|
catch {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Blockquote lines read per comment. A reply that quotes half the review is
|
|
2
|
+
* either noise or an attempt to match everything at once. */
|
|
3
|
+
const MAX_QUOTED_LINES = 50;
|
|
4
|
+
/**
|
|
5
|
+
* Minimum normalized length of a quoted line before it may match a title. A bare
|
|
6
|
+
* `> ok` or `> +1` must never collide with a short finding title.
|
|
7
|
+
*/
|
|
8
|
+
const MIN_QUOTE_LEN = 8;
|
|
9
|
+
/** `id:<hex>` as it renders in the comment, in the fingerprint alphabet
|
|
10
|
+
* (`dismiss.ts` sanitizes user input to the same one). */
|
|
11
|
+
const FINDING_ID_RE = /\bid:([a-f0-9]{6,64})\b/gi;
|
|
12
|
+
/**
|
|
13
|
+
* Our own comment always ends with the embedded `:state=` / `:fingerprints=`
|
|
14
|
+
* markers. The caller passes only non-bot comments, but matching our own body
|
|
15
|
+
* would let the review answer itself, so this is the tag-independent backstop.
|
|
16
|
+
*/
|
|
17
|
+
const OWN_COMMENT_RE = /<!--[^\n]*:(?:state|fingerprints)=/;
|
|
18
|
+
/**
|
|
19
|
+
* Markdown → comparable text: link text without the target, no backticks or
|
|
20
|
+
* emphasis marks, collapsed whitespace, lowercase, no trailing punctuation. A
|
|
21
|
+
* reply quoting a rendered finding title is byte-identical to it, so this only
|
|
22
|
+
* has to absorb the copy-paste noise around that.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeTitle(text) {
|
|
25
|
+
return text
|
|
26
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
27
|
+
.replace(/[`*_~]/g, "")
|
|
28
|
+
.replace(/\s+/g, " ")
|
|
29
|
+
.trim()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[.,;:!?]+$/, "")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The blockquote lines (`> …`) of a comment body, in order and bounded. Fenced
|
|
36
|
+
* code blocks are skipped: a `>` inside a fence is quoted CODE, not the author
|
|
37
|
+
* quoting the review, and treating it as a quote would let a pasted snippet
|
|
38
|
+
* match a finding nobody replied to.
|
|
39
|
+
*/
|
|
40
|
+
export function extractQuotedLines(body) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let inFence = false;
|
|
43
|
+
for (const raw of body.split("\n")) {
|
|
44
|
+
if (/^\s*(?:```|~~~)/.test(raw)) {
|
|
45
|
+
inFence = !inFence;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (inFence || !/^\s*>/.test(raw)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Strip the marker plus any nesting (`>> quoted reply`).
|
|
52
|
+
const text = raw.replace(/^\s*>[\s>]*/, "").trim();
|
|
53
|
+
if (text === "") {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push(text);
|
|
57
|
+
if (out.length >= MAX_QUOTED_LINES) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** The `id:<hex>` tokens a comment cites, lowercased and deduped. */
|
|
64
|
+
export function extractFindingIds(body) {
|
|
65
|
+
const ids = [...body.matchAll(FINDING_ID_RE)].map((match) => match[1].toLowerCase());
|
|
66
|
+
return [...new Set(ids)];
|
|
67
|
+
}
|
|
68
|
+
// @ref LLP 0011#a-quote-annotates-an-id-clears [implements] — only an id the replier wrote OUTSIDE any blockquote counts as citing the finding
|
|
69
|
+
/**
|
|
70
|
+
* The `id:<hex>` tokens the commenter wrote THEMSELVES — an id inside a blockquote
|
|
71
|
+
* line does not count. GitHub's "Quote reply" copies the target comment verbatim with
|
|
72
|
+
* every line prefixed `> `, and the untrusted PR author controls that text, so a quoted
|
|
73
|
+
* title OR a quoted id may be text they planted. Only an unquoted id is a citation by
|
|
74
|
+
* the replier, which is what `feedbackApplied` requires before a reply may CLEAR a
|
|
75
|
+
* finding.
|
|
76
|
+
*/
|
|
77
|
+
export function extractCitedFindingIds(body) {
|
|
78
|
+
const own = body
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((line) => !/^\s*>/.test(line))
|
|
81
|
+
.join("\n");
|
|
82
|
+
return extractFindingIds(own);
|
|
83
|
+
}
|
|
84
|
+
// @ref LLP 0011#deterministic-matching [implements] — an ambiguous quote records nothing; only an id disambiguates
|
|
85
|
+
/**
|
|
86
|
+
* Match author replies to the findings they answer. Deterministic by design: a
|
|
87
|
+
* quoted line is compared to the rendered title, an `id:` token is compared to
|
|
88
|
+
* the known fingerprints, and nothing else counts — no model decides which
|
|
89
|
+
* finding a reply is about.
|
|
90
|
+
*
|
|
91
|
+
* An id in the known set wins outright and is the only way a quote shared by
|
|
92
|
+
* several findings resolves; a quoted line matching 2+ findings otherwise
|
|
93
|
+
* records NOTHING, because attributing pushback to the wrong finding is worse
|
|
94
|
+
* than recording no pushback at all. One record per finding: when several
|
|
95
|
+
* comments answer the same one, the newest (highest comment id) wins.
|
|
96
|
+
*
|
|
97
|
+
* `citedId` records HOW the reply names the finding: true only when the replier cited
|
|
98
|
+
* the finding's id in their own words. It is independent of `opts.match` — that knob
|
|
99
|
+
* selects how a reply MATCHES, while `citedId` is what `feedbackApplied` requires
|
|
100
|
+
* before a reply may CLEAR (see LLP 0011).
|
|
101
|
+
*/
|
|
102
|
+
export function matchReplies(comments, findings, opts) {
|
|
103
|
+
const known = new Set(findings.map((entry) => entry.fp));
|
|
104
|
+
const byTitle = new Map();
|
|
105
|
+
for (const { finding, fp } of findings) {
|
|
106
|
+
const key = normalizeTitle(finding.title);
|
|
107
|
+
byTitle.set(key, [...(byTitle.get(key) ?? []), fp]);
|
|
108
|
+
}
|
|
109
|
+
const newest = new Map();
|
|
110
|
+
for (const comment of comments) {
|
|
111
|
+
if (OWN_COMMENT_RE.test(comment.body)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const matched = new Set();
|
|
115
|
+
// The ids this replier cited in their own words, computed whatever `match` says: a
|
|
116
|
+
// quote-matched reply still records WHETHER it cites the finding, because that is
|
|
117
|
+
// what decides clearing.
|
|
118
|
+
const cited = new Set(extractCitedFindingIds(comment.body));
|
|
119
|
+
if (opts.match !== "quote") {
|
|
120
|
+
for (const id of extractFindingIds(comment.body)) {
|
|
121
|
+
if (known.has(id)) {
|
|
122
|
+
matched.add(id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (opts.match !== "id") {
|
|
127
|
+
for (const quoted of extractQuotedLines(comment.body)) {
|
|
128
|
+
const key = normalizeTitle(quoted);
|
|
129
|
+
if (key.length < MIN_QUOTE_LEN) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const candidates = byTitle.get(key);
|
|
133
|
+
// Ambiguous (2+ findings share this title): the only resolution is an id
|
|
134
|
+
// in the same comment, which the pass above already recorded.
|
|
135
|
+
if (candidates?.length === 1) {
|
|
136
|
+
matched.add(candidates[0]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const fp of matched) {
|
|
141
|
+
const previous = newest.get(fp);
|
|
142
|
+
if (previous && previous.commentId >= comment.id) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
newest.set(fp, {
|
|
146
|
+
fp,
|
|
147
|
+
by: comment.login,
|
|
148
|
+
commentId: comment.id,
|
|
149
|
+
...(comment.url ? { url: comment.url } : {}),
|
|
150
|
+
maintainer: comment.maintainer,
|
|
151
|
+
author: comment.author,
|
|
152
|
+
citedId: cited.has(fp),
|
|
153
|
+
applied: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...newest.values()].sort((a, b) => a.fp.localeCompare(b.fp));
|
|
158
|
+
}
|