From ca6257c2be3e4b1be7b01a5cc485ccefa68c5c3b Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 6 Sep 2026 00:06:36 -0700 Subject: [PATCH] Make a blocked regen reach a human instead of an inbox nobody reads `regen` fails closed on a blocked classification, uploads its evidence artifact, and then notifies nobody: the workflow has no failure step at all. It failed every run from 2026-09-01T06:55Z, and the only signal was GitHub's default Actions email. Five days passed with the published SDKs stale at v0.36.0 while the blocked batch grew from one item to seven. A failing run now opens a GitHub issue assigned to kev1n, and the next healthy run closes it. Closing is the requirement, not a nicety: the upsert reuses an open issue by exact title, so a stale open incident turns the next real failure into comment 17 on a thread nobody watches. Between those two states a repeat rewrites the body (no notification for news that has not changed) and a changed block adds one comment naming what is newly blocking and what cleared. The issue alone is enough to decide. Its body names each blocked and removed item WITH ITS SKU SLUG, links the failing run and the evidence artifact, and carries the full classifier output. `if: failure()` catches every path, so a failure before the classifier ever ran still files, under a stable single item. The slug is the reason for the one classifier-adjacent change: `renderSummary` drops it, so the release notes say "output required fields changed" with no way to tell which SKU. `classify-cli` gains `--json-out`, which writes the same classification it already computes as JSON, and `release-notes.sh` threads a third path through. The classification itself is untouched - nothing here makes a blocked run any easier to pass. `scripts/upsert-regen-issue.mjs` reimplements the minimum of the AnyAPI monorepo's `upsert-production-incident.mjs` probe shape rather than vendoring it; `pnpm release:test` now runs every `scripts/*.test.mjs` so its 15 tests and the registry-version suite both gate. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/regen.yml | 68 ++++++++- README.md | 9 +- generator/src/classify-cli.ts | 13 +- generator/test/classify-cli.test.ts | 60 ++++++++ package.json | 2 +- scripts/release-notes.sh | 14 +- scripts/upsert-regen-issue.mjs | 208 +++++++++++++++++++++++++++ scripts/upsert-regen-issue.test.mjs | 213 ++++++++++++++++++++++++++++ 8 files changed, 577 insertions(+), 10 deletions(-) create mode 100644 generator/test/classify-cli.test.ts create mode 100644 scripts/upsert-regen-issue.mjs create mode 100644 scripts/upsert-regen-issue.test.mjs diff --git a/.github/workflows/regen.yml b/.github/workflows/regen.yml index 7e7aba4..ce847ae 100644 --- a/.github/workflows/regen.yml +++ b/.github/workflows/regen.yml @@ -13,6 +13,13 @@ name: regen # # Loop guard: the job only commits/tags for patch or minor. `none` requires byte identity # across IR, fixtures, and both emitted trees. Unsafe or unexplained changes are blocked. +# +# A blocked run needs a HUMAN, so it opens a GitHub issue assigned to the owner and keeps +# one issue per outage rather than one per run. The next healthy run closes it. Closing is +# the point: the upsert reuses an open issue by title, so a stale open issue would turn the +# next real failure into another silent comment on a thread nobody watches. Between +# 2026-09-01 and 2026-09-05 this job failed every night with no signal but GitHub's default +# Actions email, and the published SDKs sat five days stale. on: repository_dispatch: @@ -24,11 +31,17 @@ on: permissions: contents: write actions: write # to trigger release.yml via workflow_dispatch after tagging + issues: write # to open/close the blocked-regen incident concurrency: group: regen cancel-in-progress: false +env: + # The upsert deduplicates by exact title, so the failure and recovery paths must name + # the same string. Changing it orphans any issue already open under the old title. + REGEN_ISSUE_TITLE: "regen is blocked: the SDKs are not being published" + jobs: regen: name: refresh catalog + classify + tag @@ -65,7 +78,7 @@ jobs: - name: classify generated diff -> release state + change summary id: classify run: | - bash scripts/release-notes.sh release-bump.txt release-notes.md + bash scripts/release-notes.sh release-bump.txt release-notes.md release-changes.json echo "bump=$(cat release-bump.txt)" >> "$GITHUB_OUTPUT" - name: prepare blocked-change evidence @@ -77,6 +90,7 @@ jobs: fi cp generator/ir.json blocked-release/new-ir.json cp release-notes.md blocked-release/release-notes.md + cp release-changes.json blocked-release/release-changes.json git add -N -- generator/ir.json generator/fixtures.json \ packages/typescript/src/generated packages/python/src/getanyapi/platforms git diff --binary --no-ext-diff HEAD -- \ @@ -85,6 +99,7 @@ jobs: > blocked-release/generated.diff - name: upload blocked-change evidence + id: evidence if: ${{ always() && steps.classify.outputs.bump == 'blocked' }} uses: actions/upload-artifact@v4 with: @@ -160,3 +175,54 @@ jobs: # `push` tag (for a human-pushed tag, e.g. the first manual v0.1.0). Both paths reach # the same publish jobs. run: gh workflow run release.yml --ref "v$NEXT" -f tag="v$NEXT" + + # Every failing path lands here: a blocked classification (the stop step above exits + # 1) and any other failure, including one before the classifier ever ran. + - name: open or update the blocked-regen issue + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUMP: ${{ steps.classify.outputs.bump }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ARTIFACT_URL: ${{ steps.evidence.outputs.artifact-url }} + run: | + # The blocked and removed items ARE the block, and their slugs are what the + # reviewer decides from, so they are both the issue body and the fingerprint. + # A failure before or outside classification has no item list; it still files, + # under a single stable item, so the run link reaches a human. + if [ "$BUMP" = "blocked" ] && [ -f release-changes.json ]; then + jq -r '(.blocked + .removed)[] | "\(.slug): \(.detail)"' \ + release-changes.json > regen-items.txt + else + printf 'regen failed with no classification (state: %s)\n' \ + "${BUMP:-before the classifier ran}" > regen-items.txt + fi + { + printf '`regen` stopped and did not publish. The SDKs stay at the last released version until this is resolved.\n\n' + printf '## What is blocking\n\n' + sed 's/^/- /' regen-items.txt + printf '\n[Failing run](%s)\n' "$RUN_URL" + if [ -n "$ARTIFACT_URL" ]; then + printf '\n[Evidence artifact](%s) - old/new IR, release notes, and the generated diff.\n' "$ARTIFACT_URL" + fi + if [ -f release-notes.md ]; then + printf '\n## Full classifier output\n\n```\n' + cat release-notes.md + printf '```\n' + fi + printf '\nAccepting this batch is a human decision: review each blocked item against its upstream catalog change, then land a reviewed catalog-refresh PR with the version applied in lockstep. Do not relax the classifier to make it pass.\n' + } > regen-issue.md + node scripts/upsert-regen-issue.mjs \ + --title "$REGEN_ISSUE_TITLE" \ + --body-file regen-issue.md \ + --items "$(cat regen-items.txt)" + + - name: close the blocked-regen issue on recovery + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/upsert-regen-issue.mjs \ + --title "$REGEN_ISSUE_TITLE" \ + --resolve \ + --body "regen recovered: [run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) classified \`${{ steps.classify.outputs.bump }}\`." diff --git a/README.md b/README.md index 311a866..c6494ed 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,14 @@ Releases are automated from the live catalog. Two workflows drive it: The workflow first proves that the committed generated trees match the old IR. Public generated changes must then appear in both language trees, and the post-refresh drift check proves that both match the new IR. A blocked run uploads the old/new IR, release notes, and - generated diff, then fails before versioning, committing, or tagging. On patch or minor it applies the version to BOTH + generated diff, then fails before versioning, committing, or tagging. Because a blocked run + needs a person, it also opens a GitHub issue assigned to the owner naming the blocked SKUs + and linking the run and that artifact (`scripts/upsert-regen-issue.mjs`); any other job + failure files the same issue. There is one issue per outage, not one per run: a repeat + rewrites the body, a changed block adds a delta comment, and the next healthy run closes + it. Closing matters - the upsert reuses an open issue by title, so leaving it open would + turn the next real failure into another silent comment. + On patch or minor it applies the version to BOTH `packages/typescript/package.json` and `packages/python/pyproject.toml` in lockstep, commits the regenerated tree, tags `v`, pushes, and dispatches `release.yml`. The change summary is the commit body and becomes the GitHub Release notes. diff --git a/generator/src/classify-cli.ts b/generator/src/classify-cli.ts index 2d338cf..134fede 100644 --- a/generator/src/classify-cli.ts +++ b/generator/src/classify-cli.ts @@ -4,7 +4,9 @@ // // Prints the state on stdout (none | patch | minor | blocked) so the workflow can read // `$(tsx ...)`. With --summary-out it writes the human-readable change summary (commit body -// / release notes) to that path. Byte-change flags come from scripts/release-notes.sh. +// / release notes) to that path; with --json-out it writes the full classification, whose +// items carry the SKU slug the summary omits (the blocked-regen issue names them, so the +// issue alone is enough to decide). Byte-change flags come from scripts/release-notes.sh. // Exit code is always 0 so a blocked result can be uploaded before the workflow fails. import { readFileSync, writeFileSync } from "node:fs"; @@ -23,7 +25,7 @@ function main(): void { console.error( "usage: classify-cli [--ir-changed] " + "[--fixtures-changed] [--typescript-changed] [--python-changed] " + - "[--summary-out ] [--json]", + "[--summary-out ] [--json-out ] [--json]", ); process.exitCode = 2; return; @@ -31,6 +33,8 @@ function main(): void { const summaryIdx = args.indexOf("--summary-out"); const summaryOut = summaryIdx >= 0 ? args[summaryIdx + 1] : null; + const jsonIdx = args.indexOf("--json-out"); + const jsonOut = jsonIdx >= 0 ? args[jsonIdx + 1] : null; const asJson = args.includes("--json"); const flags = new Set([ "--ir-changed", @@ -41,10 +45,10 @@ function main(): void { ]); for (let index = 2; index < args.length; index += 1) { const arg = args[index] as string; - if (arg === "--summary-out") { + if (arg === "--summary-out" || arg === "--json-out") { if (!args[index + 1]) { // eslint-disable-next-line no-console - console.error("--summary-out requires a path"); + console.error(`${arg} requires a path`); process.exitCode = 2; return; } @@ -65,6 +69,7 @@ function main(): void { }); if (summaryOut) writeFileSync(summaryOut, result.summary); + if (jsonOut) writeFileSync(jsonOut, `${JSON.stringify(result, null, 2)}\n`); if (asJson) { // eslint-disable-next-line no-console console.error(JSON.stringify(result, null, 2)); diff --git a/generator/test/classify-cli.test.ts b/generator/test/classify-cli.test.ts new file mode 100644 index 0000000..4e0a6c8 --- /dev/null +++ b/generator/test/classify-cli.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Classification } from "../src/classify.js"; +import { base, ir } from "./classify-fixture.js"; +import { sku } from "./factories.js"; + +const CLI = join(import.meta.dirname, "..", "src", "classify-cli.ts"); + +function runCli(args: string[]): { stdout: string; dir: string } { + const dir = mkdtempSync(join(tmpdir(), "classify-cli-")); + const oldPath = join(dir, "old.json"); + const newPath = join(dir, "new.json"); + writeFileSync(oldPath, JSON.stringify(ir([base]))); + writeFileSync( + newPath, + JSON.stringify(ir([base, sku({ slug: "acme.extra", name: "Extra" })])), + ); + const stdout = execFileSync( + process.execPath, + [ + join(import.meta.dirname, "..", "node_modules", "tsx", "dist", "cli.mjs"), + CLI, + oldPath, + newPath, + ...args.map((arg) => arg.replace("", dir)), + ], + { encoding: "utf8" }, + ); + return { stdout, dir }; +} + +describe("classify-cli", () => { + // The blocked-regen issue names the SKU each blocked item belongs to, and the rendered + // summary drops the slug. --json-out is the only surface that carries it. + it("--json-out writes the full classification, slugs included", () => { + const { stdout, dir } = runCli([ + "--ir-changed", + "--typescript-changed", + "--python-changed", + "--fixtures-changed", + "--json-out", + join("", "changes.json"), + ]); + expect(stdout.trim()).toBe("minor"); + const parsed = JSON.parse( + readFileSync(join(dir, "changes.json"), "utf8"), + ) as Classification; + expect(parsed.bump).toBe("minor"); + expect(parsed.added).toContainEqual( + expect.objectContaining({ slug: "acme.extra" }), + ); + }); + + it("rejects --json-out with no path instead of writing somewhere else", () => { + expect(() => runCli(["--json-out"])).toThrow(); + }); +}); diff --git a/package.json b/package.json index 2943197..6b6884c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "generate": "pnpm --filter @anyapi/generator generate", "generate:check": "pnpm --filter @anyapi/generator generate -- --check", "dash-guard": "bash scripts/check-dashes.sh", - "release:test": "node --test scripts/check-registry-version.test.mjs", + "release:test": "node --test scripts/*.test.mjs", "check": "pnpm run dash-guard && pnpm run release:test && pnpm run generate:check && pnpm -r --workspace-concurrency=1 run check" }, "license": "MIT" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 4dbc47a..c7bdf12 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -8,9 +8,12 @@ # release-bump.txt) # * the human-readable change summary (commit body / release notes) to the file named # by $2 (default: release-notes.md) +# * the full classification as JSON to the file named by $3 (default: +# release-changes.json). Its items carry the SKU slug the summary omits, which is +# what the blocked-regen issue names. # # Usage: -# scripts/release-notes.sh [bumpOutPath] [summaryOutPath] +# scripts/release-notes.sh [bumpOutPath] [summaryOutPath] [jsonOutPath] # # The classifier itself lives in generator/src/classify.ts (unit-tested). This wrapper # extracts the old IR and proves which generator-owned files differ byte-for-byte from HEAD. @@ -20,6 +23,7 @@ cd "$(dirname "$0")/.." BUMP_OUT="${1:-release-bump.txt}" SUMMARY_OUT="${2:-release-notes.md}" +JSON_OUT="${3:-release-changes.json}" OLD_IR="$(mktemp)" trap 'rm -f "$OLD_IR"' EXIT @@ -35,6 +39,7 @@ fi # to the intended location, not relative to the package dir. case "$BUMP_OUT" in /*) ;; *) BUMP_OUT="$PWD/$BUMP_OUT" ;; esac case "$SUMMARY_OUT" in /*) ;; *) SUMMARY_OUT="$PWD/$SUMMARY_OUT" ;; esac +case "$JSON_OUT" in /*) ;; *) JSON_OUT="$PWD/$JSON_OUT" ;; esac # Byte equality covers every generator-consumed artifact. `git status` includes tracked, # deleted, and untracked emitter output, unlike `git diff` alone. @@ -56,10 +61,12 @@ fi # 3.2 treats an empty array expansion as unset under `set -u`, so omit it explicitly. if [ "${#CLASSIFY_ARGS[@]}" -eq 0 ]; then BUMP="$(pnpm --silent --filter @anyapi/generator exec tsx src/classify-cli.ts \ - "$OLD_IR" "$PWD/generator/ir.json" --summary-out "$SUMMARY_OUT")" + "$OLD_IR" "$PWD/generator/ir.json" --summary-out "$SUMMARY_OUT" \ + --json-out "$JSON_OUT")" else BUMP="$(pnpm --silent --filter @anyapi/generator exec tsx src/classify-cli.ts \ - "$OLD_IR" "$PWD/generator/ir.json" "${CLASSIFY_ARGS[@]}" --summary-out "$SUMMARY_OUT")" + "$OLD_IR" "$PWD/generator/ir.json" "${CLASSIFY_ARGS[@]}" --summary-out "$SUMMARY_OUT" \ + --json-out "$JSON_OUT")" fi printf '%s\n' "$BUMP" > "$BUMP_OUT" @@ -67,3 +74,4 @@ printf '%s\n' "$BUMP" > "$BUMP_OUT" echo "Generated diff classified: state=$BUMP" echo " bump -> $BUMP_OUT" echo " summary -> $SUMMARY_OUT" +echo " json -> $JSON_OUT" diff --git a/scripts/upsert-regen-issue.mjs b/scripts/upsert-regen-issue.mjs new file mode 100644 index 0000000..f5d9ee6 --- /dev/null +++ b/scripts/upsert-regen-issue.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +// Route a stopped `regen` run to a human. +// +// regen.yml fails closed on a blocked classification and on any other job failure. +// Without this, the only signal is GitHub's default Actions email, so the SDKs can sit +// stale for days while the automation quietly re-fails every night. +// +// node scripts/upsert-regen-issue.mjs --title --body-file --items +// node scripts/upsert-regen-issue.mjs --title --resolve --body +// +// The failing item set is the fingerprint, carried in the issue body. An unchanged set +// only rewrites the body (no notification); a changed set adds a comment naming the +// delta. The recovery call CLOSES the issue, and closing is the point: the upsert reuses +// an open issue by title, so a stale open incident turns the next real failure into +// another silent comment on a thread nobody watches. + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export const DEFAULT_ASSIGNEE = "kev1n"; + +const FINGERPRINT_PATTERN = //u; + +const executeGh = (args) => + execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + +export function decideIssueAction(issues, title) { + if (!Array.isArray(issues)) { + throw new Error("GitHub issue list response must be an array"); + } + const existing = issues.find((issue) => issue?.title === title); + if (!existing) return { kind: "create" }; + if (!Number.isInteger(existing.number)) { + throw new Error("matching GitHub issue has no integer number"); + } + return { + kind: "update", + issueNumber: existing.number, + body: typeof existing.body === "string" ? existing.body : "", + }; +} + +function findOpenIssue(title, runGh) { + const output = runGh([ + "issue", + "list", + "--state", + "open", + "--search", + `"${title}" in:title`, + "--json", + "number,title,body", + ]); + return decideIssueAction(JSON.parse(output), title); +} + +export function normalizeItems(items) { + return [...new Set(items.map((item) => item.trim()).filter(Boolean))].sort(); +} + +export function parseFingerprint(body) { + const match = FINGERPRINT_PATTERN.exec(typeof body === "string" ? body : ""); + if (!match) return []; + const parsed = JSON.parse(match[1]); + if (!Array.isArray(parsed) || !parsed.every((v) => typeof v === "string")) { + throw new Error("regen incident fingerprint must be an array of strings"); + } + return normalizeItems(parsed); +} + +export function renderIssueBody(body, items) { + const withoutFingerprint = String(body) + .replace(FINGERPRINT_PATTERN, "") + .trimEnd(); + return `${withoutFingerprint}\n\n\n`; +} + +export function deltaBetween(previous, current) { + const previousSet = new Set(previous); + const currentSet = new Set(current); + return { + added: current.filter((item) => !previousSet.has(item)), + cleared: previous.filter((item) => !currentSet.has(item)), + }; +} + +export function deltaComment({ added, cleared }) { + return [ + "The regen block changed.", + "", + `Newly blocking: ${added.length > 0 ? added.join("; ") : "none"}`, + `No longer blocking: ${cleared.length > 0 ? cleared.join("; ") : "none"}`, + ].join("\n"); +} + +export function upsertRegenIssue({ + title, + bodyFile, + items, + assignee = DEFAULT_ASSIGNEE, + runGh = executeGh, + readBodyFile = (path) => readFileSync(path, "utf8"), + writeBodyFile = (path, body) => writeFileSync(path, body), +}) { + const current = normalizeItems(items); + const decision = findOpenIssue(title, runGh); + const body = renderIssueBody(readBodyFile(bodyFile), current); + writeBodyFile(bodyFile, body); + + if (decision.kind === "create") { + runGh([ + "issue", + "create", + "--title", + title, + "--body-file", + bodyFile, + "--assignee", + assignee, + ]); + return { kind: "create" }; + } + + const issueNumber = String(decision.issueNumber); + // Rewriting the body refreshes the run link and evidence without notifying anyone. + runGh(["issue", "edit", issueNumber, "--body-file", bodyFile]); + const delta = deltaBetween(parseFingerprint(decision.body), current); + if (delta.added.length === 0 && delta.cleared.length === 0) { + return { kind: "unchanged", issueNumber: decision.issueNumber }; + } + runGh(["issue", "comment", issueNumber, "--body", deltaComment(delta)]); + return { kind: "change", issueNumber: decision.issueNumber, ...delta }; +} + +export function resolveRegenIssue({ title, comment, runGh = executeGh }) { + const decision = findOpenIssue(title, runGh); + if (decision.kind === "create") return { kind: "noop" }; + const issueNumber = String(decision.issueNumber); + runGh(["issue", "comment", issueNumber, "--body", comment]); + runGh(["issue", "close", issueNumber]); + return { kind: "resolved", issueNumber: decision.issueNumber }; +} + +function parseOption(argv, name) { + const index = argv.indexOf(name); + return index === -1 ? undefined : argv[index + 1]; +} + +function requireOption(argv, name) { + const value = parseOption(argv, name); + if (!value) throw new Error(`missing required option ${name}`); + return value; +} + +export function runRegenIssueCli({ + argv = process.argv, + runGh = executeGh, + log = console.log, + logError = console.error, +} = {}) { + try { + const title = requireOption(argv, "--title"); + if (argv.includes("--resolve")) { + const resolution = resolveRegenIssue({ + title, + comment: requireOption(argv, "--body"), + runGh, + }); + log( + resolution.kind === "resolved" + ? `regen incident resolved: #${resolution.issueNumber}` + : "no open regen incident to resolve", + ); + return 0; + } + const itemsOption = parseOption(argv, "--items"); + if (itemsOption === undefined) { + throw new Error("missing required option --items"); + } + const decision = upsertRegenIssue({ + title, + bodyFile: requireOption(argv, "--body-file"), + items: itemsOption.split("\n"), + runGh, + }); + log( + decision.issueNumber === undefined + ? "regen incident created" + : `regen incident ${decision.kind}: #${decision.issueNumber}`, + ); + return 0; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + logError(`::error::regen incident upsert failed: ${detail}`); + return 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = runRegenIssueCli(); +} diff --git a/scripts/upsert-regen-issue.test.mjs b/scripts/upsert-regen-issue.test.mjs new file mode 100644 index 0000000..7060d6d --- /dev/null +++ b/scripts/upsert-regen-issue.test.mjs @@ -0,0 +1,213 @@ +import { deepEqual, equal, match, ok, throws } from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { + DEFAULT_ASSIGNEE, + decideIssueAction, + deltaBetween, + parseFingerprint, + renderIssueBody, + resolveRegenIssue, + runRegenIssueCli, + upsertRegenIssue, +} from "./upsert-regen-issue.mjs"; + +const TITLE = "regen blocked"; + +/** Record every `gh` invocation and answer the issue lookup from `issues`. */ +function ghRecorder(issues) { + const calls = []; + const runGh = (args) => { + calls.push(args); + if (args[0] === "issue" && args[1] === "list") return JSON.stringify(issues); + return ""; + }; + return { calls, runGh }; +} + +function memoryBody(initial = "") { + const store = { body: initial }; + return { + store, + readBodyFile: () => store.body, + writeBodyFile: (_path, body) => { + store.body = body; + }, + }; +} + +test("no matching open issue means create", () => { + deepEqual(decideIssueAction([{ title: "something else", number: 1 }], TITLE), { + kind: "create", + }); +}); + +test("a matching open issue is reused by exact title", () => { + deepEqual(decideIssueAction([{ title: TITLE, number: 7, body: "b" }], TITLE), { + kind: "update", + issueNumber: 7, + body: "b", + }); +}); + +test("a non-array issue list is a hard error", () => { + throws(() => decideIssueAction(null, TITLE), /must be an array/u); +}); + +test("fingerprint round-trips through the body, sorted and deduplicated", () => { + const body = renderIssueBody("evidence", ["b", "a", "b", " a "]); + deepEqual(parseFingerprint(body), ["a", "b"]); +}); + +test("re-rendering replaces the old fingerprint rather than stacking them", () => { + const body = renderIssueBody(renderIssueBody("evidence", ["a"]), ["c"]); + deepEqual(parseFingerprint(body), ["c"]); + equal(body.match(/regen-blocked:/gu).length, 1); +}); + +test("a body with no fingerprint reads as an empty set", () => { + deepEqual(parseFingerprint("no marker here"), []); +}); + +test("a malformed fingerprint is a hard error, not a silent empty set", () => { + throws( + () => parseFingerprint(""), + /array of strings/u, + ); +}); + +test("delta names both directions", () => { + deepEqual(deltaBetween(["a", "b"], ["b", "c"]), { + added: ["c"], + cleared: ["a"], + }); +}); + +test("first block creates an issue assigned to the owner", () => { + const { calls, runGh } = ghRecorder([]); + const file = memoryBody("blocked: input.lang removed"); + const result = upsertRegenIssue({ + title: TITLE, + bodyFile: "body.md", + items: ["input.lang removed"], + runGh, + ...file, + }); + deepEqual(result, { kind: "create" }); + const create = calls.find((args) => args[1] === "create"); + ok(create, "expected an issue create"); + deepEqual(create.slice(-2), ["--assignee", DEFAULT_ASSIGNEE]); + deepEqual(parseFingerprint(file.store.body), ["input.lang removed"]); +}); + +test("an unchanged block refreshes the body and posts no comment", () => { + const existing = renderIssueBody("old evidence", ["input.lang removed"]); + const { calls, runGh } = ghRecorder([ + { title: TITLE, number: 12, body: existing }, + ]); + const file = memoryBody("new evidence, newer run link"); + const result = upsertRegenIssue({ + title: TITLE, + bodyFile: "body.md", + items: ["input.lang removed"], + runGh, + ...file, + }); + deepEqual(result, { kind: "unchanged", issueNumber: 12 }); + ok(calls.some((args) => args[1] === "edit")); + ok(!calls.some((args) => args[1] === "comment")); + match(file.store.body, /newer run link/u); +}); + +test("a grown block comments the delta on the same issue", () => { + const existing = renderIssueBody("old", ["a"]); + const { calls, runGh } = ghRecorder([ + { title: TITLE, number: 12, body: existing }, + ]); + const result = upsertRegenIssue({ + title: TITLE, + bodyFile: "body.md", + items: ["a", "b"], + runGh, + ...memoryBody("new"), + }); + deepEqual(result, { + kind: "change", + issueNumber: 12, + added: ["b"], + cleared: [], + }); + const comment = calls.find((args) => args[1] === "comment"); + ok(comment); + match(comment[4], /Newly blocking: b/u); +}); + +test("recovery closes the issue so the next failure notifies again", () => { + const { calls, runGh } = ghRecorder([ + { title: TITLE, number: 12, body: renderIssueBody("old", ["a"]) }, + ]); + const result = resolveRegenIssue({ + title: TITLE, + comment: "regen released v0.37.0", + runGh, + }); + deepEqual(result, { kind: "resolved", issueNumber: 12 }); + deepEqual( + calls.map((args) => args[1]), + ["list", "comment", "close"], + ); +}); + +test("recovery with no open issue does nothing", () => { + const { calls, runGh } = ghRecorder([]); + deepEqual(resolveRegenIssue({ title: TITLE, comment: "ok", runGh }), { + kind: "noop", + }); + deepEqual( + calls.map((args) => args[1]), + ["list"], + ); +}); + +test("the CLI reports a missing option instead of half-filing an issue", () => { + const { calls, runGh } = ghRecorder([]); + const errors = []; + const code = runRegenIssueCli({ + argv: ["node", "cli", "--title", TITLE], + runGh, + log: () => {}, + logError: (line) => errors.push(line), + }); + equal(code, 1); + deepEqual(calls, []); + match(errors[0], /missing required option --items/u); +}); + +test("the CLI splits --items on newlines so an item may carry a comma", () => { + const bodyFile = join(mkdtempSync(join(tmpdir(), "regen-issue-")), "body.md"); + writeFileSync(bodyFile, "evidence"); + const { calls, runGh } = ghRecorder([]); + const code = runRegenIssueCli({ + argv: [ + "node", + "cli", + "--title", + TITLE, + "--body-file", + bodyFile, + "--items", + "yelp.search: input.limit default changed, from null to 20\ntiktok.video: input required fields changed", + ], + runGh, + log: () => {}, + }); + equal(code, 0); + ok(calls.some((args) => args[1] === "create")); + deepEqual(parseFingerprint(readFileSync(bodyFile, "utf8")), [ + "tiktok.video: input required fields changed", + "yelp.search: input.limit default changed, from null to 20", + ]); +});