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", + ]); +});