@webpieces/pr-gate 0.4.592 → 0.4.594
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/package.json +2 -2
- package/src/dashboard/checklist-comment-renderer.d.ts +78 -0
- package/src/dashboard/checklist-comment-renderer.js +272 -0
- package/src/dashboard/checklist-comment-renderer.js.map +1 -0
- package/src/dashboard/dashboard.d.ts +66 -52
- package/src/dashboard/dashboard.js +108 -260
- package/src/dashboard/dashboard.js.map +1 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +11 -1
- package/src/index.js.map +1 -1
- package/src/scripts/commands/finish-upsert-pr-command.d.ts +53 -34
- package/src/scripts/commands/finish-upsert-pr-command.js +181 -160
- package/src/scripts/commands/finish-upsert-pr-command.js.map +1 -1
- package/src/scripts/workflow/finish-banner.js +9 -1
- package/src/scripts/workflow/finish-banner.js.map +1 -1
- package/src/scripts/workflow/pr-comment-upserter.d.ts +54 -0
- package/src/scripts/workflow/pr-comment-upserter.js +100 -0
- package/src/scripts/workflow/pr-comment-upserter.js.map +1 -0
- package/src/scripts/workflow/pr-merger.js +13 -1
- package/src/scripts/workflow/pr-merger.js.map +1 -1
- package/src/scripts/workflow/provenance-enforcer.d.ts +68 -0
- package/src/scripts/workflow/provenance-enforcer.js +169 -0
- package/src/scripts/workflow/provenance-enforcer.js.map +1 -0
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Dashboard = exports.DashboardInput = exports.DisableCounts = exports.ChecklistRow = exports.GateResult = exports.
|
|
3
|
+
exports.Dashboard = exports.DashboardInput = exports.DisableCounts = exports.ChecklistRow = exports.GateResult = exports.DETAIL_COMMENT_MARKER = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
6
6
|
const inversify_1 = require("inversify");
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Hidden marker on the FULL-DASHBOARD comment — the "1st comment" the PR description points at — so
|
|
9
|
+
* finish PATCHes its own comment on every push instead of appending a new one, exactly like the checklist
|
|
10
|
+
* comment (see ChecklistCommentRenderer, which owns the 2nd).
|
|
11
|
+
*
|
|
12
|
+
* This comment did not exist before the PR description became the git-log body: the dashboard WAS the
|
|
13
|
+
* description. Moving it here is what keeps a risk table out of every squash commit on main.
|
|
14
|
+
*/
|
|
15
|
+
exports.DETAIL_COMMENT_MARKER = '<!-- webpieces-pr-detail v1 -->';
|
|
16
|
+
/**
|
|
17
|
+
* Footer on the full-dashboard comment. Names the tooling by REPO URL so a reader landing on a
|
|
18
|
+
* generated comment in any consumer repo can find what generated it.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately no longer asserts "build ran via nx affected — not self-attested": that sentence was
|
|
21
|
+
* hard-coded and therefore wrong on every repo whose `buildCommand` is not nx. The build command is now
|
|
22
|
+
* named for real, from config, in the PR-body footer — see {@link Dashboard.renderPrBody}.
|
|
23
|
+
*/
|
|
24
|
+
const DETAIL_COMMENT_FOOTER = 'Generated by https://github.com/deanhiller/webpieces-ts wp-finish-upsert-pr';
|
|
25
|
+
/**
|
|
26
|
+
* The last bullet of the PR description, and therefore of every squash commit body.
|
|
27
|
+
*
|
|
28
|
+
* Positional ("1st"/"2nd") on purpose — it is what a reader of `git log` can actually act on, far more
|
|
29
|
+
* useful than naming an HTML marker they cannot see. On a PR opened by an OLDER release the checklist
|
|
30
|
+
* comment already exists, so the dashboard comment lands second and this reads one off for that PR's
|
|
31
|
+
* remaining life; it is cosmetic and self-corrects on the next PR.
|
|
32
|
+
*/
|
|
33
|
+
const DETAIL_POINTER = '(Full dashboard in 1st comment, reviewer checklist in 2nd — kept out of git log)';
|
|
22
34
|
class GateResult {
|
|
23
35
|
name;
|
|
24
36
|
warningColor; // 'yellow' | 'red' — the color shown WHEN files matched (green is implicit)
|
|
@@ -87,8 +99,21 @@ class DashboardInput {
|
|
|
87
99
|
mainHead;
|
|
88
100
|
review; // AI-authored risk/violations/summary (from review.json)
|
|
89
101
|
checklists; // consumer checklists this branch triggered; [] for non-adopting repos
|
|
102
|
+
/**
|
|
103
|
+
* `commands.pr-gate.buildCommand` VERBATIM, named in the PR-body footer so `git log` records WHICH
|
|
104
|
+
* command vouched for the commit. Read from config rather than hard-coded because the footer used to
|
|
105
|
+
* assert "build ran via nx affected" on every repo, including those whose buildCommand is not nx.
|
|
106
|
+
*
|
|
107
|
+
* REQUIRED, with no default, and `checklists` lost its `= []` for the same reason. A defaulted
|
|
108
|
+
* `buildCommand: string = ''` let every pre-existing 9-argument construction keep compiling while
|
|
109
|
+
* silently rendering a footer that claims nothing — an absence that quietly means "no build was
|
|
110
|
+
* named", which is the widening-by-omission the compatibility policy calls out. Making it required
|
|
111
|
+
* means every caller states what vouched for the commit, and the empty string stays available for a
|
|
112
|
+
* repo that genuinely has no build command, but only when someone writes it down.
|
|
113
|
+
*/
|
|
114
|
+
buildCommand;
|
|
90
115
|
// eslint-disable-next-line @typescript-eslint/max-params
|
|
91
|
-
constructor(title, gateResults, disables, buildPassed, forkPoint, featureHead, mainHead, review, checklists
|
|
116
|
+
constructor(title, gateResults, disables, buildPassed, forkPoint, featureHead, mainHead, review, checklists, buildCommand) {
|
|
92
117
|
this.title = title;
|
|
93
118
|
this.gateResults = gateResults;
|
|
94
119
|
this.disables = disables;
|
|
@@ -98,6 +123,7 @@ class DashboardInput {
|
|
|
98
123
|
this.mainHead = mainHead;
|
|
99
124
|
this.review = review;
|
|
100
125
|
this.checklists = checklists;
|
|
126
|
+
this.buildCommand = buildCommand;
|
|
101
127
|
}
|
|
102
128
|
}
|
|
103
129
|
exports.DashboardInput = DashboardInput;
|
|
@@ -134,7 +160,20 @@ let Dashboard = class Dashboard {
|
|
|
134
160
|
}
|
|
135
161
|
return new DisableCounts(webpiecesCount, eslintCount, Array.from(rules).sort());
|
|
136
162
|
}
|
|
137
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The FULL dashboard — every row (green included), the whole summary, the 3-point hash points.
|
|
165
|
+
*
|
|
166
|
+
* This is the **1st PR comment**, not the PR description. It used to be the description, and that is
|
|
167
|
+
* exactly what put it into main's history: GitHub's `squash_merge_commit_message: PR_BODY` copies the
|
|
168
|
+
* description verbatim into the squash commit, so every `git log` entry carried the risk table, the
|
|
169
|
+
* hash points and the gate token. The description now holds {@link renderPrBody}'s compact form, and
|
|
170
|
+
* everything long-form lives here where a reader can open it and `git log` never sees it.
|
|
171
|
+
*
|
|
172
|
+
* Machine-facing content belongs HERE too — this comment carries the HMAC gate token. That is the
|
|
173
|
+
* whole rule that keeps the description clean: an HTML comment is invisible in rendered markdown but
|
|
174
|
+
* perfectly visible in `git log`, so nothing hidden may live in the description.
|
|
175
|
+
*/
|
|
176
|
+
renderDetailComment(input) {
|
|
138
177
|
const lines = [];
|
|
139
178
|
lines.push('## 🚦 PR Gate Dashboard');
|
|
140
179
|
lines.push('');
|
|
@@ -165,266 +204,75 @@ let Dashboard = class Dashboard {
|
|
|
165
204
|
lines.push(`- Feature HEAD (B): \`${input.featureHead.slice(0, 12)}\``);
|
|
166
205
|
lines.push(`- Main HEAD (C): \`${input.mainHead.slice(0, 12)}\``);
|
|
167
206
|
lines.push('');
|
|
168
|
-
lines.push(
|
|
207
|
+
lines.push(`<sub>🤖 ${DETAIL_COMMENT_FOOTER}</sub>`);
|
|
169
208
|
return lines.join('\n');
|
|
170
209
|
}
|
|
171
210
|
/**
|
|
172
|
-
* The
|
|
211
|
+
* The PR DESCRIPTION — which is also, byte for byte, the squash-commit body that lands in main.
|
|
173
212
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* away. Overridden first, then warned, then passed: a reader should meet the exceptions first.
|
|
213
|
+
* ─── Why one string serves both ─────────────────────────────────────────────────────────────────
|
|
214
|
+
* There used to be two: a long dashboard in the description and this compact form passed to
|
|
215
|
+
* `gh pr merge --body-file`. That made the good `git log` reachable ONLY through an explicit
|
|
216
|
+
* `--body-file` merge, because every other route (the GitHub Merge button, a bare `gh pr merge`)
|
|
217
|
+
* takes its body from the repo's `squash_merge_commit_message`, and on `PR_BODY` that copied the
|
|
218
|
+
* whole dashboard into history. Two repos ran that way for months.
|
|
181
219
|
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
? '_Each reviewer ran as its own independent subagent, verified from the Claude Code harness._'
|
|
188
|
-
: '_⚠️ Reviewer provenance was NOT verified (no Claude Code session) — treat these as unverified._';
|
|
189
|
-
// The roster lives in the HEADER, never in a section: fitComment only ever shrinks section bodies,
|
|
190
|
-
// so a roster line can never be the thing an oversize comment silently drops.
|
|
191
|
-
const lines = [exports.CHECKLIST_COMMENT_MARKER, this.rollupHeader(rows, baseResolved)];
|
|
192
|
-
// No reviewer ran ⇒ no provenance claim to make. Printing one either way would attest to nothing.
|
|
193
|
-
if (ran.length > 0)
|
|
194
|
-
lines.push(prov);
|
|
195
|
-
lines.push('', `### Checklists (all ${rows.length})`);
|
|
196
|
-
for (const row of rows)
|
|
197
|
-
lines.push(this.rosterBullet(row));
|
|
198
|
-
const header = lines.join('\n');
|
|
199
|
-
if (ran.length === 0) {
|
|
200
|
-
return `${header}\n\n${this.nothingRanNote(rows)}`;
|
|
201
|
-
}
|
|
202
|
-
return this.fitComment(`${header}\n\n### Reviews that ran`, ran.map((r) => this.commentSection(r)));
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* The closing note when no reviewer produced a verdict. The original wording — "every configured
|
|
206
|
-
* checklist was evaluated and none of them applied" — is an all-clear, and it becomes FALSE the moment a
|
|
207
|
-
* checklist did apply and was declined. That sentence under a PR nobody reviewed is precisely the
|
|
208
|
-
* misreport this feature could otherwise introduce, so the declined case gets its own words.
|
|
209
|
-
*/
|
|
210
|
-
nothingRanNote(rows) {
|
|
211
|
-
const declined = rows.filter((r) => this.declined(r));
|
|
212
|
-
if (declined.length === 0) {
|
|
213
|
-
return '_No reviewer had to run on this diff — every configured checklist was evaluated and none of them applied._';
|
|
214
|
-
}
|
|
215
|
-
return (`_No reviewer ran. ${declined.length} OPTIONAL checklist(s) DID apply to this diff and were not ` +
|
|
216
|
-
`run; the rest were evaluated and did not apply._`);
|
|
217
|
-
}
|
|
218
|
-
// The roll-up line. `baseResolved:false` replaces it entirely: with no fork point the changed-file set is
|
|
219
|
-
// EMPTY, so nothing matched — including patternless ALWAYS-RUNS checklists — and reporting that as
|
|
220
|
-
// "all skipped ✅" would post a green all-clear for a PR where nothing was actually evaluated.
|
|
221
|
-
rollupHeader(rows, baseResolved) {
|
|
222
|
-
if (!baseResolved) {
|
|
223
|
-
return (`## 🔍 Company review checklists — ⚠️ NOT EVALUATED (${rows.length} defined)\n` +
|
|
224
|
-
`_No diff base (fork point of main) could be resolved, so no checklist was matched against ` +
|
|
225
|
-
`anything. This is **not** an all-clear._`);
|
|
226
|
-
}
|
|
227
|
-
const ran = rows.filter((r) => this.reviewerRan(r));
|
|
228
|
-
const declined = rows.filter((r) => this.declined(r));
|
|
229
|
-
const skipped = rows.length - ran.length - declined.length;
|
|
230
|
-
const parts = [];
|
|
231
|
-
for (const pair of this.rollupCounts(ran))
|
|
232
|
-
parts.push(pair);
|
|
233
|
-
const breakdown = parts.length > 0 ? ` (${parts.join(' · ')})` : '';
|
|
234
|
-
const skip = skipped > 0 ? ` · ${skipped} skipped ✅` : '';
|
|
235
|
-
// Counted SEPARATELY from "skipped", and without a ✅. A declined optional review is a legitimate
|
|
236
|
-
// outcome, but it is not the same good news as a checklist that had nothing to look at — folding the
|
|
237
|
-
// two together would let a PR that declined every optional review read as fully covered.
|
|
238
|
-
const notRun = declined.length > 0 ? ` · ${declined.length} optional not run` : '';
|
|
239
|
-
return `## 🔍 Company review checklists — ${rows.length} defined · ${ran.length} ran${breakdown}${skip}${notRun}`;
|
|
240
|
-
}
|
|
241
|
-
// `🟢 2 · 🟡 1` — only the non-zero buckets, so a clean run reads as one number rather than four.
|
|
242
|
-
rollupCounts(ran) {
|
|
243
|
-
const counts = [];
|
|
244
|
-
const emojiFor = ['🟢', '🟡', '🟠'];
|
|
245
|
-
const statusFor = [rules_config_1.CK_PASS, rules_config_1.CK_WARN, rules_config_1.CK_OVERRIDDEN];
|
|
246
|
-
statusFor.forEach((status, i) => {
|
|
247
|
-
const n = ran.filter((r) => r.status === status).length;
|
|
248
|
-
if (n > 0)
|
|
249
|
-
counts.push(`${emojiFor[i]} ${n}`);
|
|
250
|
-
});
|
|
251
|
-
return counts;
|
|
252
|
-
}
|
|
253
|
-
// One roster line + its why sub-bullet. A checked box means a reviewer ran; an unchecked one means the
|
|
254
|
-
// checklist was evaluated and did not apply, which the words state as the good news it is.
|
|
255
|
-
rosterBullet(row) {
|
|
256
|
-
const box = this.reviewerRan(row) ? '- [x]' : '- [ ]';
|
|
257
|
-
return (`${box} ${this.verdictEmoji(row)} **${row.subagent}**${this.optionalTag(row)} — ` +
|
|
258
|
-
`${this.verdictWords(row)}${this.evidenceSuffix(row)}\n` +
|
|
259
|
-
` - ${this.whyLine(row)}`);
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Did a reviewer actually produce a verdict for this row?
|
|
220
|
+
* Making the DESCRIPTION the compact form inverts it: `PR_BODY` now yields exactly the right commit,
|
|
221
|
+
* so the UI button, a bare `gh pr merge`, `wp-land-pr` and finish's own auto-merge all converge on
|
|
222
|
+
* identical bytes. The consumer requirement is two repo settings — `squash_merge_commit_title:
|
|
223
|
+
* PR_TITLE` and `squash_merge_commit_message: PR_BODY` — not a config key and not a command anyone
|
|
224
|
+
* has to remember. That convergence is the point, and `pr-body-is-merge-body.spec.ts` pins it.
|
|
263
225
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return row.ran && !row.required && (row.status === rules_config_1.CK_MISSING || row.status === '');
|
|
277
|
-
}
|
|
278
|
-
// Marks which rows the human could have declined. Without it a reader cannot tell a review that was
|
|
279
|
-
// skippable from one that simply passed, and so cannot judge how much this PR was actually reviewed.
|
|
280
|
-
optionalTag(row) {
|
|
281
|
-
return row.required ? '' : ' _(optional)_';
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Whether the reviewer demonstrably opened the diff, read from its own transcript. A QUALITY signal and
|
|
285
|
-
* never a blocker (see SubagentProvenanceService.evidenceFor) — but published, because "wrote a verdict
|
|
286
|
-
* without reading the change" is exactly what a reader of this comment would want to weigh.
|
|
287
|
-
*/
|
|
288
|
-
evidenceSuffix(row) {
|
|
289
|
-
if (!this.reviewerRan(row) || row.diffRead === '')
|
|
290
|
-
return '';
|
|
291
|
-
return row.diffRead === 'yes' ? ' _(diff read ✓)_' : ' _(⚠️ no diff read recorded)_';
|
|
292
|
-
}
|
|
293
|
-
verdictEmoji(row) {
|
|
294
|
-
if (!this.reviewerRan(row))
|
|
295
|
-
return '⚪';
|
|
296
|
-
if (row.status === rules_config_1.CK_PASS)
|
|
297
|
-
return '🟢';
|
|
298
|
-
if (row.status === rules_config_1.CK_WARN)
|
|
299
|
-
return '🟡';
|
|
300
|
-
if (row.status === rules_config_1.CK_OVERRIDDEN)
|
|
301
|
-
return '🟠';
|
|
302
|
-
if (row.status === rules_config_1.CK_FAIL)
|
|
303
|
-
return '🔴';
|
|
304
|
-
return '⚪';
|
|
305
|
-
}
|
|
306
|
-
// SHORT words for a roster line / section heading. Short on purpose: the reviewer's own output and any
|
|
307
|
-
// override justification get their own section below, and a roster exists to be scanned.
|
|
308
|
-
verdictWords(row) {
|
|
309
|
-
// Two different unchecked boxes, two different sentences. "Not applicable" is the diff's doing;
|
|
310
|
-
// "not run" is a person's, and reporting the second as the first would quietly credit a review that
|
|
311
|
-
// a human deliberately declined.
|
|
312
|
-
if (this.declined(row))
|
|
313
|
-
return 'OPTIONAL — applied to this diff but was NOT run (not selected)';
|
|
314
|
-
if (!row.ran)
|
|
315
|
-
return 'skipped, not applicable to this diff (expected ✅)';
|
|
316
|
-
if (row.status === rules_config_1.CK_PASS)
|
|
317
|
-
return 'passed';
|
|
318
|
-
if (row.status === rules_config_1.CK_WARN)
|
|
319
|
-
return 'passed with concerns';
|
|
320
|
-
if (row.status === rules_config_1.CK_OVERRIDDEN)
|
|
321
|
-
return 'OVERRIDDEN — shipped with a stated justification';
|
|
322
|
-
if (row.status === rules_config_1.CK_FAIL)
|
|
323
|
-
return 'FAILED review';
|
|
324
|
-
if (row.status === rules_config_1.CK_MISSING)
|
|
325
|
-
return 'no verdict written';
|
|
326
|
-
return `unknown verdict (${row.status})`;
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* WHY this checklist ran or did not — the line that answers "why was this reviewer involved?". Branches
|
|
330
|
-
* on `configuredPatterns`, NEVER on `firedPatterns.length`: a patternless checklist and a skipped one
|
|
331
|
-
* both fired zero globs and they mean opposite things, so keying off the fired list would tell every
|
|
332
|
-
* skipped checklist's reader that the whole diff had been in its scope.
|
|
226
|
+
* ─── The shape, and why each part earns its line ────────────────────────────────────────────────
|
|
227
|
+
* The URL leads, labelled `(for git log)` so nobody deletes it as redundant while reading the PR on
|
|
228
|
+
* GitHub — on the page it is obviously the page you are on; in `git log` it is the only way back.
|
|
229
|
+
* Then the risk score (always). Then ONLY the non-green flags: a commit log should surface what
|
|
230
|
+
* stands out, and the green rows are in the 1st comment. The last bullet is always the pointer to
|
|
231
|
+
* that comment, so a reader of main's history is never left thinking this is all there was. Then the
|
|
232
|
+
* summary capped at 4 sentences, then the footer naming the build command that vouched for it.
|
|
233
|
+
*
|
|
234
|
+
* The hidden gate-token marker is appended AFTER this by the caller, so it is the very last line of
|
|
235
|
+
* the description and therefore of the commit. It stays an HTML comment — invisible on the PR page,
|
|
236
|
+
* visible in `git log` — which is a deliberate accepted trade: the alternative was reshaping a live
|
|
237
|
+
* CI-critical surface that two consumer repos verify on every PR, to save one line of history.
|
|
333
238
|
*/
|
|
334
|
-
|
|
335
|
-
const total = row.changedFileCount;
|
|
336
|
-
if (row.configuredPatterns.length === 0) {
|
|
337
|
-
// State the fact, not a suspicion. Patternless is a deliberate configuration — an always-runs
|
|
338
|
-
// gate (every PR names a ticket, every PR has an owner) is exactly what it is FOR — so telling
|
|
339
|
-
// every such row to "add `patterns` if that is not intended" nags the repos that meant it, on
|
|
340
|
-
// every PR, forever. A reader who wants to know whether it was intended can read the config.
|
|
341
|
-
return (`ALWAYS RUNS (no patterns) — whole diff in scope, ${total} changed file(s): ` +
|
|
342
|
-
`${(0, rules_config_1.formatFileList)(row.matchedFiles)}`);
|
|
343
|
-
}
|
|
344
|
-
const configured = this.asCode(row.configuredPatterns);
|
|
345
|
-
if (row.firedPatterns.length === 0) {
|
|
346
|
-
return `${configured} matched 0 of ${total} changed file(s)`;
|
|
347
|
-
}
|
|
348
|
-
return (`matched ${this.asCode(row.firedPatterns)} → ${row.matchedFiles.length} of ${total} ` +
|
|
349
|
-
`changed file(s): ${(0, rules_config_1.formatFileList)(row.matchedFiles)}`);
|
|
350
|
-
}
|
|
351
|
-
asCode(patterns) {
|
|
352
|
-
return patterns.map((p) => `\`${p}\``).join(', ');
|
|
353
|
-
}
|
|
354
|
-
// Reviewers that ran, exceptions first (overridden → warned → passed) so a reader meets what needs
|
|
355
|
-
// attention before a wall of green.
|
|
356
|
-
ranOrdered(rows) {
|
|
357
|
-
const rank = [rules_config_1.CK_OVERRIDDEN, rules_config_1.CK_WARN, rules_config_1.CK_PASS];
|
|
358
|
-
return rows
|
|
359
|
-
.filter((r) => this.reviewerRan(r))
|
|
360
|
-
.slice()
|
|
361
|
-
.sort((a, b) => this.rankOf(rank, a.status) - this.rankOf(rank, b.status));
|
|
362
|
-
}
|
|
363
|
-
rankOf(rank, status) {
|
|
364
|
-
const idx = rank.indexOf(status);
|
|
365
|
-
return idx < 0 ? rank.length : idx;
|
|
366
|
-
}
|
|
367
|
-
commentSection(row) {
|
|
368
|
-
const heading = `#### ${this.verdictEmoji(row)} ${row.subagent} — ${this.verdictWords(row)}`;
|
|
369
|
-
const body = row.detail.trim() !== '' ? row.detail.trim() : '_(reviewer recorded no output)_';
|
|
370
|
-
return new CommentSection(heading, body);
|
|
371
|
-
}
|
|
372
|
-
// Keep the comment under GitHub's size cap by shrinking the LONGEST section body first (so a short
|
|
373
|
-
// overridden note is never cut to make room for a long passing one), never dropping a verdict heading.
|
|
374
|
-
fitComment(header, sections) {
|
|
375
|
-
const assemble = () => `${header}\n\n${sections.map((s) => `${s.heading}\n\n${s.body}`).join('\n\n')}`;
|
|
376
|
-
const trunc = '\n\n…_[truncated to fit the GitHub comment size limit]_';
|
|
377
|
-
let out = assemble();
|
|
378
|
-
while (out.length > COMMENT_LIMIT) {
|
|
379
|
-
const idx = this.longestBodyIndex(sections);
|
|
380
|
-
if (idx < 0 || sections[idx].body.length <= trunc.length + 1)
|
|
381
|
-
break;
|
|
382
|
-
const over = out.length - COMMENT_LIMIT;
|
|
383
|
-
const keep = Math.max(0, sections[idx].body.length - over - trunc.length - 8);
|
|
384
|
-
sections[idx].body = sections[idx].body.slice(0, keep).trimEnd() + trunc;
|
|
385
|
-
out = assemble();
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
388
|
-
}
|
|
389
|
-
longestBodyIndex(sections) {
|
|
390
|
-
let idx = -1;
|
|
391
|
-
let max = -1;
|
|
392
|
-
sections.forEach((s, i) => {
|
|
393
|
-
if (s.body.length > max) {
|
|
394
|
-
max = s.body.length;
|
|
395
|
-
idx = i;
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
return idx;
|
|
399
|
-
}
|
|
400
|
-
// The squash-merge COMMIT body that lands in main's history (subject is the PR title, passed to
|
|
401
|
-
// `gh pr merge --subject`). Deliberately compact — unlike the full PR-body dashboard: the risk score
|
|
402
|
-
// (always), every NON-green flag (green rows omitted — a commit log should surface only what stands
|
|
403
|
-
// out), the summary capped at 4 sentences, and a quick link back to the PR for the full dashboard.
|
|
404
|
-
renderCommitBody(input, prUrl) {
|
|
239
|
+
renderPrBody(input, prUrl) {
|
|
405
240
|
const lines = [];
|
|
241
|
+
if (prUrl !== '') {
|
|
242
|
+
lines.push(`${prUrl} (for git log)`);
|
|
243
|
+
lines.push('');
|
|
244
|
+
}
|
|
406
245
|
lines.push(`Risk: ${this.riskBar(input.review.riskScore)} ${input.review.riskScore}/100 ${input.review.riskEmoji} (${input.review.riskLevel})`);
|
|
407
246
|
lines.push('');
|
|
408
247
|
const flags = this.nonGreenFlags(input);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
248
|
+
lines.push(flags.length === 0 ? 'Flags: 🟢 all green' : 'Flags (non-green):');
|
|
249
|
+
for (const flag of flags)
|
|
250
|
+
lines.push(`- ${flag}`);
|
|
251
|
+
// ALWAYS the last bullet, green case included. Without it the compact body reads as the whole
|
|
252
|
+
// record, and the reader never learns that every green row, the full summary and each reviewer's
|
|
253
|
+
// output are one click away.
|
|
254
|
+
lines.push(`- ${DETAIL_POINTER}`);
|
|
417
255
|
const summary = this.firstSentences(input.review.summary.trim(), 4);
|
|
418
256
|
if (summary !== '') {
|
|
419
257
|
lines.push('');
|
|
420
258
|
lines.push(summary);
|
|
421
259
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
lines.push(`PR: ${prUrl}`);
|
|
425
|
-
}
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push(this.prBodyFooter(input.buildCommand));
|
|
426
262
|
return lines.join('\n');
|
|
427
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* The footer, naming the build command from `commands.pr-gate.buildCommand` VERBATIM.
|
|
266
|
+
*
|
|
267
|
+
* No backticks: this string's primary home is `git log` in a terminal, where markdown ticks are
|
|
268
|
+
* literal punctuation. "run locally" is the honest claim — this is the LOCAL gate's receipt, and the
|
|
269
|
+
* server-side proof is the gate token in the 1st comment, not this sentence.
|
|
270
|
+
*/
|
|
271
|
+
prBodyFooter(buildCommand) {
|
|
272
|
+
const cmd = buildCommand.trim();
|
|
273
|
+
const ran = cmd === '' ? '' : ` (${cmd} run locally)`;
|
|
274
|
+
return `Generated by webpieces-ts wp-finish-upsert-pr${ran}`;
|
|
275
|
+
}
|
|
428
276
|
// Every dashboard row that is NOT green, as a flat bullet list for the commit body. Green rows
|
|
429
277
|
// (build passed, gate did not match, zero disables/violations) are intentionally omitted.
|
|
430
278
|
nonGreenFlags(input) {
|