From eb1ddd382392b821cb2ac10ace85f91af33ba6a8 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 30 Aug 2026 00:51:27 -0700 Subject: [PATCH 1/4] fix(release): pass through --prerelease=false and --latest on edit Bare-boolean parsing dropped the unset form so promoting a prerelease silently no-op'd, and --latest was rejected as unknown. Forward both equals-forms to gh so the GitHub API actually applies the change. Co-authored-by: Cursor --- AGENTS.md | 4 ++ src/commands/release.ts | 33 ++++++------ test/commands/api.test.ts | 31 +++++++++++ test/commands/release.test.ts | 99 +++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1395f46..75d1064 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,10 @@ Instead, `resolveOwner()` defaults `--owner` to the current repo's owner (`ctx?. Since Projects v2 items carry per-project custom fields (Status, Priority, ...) with no fixed schema, `item-list`/`field-list` render through bespoke functions (`renderProjectItems`/`renderProjectFields`) that flatten any unknown scalar top-level key into its own column, rather than a fixed `FieldDef` schema. Requires the `project` (or `read:project`) OAuth scope on the `gh` token; `src/errors.ts` matches gh's literal `"authentication token is missing required scopes [...]"` stderr (verified against a live token missing the scope) and maps it to `FORBIDDEN` with a `gh auth refresh -s ` suggestion — this pattern is generic, not project-specific, so it also covers other gh features gated by OAuth scopes. +## Release edit boolean-with-value flags (`src/commands/release.ts`) + +`gh release edit` accepts `--flag=false` to unset a boolean (`--prerelease=false` promotes a prerelease; `--latest=false` demotes repo-latest; `--draft=false` publishes a draft). `takeBoolFlag` / `appendBoolFlag` only match the bare `--flag` and treat it as always-true, so the `=false` form is dropped and the edit silently no-ops (prints the tag, changes nothing). Use `appendOptionalValueBoolFlag` for `--prerelease`, `--draft`, and `--latest` on edit. `RELEASE_FLAGS.edit` must list `--latest` or `rejectUnknownFlags` rejects the promote path before the flags can be forwarded. + ## Repeatable flags (`src/args.ts`) `gh` accepts `--label`, `--assignee`, `--reviewer`, `--project`, and the `--add-*`/`--remove-*` variants once per value, so gh-axi must collect _every_ occurrence. diff --git a/src/commands/release.ts b/src/commands/release.ts index 5aa86f8..a21ecdf 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -31,7 +31,7 @@ const RELEASE_FLAGS: Record = { ], edit: [ "--body", "--body-file", "--title", "--notes", "-n", "--notes-file", - "-F", "--draft", "--prerelease", + "-F", "--draft", "--prerelease", "--latest", ], delete: [], download: ["--pattern", "--dir"], @@ -48,13 +48,14 @@ flags{view}: flags{create}: --title/-t, --notes/-n or --body, --notes-file/-F or --body-file, --draft/-d, --prerelease/-p, --target, --generate-notes, --discussion-category, --notes-start-tag, --verify-tag, --notes-from-tag, --fail-on-no-commits, --latest[=true|false], flags{edit}: - --title, --notes/-n or --body, --notes-file/-F or --body-file, --draft, --prerelease + --title, --notes/-n or --body, --notes-file/-F or --body-file, --draft[=true|false], --prerelease[=true|false], --latest[=true|false] flags{download}: --pattern, --dir examples: gh-axi release list --exclude-drafts gh-axi release view v1.2.0 --full - gh-axi release create v1.3.0 --body-file notes.md --draft dist/app.zip`; + gh-axi release create v1.3.0 --body-file notes.md --draft dist/app.zip + gh-axi release edit v1.3.0 --prerelease=false --latest`; const listSchema: FieldDef[] = [ field("tagName", "tag"), @@ -278,11 +279,19 @@ async function editRelease(args: string[], ctx?: RepoContext): Promise { const remaining = [...args]; const body = takeReleaseBodyAlias(remaining); assertNoReleaseNotesConflict(body, remaining, RELEASE_NOTES_FLAGS); - const title = takeFirstFlag(remaining, ["--title"]); - const notes = takeFirstFlag(remaining, ["--notes", "-n"]); - const notesFile = takeFirstFlag(remaining, ["--notes-file", "-F"]); - const draft = takeBoolFlag(remaining, "--draft"); - const prerelease = takeBoolFlag(remaining, "--prerelease"); + const optionArgs: string[] = []; + appendValueFlag(optionArgs, remaining, "--title"); + if (body !== undefined) optionArgs.push("--notes", body); + appendValueFlag(optionArgs, remaining, "--notes", ["--notes", "-n"]); + appendValueFlag(optionArgs, remaining, "--notes-file", [ + "--notes-file", + "-F", + ]); + // gh accepts --flag=false to unset these; takeBoolFlag would drop that form + // and the edit would silently no-op (prints the tag, changes nothing). + appendOptionalValueBoolFlag(optionArgs, remaining, "--draft"); + appendOptionalValueBoolFlag(optionArgs, remaining, "--prerelease"); + appendOptionalValueBoolFlag(optionArgs, remaining, "--latest"); const positionals = remaining.filter((a) => !a.startsWith("-")); const tag = positionals[1]; if (!tag) @@ -291,13 +300,7 @@ async function editRelease(args: string[], ctx?: RepoContext): Promise { "VALIDATION_ERROR", ); - const ghArgs = ["release", "edit", tag]; - if (title) ghArgs.push("--title", title); - if (body !== undefined) ghArgs.push("--notes", body); - if (notes) ghArgs.push("--notes", notes); - if (notesFile) ghArgs.push("--notes-file", notesFile); - if (draft) ghArgs.push("--draft"); - if (prerelease) ghArgs.push("--prerelease"); + const ghArgs = ["release", "edit", tag, ...optionArgs]; await ghExec(ghArgs, ctx); const suggestions = getSuggestions({ diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 81ff85b..379fbcc 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -61,6 +61,37 @@ describe("apiCommand", () => { ); }); + it("round-trips a PATCH with --field instead of silently sending GET", async () => { + mockedGhExec.mockResolvedValue( + JSON.stringify({ id: 42, prerelease: false, make_latest: true }), + ); + + const result = await apiCommand([ + "PATCH", + "/repos/octo/repo/releases/1", + "--field", + "prerelease=false", + "--field", + "make_latest=true", + ]); + + const ghArgs = mockedGhExec.mock.calls[0][0]; + expect(ghArgs).toEqual( + expect.arrayContaining([ + "api", + "/repos/octo/repo/releases/1", + "--method", + "PATCH", + "--field", + "prerelease=false", + "--field", + "make_latest=true", + ]), + ); + expect(ghArgs).not.toContain("GET"); + expect(result).toContain("prerelease: false"); + }); + it("maps -X to --method instead of silently sending GET", async () => { mockedGhExec.mockResolvedValue("{}"); diff --git a/test/commands/release.test.ts b/test/commands/release.test.ts index 5a9d11b..d338161 100644 --- a/test/commands/release.test.ts +++ b/test/commands/release.test.ts @@ -368,6 +368,105 @@ describe("releaseCommand", () => { }); }); + describe("edit boolean-with-value flags (promote / demote)", () => { + /** + * Semantic stand-in for `gh release edit`: apply boolean-with-value flags + * the way gh itself does. The defect was a silent no-op (print tag, change + * nothing), so the assertion is the resulting release state, not argv text. + */ + type ReleaseEditState = { + prerelease: boolean; + latest: boolean; + draft: boolean; + }; + + function applyGhReleaseEdit( + ghArgs: string[], + state: ReleaseEditState, + ): void { + for (const arg of ghArgs) { + if (arg === "--prerelease" || arg === "--prerelease=true") { + state.prerelease = true; + } else if (arg === "--prerelease=false") { + state.prerelease = false; + } else if (arg === "--latest" || arg === "--latest=true") { + state.latest = true; + } else if (arg === "--latest=false") { + state.latest = false; + } else if (arg === "--draft" || arg === "--draft=true") { + state.draft = true; + } else if (arg === "--draft=false") { + state.draft = false; + } + } + } + + function fakeGhFromState(state: ReleaseEditState) { + mockedGhExec.mockImplementation(async (args) => { + applyGhReleaseEdit(args, state); + return ""; + }); + } + + it("promotes a prerelease to a full latest release", async () => { + const state: ReleaseEditState = { + prerelease: true, + latest: false, + draft: false, + }; + fakeGhFromState(state); + + const result = await releaseCommand( + ["edit", "v1.0.0", "--prerelease=false", "--latest"], + ctx, + ); + + expect(result).toContain("v1.0.0"); + expect(state.prerelease).toBe(false); + expect(state.latest).toBe(true); + }); + + it("demotes latest with --latest=false", async () => { + const state: ReleaseEditState = { + prerelease: false, + latest: true, + draft: false, + }; + fakeGhFromState(state); + + await releaseCommand(["edit", "v1.0.0", "--latest=false"], ctx); + + expect(state.latest).toBe(false); + expect(state.prerelease).toBe(false); + }); + + it("marks a release as prerelease with the bare --prerelease set form", async () => { + const state: ReleaseEditState = { + prerelease: false, + latest: true, + draft: false, + }; + fakeGhFromState(state); + + await releaseCommand(["edit", "v1.0.0", "--prerelease"], ctx); + + expect(state.prerelease).toBe(true); + }); + + it("unsets draft with --draft=false", async () => { + const state: ReleaseEditState = { + prerelease: false, + latest: false, + draft: true, + }; + fakeGhFromState(state); + + await releaseCommand(["edit", "v1.0.0", "--draft=false"], ctx); + + expect(state.draft).toBe(false); + }); + }); + describe("repo context threading", () => { beforeEach(() => { mockedGhJson.mockImplementation(async (args) => { From 142a74779c7bfd1e74c95299100866af4a2004ea Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 30 Aug 2026 01:04:34 -0700 Subject: [PATCH 2/4] no-mistakes(review): Reject conflicting repeated release boolean flags --- src/commands/release.ts | 34 +++++++++++++++++++++++----------- test/commands/release.test.ts | 13 +++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/commands/release.ts b/src/commands/release.ts index a21ecdf..95caa6b 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -116,18 +116,30 @@ function appendOptionalValueBoolFlag( outputFlag: string, inputFlags: string[] = [outputFlag], ): void { - for (const flag of inputFlags) { - const equalsPrefix = `${flag}=`; - const equalsIndex = args.findIndex((arg) => arg.startsWith(equalsPrefix)); - if (equalsIndex !== -1) { - ghArgs.push( - `${outputFlag}=${args[equalsIndex].slice(equalsPrefix.length)}`, - ); - args.splice(equalsIndex, 1); - return; - } + const occurrences = args.flatMap((arg, index) => + inputFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)) + ? [index] + : [], + ); + if (occurrences.length > 1) { + throw new AxiError( + `${outputFlag} may only be specified once`, + "VALIDATION_ERROR", + ); } - appendBoolFlag(ghArgs, args, outputFlag, inputFlags); + if (occurrences.length === 0) return; + + const index = occurrences[0]; + const arg = args[index]; + const inputFlag = inputFlags.find( + (flag) => arg === flag || arg.startsWith(`${flag}=`), + )!; + ghArgs.push( + arg === inputFlag + ? outputFlag + : `${outputFlag}=${arg.slice(inputFlag.length + 1)}`, + ); + args.splice(index, 1); } function findProvidedFlags(args: string[], flags: string[]): string[] { diff --git a/test/commands/release.test.ts b/test/commands/release.test.ts index d338161..45a15a7 100644 --- a/test/commands/release.test.ts +++ b/test/commands/release.test.ts @@ -465,6 +465,19 @@ describe("releaseCommand", () => { expect(state.draft).toBe(false); }); + + it.each([ + ["--latest=false", "--latest"], + ["--prerelease", "--prerelease=false"], + ["--draft=true", "--draft=false"], + ])("rejects conflicting repeated forms %s %s", async (first, second) => { + await expect( + releaseCommand(["edit", "v1.0.0", first, second], ctx), + ).rejects.toMatchObject({ + code: "VALIDATION_ERROR", + }); + expect(mockedGhExec).not.toHaveBeenCalled(); + }); }); describe("repo context threading", () => { From ae3a4b32039ba1413de4a50acb0ded8e514babc2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 30 Aug 2026 01:32:21 -0700 Subject: [PATCH 3/4] no-mistakes(review): Add stateful CLI release and API round-trips --- test/commands/api.test.ts | 31 ---- test/commands/release.test.ts | 99 +------------ test/fixtures/stateful-gh.mjs | 108 ++++++++++++++ .../release-api-roundtrip.integration.test.ts | 133 ++++++++++++++++++ 4 files changed, 242 insertions(+), 129 deletions(-) create mode 100644 test/fixtures/stateful-gh.mjs create mode 100644 test/integration/release-api-roundtrip.integration.test.ts diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 379fbcc..81ff85b 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -61,37 +61,6 @@ describe("apiCommand", () => { ); }); - it("round-trips a PATCH with --field instead of silently sending GET", async () => { - mockedGhExec.mockResolvedValue( - JSON.stringify({ id: 42, prerelease: false, make_latest: true }), - ); - - const result = await apiCommand([ - "PATCH", - "/repos/octo/repo/releases/1", - "--field", - "prerelease=false", - "--field", - "make_latest=true", - ]); - - const ghArgs = mockedGhExec.mock.calls[0][0]; - expect(ghArgs).toEqual( - expect.arrayContaining([ - "api", - "/repos/octo/repo/releases/1", - "--method", - "PATCH", - "--field", - "prerelease=false", - "--field", - "make_latest=true", - ]), - ); - expect(ghArgs).not.toContain("GET"); - expect(result).toContain("prerelease: false"); - }); - it("maps -X to --method instead of silently sending GET", async () => { mockedGhExec.mockResolvedValue("{}"); diff --git a/test/commands/release.test.ts b/test/commands/release.test.ts index 45a15a7..ba33b21 100644 --- a/test/commands/release.test.ts +++ b/test/commands/release.test.ts @@ -368,104 +368,7 @@ describe("releaseCommand", () => { }); }); - describe("edit boolean-with-value flags (promote / demote)", () => { - /** - * Semantic stand-in for `gh release edit`: apply boolean-with-value flags - * the way gh itself does. The defect was a silent no-op (print tag, change - * nothing), so the assertion is the resulting release state, not argv text. - */ - type ReleaseEditState = { - prerelease: boolean; - latest: boolean; - draft: boolean; - }; - - function applyGhReleaseEdit( - ghArgs: string[], - state: ReleaseEditState, - ): void { - for (const arg of ghArgs) { - if (arg === "--prerelease" || arg === "--prerelease=true") { - state.prerelease = true; - } else if (arg === "--prerelease=false") { - state.prerelease = false; - } else if (arg === "--latest" || arg === "--latest=true") { - state.latest = true; - } else if (arg === "--latest=false") { - state.latest = false; - } else if (arg === "--draft" || arg === "--draft=true") { - state.draft = true; - } else if (arg === "--draft=false") { - state.draft = false; - } - } - } - - function fakeGhFromState(state: ReleaseEditState) { - mockedGhExec.mockImplementation(async (args) => { - applyGhReleaseEdit(args, state); - return ""; - }); - } - - it("promotes a prerelease to a full latest release", async () => { - const state: ReleaseEditState = { - prerelease: true, - latest: false, - draft: false, - }; - fakeGhFromState(state); - - const result = await releaseCommand( - ["edit", "v1.0.0", "--prerelease=false", "--latest"], - ctx, - ); - - expect(result).toContain("v1.0.0"); - expect(state.prerelease).toBe(false); - expect(state.latest).toBe(true); - }); - - it("demotes latest with --latest=false", async () => { - const state: ReleaseEditState = { - prerelease: false, - latest: true, - draft: false, - }; - fakeGhFromState(state); - - await releaseCommand(["edit", "v1.0.0", "--latest=false"], ctx); - - expect(state.latest).toBe(false); - expect(state.prerelease).toBe(false); - }); - - it("marks a release as prerelease with the bare --prerelease set form", async () => { - const state: ReleaseEditState = { - prerelease: false, - latest: true, - draft: false, - }; - fakeGhFromState(state); - - await releaseCommand(["edit", "v1.0.0", "--prerelease"], ctx); - - expect(state.prerelease).toBe(true); - }); - - it("unsets draft with --draft=false", async () => { - const state: ReleaseEditState = { - prerelease: false, - latest: false, - draft: true, - }; - fakeGhFromState(state); - - await releaseCommand(["edit", "v1.0.0", "--draft=false"], ctx); - - expect(state.draft).toBe(false); - }); - + describe("edit boolean-with-value validation", () => { it.each([ ["--latest=false", "--latest"], ["--prerelease", "--prerelease=false"], diff --git a/test/fixtures/stateful-gh.mjs b/test/fixtures/stateful-gh.mjs new file mode 100644 index 0000000..3811ce9 --- /dev/null +++ b/test/fixtures/stateful-gh.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; + +const stateFile = process.env.GH_AXI_FAKE_STATE; +if (!stateFile) throw new Error("GH_AXI_FAKE_STATE is required"); + +const state = JSON.parse(readFileSync(stateFile, "utf8")); +const args = process.argv.slice(2); + +function optionValue(name) { + const index = args.indexOf(name); + if (index !== -1) return args[index + 1]; + const prefix = `${name}=`; + const arg = args.find((candidate) => candidate.startsWith(prefix)); + return arg?.slice(prefix.length); +} + +function optionValues(name) { + const values = []; + const prefix = `${name}=`; + for (let index = 0; index < args.length; index++) { + if (args[index] === name) { + values.push(args[index + 1]); + index++; + } else if (args[index].startsWith(prefix)) { + values.push(args[index].slice(prefix.length)); + } + } + return values; +} + +function booleanOption(name) { + const bare = args.includes(name); + const value = optionValue(name); + if (!bare && value === undefined) return undefined; + return bare || value === "true"; +} + +function save() { + writeFileSync(stateFile, JSON.stringify(state), "utf8"); +} + +function releaseByPath(path) { + const tagPrefix = "/repos/octo/repo/releases/tags/"; + if (path.startsWith(tagPrefix)) { + return state.releases.find((release) => release.tag_name === path.slice(tagPrefix.length)); + } + if (path === "/repos/octo/repo/releases/latest") { + return state.releases.find((release) => release.tag_name === state.latestTag); + } + const idMatch = path.match(/^\/repos\/octo\/repo\/releases\/(\d+)$/); + if (idMatch) { + return state.releases.find((release) => release.id === Number(idMatch[1])); + } + return undefined; +} + +if (args[0] === "release" && args[1] === "edit") { + const release = state.releases.find((candidate) => candidate.tag_name === args[2]); + if (!release) process.exit(1); + + for (const key of ["prerelease", "draft"]) { + const value = booleanOption(`--${key}`); + if (value !== undefined) release[key] = value; + } + const latest = booleanOption("--latest"); + if (latest === true) state.latestTag = release.tag_name; + if (latest === false && state.latestTag === release.tag_name) { + state.latestTag = state.fallbackLatestTag; + } + save(); + console.log(`https://github.com/octo/repo/releases/tag/${release.tag_name}`); + process.exit(0); +} + +if (args[0] === "api") { + const path = args[1]; + const method = optionValue("--method") ?? "GET"; + const release = releaseByPath(path); + if (!release) { + console.error("release not found"); + process.exit(1); + } + + if (method === "PATCH" || method === "POST") { + for (const field of optionValues("--field")) { + const separator = field.indexOf("="); + const key = field.slice(0, separator); + const raw = field.slice(separator + 1); + const value = raw === "true" ? true : raw === "false" ? false : raw; + if (key === "make_latest") { + if (value === true) state.latestTag = release.tag_name; + if (value === false && state.latestTag === release.tag_name) { + state.latestTag = state.fallbackLatestTag; + } + } else { + release[key] = value; + } + } + save(); + } + + console.log(JSON.stringify(release)); + process.exit(0); +} + +console.error(`unsupported fake gh invocation: ${args.join(" ")}`); +process.exit(1); diff --git a/test/integration/release-api-roundtrip.integration.test.ts b/test/integration/release-api-roundtrip.integration.test.ts new file mode 100644 index 0000000..db1f478 --- /dev/null +++ b/test/integration/release-api-roundtrip.integration.test.ts @@ -0,0 +1,133 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); +const cli = join(repoRoot, "bin", "gh-axi.ts"); +const fakeGh = fileURLToPath( + new URL("../fixtures/stateful-gh.mjs", import.meta.url), +); + +type FakeState = { + latestTag: string; + fallbackLatestTag: string; + releases: Array<{ + id: number; + tag_name: string; + name: string; + prerelease: boolean; + draft: boolean; + }>; +}; + +function initialState(): FakeState { + return { + latestTag: "v0.9.0", + fallbackLatestTag: "v0.9.0", + releases: [ + { + id: 1, + tag_name: "v1.0.0", + name: "Version 1 prerelease", + prerelease: true, + draft: false, + }, + { + id: 2, + tag_name: "v0.9.0", + name: "Version 0.9", + prerelease: false, + draft: false, + }, + ], + }; +} + +describe("CLI release and API state round-trips", () => { + let dir: string; + let stateFile: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gh-axi-stateful-gh-")); + stateFile = join(dir, "state.json"); + writeFileSync(stateFile, JSON.stringify(initialState()), "utf8"); + const fakeBin = join(dir, "gh"); + copyFileSync(fakeGh, fakeBin); + chmodSync(fakeBin, 0o755); + env = { + ...process.env, + GH_AXI_FAKE_STATE: stateFile, + PATH: `${dir}${delimiter}${process.env.PATH ?? ""}`, + }; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function runCli(...args: string[]): string { + const result = spawnSync( + process.execPath, + ["--import", "tsx", cli, ...args, "-R", "octo/repo"], + { cwd: repoRoot, encoding: "utf8", env }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + return result.stdout; + } + + function readRelease(tag = "v1.0.0"): string { + return runCli("api", `/repos/octo/repo/releases/tags/${tag}`); + } + + function readLatest(): string { + return runCli("api", "/repos/octo/repo/releases/latest"); + } + + it("promotes a prerelease and reads it back as repository latest", () => { + runCli("release", "edit", "v1.0.0", "--prerelease=false", "--latest"); + + expect(readRelease()).toContain("prerelease: false"); + expect(readLatest()).toContain("tag_name: v1.0.0"); + }); + + it("demotes latest and reads back the persisted latest release", () => { + runCli("release", "edit", "v1.0.0", "--prerelease=false", "--latest"); + runCli("release", "edit", "v1.0.0", "--latest=false"); + + expect(readRelease()).toContain("prerelease: false"); + expect(readLatest()).toContain("tag_name: v0.9.0"); + }); + + it("sets prerelease true and reads back the persisted release", () => { + runCli("release", "edit", "v1.0.0", "--prerelease=false"); + runCli("release", "edit", "v1.0.0", "--prerelease"); + + expect(readRelease()).toContain("prerelease: true"); + }); + + it("persists an API PATCH for a subsequent API GET", () => { + runCli( + "api", + "PATCH", + "/repos/octo/repo/releases/1", + "--field", + "name=Version 1 stable", + "--field", + "prerelease=false", + ); + + const release = runCli("api", "/repos/octo/repo/releases/1"); + expect(release).toContain("name: Version 1 stable"); + expect(release).toContain("prerelease: false"); + }); +}); From 04bd8c095b7dcb865f227df326767f4c8bef0f40 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 30 Aug 2026 01:35:37 -0700 Subject: [PATCH 4/4] no-mistakes(document): Consolidate release flag guidance and formatting --- AGENTS.md | 4 -- src/commands/release.ts | 73 +++++++++++++++++++++++++++++------ test/fixtures/stateful-gh.mjs | 12 ++++-- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75d1064..1395f46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,10 +64,6 @@ Instead, `resolveOwner()` defaults `--owner` to the current repo's owner (`ctx?. Since Projects v2 items carry per-project custom fields (Status, Priority, ...) with no fixed schema, `item-list`/`field-list` render through bespoke functions (`renderProjectItems`/`renderProjectFields`) that flatten any unknown scalar top-level key into its own column, rather than a fixed `FieldDef` schema. Requires the `project` (or `read:project`) OAuth scope on the `gh` token; `src/errors.ts` matches gh's literal `"authentication token is missing required scopes [...]"` stderr (verified against a live token missing the scope) and maps it to `FORBIDDEN` with a `gh auth refresh -s ` suggestion — this pattern is generic, not project-specific, so it also covers other gh features gated by OAuth scopes. -## Release edit boolean-with-value flags (`src/commands/release.ts`) - -`gh release edit` accepts `--flag=false` to unset a boolean (`--prerelease=false` promotes a prerelease; `--latest=false` demotes repo-latest; `--draft=false` publishes a draft). `takeBoolFlag` / `appendBoolFlag` only match the bare `--flag` and treat it as always-true, so the `=false` form is dropped and the edit silently no-ops (prints the tag, changes nothing). Use `appendOptionalValueBoolFlag` for `--prerelease`, `--draft`, and `--latest` on edit. `RELEASE_FLAGS.edit` must list `--latest` or `rejectUnknownFlags` rejects the promote path before the flags can be forwarded. - ## Repeatable flags (`src/args.ts`) `gh` accepts `--label`, `--assignee`, `--reviewer`, `--project`, and the `--add-*`/`--remove-*` variants once per value, so gh-axi must collect _every_ occurrence. diff --git a/src/commands/release.ts b/src/commands/release.ts index 95caa6b..7cd0ad8 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -2,7 +2,13 @@ import { encode } from "@toon-format/toon"; import type { RepoContext } from "../context.js"; import { ghJson, ghExec } from "../gh.js"; import { AxiError } from "../errors.js"; -import { getFlag, hasFlag, takeBoolFlag, takeFlag, rejectUnknownFlags } from "../args.js"; +import { + getFlag, + hasFlag, + takeBoolFlag, + takeFlag, + rejectUnknownFlags, +} from "../args.js"; import { takeBody, truncateBody } from "../body.js"; import { field, @@ -23,15 +29,38 @@ const RELEASE_FLAGS: Record = { list: ["--limit", "--exclude-drafts", "--exclude-pre-releases"], view: ["--full"], create: [ - "--body", "--body-file", "--title", "-t", "--notes", "-n", - "--notes-file", "-F", "--target", "--discussion-category", - "--notes-start-tag", "--draft", "-d", "--prerelease", "-p", - "--generate-notes", "--verify-tag", "--notes-from-tag", - "--fail-on-no-commits", "--latest", + "--body", + "--body-file", + "--title", + "-t", + "--notes", + "-n", + "--notes-file", + "-F", + "--target", + "--discussion-category", + "--notes-start-tag", + "--draft", + "-d", + "--prerelease", + "-p", + "--generate-notes", + "--verify-tag", + "--notes-from-tag", + "--fail-on-no-commits", + "--latest", ], edit: [ - "--body", "--body-file", "--title", "--notes", "-n", "--notes-file", - "-F", "--draft", "--prerelease", "--latest", + "--body", + "--body-file", + "--title", + "--notes", + "-n", + "--notes-file", + "-F", + "--draft", + "--prerelease", + "--latest", ], delete: [], download: ["--pattern", "--dir"], @@ -447,19 +476,39 @@ export async function releaseCommand( rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.view, "release", "view"); return viewRelease(args, ctx); case "create": - rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.create, "release", "create"); + rejectUnknownFlags( + args.slice(1), + RELEASE_FLAGS.create, + "release", + "create", + ); return createRelease(args, ctx); case "edit": rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.edit, "release", "edit"); return editRelease(args, ctx); case "delete": - rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.delete, "release", "delete"); + rejectUnknownFlags( + args.slice(1), + RELEASE_FLAGS.delete, + "release", + "delete", + ); return deleteRelease(args, ctx); case "download": - rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.download, "release", "download"); + rejectUnknownFlags( + args.slice(1), + RELEASE_FLAGS.download, + "release", + "download", + ); return downloadRelease(args, ctx); case "upload": - rejectUnknownFlags(args.slice(1), RELEASE_FLAGS.upload, "release", "upload"); + rejectUnknownFlags( + args.slice(1), + RELEASE_FLAGS.upload, + "release", + "upload", + ); return uploadRelease(args, ctx); default: return renderError(`Unknown subcommand: ${sub}`, "VALIDATION_ERROR", [ diff --git a/test/fixtures/stateful-gh.mjs b/test/fixtures/stateful-gh.mjs index 3811ce9..7133b5a 100644 --- a/test/fixtures/stateful-gh.mjs +++ b/test/fixtures/stateful-gh.mjs @@ -43,10 +43,14 @@ function save() { function releaseByPath(path) { const tagPrefix = "/repos/octo/repo/releases/tags/"; if (path.startsWith(tagPrefix)) { - return state.releases.find((release) => release.tag_name === path.slice(tagPrefix.length)); + return state.releases.find( + (release) => release.tag_name === path.slice(tagPrefix.length), + ); } if (path === "/repos/octo/repo/releases/latest") { - return state.releases.find((release) => release.tag_name === state.latestTag); + return state.releases.find( + (release) => release.tag_name === state.latestTag, + ); } const idMatch = path.match(/^\/repos\/octo\/repo\/releases\/(\d+)$/); if (idMatch) { @@ -56,7 +60,9 @@ function releaseByPath(path) { } if (args[0] === "release" && args[1] === "edit") { - const release = state.releases.find((candidate) => candidate.tag_name === args[2]); + const release = state.releases.find( + (candidate) => candidate.tag_name === args[2], + ); if (!release) process.exit(1); for (const key of ["prerelease", "draft"]) {