Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 101 additions & 37 deletions src/commands/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,15 +29,38 @@ const RELEASE_FLAGS: Record<string, readonly string[]> = {
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",
"--body",
"--body-file",
"--title",
"--notes",
"-n",
"--notes-file",
"-F",
"--draft",
"--prerelease",
"--latest",
],
delete: [],
download: ["--pattern", "--dir"],
Expand All @@ -48,13 +77,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], <files...>
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"),
Expand Down Expand Up @@ -115,18 +145,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[] {
Expand Down Expand Up @@ -278,11 +320,19 @@ async function editRelease(args: string[], ctx?: RepoContext): Promise<string> {
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)
Expand All @@ -291,13 +341,7 @@ async function editRelease(args: string[], ctx?: RepoContext): Promise<string> {
"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({
Expand Down Expand Up @@ -432,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", [
Expand Down
15 changes: 15 additions & 0 deletions test/commands/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,21 @@ describe("releaseCommand", () => {
});
});

describe("edit boolean-with-value validation", () => {
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", () => {
beforeEach(() => {
mockedGhJson.mockImplementation(async (args) => {
Expand Down
114 changes: 114 additions & 0 deletions test/fixtures/stateful-gh.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/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);
Loading
Loading