mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
fix: pre-landing review fixes (pass 1)
Fence-length-aware section splitting in gstack-post pr-body (an unclosed receipt fence or a four-backtick block no longer exposes a heading to the merge), the run-id allowlist drops generic words (id, job, attempt, workflow), ready accepts a by-tree pregate mirror only from a complete run against origin/<base> under a reviewed policy and prints no home path, release-cut skips non-object notes and never substitutes train evidence for the required check when both names are equal, issue-links treats closing/cf./e.g./v1.2 as words rather than repositories. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
15438065f1
commit
90f08a3eb3
13 files changed
+350
-73
No files matched your search
+4
-4
@@ -6,10 +6,10 @@
|
||||
|
||||
### Changed
|
||||
- **`scripts/release-cut.ts`**: a commit on main's first-parent history is gated either by a successful backstop check run or by a train record (kind `train`, state `green`, `merging` or `merged`) whose `merged[]` lists its sha or its tree, the main commits `gstack-ship-queue verify --train` confirmed. Everything else stays required: the required check run succeeded, no red check run (a red backstop on a covered commit still refuses), nothing still running, no active freeze. A commit with neither is refused with the same message as before. The `CANDIDATE` line says `gated by main-backstop` or `gated by train <nonce>`. The notes ref is fetched and read once (`readTrainNotes`: the freeze and the train evidence together; `activeFreeze` is folded into it). New flags `--required-check <name>` (default `free-tests`), `--backstop-check <name>` (default `main-backstop`) and `--notes-ref <ref>` (default `refs/notes/gstack-trains`) let another repository run the script; `weekly-release.yml` is unchanged.
|
||||
- **`lib/issue-links.ts`**: a parenthesised named reference, `(name #N[, #M]…)`, `(name PR #N)` or `(name issue #N)`, where the name is one token that is not a closure or reference word (fix, fixes, closes, resolves, refs, see, related, part, follow-up, deferred, PR, issue, …), is a cross-repo reference to `name` (`Refs name#N`), never a same-repo Refs line or a `fix #N` promotion candidate. `(see #12)`, `(#301)` and `Fixes #12` keep their meaning; the `owner/repo#N` form is unchanged.
|
||||
- **`lib/gstack-post.ts`**: an 11-digit GitHub Actions run id right after a run word on the same line (run, runs, screen, screens, gate, gates, backstop, bisect, rerun, dispatched, dispatch, failed, passed, workflow, job, attempt, id, ids, with an optional `:` `#` `=`), and every id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / ` (a `(#1234)` between allowed), is exempt from `pii.phone.e164`. `failed 40269992077 | rerun passed 40287239206`, `screen 40269992077 (#3434) and 40275443393 (#3453)` and `gate 40323989742` post without `--confirm`. A bare 11-digit number, a 10-digit national number and `+14155551234` still report.
|
||||
- **`lib/gstack-post.ts`** (`pr-body`): the body file is merged by `## ` section into the PR's current body (read with `gh pr view --json body`, or `glab mr view`, through the same runner) instead of replacing it. A section in the file replaces the same-titled section in place (case-insensitive), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite are kept byte for byte, new sections are appended after them, and a current body with no `## ` headings is kept as the leading text. One stderr line names the replaced, kept and added sections. `--replace` keeps the plain overwrite for a caller that composed the whole body. The redaction scan and the confirmation token cover the merged bytes, which are the bytes sent. A current body that cannot be read posts nothing (exit 3) and names `--replace`.
|
||||
- **`lib/pregate/index.ts`**, **`lib/ship-queue.ts`**: `gstack-pregate` mirrors every record it writes to `<state root>/pregate/by-tree/<tree>.json` (best effort; a mirror failure never fails the record). `ready` reads the checkout's `.gstack/tmp/pregate.json` (or `--pregate`) first; when it is missing or was recorded for another tree, it looks the current tree up in the mirror and uses that record when it verifies, printing `PREGATE: … (from <mirror path>, recorded by another checkout)`. A lane whose `gstack-pregate --remote` had to run in a separate clone (a worktree cannot host it) no longer gets `PREGATE_STALE` or `missing` from its own checkout. A mirror for another tree changes nothing.
|
||||
- **`lib/issue-links.ts`**: a parenthesised named reference, `(name #N[, #M]…)`, `(name PR #N)` or `(name issue #N)`, where the name is one token that is not a closure, reference, filler or version word (fix, fixes, fixing, closes, closing, resolves, addresses, implements, supersedes, refs, see, related, part, follow-up, deferred, PR, issue, bug, upstream, cf., e.g., i.e., of, to, the, v1.2, …), is a cross-repo reference to `name` (`Refs name#N`), never a same-repo Refs line or a `fix #N` promotion candidate. `(see #12)`, `(closing #12)`, `(cf. #12)`, `(#301)` and `Fixes #12` keep their meaning; the `owner/repo#N` form is unchanged.
|
||||
- **`lib/gstack-post.ts`**: an 11-digit GitHub Actions run id right after a run word on the same line (run, runs, screen, screens, gate, gates, backstop, bisect, rerun, dispatched, dispatch, failed, passed, with an optional `:` `#` `=`; a generic word such as id, job, attempt or workflow exempts nothing), and every id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / ` (a `(#1234)` between allowed), is exempt from `pii.phone.e164`. `failed 40269992077 | rerun passed 40287239206`, `screen 40269992077 (#3434) and 40275443393 (#3453)` and `gate 40323989742` post without `--confirm`. A bare 11-digit number, a 10-digit national number and `+14155551234` still report.
|
||||
- **`lib/gstack-post.ts`** (`pr-body`): the body file is merged by `## ` section into the PR's current body (read with `gh pr view --json body`, or `glab mr view`, through the same runner) instead of replacing it. A section in the file replaces the same-titled section in place (case-insensitive), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite are kept byte for byte, new sections are appended after them, and a current body with no `## ` headings is kept as the leading text. A `## ` line inside a code fence is not a section: a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a ```` block or a ```js line closes nothing, and a receipt fence that never closes is an unclosed fence (the body after it stays one kept section) rather than a receipt that swallows `## Approvals`. The file's leading text replaces the current leading text; text after a receipt block never replaces an intro; whitespace-only text in the file is dropped, so reposting the body /ship composes (receipt, blank line, sections) is byte-stable. One stderr line names the replaced, kept and added sections. `--replace` keeps the plain overwrite for a caller that composed the whole body. The redaction scan and the confirmation token cover the merged bytes, which are the bytes sent. A current body that cannot be read posts nothing (exit 3) and names `--replace`.
|
||||
- **`lib/pregate/index.ts`**, **`lib/ship-queue.ts`**: `gstack-pregate` mirrors every record it writes to `<state root>/pregate/by-tree/<tree>.json` (best effort; a mirror failure never fails the record). `ready` reads the checkout's `.gstack/tmp/pregate.json` (or `--pregate`) first; when it is missing, unreadable, recorded for another tree, or current but still waiting on a remote receipt, it looks the current tree up in the mirror and uses that record when it verifies, printing `PREGATE: … (from the state root's pregate/by-tree/<tree12>.json, recorded by another checkout)`. A lane whose `gstack-pregate --remote` had to run in a separate clone (a worktree cannot host it) no longer gets `PREGATE_STALE`, `PREGATE_REMOTE_UNCLEARED` or `missing` from its own checkout. Only a complete run stands in: a mirror that misses a registered check (an `--only` or single-stage run), was recorded against another base, or ran under `--policy-from head` is named on the `PREGATE:` line and never certifies READY; a record whose own checks failed or stayed incomplete is never overridden. A mirror for another tree changes nothing; the mirror is written through a temp file and rename.
|
||||
|
||||
### Docs
|
||||
- `docs/releases.md`: the release-gate paragraph describes both kinds of evidence, what train evidence does not relax, the `CANDIDATE` wording and the three flags. `docs/pregate.md`: the by-tree mirror and how `ready` reads it.
|
||||
|
||||
+14
-8
@@ -278,14 +278,20 @@ runs the same check standalone.
|
||||
byte, to `<state root>/pregate/by-tree/<tree>.json` (the state root from
|
||||
docs/state-root.md). `gstack-ship-queue ready` reads the checkout's
|
||||
`.gstack/tmp/pregate.json` first; when that file is missing or was recorded
|
||||
for another tree, it looks the current tree up in the mirror and uses that
|
||||
record when it verifies, printing `PREGATE: … (from <mirror path>, recorded by
|
||||
another checkout)`. This is for the lane whose `gstack-pregate --remote` had
|
||||
to run from a separate clone (a worktree cannot host it): the clone's record
|
||||
clears the lane's own checkout as long as both hold the same tree. An explicit
|
||||
`--pregate <file>` that verifies is used as before; a mirror for a different
|
||||
tree changes nothing. Writing the mirror is best effort and never fails the
|
||||
record itself.
|
||||
for another tree, or is current but still waits on a remote receipt, it looks
|
||||
the current tree up in the mirror and uses that record when it verifies,
|
||||
printing `PREGATE: … (from the state root's pregate/by-tree/<tree12>.json,
|
||||
recorded by another checkout)`. This is for the lane whose `gstack-pregate
|
||||
--remote` had to run from a separate clone (a worktree cannot host it): the
|
||||
clone's record clears the lane's own checkout as long as both hold the same
|
||||
tree. Only a complete run stands in: the mirror must name every registered
|
||||
check (so a `--only` or single-stage run is "a partial run without …"), be
|
||||
recorded against `origin/<base>` and not under `--policy-from head`;
|
||||
otherwise the `PREGATE:` line says why the mirror cannot stand in. A record
|
||||
whose own checks failed or stayed incomplete is final and no mirror overrides
|
||||
it. An explicit `--pregate <file>` that verifies is used as before; a mirror
|
||||
for a different tree changes nothing. Writing the mirror is best effort (a
|
||||
temp file renamed into place) and never fails the record itself.
|
||||
|
||||
Unknown flags exit 2 with usage on every verb (an agent told to run a flag this
|
||||
gstack lacks never gets a green it did not ask for). Tier 2 checks still to come
|
||||
|
||||
@@ -2305,9 +2305,9 @@ exact tokens it finds in these positions:
|
||||
- an 11-digit id right after a run word on the same line, with an optional
|
||||
`:`, `#` or `=` between: `run`, `runs`, `screen`, `screens`, `gate`,
|
||||
`gates`, `backstop`, `bisect`, `rerun`, `dispatched`, `dispatch`,
|
||||
`failed`, `passed`, `workflow`, `job`, `attempt`, `id`, `ids`
|
||||
(`failed 40269992077 | rerun passed 40287239206`, `gate 40323989742`,
|
||||
`run #19368712345`);
|
||||
`failed`, `passed` (`failed 40269992077 | rerun passed 40287239206`,
|
||||
`gate 40323989742`, `run #19368712345`); a generic word such as `id`,
|
||||
`job`, `attempt` or `workflow` exempts nothing;
|
||||
- every 11-digit id chained after an allowlisted one through `, `, ` and `,
|
||||
` & `, ` or ` or ` / `, optionally with a `(#1234)` between
|
||||
(`screen 40269992077 (#3434) and 40275443393 (#3453)`).
|
||||
|
||||
+30
-18
@@ -142,25 +142,32 @@ interface Section { key: string; label: string; text: string }
|
||||
* ````gstack-ship-receipt```` block is its own section (lib/ship-receipt.ts
|
||||
* extractReceipt reads it); text before the first heading is the leading
|
||||
* text, and text after a receipt block before the next heading is kept with
|
||||
* a key of its own, so it is never replaced.
|
||||
* a key of its own, so it is never replaced. A fence closes only on a run of
|
||||
* its own character at least as long as the opener with nothing after it
|
||||
* (CommonMark), so a ``` line inside a ```` block and a ```js line close
|
||||
* nothing; a receipt fence that never closes is an ordinary unclosed fence,
|
||||
* so the rest of the body stays one kept section instead of a receipt.
|
||||
*/
|
||||
function splitSections(body: string): Section[] {
|
||||
function splitSections(body: string, receipts = true): Section[] {
|
||||
const out: Section[] = [];
|
||||
let fence: string | null = null;
|
||||
let plain = 0;
|
||||
const open = (key: string, label: string) => out.push({ key, label, text: "" });
|
||||
const openText = () => open(`text:${plain}`, plain++ === 0 ? "leading text" : "trailing text");
|
||||
const openText = () => out.length === 0 ? open("text:lead", "leading text") : open(`text:${plain++}`, "trailing text");
|
||||
const closes = (run: string) => new RegExp(`^ {0,3}${run[0] === "`" ? "`" : "~"}{${run.length},}\\s*$`).test(bare);
|
||||
let bare = "";
|
||||
for (const line of body.split(/(?<=\n)/)) {
|
||||
const bare = line.replace(/\r?\n$/, "");
|
||||
const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(```|~~~)/.exec(bare) : null;
|
||||
if (fenceOpen && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; }
|
||||
else if (fence === "receipt") { out.at(-1)!.text += line; if (bare.trim() === "```") { fence = null; openText(); } continue; }
|
||||
bare = line.replace(/\r?\n$/, "");
|
||||
const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(`{3,}|~{3,})/.exec(bare) : null;
|
||||
if (fenceOpen && receipts && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; }
|
||||
else if (fence === "receipt") { out.at(-1)!.text += line; if (closes("```")) { fence = null; openText(); } continue; }
|
||||
else if (fenceOpen) fence = fenceOpen[1]!;
|
||||
else if (fence && bare.trim().startsWith(fence)) fence = null;
|
||||
else if (fence && closes(fence)) fence = null;
|
||||
else if (fence === null && /^ {0,3}## /.test(bare)) { const title = bare.replace(/^ {0,3}## /, "").trim(); open(`h2:${title.toLowerCase()}`, title); }
|
||||
if (out.length === 0) openText();
|
||||
out.at(-1)!.text += line;
|
||||
}
|
||||
if (fence === "receipt") return splitSections(body, false);
|
||||
return out.filter(s => s.text !== "");
|
||||
}
|
||||
|
||||
@@ -169,17 +176,21 @@ function splitSections(body: string): Section[] {
|
||||
* file replaces the same-keyed section of the current body in place, current
|
||||
* sections the file does not rewrite are kept byte for byte, new `## `
|
||||
* sections are appended after them, and a new receipt or leading text goes
|
||||
* first (where lib/ship-receipt.ts puts the receipt). A blank line separates
|
||||
* the two sides of every seam the merge creates; two kept neighbours keep
|
||||
* their bytes. An empty current body yields the file unchanged.
|
||||
* first (where lib/ship-receipt.ts puts the receipt). Text outside sections
|
||||
* matches by role (the file's leading text replaces the leading text; text
|
||||
* after a receipt block never replaces an intro), and whitespace-only text in
|
||||
* the file is dropped, so reposting the body /ship composes (receipt, blank
|
||||
* line, sections) yields the same bytes. A blank
|
||||
* line separates the two sides of every seam the merge creates; two kept
|
||||
* neighbours keep their bytes. An empty current body yields the file unchanged.
|
||||
*/
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[] } {
|
||||
const now = splitSections(current);
|
||||
const next = splitSections(incoming);
|
||||
const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === ""));
|
||||
const used = new Set<Section>();
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [];
|
||||
const merged: Section[] = now.map(s => {
|
||||
const match = next.find(n => !used.has(n) && n.key === s.key && !n.key.startsWith("text:"));
|
||||
const match = next.find(n => !used.has(n) && n.key === s.key);
|
||||
if (!match) { if (s.text.trim()) kept.push(s.label); return s; }
|
||||
used.add(match); replaced.push(s.label); return match;
|
||||
});
|
||||
@@ -317,8 +328,8 @@ function repoReleaseVersions(env: PostEnv): string[] {
|
||||
return [...versions];
|
||||
}
|
||||
|
||||
/** A run word with an optional ':' '#' '=' right before an 11-digit token: `run 19368712345`, `gate=40323989742`, `job: 40323989748`. */
|
||||
const RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed|workflow|job|attempt|ids?)\s*[:#=]?\s*$/i;
|
||||
/** A run word with an optional ':' '#' '=' right before an 11-digit token: `run 19368712345`, `gate=40323989742`, `rerun passed 40287239206`. */
|
||||
const RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed)\s*[:#=]?\s*$/i;
|
||||
/** Between two 11-digit tokens of one list: `, `, ` and `, ` & `, ` or `, ` / `, with an optional `(#1234)` after the first. */
|
||||
const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i;
|
||||
|
||||
@@ -330,9 +341,10 @@ const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i;
|
||||
* run id inside an /actions/runs/ URL (the engine already exempts the URL
|
||||
* itself; this covers its link text), and an 11-digit run id right after a run
|
||||
* word on the same line (run, runs, screen, screens, gate, gates, backstop,
|
||||
* bisect, rerun, dispatched, dispatch, failed, passed, workflow, job, attempt,
|
||||
* id, ids, with an optional ':' '#' '=' between: `failed 40269992077`,
|
||||
* `gate 40323989742`, `run #19368712345`), plus every 11-digit id chained
|
||||
* bisect, rerun, dispatched, dispatch, failed, passed, with an optional ':'
|
||||
* '#' '=' between: `failed 40269992077`, `gate 40323989742`,
|
||||
* `run #19368712345`; generic words such as id, job, attempt and workflow
|
||||
* exempt nothing, since `customer id 14155551234` is a number), plus every 11-digit id chained
|
||||
* after an allowlisted one through ", ", " and ", " & ", " or " or " / ",
|
||||
* optionally with a "(#1234)" between (`screen 40269992077 (#3434) and
|
||||
* 40275443393 (#3453)`). Only the exact tokens found in those positions are
|
||||
|
||||
+4
-3
@@ -10,8 +10,9 @@
|
||||
* deferred ask (`defer`, `follow-up`) and every cross-repo id
|
||||
* (`owner/repo#N`) are `Refs #N` — never auto-closed.
|
||||
* - A parenthesised named reference, `(name #N, #M)`, `(name PR #N)` or
|
||||
* `(name issue #N)` where name is one token that is not a closure or
|
||||
* reference word, is a cross-repo reference to `name`: rendered as
|
||||
* `(name issue #N)` where name is one token that is not a closure,
|
||||
* reference, filler or version word (`closing`, `cf.`, `e.g.`, `v1.2`
|
||||
* name nothing), is a cross-repo reference to `name`: rendered as
|
||||
* `Refs name#N`, never a same-repo Refs line or a promotion candidate.
|
||||
* - A closure issue that cannot be read through gstack-issue-guard is
|
||||
* downgraded to `Refs #N` with ISSUE_UNREADABLE (never Fixes).
|
||||
@@ -47,7 +48,7 @@ const BARE = /(?<![A-Za-z0-9_./-])#(\d+)\b/g;
|
||||
/** `(name #N[, #M]…)`, `(name PR #N)`, `(name issue #N)`: the name, then every number. */
|
||||
const NAMED = /\(([A-Za-z][\w.-]*(?:\/[\w.-]+)?)\s+(?:(?:PR|issue)\s+)?(#\d+(?:,\s*#\d+)*)\)/gi;
|
||||
/** Words that open a reference in parentheses without naming a repository. */
|
||||
const NOT_A_NAME = /^(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?|refs?|see|related|partial(?:ly)?|part|context|follow-?up|defer(?:red)?|prs?|issues?|gh|via|from|after|since|with|in|by|for|also|cf|and|or|per)$/i;
|
||||
const NOT_A_NAME = /^(?:fix(?:es|ed|ing)?|clos(?:e[sd]?|ing)|resolv(?:e[sd]?|ing)|address(?:es|ed|ing)?|implement(?:s|ed|ing)?|supersed(?:es|ed|ing)|replac(?:es|ed|ing)|track(?:s|ed|ing)?|block(?:s|ed|ing)?|dup|duplicates?|bugs?|upstream|refs?|see|related|partial(?:ly)?|part|context|follow-?up|defer(?:red)?|prs?|issues?|gh|via|from|after|since|with|in|by|for|also|cf\.?|e\.?g\.?|i\.?e\.?|and|or|per|of|to|on|at|as|is|was|the|an?|this|that|not|no|vs\.?|like|v\d+(?:\.\d+)*)$/i;
|
||||
|
||||
/** Mentions in one commit message: trailer lines carry closure intent, everything else is a reference. */
|
||||
export function mentionsIn(message: string, source = 'commit'): IssueMention[] {
|
||||
|
||||
@@ -123,7 +123,8 @@ export function writePregateFile(repoRoot: string, rel: string, file: PregateFil
|
||||
try {
|
||||
const mirror = pregateMirrorPath(file.tree);
|
||||
fs.mkdirSync(path.dirname(mirror), { recursive: true });
|
||||
fs.writeFileSync(mirror, json);
|
||||
fs.writeFileSync(`${mirror}.${process.pid}.tmp`, json);
|
||||
fs.renameSync(`${mirror}.${process.pid}.tmp`, mirror);
|
||||
} catch {}
|
||||
}
|
||||
return abs;
|
||||
|
||||
+30
-10
@@ -8,10 +8,12 @@
|
||||
* CI at that exact head, the PREGATE line, the gate state and the
|
||||
* /ship receipt; refused when the tree is not the pushed PR head
|
||||
* or the PR body carries no receipt for it (QUEUE_RECEIPT_*).
|
||||
* A pregate record missing from this checkout, or recorded for
|
||||
* another tree, is looked up by the current tree under
|
||||
* A pregate record missing from this checkout, unreadable,
|
||||
* recorded for another tree, or current but still waiting on a
|
||||
* remote receipt, is looked up by the current tree under
|
||||
* <state root>/pregate/by-tree/ (gstack-pregate mirrors every
|
||||
* record there), and the PREGATE line says where it came from.
|
||||
* record there), and the PREGATE line says where it came from;
|
||||
* a record whose own checks failed or stayed incomplete stands.
|
||||
* stamp preflight (gated head equals HEAD modulo stamps, base, policy,
|
||||
* gate record under full_e2e), restamp, commit, tree receipt,
|
||||
* each stage journaled; QUEUE_STAMP: <pr> <version> head= gated=
|
||||
@@ -25,7 +27,8 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import type { PregateFile } from './headless-artifacts';
|
||||
import { CHECK_IDS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { bindReceipt, extractReceipt } from './ship-receipt';
|
||||
@@ -183,6 +186,20 @@ function gateEvidence(ctx: QueueContext, pr: string, head: string, attempt: Atte
|
||||
return { ok: false, line: `gate-evidence: missing for ${head.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log <file>)` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a by-tree mirror cannot stand in for this checkout's own record, or
|
||||
* null. The mirror is keyed by tree alone, so any pregate run anywhere mints
|
||||
* one; only a complete run (every registered check, so no `--only` or single
|
||||
* stage) against the same base under a reviewed policy (not `--policy-from
|
||||
* head`) is publication evidence for this PR.
|
||||
*/
|
||||
function mirrorGap(file: PregateFile, base: string): string | null {
|
||||
if (!(file.base ?? '').startsWith(`origin/${base}@`)) return `recorded against ${file.base ?? 'no base'}, not origin/${base}`;
|
||||
if (file.policy?.startsWith('head ')) return 'recorded under the unreviewed head policy';
|
||||
const missing = CHECK_IDS.filter(id => !file.checks.some(c => c.id === id));
|
||||
return missing.length ? `a partial run without ${missing.join(', ')}` : null;
|
||||
}
|
||||
|
||||
function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string {
|
||||
const p = policy.policy;
|
||||
const rec = attempt ? `attempt=${attempt.attempt} gated_tree=${attempt.gated_tree.slice(0, 12)} ${attempt.result ? `exit=${attempt.result.exit}` : 'not recorded'}` : 'none';
|
||||
@@ -211,24 +228,27 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`;
|
||||
let pregateOk = false;
|
||||
const treeNow = wtreeFingerprint({ cwd: ctx.repoRoot });
|
||||
let otherTree = true;
|
||||
// The mirror stands in for a record that is missing, unreadable, recorded for another tree, or current but
|
||||
// waiting on a remote receipt another checkout holds; a record whose own checks failed or stayed incomplete is final.
|
||||
let useMirror = true;
|
||||
if (fs.existsSync(pregateFile)) {
|
||||
const read = readPregateFile(pregateFile);
|
||||
otherTree = read.file !== null && 'tree' in treeNow && read.file.tree !== treeNow.tree;
|
||||
if (read.file) {
|
||||
const v = verifyPregate(ctx.repoRoot, read.file, treeNow);
|
||||
pregateOk = v.ok;
|
||||
useMirror = !v.ok && v.code !== 'PREGATE_LANE_FAILED' && v.code !== 'PREGATE_INCOMPLETE';
|
||||
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
|
||||
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
|
||||
}
|
||||
if (!pregateOk && otherTree && 'tree' in treeNow) {
|
||||
if (useMirror && 'tree' in treeNow) {
|
||||
const mirror = pregateMirrorPath(treeNow.tree);
|
||||
const read = fs.existsSync(mirror) ? readPregateFile(mirror) : null;
|
||||
const v = read?.file ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null;
|
||||
const gap = read?.file ? mirrorGap(read.file, ctx.base) : null;
|
||||
const v = read?.file && !gap ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null;
|
||||
if (read?.file && v?.ok) {
|
||||
pregateOk = true;
|
||||
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from ${mirror}, recorded by another checkout)`;
|
||||
}
|
||||
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`;
|
||||
} else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`;
|
||||
}
|
||||
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
|
||||
const attempt = currentAttempt(ctx.repoRoot, o.pr);
|
||||
|
||||
+10
-7
@@ -97,7 +97,7 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions =
|
||||
const named = runs.filter(c => c.name === name);
|
||||
if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true };
|
||||
if (named.some(c => c.conclusion === 'success')) continue;
|
||||
const train = name === backstop && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined;
|
||||
const train = name === backstop && name !== required && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined;
|
||||
if (train) { via = `train ${train}`; continue; }
|
||||
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
|
||||
return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
|
||||
@@ -131,13 +131,16 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
|
||||
for (const line of listed.out.split('\n')) {
|
||||
const blob = line.split(' ')[0];
|
||||
if (!blob) continue;
|
||||
let r: NoteRecord;
|
||||
try { r = JSON.parse(git(['cat-file', 'blob', blob]).out) as NoteRecord; } catch { continue; }
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(git(['cat-file', 'blob', blob]).out); } catch { continue; }
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
||||
const r = parsed as NoteRecord;
|
||||
if (r.kind === 'freeze' && !r.cleared_at) notes.freeze = `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
|
||||
if (r.kind !== 'train' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue;
|
||||
for (const m of r.merged ?? []) {
|
||||
if (m.sha) notes.evidence.bySha.set(m.sha, r.nonce);
|
||||
if (m.tree) notes.evidence.byTree.set(m.tree, r.nonce);
|
||||
if (r.kind !== 'train' || typeof r.nonce !== 'string' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue;
|
||||
for (const m of Array.isArray(r.merged) ? r.merged : []) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
if (typeof m.sha === 'string') notes.evidence.bySha.set(m.sha, r.nonce);
|
||||
if (typeof m.tree === 'string') notes.evidence.byTree.set(m.tree, r.nonce);
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
|
||||
+123
-5
@@ -7,7 +7,7 @@
|
||||
* the real executable against stub gh/glab on POSIX.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EXIT, runPost, type PostEnv } from "../lib/gstack-post";
|
||||
import { EXIT, mergeBodySections, runPost, type PostEnv } from "../lib/gstack-post";
|
||||
|
||||
interface Call { cmd: string; args: string[]; input?: string }
|
||||
|
||||
@@ -227,9 +227,10 @@ describe("gstack-post owns the redaction scan", () => {
|
||||
});
|
||||
|
||||
// Value: protects=an 11-digit GitHub Actions run id written after a run word (run, screen, gate, backstop, bisect, rerun, dispatched, failed, passed,
|
||||
// workflow, job, attempt, id, with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts
|
||||
// without a pii.phone.e164 confirmation; fails_when=real receipts ("failed 40269992077 | rerun passed 40287239206", "screen 40269992077 (#3434) and
|
||||
// 40275443393 (#3453)", "gate 40323989742") need --confirm, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting;
|
||||
// with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts without a pii.phone.e164
|
||||
// confirmation, while a generic word (id, job, attempt, workflow) exempts nothing; fails_when=real receipts ("failed 40269992077 | rerun passed
|
||||
// 40287239206", "screen 40269992077 (#3434) and 40275443393 (#3453)", "gate 40323989742") need --confirm, "customer id 14155551234" or "job 40323989748"
|
||||
// posts unconfirmed, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting;
|
||||
// why_new=the receipt-token test allowlists only "run <id>" and the /actions/runs/ URL; seam=none; no_claim=none beyond protects
|
||||
test("run ids next to run words, and the ids chained after them, are not phone numbers; a bare id, a national number and an E.164 number still are", () => {
|
||||
const clean = [
|
||||
@@ -239,7 +240,6 @@ describe("gstack-post owns the redaction scan", () => {
|
||||
"gates: 40323989742, 40323989743 or 40323989744",
|
||||
"backstop=40323989742 bisect #40323989745",
|
||||
"dispatched 40323989742 / 40323989746 & 40323989747",
|
||||
"workflow 40323989742; job: 40323989748; attempt 40323989749; id 40323989750; ids 40323989751, 40323989752",
|
||||
"runs 40323989742 and 40323989753 (#3454), 40323989754",
|
||||
];
|
||||
for (const body of clean) {
|
||||
@@ -249,6 +249,9 @@ describe("gstack-post owns the redaction scan", () => {
|
||||
}
|
||||
const controls = [
|
||||
"the id is 19368712345",
|
||||
"customer id 14155551234",
|
||||
"job 40323989748",
|
||||
"workflow 40323989742; attempt 40323989749; ids 40323989751, 40323989752",
|
||||
"40269992077 failed",
|
||||
"gate 40323989742. Then 40323989743 on its own",
|
||||
"gate 4155551234",
|
||||
@@ -338,6 +341,29 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(sent(fenced).input).toBe("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nnew\n");
|
||||
});
|
||||
|
||||
// Value: protects=a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a
|
||||
// ```` block, a ```js line and a ~~~ line close nothing and the headings inside stay kept bytes, and a receipt fence that never closes is an unclosed
|
||||
// fence rather than a receipt that swallows the sections after it; fails_when=a longer or info-string fence closes early and a heading inside the
|
||||
// block is replaced (dropping the block's own closing fence and the sections after it), or an unclosed receipt fence loses ## Approvals;
|
||||
// why_new=the fenced case above covers only a three-backtick block closed by its own fence; seam=fake run; no_claim=real gh
|
||||
test("longer fences, info-string closers and an unclosed receipt fence never expose a heading inside them to the merge", () => {
|
||||
const long = "## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nold\n";
|
||||
const four = withBody(long, { "/b": "## Tests\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], four)).toBe(EXIT.posted);
|
||||
expect(sent(four).input).toBe("## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nnew\n");
|
||||
expect(four.stderr()).toBe("gstack-post: pr-body merged by section: replaced Tests; kept Notes; added none\n");
|
||||
|
||||
const info = withBody("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nold\n", { "/b": "## Tests\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], info)).toBe(EXIT.posted);
|
||||
expect(sent(info).input).toBe("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nnew\n");
|
||||
|
||||
const unclosed = "```gstack-ship-receipt\nSHIP_RECEIPT: pr=5 head=aaaa\n\n## Summary\n\nold\n\n## Approvals\n\n- owner: do it\n";
|
||||
const open = withBody(unclosed, { "/b": RECEIPT("bbbb") + "\n## Summary\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], open)).toBe(EXIT.posted);
|
||||
expect(sent(open).input).toBe(RECEIPT("bbbb") + "\n" + unclosed + "\n## Summary\n\nnew\n");
|
||||
expect(open.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added ship receipt, Summary\n");
|
||||
});
|
||||
|
||||
// Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading
|
||||
// section, an empty current body posts the new body byte for byte, the scan runs on the merged bytes, and an unreadable current body is exit 3 with
|
||||
// the --replace hint rather than an overwrite; fails_when=--replace merges, headingless text is dropped, the merge changes a lone new body, a secret in a
|
||||
@@ -370,4 +396,96 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(unreadable.posts()).toEqual([]);
|
||||
expect(run(["pr-comment", "5", "--replace", "--body-file", "/b"], fakeEnv({ files: { "/b": "x" } }))).toBe(EXIT.usage);
|
||||
});
|
||||
|
||||
// Value: protects=on GitLab the merge reads the MR's description from `glab mr view <n> --output json`, posts the merged bytes through
|
||||
// `glab mr update --description=`, treats an empty answer as an empty body (the file is posted unchanged), and a non-JSON answer is exit 3 with the
|
||||
// --replace hint and nothing posted; fails_when=the GitLab merge reads the wrong field or command, overwrites the MR on a failed read, or refuses an MR
|
||||
// whose description is empty; why_new=the merge tests above run against gh only; seam=fake run answers glab mr view; no_claim=real glab
|
||||
test("on GitLab the current description comes from glab mr view JSON; an empty answer is an empty body; a non-JSON answer refuses", () => {
|
||||
const remote = "https://gitlab.com/grp/sub/proj.git";
|
||||
const view = (stdout: string) => (c: Call) => (c.cmd === "glab" && c.args[0] === "mr" && c.args[1] === "view" ? { status: 0, stdout } : undefined);
|
||||
const merged = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view(JSON.stringify({ iid: 8, description: "## What changed\n\n- old\n\n## Why\n\nold\n" }) + "\n") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], merged)).toBe(EXIT.posted);
|
||||
expect(merged.calls.find(c => c.cmd === "glab" && c.args[1] === "view")!.args).toEqual(["mr", "view", "8", "--output", "json"]);
|
||||
expect(merged.posts()).toEqual([{ cmd: "glab", args: ["mr", "update", "8", "--description=## What changed\n\n- old\n\n## Why\n\nnew\n"], input: undefined }]);
|
||||
expect(merged.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept What changed; added none\n");
|
||||
|
||||
const empty = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], empty)).toBe(EXIT.posted);
|
||||
expect(empty.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
|
||||
const nullDescription = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view('{"iid":8,"description":null}\n') });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], nullDescription)).toBe(EXIT.posted);
|
||||
expect(nullDescription.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
|
||||
|
||||
const garbled = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("<html>502</html>\n") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], garbled)).toBe(EXIT.cliFailed);
|
||||
expect(garbled.stderr()).toContain("cannot read PR 8's current body to merge into (glab returned no JSON)");
|
||||
expect(garbled.stderr()).toContain("--replace");
|
||||
expect(garbled.posts()).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=section matching survives the bodies GitHub actually returns and the shapes /ship composes: a CRLF current body (web-edited) keeps
|
||||
// its kept section's bytes and still replaces the same-titled section, a receipt new to a body without one goes first (where gstack-ship-receipt puts
|
||||
// it), a current body with two same-titled sections replaces the first and keeps the second, and a current body with no trailing newline still gets a
|
||||
// blank-line seam before an appended section; fails_when=a CRLF title no longer matches an LF one (the section is appended as a duplicate), a kept
|
||||
// CRLF section is rewritten, a new receipt lands after the sections, the second duplicate is dropped, or an appended section is glued to the last line;
|
||||
// why_new=the merge tests above use LF bodies with unique titles and a receipt that already exists; seam=none (mergeBodySections); no_claim=rendering
|
||||
test("a CRLF current body, a receipt new to the body, duplicate titles and a body without a trailing newline merge by section", () => {
|
||||
const crlfKept = "## What changed\r\n\r\n- old\r\n\r\n";
|
||||
const crlf = mergeBodySections(crlfKept + "## Why\r\n\r\nBecause.\r\n", "## why\n\nnew\n");
|
||||
expect([crlf.replaced, crlf.kept, crlf.added]).toEqual([["Why"], ["What changed"], []]);
|
||||
expect(crlf.body.startsWith(crlfKept)).toBe(true);
|
||||
expect(crlf.body.endsWith("\n## why\n\nnew\n")).toBe(true);
|
||||
expect(crlf.body).not.toContain("Because.");
|
||||
expect(crlf.body.match(/^ {0,3}## /gm)).toHaveLength(2);
|
||||
|
||||
const receipt = "```gstack-ship-receipt\n{\n \"pr\": \"5\"\n}\n```\n";
|
||||
const fresh = mergeBodySections("## A\n\na\n", receipt + "## A\n\nb\n");
|
||||
expect(fresh.body).toBe(receipt + "\n## A\n\nb\n");
|
||||
expect([fresh.replaced, fresh.kept, fresh.added]).toEqual([["A"], [], ["ship receipt"]]);
|
||||
|
||||
const twice = mergeBodySections("## A\na\n## A\na2\n", "## A\nnew\n");
|
||||
expect(twice.body).toBe("## A\nnew\n\n## A\na2\n");
|
||||
expect([twice.replaced, twice.kept]).toEqual([["A"], ["A"]]);
|
||||
|
||||
const glued = mergeBodySections("## A\n\na", "## B\n\nb");
|
||||
expect(glued.body).toBe("## A\n\na\n\n## B\n\nb");
|
||||
expect(mergeBodySections("## A\n\na\n\n", "## B\n\nb\n").body).toBe("## A\n\na\n\n## B\n\nb\n");
|
||||
});
|
||||
|
||||
// Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceiptFirst: the receipt block, one blank line, then the sections) reposts
|
||||
// idempotently: merged into a PR body of the same shape it yields the file's bytes, the receipt stays byte-first, and the stderr line reports no
|
||||
// addition; fails_when=the blank line after the receipt is read as new leading text and prepended before the receipt (one more leading newline per
|
||||
// repost, "added leading text" on every /ship re-READY), or a kept blank line is counted as added; why_new=the merge tests above post a receipt-only
|
||||
// file or a file without a receipt, never the composed shape; seam=none (mergeBodySections); no_claim=the gh round trip
|
||||
test("the composed receipt-then-sections body reposts byte for byte, receipt first, with nothing reported as added", () => {
|
||||
const receipt = (head: string) => "```gstack-ship-receipt\n{\n \"head\": \"" + head + "\"\n}\n```\n";
|
||||
const composed = (head: string, summary: string) => receipt(head) + "\n## Summary\n\n" + summary + "\n\n## Test plan\n\n- [x] bun test\n";
|
||||
const first = mergeBodySections(composed("aaaa", "old"), composed("bbbb", "new"));
|
||||
expect(first.body).toBe(composed("bbbb", "new"));
|
||||
expect([first.replaced, first.kept, first.added]).toEqual([["ship receipt", "Summary", "Test plan"], [], []]);
|
||||
const again = mergeBodySections(first.body, composed("bbbb", "new"));
|
||||
expect(again.body).toBe(composed("bbbb", "new"));
|
||||
expect(again.added).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=leading prose in the file replaces the current body's leading prose instead of being prepended again, so a repost of a file that
|
||||
// opens with text keeps one copy; fails_when=text sections are never matched and the file's leading text is treated as fresh on every post;
|
||||
// why_new=the merge tests above post files that open with a heading or a receipt, never with prose; seam=none (mergeBodySections); no_claim=the gh round trip
|
||||
test("leading prose in the file replaces the current leading prose and reposts once", () => {
|
||||
const current = "Intro line\n\n## Summary\n\nold\n\n## Approvals\n\nx\n";
|
||||
const once = mergeBodySections(current, "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(once.body).toBe("Intro line\n\n## Summary\n\nnew\n\n## Approvals\n\nx\n");
|
||||
expect([once.replaced, once.kept, once.added]).toEqual([["leading text", "Summary"], ["Approvals"], []]);
|
||||
const twice = mergeBodySections(once.body, "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(twice.body).toBe(once.body);
|
||||
const fresh = mergeBodySections("## Summary\n\nold\n", "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(fresh.body).toBe("Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(fresh.added).toEqual(["leading text"]);
|
||||
const receipt = "```gstack-ship-receipt\n{\n \"head\": \"a\"\n}\n```\n";
|
||||
const afterReceipt = mergeBodySections("Intro line\n\n## Summary\n\nold\n", receipt + "note\n\n## Summary\n\nnew\n");
|
||||
expect(afterReceipt.body).toContain("Intro line\n");
|
||||
expect(afterReceipt.body).toContain("note\n");
|
||||
expect(afterReceipt.replaced).toEqual(["Summary"]);
|
||||
});
|
||||
});
|
||||
@@ -293,29 +293,63 @@ describe('ready', () => {
|
||||
expect(stalePregate.status).toBe(EXIT.refused);
|
||||
expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
// A lane that cleared its pregate from a separate clone (gstack-pregate --remote cannot run in a worktree) holds the verdict under
|
||||
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing.
|
||||
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a
|
||||
// complete run (every registered check) against origin/main under a reviewed policy stands in: a --only run, another base or a head policy is named.
|
||||
const stateRoot = path.join(repo.root, 'state-root');
|
||||
const CHECK_IDS = ['regen', 'secrets', 'strays', 'literals', 'timing', 'guards', 'lanes'];
|
||||
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: CHECK_IDS.map(id => ({ id, status: 'pass' })) };
|
||||
const fromSuffix = `(from the state root's pregate/by-tree/${'TREE'}.json, recorded by another checkout)`;
|
||||
const movedShim = ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } });
|
||||
const movedTree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const mirror = path.join(stateRoot, 'pregate', 'by-tree', `${movedTree}.json`);
|
||||
fs.mkdirSync(path.dirname(mirror), { recursive: true });
|
||||
fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', ...complete }));
|
||||
const otherTree = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(otherTree.status).toBe(EXIT.refused);
|
||||
expect(otherTree.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
const missing = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.out).toMatch(/^PREGATE: missing \(\.gstack\/tmp\/pregate\.json; run gstack-pregate run --allow-repo-commands\)$/m);
|
||||
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }, { id: 'lanes', status: 'pass' }] }));
|
||||
const suffix = fromSuffix.replace('TREE', movedTree.slice(0, 12));
|
||||
const partial = { ...complete, checks: [{ id: 'timing', status: 'pass' }] };
|
||||
for (const [bad, why] of [[partial, 'a partial run without regen, secrets, strays, literals, guards, lanes'], [{ ...complete, base: 'origin/develop@0123456789ab' }, 'recorded against origin/develop@0123456789ab, not origin/main'], [{ ...complete, policy: 'head (unreviewed) .gstack/pregate.json' }, 'recorded under the unreviewed head policy']] as const) {
|
||||
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...bad }));
|
||||
const ignored = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(ignored.status).toBe(EXIT.refused);
|
||||
expect(line(ignored.out, 'PREGATE:')).toBe(`PREGATE: missing (.gstack/tmp/pregate.json; run gstack-pregate run --allow-repo-commands); the state root's mirror for this tree cannot stand in: ${why}`);
|
||||
}
|
||||
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...complete }));
|
||||
const fromMirror = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([fromMirror.status, fromMirror.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=2 (from ${mirror}, recorded by another checkout)`);
|
||||
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=7 ${suffix}`);
|
||||
const missingFromMirror = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([missingFromMirror.status, missingFromMirror.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(missingFromMirror.out, 'PREGATE:')).toContain(`(from ${mirror}, recorded by another checkout)`);
|
||||
expect(line(missingFromMirror.out, 'PREGATE:')).toContain(suffix);
|
||||
const current = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, moved)], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([current.status, current.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1`);
|
||||
// Value: protects=a same-tree record that only waits on a remote receipt (the small-machine run) is cleared by the clone's mirror, an
|
||||
// unreadable record falls through to the mirror, and a record whose own checks failed or stayed incomplete is never overridden by a green mirror;
|
||||
// fails_when=ready reads the mirror only for another tree (the documented lane case refuses PREGATE_REMOTE_UNCLEARED) or lets a mirror hide a red
|
||||
// or incomplete local run; why_new=the rows above cover only stale, missing and current records; seam=none; no_claim=the mirror's writer
|
||||
const local = path.join(repo.root, 'pregate-local.json');
|
||||
const obligation = { lane: 'pregate.json/remote_gate', platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: null };
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'requires-remote', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: [obligation] }));
|
||||
const uncleared = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([uncleared.status, uncleared.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=7 ${suffix}`);
|
||||
fs.writeFileSync(local, '{ not json');
|
||||
const unreadable = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([unreadable.status, unreadable.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(unreadable.out, 'PREGATE:')).toContain(suffix);
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'fail', checks: [{ id: 'lanes', status: 'fail' }] }));
|
||||
const redLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(redLocal.status).toBe(EXIT.refused);
|
||||
expect(line(redLocal.out, 'PREGATE:')).toBe(`PREGATE: fail tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 — 1 check(s) failed: lanes (PREGATE_LANE_FAILED)`);
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'lanes', status: 'incomplete' }] }));
|
||||
const incompleteLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(incompleteLocal.status).toBe(EXIT.refused);
|
||||
expect(line(incompleteLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_INCOMPLETE\)$/);
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n');
|
||||
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
|
||||
}, 180_000);
|
||||
|
||||
@@ -90,4 +90,35 @@ describe('pregate invalidation', () => {
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain('(ARTIFACT_SCHEMA)');
|
||||
});
|
||||
|
||||
// Value: protects=every record gstack-pregate writes (run and clear) is mirrored byte for byte to <state root>/pregate/by-tree/<tree>.json, the path
|
||||
// gstack-ship-queue ready reads for a checkout that did not run the pregate, and a state root that cannot be written still leaves the record and
|
||||
// the exit code untouched; fails_when=the mirror is missing, lands under another path or name, holds different bytes than the record, is not
|
||||
// refreshed by clear, or an unwritable state root fails the run; why_new=the ship-queue test writes the mirror by hand and covers only the reader;
|
||||
// seam=none; no_claim=a second checkout reading it (test/gstack-ship-queue.test.ts covers ready)
|
||||
test('run and clear mirror the record to <state root>/pregate/by-tree/<tree>.json; an unwritable state root never fails the record', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
|
||||
expect(run(repo, []).status).toBe(EXIT.ok);
|
||||
const record = fs.readFileSync(outFile(repo), 'utf8');
|
||||
const tree = (JSON.parse(record) as { tree: string }).tree;
|
||||
expect(tree).toMatch(/^[0-9a-f]{12,128}$/);
|
||||
const mirror = path.join(repo.home, 'pregate', 'by-tree', `${tree}.json`);
|
||||
expect(fs.readdirSync(path.join(repo.home, 'pregate', 'by-tree'))).toEqual([`${tree}.json`]);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(record);
|
||||
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'windows.yml/windows-shard', '--run-url', 'https://github.com/o/r/actions/runs/42']).status).toBe(EXIT.ok);
|
||||
const cleared = fs.readFileSync(outFile(repo), 'utf8');
|
||||
expect(cleared).not.toBe(record);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
||||
expect(JSON.parse(fs.readFileSync(mirror, 'utf8')).requires_remote[0].receipt).toBe('https://github.com/o/r/actions/runs/42');
|
||||
|
||||
const blocked = path.join(repo.root, 'not-a-dir');
|
||||
fs.writeFileSync(blocked, 'a file where the state root should be\n');
|
||||
const unwritable = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands'], { GSTACK_STATE_ROOT: path.join(blocked, 'root') });
|
||||
expect(unwritable.status, unwritable.out + unwritable.err).toBe(EXIT.ok);
|
||||
expect(unwritable.err).not.toMatch(/ENOTDIR|EACCES|by-tree/);
|
||||
expect(JSON.parse(fs.readFileSync(outFile(repo), 'utf8')).tree).toBe(tree);
|
||||
expect(fs.existsSync(path.join(blocked, 'root'))).toBe(false);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import {
|
||||
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
|
||||
planRelease, releaseNotes, type Gh,
|
||||
planRelease, readTrainNotes, releaseNotes, type Gh, type Git,
|
||||
} from '../scripts/release-cut';
|
||||
|
||||
const bases: string[] = [];
|
||||
@@ -175,6 +175,48 @@ describe('release-cut: planning', () => {
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=one read of the trains notes ref feeds both gates: a fetch failure other than a missing ref refuses the plan with its message, a
|
||||
// missing ref is no freeze and no evidence, a blob that is not JSON, is JSON that is not an object (null, an array, a string, a number), carries a
|
||||
// malformed merged[] or a non-string nonce, or is a train without a nonce is skipped while a freeze and a train in the same
|
||||
// ref are both read (the freeze still wins over the evidence); fails_when=a fetch error is read as "no notes" and a frozen or ungated main is
|
||||
// released, an unparsable note aborts the read, or evidence from a later note is lost once a freeze is seen; why_new=the freeze test covers one
|
||||
// readable freeze record only and the evidence tests never mix record kinds or break the fetch; seam=none; no_claim=a real origin outage
|
||||
test('readTrainNotes: a fetch error refuses, a missing ref holds nothing, junk notes are skipped, and a freeze beside train evidence still refuses', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
const failing: Git = (args) => (args[0] === 'fetch' ? { ok: false, out: 'fatal: unable to access the remote: Could not resolve host' } : f.git(args));
|
||||
const broken = planRelease({ repo: f.repo, git: failing, gh: f.gh });
|
||||
expect(broken).toEqual({ ok: false, code: 'REFUSED', message: 'cannot fetch ' + NOTES_REF + ': fatal: unable to access the remote: Could not resolve host' });
|
||||
const absent = readTrainNotes(f.git);
|
||||
expect([absent.error, absent.freeze, absent.evidence.bySha.size, absent.evidence.byTree.size]).toEqual([null, null, 0, 0]);
|
||||
|
||||
const add = (record: string, on: string) => git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', record, on);
|
||||
add('not json {', f.shas['1.0.0']!);
|
||||
for (const junk of ['null', '[]', '"train"', '7', JSON.stringify({ kind: 'train', nonce: 'bad1', state: 'green', merged: 'x' }), JSON.stringify({ kind: 'train', nonce: 'bad2', state: 'green', merged: [null, 'y', { sha: 3, tree: ['t'] }] }), JSON.stringify({ kind: 'train', nonce: 9, state: 'green', merged: [{ sha: f.shas['1.0.0'] }] })]) {
|
||||
add(junk, f.shas['1.0.0']!);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const read = readTrainNotes(f.git);
|
||||
expect([junk, read.error, read.freeze, read.evidence.bySha.size, read.evidence.byTree.size]).toEqual([junk, null, null, 0, 0]);
|
||||
}
|
||||
add(JSON.stringify({ schema: 1, kind: 'train', state: 'merged', merged: [{ sha: f.shas['1.0.1'], tree: 't1' }] }), f.shas['1.0.1']!);
|
||||
add(JSON.stringify({ schema: 1, kind: 'train', nonce: 'ok1', state: 'green', merged: [{ pr: '2' }, { sha: f.shas['1.0.2'] }, { tree: 'tree-only' }] }), f.shas['1.0.2']!);
|
||||
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: null }), head);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const mixed = readTrainNotes(f.git);
|
||||
expect(mixed.error).toBeNull();
|
||||
expect(mixed.freeze).toBe('queue frozen since train ok1: backstop red');
|
||||
expect([...mixed.evidence.bySha]).toEqual([[f.shas['1.0.2']!, 'ok1']]);
|
||||
expect([...mixed.evidence.byTree]).toEqual([['tree-only', 'ok1']]);
|
||||
f.state.checks[f.shas['1.0.2']!] = backstop('completed', 'neutral');
|
||||
const frozen = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(frozen).toEqual({ ok: false, code: 'REFUSED', message: 'queue frozen since train ok1: backstop red' });
|
||||
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: 'y' }), head);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const thawed = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(thawed.ok, JSON.stringify(thawed)).toBe(true);
|
||||
if (thawed.ok) expect(thawed.plan.via).toBe('train ok1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release-cut: train evidence gates a commit whose backstop never settled', () => {
|
||||
@@ -217,10 +259,11 @@ describe('release-cut: train evidence gates a commit whose backstop never settle
|
||||
if (plain.ok) { expect(plain.plan.via).toBe(BACKSTOP_CHECK); expect(describePlan(plain.plan, false)[0]).toBe('CANDIDATE: ' + f.shas['1.0.2'] + ' (' + plain.plan.date + ') [hotfix: --ref] gated by ' + BACKSTOP_CHECK); }
|
||||
});
|
||||
|
||||
// Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run, no red check run (a red
|
||||
// backstop included), nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run,
|
||||
// a red windows lane or a red backstop is released, a running check is not waited for, or a red or superseded train record is read as evidence;
|
||||
// why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects
|
||||
// Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run (also when
|
||||
// --required-check and --backstop-check name the same check, where evidence substitutes for nothing), no red check run (a red backstop included),
|
||||
// nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run, a red windows lane
|
||||
// or a red backstop is released, a running check is not waited for, a red or superseded train record is read as evidence, or equal check names let a
|
||||
// covered commit with zero check runs through; why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects
|
||||
test('a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, or a red/superseded record', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
@@ -234,11 +277,18 @@ describe('release-cut: train evidence gates a commit whose backstop never settle
|
||||
expect(gate(backstop('in_progress', null))).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' is still running');
|
||||
expect(gate([...backstop('completed', 'neutral'), { name: 'slices-report', status: 'queued', conclusion: null }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: check runs still going: slices-report');
|
||||
expect(gate(backstop('completed', 'neutral'))).toBe('green');
|
||||
f.state.checks[head] = [];
|
||||
const same = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
|
||||
expect(same.ok ? 'green' : same.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ci check run');
|
||||
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }];
|
||||
const sameGreen = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
|
||||
expect(sameGreen.ok ? sameGreen.plan.via : sameGreen.message).toBe('ci');
|
||||
|
||||
for (const state of ['red', 'superseded', 'incomplete', 'free-green']) {
|
||||
note(f, train('t1', state, [{ sha: head, tree }]), head);
|
||||
expect([state, gate(backstop('completed', 'neutral'))]).toEqual([state, '--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded neutral']);
|
||||
}
|
||||
|
||||
for (const sha of Object.values(f.shas)) f.state.checks[sha] = backstop('completed', 'neutral');
|
||||
const none = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(none.ok).toBe(false);
|
||||
|
||||
@@ -27,9 +27,10 @@ describe('issue intent (pure)', () => {
|
||||
});
|
||||
|
||||
// Value: protects=a parenthesised named reference "(name #N, #M)", "(name PR #N)" or "(name issue #N)" is a cross-repo reference to that name, never a
|
||||
// same-repo Refs line or a promotion candidate, while "(see #12)", "(#301)" and "Fixes #12" keep their meaning; fails_when=the bare-#N rule reads the
|
||||
// numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or a reference word in the parentheses is taken
|
||||
// for a repository name; why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects
|
||||
// same-repo Refs line or a promotion candidate, while "(see #12)", "(closing #12)", "(cf. #12)", "(v1.2 #3)", "(#301)" and "Fixes #12" keep their
|
||||
// meaning; fails_when=the bare-#N rule reads the numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or
|
||||
// a closure, reference, filler or version word in the parentheses is taken for a repository name (the issue then vanishes from Refs and from the fix-#N
|
||||
// offer as a phantom cross-repo reference); why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects
|
||||
test('a parenthesised named reference is a cross-repo reference, never a same-repo Refs or promotion candidate', () => {
|
||||
const named = mentionsIn('ship-queue: screen verdict from the run (gbrain #7625, #7606)\n\nPorts the fix from (gbrain PR #7630) and (acme/tools issue #4).\n');
|
||||
expect(named.map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['gbrain', 7625, 'refs'], ['gbrain', 7606, 'refs'], ['gbrain', 7630, 'refs'], ['acme/tools', 4, 'refs']]);
|
||||
@@ -37,7 +38,7 @@ describe('issue intent (pure)', () => {
|
||||
expect(links).toEqual({ fixes: [], refs: [], flakes: [], cross: ['acme/tools#4', 'gbrain#7606', 'gbrain#7625', 'gbrain#7630'], unreadable: [] });
|
||||
expect(renderIssueLines(links)).toEqual(['Refs acme/tools#4', 'Refs gbrain#7606', 'Refs gbrain#7625', 'Refs gbrain#7630']);
|
||||
expect(promotionCandidates(named, links)).toEqual([]);
|
||||
for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue']) {
|
||||
for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue', 'closing', 'addresses', 'fixing', 'implements', 'cf.', 'e.g.', 'i.e.', 'bug', 'upstream PR', 'v1.2', 'supersedes', 'duplicate of']) {
|
||||
expect([word, mentionsIn(`retry (${word} #12)`).map(x => [x.repo ?? '', x.issue, x.intent])]).toEqual([word, [['', 12, 'refs']]]);
|
||||
}
|
||||
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => [x.repo ?? '', x.issue])).toEqual([['', 301]]);
|
||||
|
||||
Reference in new issue
Block a user