@biffo/cli 0.298.27 → 0.298.28
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.
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* what this file's lexical scan calls "deliberate" — see
|
|
14
14
|
* `deliberateClosingReferences` and the "3. Ground truth" section below
|
|
15
15
|
* (#1686). This is the one that reconciles the guard's model against the
|
|
16
|
-
* thing that actually acts, rather than trying to out-regex it.
|
|
16
|
+
* thing that actually acts, rather than trying to out-regex it. Extended
|
|
17
|
+
* in "3b" below (#1732) to a document `closingIssuesReferences` itself
|
|
18
|
+
* cannot see.
|
|
17
19
|
*
|
|
18
20
|
* ── Three documents, not one (#1334, #1362) ──────────────────────────────
|
|
19
21
|
*
|
|
@@ -153,6 +155,73 @@
|
|
|
153
155
|
* meaning. What IS achievable, and what this does, is refuse to let our own
|
|
154
156
|
* heuristic's blind spot silently diverge from GitHub's actual behaviour: the
|
|
155
157
|
* two are reconciled every time, and a mismatch fails rather than passing.
|
|
158
|
+
*
|
|
159
|
+
* ── 3b. Ground truth's own blind spot: it never reads a commit (#1732) ───
|
|
160
|
+
*
|
|
161
|
+
* Section 3 above reconciles this file's lexical model against
|
|
162
|
+
* `closingIssuesReferences` — but that field is itself only a MODEL of one
|
|
163
|
+
* of the three documents GitHub honours: it is GitHub's ground truth for the
|
|
164
|
+
* PR BODY, computed by the same markdown-aware linker that renders the PR
|
|
165
|
+
* page, and it structurally cannot see a commit message at all. This repo's
|
|
166
|
+
* squash-merge strategy is `squash_merge_commit_message = COMMIT_MESSAGES`
|
|
167
|
+
* (confirmed via `gh api repos/{owner}/{repo}` → `squash_merge_commit_title`/
|
|
168
|
+
* `_message`), so the actual merge commit GitHub creates is composed from the
|
|
169
|
+
* branch's own commit messages, verbatim — not from the PR body at all. A
|
|
170
|
+
* closing-keyword hit that lives only in a commit message is therefore
|
|
171
|
+
* something GitHub WILL act on that section 3's check is a structural no-op
|
|
172
|
+
* for, not a considered "safe": `closingIssuesReferences.length > 0` is
|
|
173
|
+
* simply never true for it, no matter how dangerous the commit text is.
|
|
174
|
+
*
|
|
175
|
+
* Real instance: merging PR #1730 (this very guard's own #1686 fix)
|
|
176
|
+
* spuriously closed unrelated issue #1664. Its body quoted the historical
|
|
177
|
+
* bug it was fixing — "the one-word fix #1664 asked for" — inside a markdown
|
|
178
|
+
* code span, so GitHub's PR-body linker correctly ignored it and
|
|
179
|
+
* `closingIssuesReferences` read `[]`, exactly what section 3 checks and
|
|
180
|
+
* exactly what let it through. The identical phrase reached the real squash
|
|
181
|
+
* commit unchanged, in the PR's own commit message — WITHOUT a code span
|
|
182
|
+
* there, because a git commit message has no markdown semantics at all: a
|
|
183
|
+
* backtick in one is two literal characters, not a code-span delimiter, and
|
|
184
|
+
* GitHub's push-based "closes on merge to the default branch" keyword scan
|
|
185
|
+
* is a completely different mechanism from the PR-body linker, with no
|
|
186
|
+
* concept of markdown to respect. #1664 closed one second after merge.
|
|
187
|
+
*
|
|
188
|
+
* This is also why `stripCode` cannot simply be applied to a commit-message
|
|
189
|
+
* document the way it is to the body/title: doing so would make this file's
|
|
190
|
+
* OWN lexical scan (`closingReferences`, `deliberateClosingReferences`,
|
|
191
|
+
* `negatedClosingReferences` — checks 1 and 2 above, not just this one) less
|
|
192
|
+
* sensitive than GitHub's real behaviour for that document, the same
|
|
193
|
+
* "guard reads a different document from the one that acts" shape #1362
|
|
194
|
+
* names, just one level further in: the guard was reading the RIGHT document
|
|
195
|
+
* (#1334 already fixed that) but modelling it with the WRONG renderer's
|
|
196
|
+
* rules. Every function above therefore takes a `{ code: false }` option
|
|
197
|
+
* (see `documentsFor`'s `kind` tag and `assess`'s `rawScan`) that skips
|
|
198
|
+
* `stripCode` for a `'commit'` document — never for `'body'`/`'title'`,
|
|
199
|
+
* where a code span is genuine, GitHub-honoured protection.
|
|
200
|
+
*
|
|
201
|
+
* With that in place, `assess` runs a second, independent ground-truth
|
|
202
|
+
* reconciliation scoped to commit documents alone, using the commit text's
|
|
203
|
+
* own (now un-stripped) lexical hit as the ground truth `closingIssuesReferences`
|
|
204
|
+
* can never supply: on an otherwise-safe (non-deploy-only) path, if a commit
|
|
205
|
+
* document carries a hit and nothing anywhere reads as deliberate, fail —
|
|
206
|
+
* `kind: 'commit-ground-truth-mismatch'`. A hit on a genuinely deploy-only
|
|
207
|
+
* path is still caught by check 1 regardless, exactly as before; this only
|
|
208
|
+
* closes the gap check 1 always had by design (ordinary paths pass) and
|
|
209
|
+
* section 3 could not close for this one document (ground truth never
|
|
210
|
+
* arrives). See `scripts/check-closing-keywords-ground-truth.test.sh` for
|
|
211
|
+
* the fail-first reproduction of PR #1730's exact real shape, plus the
|
|
212
|
+
* corpus cases either side of it.
|
|
213
|
+
*
|
|
214
|
+
* Level of fix: still 3 (fail closed), same reasoning as section 3 — a
|
|
215
|
+
* commit message is prose an author legitimately writes, so intent cannot be
|
|
216
|
+
* derived with certainty here either. Not made MORE strict than GitHub's own
|
|
217
|
+
* closing behaviour: GitHub will act on a commit-message hit regardless of
|
|
218
|
+
* position or backticks, and this check only refuses the ones this file
|
|
219
|
+
* cannot explain as intentional, using the identical `deliberateClosingReferences`
|
|
220
|
+
* heuristic and its identical, already-accepted trade-off (see that
|
|
221
|
+
* function's docstring for two real, intentional, mid-line-parenthetical
|
|
222
|
+
* closes this heuristic already did not recognise before this change,
|
|
223
|
+
* unrelated to commits — this does not introduce a new blind spot, it
|
|
224
|
+
* extends an existing, documented one to a new document).
|
|
156
225
|
*/
|
|
157
226
|
|
|
158
227
|
/** Closing keywords GitHub actually acts on, per its own documentation. */
|
|
@@ -207,11 +276,20 @@ export function stripCode(body) {
|
|
|
207
276
|
* The issue references a body would close on merge.
|
|
208
277
|
*
|
|
209
278
|
* Matches `Closes #12`, `fixes owner/repo#12` and the `Closes: #12` colon
|
|
210
|
-
* form. Ignores keywords inside code — see `stripCode
|
|
279
|
+
* form. Ignores keywords inside code — see `stripCode` — UNLESS `{ code:
|
|
280
|
+
* false }` is passed, which skips that blanking entirely.
|
|
281
|
+
*
|
|
282
|
+
* `code` must be `false` for a COMMIT MESSAGE document (#1732): a git commit
|
|
283
|
+
* message has no markdown semantics, so a backtick there is two literal
|
|
284
|
+
* characters, not a code-span delimiter, and GitHub's push-based "closes on
|
|
285
|
+
* merge to the default branch" keyword scan reads it exactly that way — it
|
|
286
|
+
* is not the same renderer as the PR body/title, which genuinely are
|
|
287
|
+
* markdown and where `stripCode` correctly models GitHub's own linker. See
|
|
288
|
+
* `assess`'s `rawForDoc` and the module docstring's "3b" section.
|
|
211
289
|
*/
|
|
212
|
-
export function closingReferences(body) {
|
|
290
|
+
export function closingReferences(body, { code = true } = {}) {
|
|
213
291
|
if (!body) return []
|
|
214
|
-
const withoutCode = stripCode(body)
|
|
292
|
+
const withoutCode = code ? stripCode(body) : body
|
|
215
293
|
const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
|
|
216
294
|
return [...withoutCode.matchAll(pattern)].map((m) => m[2])
|
|
217
295
|
}
|
|
@@ -254,14 +332,29 @@ const CLAUSE_DECORATION = '(?:[-*•]\\s+|\\d+[.)]\\s+|#{1,6}\\s+|\\*{1,2})*'
|
|
|
254
332
|
* `closingReferences` still does for checks 1 and 2 above.
|
|
255
333
|
*
|
|
256
334
|
* Known residual gap, accepted rather than solved: a trailer that starts
|
|
257
|
-
* mid-line without sentence-ending punctuation before it
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
335
|
+
* mid-line without sentence-ending punctuation before it reads as
|
|
336
|
+
* not-deliberate. That is the conservative direction — it can make the
|
|
337
|
+
* ground-truth check (below) ask for a clarifying reword it didn't strictly
|
|
338
|
+
* need, never the reverse. Two real instances, both intentional and both
|
|
339
|
+
* missed by this heuristic because a parenthesis is not a boundary this
|
|
340
|
+
* function looks for: `chore(core): mark services/pr-signer/ template-owned
|
|
341
|
+
* (closes #243/#548-shaped gap) (#581)` and `feat(cli): publish the CLI to
|
|
342
|
+
* npm as versioned `biffo` (closes #259) (#300)` (both real commit subjects,
|
|
343
|
+
* `git log --all --format='%B'`). Widening the boundary set to also start a
|
|
344
|
+
* clause after `(` was considered and rejected here: it would not even have
|
|
345
|
+
* caught either example (neither open-paren sits at a position this
|
|
346
|
+
* function currently recognises as a clause start), and it is exactly the
|
|
347
|
+
* kind of heuristic change #1628 warns against making without a full case
|
|
348
|
+
* matrix — see AGENTS.md. The remedy is the same as always: a `Closes #N` on
|
|
349
|
+
* its own line or sentence.
|
|
350
|
+
*
|
|
351
|
+
* `code`, same contract as `closingReferences` (#1732): pass `{ code: false
|
|
352
|
+
* }` for a commit-message document, since backticks are not markdown there
|
|
353
|
+
* and must not be treated as protection.
|
|
261
354
|
*/
|
|
262
|
-
export function deliberateClosingReferences(text) {
|
|
355
|
+
export function deliberateClosingReferences(text, { code = true } = {}) {
|
|
263
356
|
if (!text) return []
|
|
264
|
-
const stripped = stripCode(text)
|
|
357
|
+
const stripped = code ? stripCode(text) : text
|
|
265
358
|
const starts = new Set([0])
|
|
266
359
|
const boundary = /\n|[.!?]\s+/g
|
|
267
360
|
let m
|
|
@@ -333,11 +426,13 @@ const NEGATIONS = [
|
|
|
333
426
|
* The negated closing references in a body, each with the line that carries
|
|
334
427
|
* it — a guard that says only "no" gets worked around.
|
|
335
428
|
*
|
|
336
|
-
* Returns `[{ reference, line, lineNumber }]`, in body order.
|
|
429
|
+
* Returns `[{ reference, line, lineNumber }]`, in body order. `code`, same
|
|
430
|
+
* contract as `closingReferences` — pass `{ code: false }` for a commit
|
|
431
|
+
* message (#1732), since backticks do not protect text there.
|
|
337
432
|
*/
|
|
338
|
-
export function negatedClosingReferences(body) {
|
|
433
|
+
export function negatedClosingReferences(body, { code = true } = {}) {
|
|
339
434
|
if (!body) return []
|
|
340
|
-
const text = stripCode(body)
|
|
435
|
+
const text = code ? stripCode(body) : body
|
|
341
436
|
const authored = body.split('\n')
|
|
342
437
|
const pattern = new RegExp(
|
|
343
438
|
`(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
|
|
@@ -381,18 +476,26 @@ export function deployOnlyPaths(changedFiles) {
|
|
|
381
476
|
* `commits` is the shape `gh pr view --json commits` returns: an array of
|
|
382
477
|
* `{ messageHeadline, messageBody }`. Both are scanned — a keyword can sit
|
|
383
478
|
* in either, and #1334's own repro had it in the headline.
|
|
479
|
+
*
|
|
480
|
+
* Each doc also carries `kind` — `'body'`, `'title'`, or `'commit'` (#1732).
|
|
481
|
+
* The PR body and title are genuinely markdown, rendered by GitHub's own PR
|
|
482
|
+
* page, so a code span in either is real protection. A commit message is
|
|
483
|
+
* neither: it has no markdown semantics for GitHub's push-based "closes on
|
|
484
|
+
* merge to the default branch" keyword scan, so `assess` must scan `'commit'`
|
|
485
|
+
* documents with `{ code: false }` — see that function and the module
|
|
486
|
+
* docstring's "3b" section.
|
|
384
487
|
*/
|
|
385
488
|
export function documentsFor({ body, title, commits }) {
|
|
386
|
-
const docs = [{ source: 'the PR body', text: body }]
|
|
387
|
-
if (title) docs.push({ source: 'the PR title', text: title })
|
|
489
|
+
const docs = [{ source: 'the PR body', text: body, kind: 'body' }]
|
|
490
|
+
if (title) docs.push({ source: 'the PR title', text: title, kind: 'title' })
|
|
388
491
|
const list = commits ?? []
|
|
389
492
|
list.forEach((commit, i) => {
|
|
390
493
|
const label = list.length === 1 ? 'the commit message' : `commit ${i + 1}`
|
|
391
494
|
if (commit?.messageHeadline) {
|
|
392
|
-
docs.push({ source: `${label} (subject)`, text: commit.messageHeadline })
|
|
495
|
+
docs.push({ source: `${label} (subject)`, text: commit.messageHeadline, kind: 'commit' })
|
|
393
496
|
}
|
|
394
497
|
if (commit?.messageBody) {
|
|
395
|
-
docs.push({ source: `${label} (body)`, text: commit.messageBody })
|
|
498
|
+
docs.push({ source: `${label} (body)`, text: commit.messageBody, kind: 'commit' })
|
|
396
499
|
}
|
|
397
500
|
})
|
|
398
501
|
return docs
|
|
@@ -420,28 +523,82 @@ export function documentsFor({ body, title, commits }) {
|
|
|
420
523
|
* caller (and every existing test) keeps working unchanged, the same reason
|
|
421
524
|
* `title`/`commits` are optional — see `documentsFor`.
|
|
422
525
|
*/
|
|
526
|
+
// A document is markdown, and therefore genuinely protected by a code span,
|
|
527
|
+
// only if GitHub's OWN renderer treats it that way. The PR body and title
|
|
528
|
+
// are; a commit message is not — see `documentsFor` and the module
|
|
529
|
+
// docstring's "3b" section (#1732). `closingReferences`, `deliberateClosingReferences`
|
|
530
|
+
// and `negatedClosingReferences` all take `{ code: false }` to mean "scan
|
|
531
|
+
// this raw, backticks are literal characters here".
|
|
532
|
+
const rawScan = (doc) => ({ code: doc.kind !== 'commit' })
|
|
533
|
+
|
|
423
534
|
export function assess({ body, title, commits, changedFiles, closingIssuesReferences = [] }) {
|
|
424
535
|
const docs = documentsFor({ body, title, commits })
|
|
425
536
|
|
|
426
537
|
const negated = docs.flatMap((doc) =>
|
|
427
|
-
negatedClosingReferences(doc.text).map((n) => ({ ...n, source: doc.source })),
|
|
538
|
+
negatedClosingReferences(doc.text, rawScan(doc)).map((n) => ({ ...n, source: doc.source })),
|
|
428
539
|
)
|
|
429
540
|
if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
|
|
430
541
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
542
|
+
// Whether ANY document reads as a deliberate closing directive — shared
|
|
543
|
+
// between the two ground-truth checks below, since both ask the identical
|
|
544
|
+
// question ("is this hit something the author actually meant"), just
|
|
545
|
+
// triggered by two different sources of ground truth.
|
|
546
|
+
const deliberate = docs.some(
|
|
547
|
+
(doc) => deliberateClosingReferences(doc.text, rawScan(doc)).length > 0,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if (closingIssuesReferences.length > 0 && !deliberate) {
|
|
551
|
+
return { ok: false, kind: 'ground-truth-mismatch', closingIssuesReferences }
|
|
436
552
|
}
|
|
437
553
|
|
|
438
554
|
const hits = docs
|
|
439
|
-
.map((doc) => ({
|
|
555
|
+
.map((doc) => ({
|
|
556
|
+
source: doc.source,
|
|
557
|
+
isCommit: doc.kind === 'commit',
|
|
558
|
+
references: closingReferences(doc.text, rawScan(doc)),
|
|
559
|
+
}))
|
|
440
560
|
.filter((h) => h.references.length > 0)
|
|
441
561
|
if (hits.length === 0) return { ok: true, reason: 'no-closing-keyword' }
|
|
442
562
|
|
|
443
563
|
const paths = deployOnlyPaths(changedFiles)
|
|
444
|
-
if (paths.length === 0)
|
|
564
|
+
if (paths.length === 0) {
|
|
565
|
+
// ── 3b. Ground truth, extended to the document GitHub actually squashes
|
|
566
|
+
// (#1732) ──────────────────────────────────────────────────────────────
|
|
567
|
+
//
|
|
568
|
+
// `closingIssuesReferences` is GitHub's OWN ground truth for what the PR
|
|
569
|
+
// BODY will close — but it structurally cannot see a commit message, and
|
|
570
|
+
// this repo's squash-merge composes the real merge commit from commit
|
|
571
|
+
// messages verbatim (`squash_merge_commit_message = COMMIT_MESSAGES`).
|
|
572
|
+
// A closing-keyword hit that lives only in a commit message is therefore
|
|
573
|
+
// something GitHub WILL act on that `closingIssuesReferences` can never
|
|
574
|
+
// confirm OR deny — the check above is a structural no-op for it, not a
|
|
575
|
+
// considered "safe". Real instance: PR #1730's body quoted the phrase
|
|
576
|
+
// "the one-word fix #1664 asked for" inside a markdown code span, so
|
|
577
|
+
// GitHub's PR-body linker correctly ignored it (closingIssuesReferences
|
|
578
|
+
// read `[]`) — but the identical phrase reached the actual squash commit
|
|
579
|
+
// verbatim from the branch's own commit message, WITHOUT a code span
|
|
580
|
+
// (a git commit message has no markdown semantics: a backtick there is
|
|
581
|
+
// two literal characters, not a code-span delimiter), and closed #1664
|
|
582
|
+
// one second after merge.
|
|
583
|
+
//
|
|
584
|
+
// So a commit-only hit gets the same reconciliation the body already
|
|
585
|
+
// gets from `closingIssuesReferences`, using the commit text itself as
|
|
586
|
+
// the ground truth `closingIssuesReferences` cannot supply: if a commit
|
|
587
|
+
// document carries a hit and nothing anywhere reads as deliberate, fail
|
|
588
|
+
// — regardless of path, and regardless of what `closingIssuesReferences`
|
|
589
|
+
// said, since it was never asked about this document.
|
|
590
|
+
//
|
|
591
|
+
// Known residual gap, same shape and same acceptance as
|
|
592
|
+
// `deliberateClosingReferences`'s own docstring: a deliberate close
|
|
593
|
+
// written as a mid-line parenthetical (`(closes #NNN)`) is not
|
|
594
|
+
// recognised as deliberate either, so it would ask for a reword it did
|
|
595
|
+
// not strictly need. Conservative direction only — see that docstring.
|
|
596
|
+
const commitHits = hits.filter((h) => h.isCommit)
|
|
597
|
+
if (commitHits.length > 0 && !deliberate) {
|
|
598
|
+
return { ok: false, kind: 'commit-ground-truth-mismatch', hits: commitHits }
|
|
599
|
+
}
|
|
600
|
+
return { ok: true, reason: 'no-deploy-only-paths' }
|
|
601
|
+
}
|
|
445
602
|
|
|
446
603
|
if (hasVerifiedTrailer(body)) return { ok: true, reason: 'verified-trailer' }
|
|
447
604
|
|
|
@@ -452,6 +609,7 @@ export function assess({ body, title, commits, changedFiles, closingIssuesRefere
|
|
|
452
609
|
export function formatFailure(result) {
|
|
453
610
|
if (result.kind === 'negated-keyword') return formatNegatedFailure(result)
|
|
454
611
|
if (result.kind === 'ground-truth-mismatch') return formatGroundTruthFailure(result)
|
|
612
|
+
if (result.kind === 'commit-ground-truth-mismatch') return formatCommitGroundTruthFailure(result)
|
|
455
613
|
return formatDeployOnlyFailure(result)
|
|
456
614
|
}
|
|
457
615
|
|
|
@@ -518,6 +676,48 @@ function formatGroundTruthFailure({ closingIssuesReferences }) {
|
|
|
518
676
|
].join('\n')
|
|
519
677
|
}
|
|
520
678
|
|
|
679
|
+
function formatCommitGroundTruthFailure({ hits }) {
|
|
680
|
+
const refs = [...new Set(hits.flatMap((h) => h.references))]
|
|
681
|
+
return [
|
|
682
|
+
`A COMMIT message would close ${refs.join(', ')} on merge — found in:`,
|
|
683
|
+
'',
|
|
684
|
+
...hits.map((h) => ` - ${h.source}: ${h.references.join(', ')}`),
|
|
685
|
+
'',
|
|
686
|
+
"GitHub's own `closingIssuesReferences` cannot see this: that field",
|
|
687
|
+
'reflects only the PR body as GitHub itself parses it, and this repo',
|
|
688
|
+
"builds the real squash-merge commit from the branch's own commit",
|
|
689
|
+
'messages verbatim (squash_merge_commit_message = COMMIT_MESSAGES) — a',
|
|
690
|
+
'separate mechanism GitHub applies to that text with no markdown',
|
|
691
|
+
'awareness at all: a backtick in a commit message is a literal',
|
|
692
|
+
'character, not a code-span delimiter, so it does NOT protect a',
|
|
693
|
+
'closing keyword there the way it would in the PR body.',
|
|
694
|
+
'',
|
|
695
|
+
'This is #1732: PR #1730\'s body quoted "the one-word fix #1664 asked',
|
|
696
|
+
'for" inside a markdown code span, so closingIssuesReferences correctly',
|
|
697
|
+
'read [] — but the identical phrase, without a code span, was already',
|
|
698
|
+
"sitting in the branch's own commit message, and closed #1664 one",
|
|
699
|
+
'second after merge.',
|
|
700
|
+
'',
|
|
701
|
+
'Nothing in the PR body, title or commit messages reads as a DELIBERATE',
|
|
702
|
+
'closing directive (a keyword+reference at the start of the document, a',
|
|
703
|
+
'line, or a sentence). Either:',
|
|
704
|
+
' - this close is NOT intended: reword the COMMIT (`git commit --amend`',
|
|
705
|
+
' or an interactive rebase) so the keyword and reference are not',
|
|
706
|
+
' adjacent, or move the reference into its own `Refs #N` line, and',
|
|
707
|
+
' force-push; or',
|
|
708
|
+
' - this close IS intended: make it a deliberate directive in the',
|
|
709
|
+
' COMMIT — its own line, its own sentence, e.g. `Closes #1664` — so',
|
|
710
|
+
' this file, and anyone reading `git log`, can tell the difference.',
|
|
711
|
+
'',
|
|
712
|
+
'Editing the PR body does NOT fix this: the commit message is what',
|
|
713
|
+
'reaches the squash-merge commit GitHub actually reads, independent of',
|
|
714
|
+
'anything in the PR description. Re-run after amending and force-pushing',
|
|
715
|
+
'— commits are read live, so a re-run genuinely re-evaluates:',
|
|
716
|
+
'',
|
|
717
|
+
' gh run rerun <run-id> --failed',
|
|
718
|
+
].join('\n')
|
|
719
|
+
}
|
|
720
|
+
|
|
521
721
|
function formatDeployOnlyFailure({ references, paths, hits }) {
|
|
522
722
|
const shown = paths.slice(0, 10)
|
|
523
723
|
const more = paths.length - shown.length
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* what this file's lexical scan calls "deliberate" — see
|
|
14
14
|
* `deliberateClosingReferences` and the "3. Ground truth" section below
|
|
15
15
|
* (#1686). This is the one that reconciles the guard's model against the
|
|
16
|
-
* thing that actually acts, rather than trying to out-regex it.
|
|
16
|
+
* thing that actually acts, rather than trying to out-regex it. Extended
|
|
17
|
+
* in "3b" below (#1732) to a document `closingIssuesReferences` itself
|
|
18
|
+
* cannot see.
|
|
17
19
|
*
|
|
18
20
|
* ── Three documents, not one (#1334, #1362) ──────────────────────────────
|
|
19
21
|
*
|
|
@@ -153,6 +155,73 @@
|
|
|
153
155
|
* meaning. What IS achievable, and what this does, is refuse to let our own
|
|
154
156
|
* heuristic's blind spot silently diverge from GitHub's actual behaviour: the
|
|
155
157
|
* two are reconciled every time, and a mismatch fails rather than passing.
|
|
158
|
+
*
|
|
159
|
+
* ── 3b. Ground truth's own blind spot: it never reads a commit (#1732) ───
|
|
160
|
+
*
|
|
161
|
+
* Section 3 above reconciles this file's lexical model against
|
|
162
|
+
* `closingIssuesReferences` — but that field is itself only a MODEL of one
|
|
163
|
+
* of the three documents GitHub honours: it is GitHub's ground truth for the
|
|
164
|
+
* PR BODY, computed by the same markdown-aware linker that renders the PR
|
|
165
|
+
* page, and it structurally cannot see a commit message at all. This repo's
|
|
166
|
+
* squash-merge strategy is `squash_merge_commit_message = COMMIT_MESSAGES`
|
|
167
|
+
* (confirmed via `gh api repos/{owner}/{repo}` → `squash_merge_commit_title`/
|
|
168
|
+
* `_message`), so the actual merge commit GitHub creates is composed from the
|
|
169
|
+
* branch's own commit messages, verbatim — not from the PR body at all. A
|
|
170
|
+
* closing-keyword hit that lives only in a commit message is therefore
|
|
171
|
+
* something GitHub WILL act on that section 3's check is a structural no-op
|
|
172
|
+
* for, not a considered "safe": `closingIssuesReferences.length > 0` is
|
|
173
|
+
* simply never true for it, no matter how dangerous the commit text is.
|
|
174
|
+
*
|
|
175
|
+
* Real instance: merging PR #1730 (this very guard's own #1686 fix)
|
|
176
|
+
* spuriously closed unrelated issue #1664. Its body quoted the historical
|
|
177
|
+
* bug it was fixing — "the one-word fix #1664 asked for" — inside a markdown
|
|
178
|
+
* code span, so GitHub's PR-body linker correctly ignored it and
|
|
179
|
+
* `closingIssuesReferences` read `[]`, exactly what section 3 checks and
|
|
180
|
+
* exactly what let it through. The identical phrase reached the real squash
|
|
181
|
+
* commit unchanged, in the PR's own commit message — WITHOUT a code span
|
|
182
|
+
* there, because a git commit message has no markdown semantics at all: a
|
|
183
|
+
* backtick in one is two literal characters, not a code-span delimiter, and
|
|
184
|
+
* GitHub's push-based "closes on merge to the default branch" keyword scan
|
|
185
|
+
* is a completely different mechanism from the PR-body linker, with no
|
|
186
|
+
* concept of markdown to respect. #1664 closed one second after merge.
|
|
187
|
+
*
|
|
188
|
+
* This is also why `stripCode` cannot simply be applied to a commit-message
|
|
189
|
+
* document the way it is to the body/title: doing so would make this file's
|
|
190
|
+
* OWN lexical scan (`closingReferences`, `deliberateClosingReferences`,
|
|
191
|
+
* `negatedClosingReferences` — checks 1 and 2 above, not just this one) less
|
|
192
|
+
* sensitive than GitHub's real behaviour for that document, the same
|
|
193
|
+
* "guard reads a different document from the one that acts" shape #1362
|
|
194
|
+
* names, just one level further in: the guard was reading the RIGHT document
|
|
195
|
+
* (#1334 already fixed that) but modelling it with the WRONG renderer's
|
|
196
|
+
* rules. Every function above therefore takes a `{ code: false }` option
|
|
197
|
+
* (see `documentsFor`'s `kind` tag and `assess`'s `rawScan`) that skips
|
|
198
|
+
* `stripCode` for a `'commit'` document — never for `'body'`/`'title'`,
|
|
199
|
+
* where a code span is genuine, GitHub-honoured protection.
|
|
200
|
+
*
|
|
201
|
+
* With that in place, `assess` runs a second, independent ground-truth
|
|
202
|
+
* reconciliation scoped to commit documents alone, using the commit text's
|
|
203
|
+
* own (now un-stripped) lexical hit as the ground truth `closingIssuesReferences`
|
|
204
|
+
* can never supply: on an otherwise-safe (non-deploy-only) path, if a commit
|
|
205
|
+
* document carries a hit and nothing anywhere reads as deliberate, fail —
|
|
206
|
+
* `kind: 'commit-ground-truth-mismatch'`. A hit on a genuinely deploy-only
|
|
207
|
+
* path is still caught by check 1 regardless, exactly as before; this only
|
|
208
|
+
* closes the gap check 1 always had by design (ordinary paths pass) and
|
|
209
|
+
* section 3 could not close for this one document (ground truth never
|
|
210
|
+
* arrives). See `scripts/check-closing-keywords-ground-truth.test.sh` for
|
|
211
|
+
* the fail-first reproduction of PR #1730's exact real shape, plus the
|
|
212
|
+
* corpus cases either side of it.
|
|
213
|
+
*
|
|
214
|
+
* Level of fix: still 3 (fail closed), same reasoning as section 3 — a
|
|
215
|
+
* commit message is prose an author legitimately writes, so intent cannot be
|
|
216
|
+
* derived with certainty here either. Not made MORE strict than GitHub's own
|
|
217
|
+
* closing behaviour: GitHub will act on a commit-message hit regardless of
|
|
218
|
+
* position or backticks, and this check only refuses the ones this file
|
|
219
|
+
* cannot explain as intentional, using the identical `deliberateClosingReferences`
|
|
220
|
+
* heuristic and its identical, already-accepted trade-off (see that
|
|
221
|
+
* function's docstring for two real, intentional, mid-line-parenthetical
|
|
222
|
+
* closes this heuristic already did not recognise before this change,
|
|
223
|
+
* unrelated to commits — this does not introduce a new blind spot, it
|
|
224
|
+
* extends an existing, documented one to a new document).
|
|
156
225
|
*/
|
|
157
226
|
|
|
158
227
|
/** Closing keywords GitHub actually acts on, per its own documentation. */
|
|
@@ -207,11 +276,20 @@ export function stripCode(body) {
|
|
|
207
276
|
* The issue references a body would close on merge.
|
|
208
277
|
*
|
|
209
278
|
* Matches `Closes #12`, `fixes owner/repo#12` and the `Closes: #12` colon
|
|
210
|
-
* form. Ignores keywords inside code — see `stripCode
|
|
279
|
+
* form. Ignores keywords inside code — see `stripCode` — UNLESS `{ code:
|
|
280
|
+
* false }` is passed, which skips that blanking entirely.
|
|
281
|
+
*
|
|
282
|
+
* `code` must be `false` for a COMMIT MESSAGE document (#1732): a git commit
|
|
283
|
+
* message has no markdown semantics, so a backtick there is two literal
|
|
284
|
+
* characters, not a code-span delimiter, and GitHub's push-based "closes on
|
|
285
|
+
* merge to the default branch" keyword scan reads it exactly that way — it
|
|
286
|
+
* is not the same renderer as the PR body/title, which genuinely are
|
|
287
|
+
* markdown and where `stripCode` correctly models GitHub's own linker. See
|
|
288
|
+
* `assess`'s `rawForDoc` and the module docstring's "3b" section.
|
|
211
289
|
*/
|
|
212
|
-
export function closingReferences(body) {
|
|
290
|
+
export function closingReferences(body, { code = true } = {}) {
|
|
213
291
|
if (!body) return []
|
|
214
|
-
const withoutCode = stripCode(body)
|
|
292
|
+
const withoutCode = code ? stripCode(body) : body
|
|
215
293
|
const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
|
|
216
294
|
return [...withoutCode.matchAll(pattern)].map((m) => m[2])
|
|
217
295
|
}
|
|
@@ -254,14 +332,29 @@ const CLAUSE_DECORATION = '(?:[-*•]\\s+|\\d+[.)]\\s+|#{1,6}\\s+|\\*{1,2})*'
|
|
|
254
332
|
* `closingReferences` still does for checks 1 and 2 above.
|
|
255
333
|
*
|
|
256
334
|
* Known residual gap, accepted rather than solved: a trailer that starts
|
|
257
|
-
* mid-line without sentence-ending punctuation before it
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
335
|
+
* mid-line without sentence-ending punctuation before it reads as
|
|
336
|
+
* not-deliberate. That is the conservative direction — it can make the
|
|
337
|
+
* ground-truth check (below) ask for a clarifying reword it didn't strictly
|
|
338
|
+
* need, never the reverse. Two real instances, both intentional and both
|
|
339
|
+
* missed by this heuristic because a parenthesis is not a boundary this
|
|
340
|
+
* function looks for: `chore(core): mark services/pr-signer/ template-owned
|
|
341
|
+
* (closes #243/#548-shaped gap) (#581)` and `feat(cli): publish the CLI to
|
|
342
|
+
* npm as versioned `biffo` (closes #259) (#300)` (both real commit subjects,
|
|
343
|
+
* `git log --all --format='%B'`). Widening the boundary set to also start a
|
|
344
|
+
* clause after `(` was considered and rejected here: it would not even have
|
|
345
|
+
* caught either example (neither open-paren sits at a position this
|
|
346
|
+
* function currently recognises as a clause start), and it is exactly the
|
|
347
|
+
* kind of heuristic change #1628 warns against making without a full case
|
|
348
|
+
* matrix — see AGENTS.md. The remedy is the same as always: a `Closes #N` on
|
|
349
|
+
* its own line or sentence.
|
|
350
|
+
*
|
|
351
|
+
* `code`, same contract as `closingReferences` (#1732): pass `{ code: false
|
|
352
|
+
* }` for a commit-message document, since backticks are not markdown there
|
|
353
|
+
* and must not be treated as protection.
|
|
261
354
|
*/
|
|
262
|
-
export function deliberateClosingReferences(text) {
|
|
355
|
+
export function deliberateClosingReferences(text, { code = true } = {}) {
|
|
263
356
|
if (!text) return []
|
|
264
|
-
const stripped = stripCode(text)
|
|
357
|
+
const stripped = code ? stripCode(text) : text
|
|
265
358
|
const starts = new Set([0])
|
|
266
359
|
const boundary = /\n|[.!?]\s+/g
|
|
267
360
|
let m
|
|
@@ -333,11 +426,13 @@ const NEGATIONS = [
|
|
|
333
426
|
* The negated closing references in a body, each with the line that carries
|
|
334
427
|
* it — a guard that says only "no" gets worked around.
|
|
335
428
|
*
|
|
336
|
-
* Returns `[{ reference, line, lineNumber }]`, in body order.
|
|
429
|
+
* Returns `[{ reference, line, lineNumber }]`, in body order. `code`, same
|
|
430
|
+
* contract as `closingReferences` — pass `{ code: false }` for a commit
|
|
431
|
+
* message (#1732), since backticks do not protect text there.
|
|
337
432
|
*/
|
|
338
|
-
export function negatedClosingReferences(body) {
|
|
433
|
+
export function negatedClosingReferences(body, { code = true } = {}) {
|
|
339
434
|
if (!body) return []
|
|
340
|
-
const text = stripCode(body)
|
|
435
|
+
const text = code ? stripCode(body) : body
|
|
341
436
|
const authored = body.split('\n')
|
|
342
437
|
const pattern = new RegExp(
|
|
343
438
|
`(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
|
|
@@ -381,18 +476,26 @@ export function deployOnlyPaths(changedFiles) {
|
|
|
381
476
|
* `commits` is the shape `gh pr view --json commits` returns: an array of
|
|
382
477
|
* `{ messageHeadline, messageBody }`. Both are scanned — a keyword can sit
|
|
383
478
|
* in either, and #1334's own repro had it in the headline.
|
|
479
|
+
*
|
|
480
|
+
* Each doc also carries `kind` — `'body'`, `'title'`, or `'commit'` (#1732).
|
|
481
|
+
* The PR body and title are genuinely markdown, rendered by GitHub's own PR
|
|
482
|
+
* page, so a code span in either is real protection. A commit message is
|
|
483
|
+
* neither: it has no markdown semantics for GitHub's push-based "closes on
|
|
484
|
+
* merge to the default branch" keyword scan, so `assess` must scan `'commit'`
|
|
485
|
+
* documents with `{ code: false }` — see that function and the module
|
|
486
|
+
* docstring's "3b" section.
|
|
384
487
|
*/
|
|
385
488
|
export function documentsFor({ body, title, commits }) {
|
|
386
|
-
const docs = [{ source: 'the PR body', text: body }]
|
|
387
|
-
if (title) docs.push({ source: 'the PR title', text: title })
|
|
489
|
+
const docs = [{ source: 'the PR body', text: body, kind: 'body' }]
|
|
490
|
+
if (title) docs.push({ source: 'the PR title', text: title, kind: 'title' })
|
|
388
491
|
const list = commits ?? []
|
|
389
492
|
list.forEach((commit, i) => {
|
|
390
493
|
const label = list.length === 1 ? 'the commit message' : `commit ${i + 1}`
|
|
391
494
|
if (commit?.messageHeadline) {
|
|
392
|
-
docs.push({ source: `${label} (subject)`, text: commit.messageHeadline })
|
|
495
|
+
docs.push({ source: `${label} (subject)`, text: commit.messageHeadline, kind: 'commit' })
|
|
393
496
|
}
|
|
394
497
|
if (commit?.messageBody) {
|
|
395
|
-
docs.push({ source: `${label} (body)`, text: commit.messageBody })
|
|
498
|
+
docs.push({ source: `${label} (body)`, text: commit.messageBody, kind: 'commit' })
|
|
396
499
|
}
|
|
397
500
|
})
|
|
398
501
|
return docs
|
|
@@ -420,28 +523,82 @@ export function documentsFor({ body, title, commits }) {
|
|
|
420
523
|
* caller (and every existing test) keeps working unchanged, the same reason
|
|
421
524
|
* `title`/`commits` are optional — see `documentsFor`.
|
|
422
525
|
*/
|
|
526
|
+
// A document is markdown, and therefore genuinely protected by a code span,
|
|
527
|
+
// only if GitHub's OWN renderer treats it that way. The PR body and title
|
|
528
|
+
// are; a commit message is not — see `documentsFor` and the module
|
|
529
|
+
// docstring's "3b" section (#1732). `closingReferences`, `deliberateClosingReferences`
|
|
530
|
+
// and `negatedClosingReferences` all take `{ code: false }` to mean "scan
|
|
531
|
+
// this raw, backticks are literal characters here".
|
|
532
|
+
const rawScan = (doc) => ({ code: doc.kind !== 'commit' })
|
|
533
|
+
|
|
423
534
|
export function assess({ body, title, commits, changedFiles, closingIssuesReferences = [] }) {
|
|
424
535
|
const docs = documentsFor({ body, title, commits })
|
|
425
536
|
|
|
426
537
|
const negated = docs.flatMap((doc) =>
|
|
427
|
-
negatedClosingReferences(doc.text).map((n) => ({ ...n, source: doc.source })),
|
|
538
|
+
negatedClosingReferences(doc.text, rawScan(doc)).map((n) => ({ ...n, source: doc.source })),
|
|
428
539
|
)
|
|
429
540
|
if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
|
|
430
541
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
542
|
+
// Whether ANY document reads as a deliberate closing directive — shared
|
|
543
|
+
// between the two ground-truth checks below, since both ask the identical
|
|
544
|
+
// question ("is this hit something the author actually meant"), just
|
|
545
|
+
// triggered by two different sources of ground truth.
|
|
546
|
+
const deliberate = docs.some(
|
|
547
|
+
(doc) => deliberateClosingReferences(doc.text, rawScan(doc)).length > 0,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if (closingIssuesReferences.length > 0 && !deliberate) {
|
|
551
|
+
return { ok: false, kind: 'ground-truth-mismatch', closingIssuesReferences }
|
|
436
552
|
}
|
|
437
553
|
|
|
438
554
|
const hits = docs
|
|
439
|
-
.map((doc) => ({
|
|
555
|
+
.map((doc) => ({
|
|
556
|
+
source: doc.source,
|
|
557
|
+
isCommit: doc.kind === 'commit',
|
|
558
|
+
references: closingReferences(doc.text, rawScan(doc)),
|
|
559
|
+
}))
|
|
440
560
|
.filter((h) => h.references.length > 0)
|
|
441
561
|
if (hits.length === 0) return { ok: true, reason: 'no-closing-keyword' }
|
|
442
562
|
|
|
443
563
|
const paths = deployOnlyPaths(changedFiles)
|
|
444
|
-
if (paths.length === 0)
|
|
564
|
+
if (paths.length === 0) {
|
|
565
|
+
// ── 3b. Ground truth, extended to the document GitHub actually squashes
|
|
566
|
+
// (#1732) ──────────────────────────────────────────────────────────────
|
|
567
|
+
//
|
|
568
|
+
// `closingIssuesReferences` is GitHub's OWN ground truth for what the PR
|
|
569
|
+
// BODY will close — but it structurally cannot see a commit message, and
|
|
570
|
+
// this repo's squash-merge composes the real merge commit from commit
|
|
571
|
+
// messages verbatim (`squash_merge_commit_message = COMMIT_MESSAGES`).
|
|
572
|
+
// A closing-keyword hit that lives only in a commit message is therefore
|
|
573
|
+
// something GitHub WILL act on that `closingIssuesReferences` can never
|
|
574
|
+
// confirm OR deny — the check above is a structural no-op for it, not a
|
|
575
|
+
// considered "safe". Real instance: PR #1730's body quoted the phrase
|
|
576
|
+
// "the one-word fix #1664 asked for" inside a markdown code span, so
|
|
577
|
+
// GitHub's PR-body linker correctly ignored it (closingIssuesReferences
|
|
578
|
+
// read `[]`) — but the identical phrase reached the actual squash commit
|
|
579
|
+
// verbatim from the branch's own commit message, WITHOUT a code span
|
|
580
|
+
// (a git commit message has no markdown semantics: a backtick there is
|
|
581
|
+
// two literal characters, not a code-span delimiter), and closed #1664
|
|
582
|
+
// one second after merge.
|
|
583
|
+
//
|
|
584
|
+
// So a commit-only hit gets the same reconciliation the body already
|
|
585
|
+
// gets from `closingIssuesReferences`, using the commit text itself as
|
|
586
|
+
// the ground truth `closingIssuesReferences` cannot supply: if a commit
|
|
587
|
+
// document carries a hit and nothing anywhere reads as deliberate, fail
|
|
588
|
+
// — regardless of path, and regardless of what `closingIssuesReferences`
|
|
589
|
+
// said, since it was never asked about this document.
|
|
590
|
+
//
|
|
591
|
+
// Known residual gap, same shape and same acceptance as
|
|
592
|
+
// `deliberateClosingReferences`'s own docstring: a deliberate close
|
|
593
|
+
// written as a mid-line parenthetical (`(closes #NNN)`) is not
|
|
594
|
+
// recognised as deliberate either, so it would ask for a reword it did
|
|
595
|
+
// not strictly need. Conservative direction only — see that docstring.
|
|
596
|
+
const commitHits = hits.filter((h) => h.isCommit)
|
|
597
|
+
if (commitHits.length > 0 && !deliberate) {
|
|
598
|
+
return { ok: false, kind: 'commit-ground-truth-mismatch', hits: commitHits }
|
|
599
|
+
}
|
|
600
|
+
return { ok: true, reason: 'no-deploy-only-paths' }
|
|
601
|
+
}
|
|
445
602
|
|
|
446
603
|
if (hasVerifiedTrailer(body)) return { ok: true, reason: 'verified-trailer' }
|
|
447
604
|
|
|
@@ -452,6 +609,7 @@ export function assess({ body, title, commits, changedFiles, closingIssuesRefere
|
|
|
452
609
|
export function formatFailure(result) {
|
|
453
610
|
if (result.kind === 'negated-keyword') return formatNegatedFailure(result)
|
|
454
611
|
if (result.kind === 'ground-truth-mismatch') return formatGroundTruthFailure(result)
|
|
612
|
+
if (result.kind === 'commit-ground-truth-mismatch') return formatCommitGroundTruthFailure(result)
|
|
455
613
|
return formatDeployOnlyFailure(result)
|
|
456
614
|
}
|
|
457
615
|
|
|
@@ -518,6 +676,48 @@ function formatGroundTruthFailure({ closingIssuesReferences }) {
|
|
|
518
676
|
].join('\n')
|
|
519
677
|
}
|
|
520
678
|
|
|
679
|
+
function formatCommitGroundTruthFailure({ hits }) {
|
|
680
|
+
const refs = [...new Set(hits.flatMap((h) => h.references))]
|
|
681
|
+
return [
|
|
682
|
+
`A COMMIT message would close ${refs.join(', ')} on merge — found in:`,
|
|
683
|
+
'',
|
|
684
|
+
...hits.map((h) => ` - ${h.source}: ${h.references.join(', ')}`),
|
|
685
|
+
'',
|
|
686
|
+
"GitHub's own `closingIssuesReferences` cannot see this: that field",
|
|
687
|
+
'reflects only the PR body as GitHub itself parses it, and this repo',
|
|
688
|
+
"builds the real squash-merge commit from the branch's own commit",
|
|
689
|
+
'messages verbatim (squash_merge_commit_message = COMMIT_MESSAGES) — a',
|
|
690
|
+
'separate mechanism GitHub applies to that text with no markdown',
|
|
691
|
+
'awareness at all: a backtick in a commit message is a literal',
|
|
692
|
+
'character, not a code-span delimiter, so it does NOT protect a',
|
|
693
|
+
'closing keyword there the way it would in the PR body.',
|
|
694
|
+
'',
|
|
695
|
+
'This is #1732: PR #1730\'s body quoted "the one-word fix #1664 asked',
|
|
696
|
+
'for" inside a markdown code span, so closingIssuesReferences correctly',
|
|
697
|
+
'read [] — but the identical phrase, without a code span, was already',
|
|
698
|
+
"sitting in the branch's own commit message, and closed #1664 one",
|
|
699
|
+
'second after merge.',
|
|
700
|
+
'',
|
|
701
|
+
'Nothing in the PR body, title or commit messages reads as a DELIBERATE',
|
|
702
|
+
'closing directive (a keyword+reference at the start of the document, a',
|
|
703
|
+
'line, or a sentence). Either:',
|
|
704
|
+
' - this close is NOT intended: reword the COMMIT (`git commit --amend`',
|
|
705
|
+
' or an interactive rebase) so the keyword and reference are not',
|
|
706
|
+
' adjacent, or move the reference into its own `Refs #N` line, and',
|
|
707
|
+
' force-push; or',
|
|
708
|
+
' - this close IS intended: make it a deliberate directive in the',
|
|
709
|
+
' COMMIT — its own line, its own sentence, e.g. `Closes #1664` — so',
|
|
710
|
+
' this file, and anyone reading `git log`, can tell the difference.',
|
|
711
|
+
'',
|
|
712
|
+
'Editing the PR body does NOT fix this: the commit message is what',
|
|
713
|
+
'reaches the squash-merge commit GitHub actually reads, independent of',
|
|
714
|
+
'anything in the PR description. Re-run after amending and force-pushing',
|
|
715
|
+
'— commits are read live, so a re-run genuinely re-evaluates:',
|
|
716
|
+
'',
|
|
717
|
+
' gh run rerun <run-id> --failed',
|
|
718
|
+
].join('\n')
|
|
719
|
+
}
|
|
720
|
+
|
|
521
721
|
function formatDeployOnlyFailure({ references, paths, hits }) {
|
|
522
722
|
const shown = paths.slice(0, 10)
|
|
523
723
|
const more = paths.length - shown.length
|