feat: add issue blocks and blocked-by (task 0007) #10

Merged
alexion merged 1 commits from task-0007-issue-blocks-blocked-by into main 2026-07-13 19:44:17 -04:00
4 changed files with 590 additions and 16 deletions

View File

@@ -13,8 +13,22 @@ No gh-axi equivalent exists — the interface shape follows this spec alone.
## Acceptance criteria ## Acceptance criteria
- [ ] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states - [x] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states
- [ ] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }` - [x] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }`
- [ ] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully - [x] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully
- [ ] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message - [x] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message
- [ ] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups - [x] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups
## Implementation Notes
Both groups are one config-parameterised implementation (`DependencyGroup`): `blocks` over `/issues/{index}/blocks`, `blocked-by` over `/issues/{index}/dependencies`, differing only in endpoint calls and the spec-fixed output names (`blocked_issues`/`blocking_issues`, `blocks`/`blocked_by`).
Decisions made mid-implementation, where the spec was silent:
- **`remove` output.** The spec fixes the `add` output shape but not `remove`'s. A successful deletion reports `<noun>: { issue, <target>, removed: true }`; a no-op removal (relationship already absent) reports `already: true` instead, mirroring `add`'s no-op and honouring the action/entity-block convention (a no-op reports the already-reached state rather than claiming an action it did not perform). Both `add` and `remove` are fetch-first against the fully paginated current set, so a nonexistent issue surfaces as `ISSUE_NOT_FOUND` before any mutation.
- **`list` row fields.** Rendered as `number`, `title`, `state` — the identifying essentials; the spec did not fix a row shape.
Review follow-ups addressed in this change:
- Extracted `parseIssueNumber` into `flags.ts` so the two-positional dependency parser and the existing `parsePositionalNumber` share one positive-integer rule and message (was duplicated).
- Added test coverage for the `ISSUE_NOT_FOUND` path (issue itself absent), which the code comments claim but nothing exercised.

View File

@@ -1,4 +1,4 @@
import type { Comment, CreateIssueOption, EditIssueOption, Issue } from "gitea-js"; import type { Comment, CreateIssueOption, EditIssueOption, Issue, IssueMeta } from "gitea-js";
import { BODY_TRUNCATE_LIMIT, truncateBody } from "../body.js"; import { BODY_TRUNCATE_LIMIT, truncateBody } from "../body.js";
import { requireBodySource, resolveBodySource } from "../body-source.js"; import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient, type GiteaClient } from "../client.js"; import { createClient, type GiteaClient } from "../client.js";
@@ -19,6 +19,7 @@ import {
flagValue, flagValue,
parseEnumFlag, parseEnumFlag,
parseFlags, parseFlags,
parseIssueNumber,
parsePositionalNumber, parsePositionalNumber,
parsePositiveInt, parsePositiveInt,
splitFlag, splitFlag,
@@ -41,10 +42,56 @@ commands:
pin Pin an issue to the repository pin Pin an issue to the repository
unpin Unpin an issue unpin Unpin an issue
comment Post a comment on an issue or pull request comment Post a comment on an issue or pull request
blocks Manage the issues this issue blocks (Gitea-specific)
blocked-by Manage the issues that block this issue (Gitea-specific)
Run \`gitea-axi issue <command> --help\` for the flags of a command. Run \`gitea-axi issue <command> --help\` for the flags of a command.
`; `;
export const ISSUE_BLOCKS_HELP = `usage: gitea-axi issue blocks <list|add|remove> <number> [target]
Manage the issues that an issue blocks — downstream dependents that cannot
proceed until it is resolved (Gitea-specific; no gh-axi equivalent).
commands:
list <n> List the issues blocked by issue <n>
add <n> <target> Make issue <n> block issue <target>
remove <n> <target> Remove the blocking relationship
Adding a relationship that already exists is a no-op that reports \`already: true\`;
removing one that does not exist succeeds silently. Self-reference and cycle
rejections from Gitea surface as VALIDATION_ERROR.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_BLOCKED_BY_HELP = `usage: gitea-axi issue blocked-by <list|add|remove> <number> [blocker]
Manage the issues that block an issue — upstream blockers that must be resolved
before it can proceed (Gitea-specific; no gh-axi equivalent).
commands:
list <n> List the issues that block issue <n>
add <n> <blocker> Make issue <n> depend on issue <blocker>
remove <n> <blocker> Remove the dependency
Adding a relationship that already exists is a no-op that reports \`already: true\`;
removing one that does not exist succeeds silently. Self-reference and cycle
rejections from Gitea surface as VALIDATION_ERROR.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_EDIT_HELP = `usage: gitea-axi issue edit <number> [flags] export const ISSUE_EDIT_HELP = `usage: gitea-axi issue edit <number> [flags]
Edit an issue in the current repository. At least one change is required. Edit an issue in the current repository. At least one change is required.
@@ -983,6 +1030,293 @@ async function issueUnpin(deps: CliDeps, args: string[]): Promise<string> {
}); });
} }
/**
* A response page from a relationship-listing endpoint, in the shape
* {@link fetchAllPages} consumes. Both `/blocks` and `/dependencies` return
* `Issue[]` with the standard pagination headers.
*/
interface RelationshipPage {
data?: Issue[];
headers: Headers;
}
/**
* One of the two Gitea-specific dependency directions. `blocks` and `blocked-by`
* are the same three operations (`list`/`add`/`remove`) over two different
* endpoints, so a single implementation is parameterised by this config: the
* endpoint calls, the output block/field names the spec fixes, and the noun the
* second positional carries in errors and help.
*/
interface DependencyGroup {
/** Subcommand as typed: "blocks" | "blocked-by". */
command: string;
/** What the second positional identifies: "target" | "blocker". */
targetNoun: string;
/** Output block name for `list`. */
listNoun: string;
/** Output entity name for `add`/`remove`. */
mutationNoun: string;
/** Key naming the related issue in `add`/`remove` output. */
targetKey: string;
/** Help text for the group. */
help: string;
listPage: (
api: GiteaClient,
context: RepoContext,
index: number,
page: number,
limit: number,
) => Promise<RelationshipPage>;
add: (
api: GiteaClient,
context: RepoContext,
index: number,
meta: IssueMeta,
) => Promise<unknown>;
remove: (
api: GiteaClient,
context: RepoContext,
index: number,
meta: IssueMeta,
) => Promise<unknown>;
}
// The identifying essentials of a related issue — enough to recognise it without
// the noise of a full issue listing.
const RELATIONSHIP_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
];
const BLOCKS_GROUP: DependencyGroup = {
command: "blocks",
targetNoun: "target",
listNoun: "blocked_issues",
mutationNoun: "blocks",
targetKey: "blocks",
help: ISSUE_BLOCKS_HELP,
listPage: (api, context, index, page, limit) =>
api.repos.issueListBlocks(context.owner, context.name, String(index), { page, limit }),
add: (api, context, index, meta) =>
api.repos.issueCreateIssueBlocking(context.owner, context.name, String(index), meta),
remove: (api, context, index, meta) =>
api.repos.issueRemoveIssueBlocking(context.owner, context.name, String(index), meta),
};
const BLOCKED_BY_GROUP: DependencyGroup = {
command: "blocked-by",
targetNoun: "blocker",
listNoun: "blocking_issues",
mutationNoun: "blocked_by",
targetKey: "blocked_by",
help: ISSUE_BLOCKED_BY_HELP,
listPage: (api, context, index, page, limit) =>
api.repos.issueListIssueDependencies(context.owner, context.name, String(index), {
page,
limit,
}),
add: (api, context, index, meta) =>
api.repos.issueCreateIssueDependencies(context.owner, context.name, String(index), meta),
remove: (api, context, index, meta) =>
api.repos.issueRemoveIssueDependencies(context.owner, context.name, String(index), meta),
};
/**
* Parse the `<number> <target>` positionals shared by `add` and `remove`. Both
* arguments are required issue numbers; a missing or extra one is a
* VALIDATION_ERROR naming what is expected.
*/
function parseIssueAndTarget(
positionals: string[],
command: string,
targetNoun: string,
): { issue: number; target: number } {
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
if (positionals.length === 0) {
throw axiError(`${command} requires an issue number`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} <number> <${targetNoun}>\``,
]);
}
if (positionals.length === 1) {
throw axiError(`${command} requires a ${targetNoun} issue number`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} <number> <${targetNoun}>\``,
]);
}
if (positionals.length > 2) {
throw axiError(`Unexpected argument: ${positionals[2]}`, "VALIDATION_ERROR", helpSuggestion);
}
return {
issue: parseIssueNumber(positionals[0]!, "issue", helpSuggestion),
target: parseIssueNumber(positionals[1]!, targetNoun, helpSuggestion),
};
}
/**
* Every related issue currently on this side of the relationship. Fully
* paginated so the fetch-first idempotency check (add) and the
* remove-if-present check (remove) see the complete set, and so `list`'s count
* describes the whole set rather than a first page (see ADR 0005).
*/
async function fetchRelationships(
api: GiteaClient,
context: RepoContext,
group: DependencyGroup,
index: number,
): Promise<Issue[]> {
try {
const result = await fetchAllPages<Issue>((page, limit) =>
group.listPage(api, context, index, page, limit),
);
return result.items;
} catch (error) {
throw classifyHttpError(error);
}
}
async function listRelationships(
deps: CliDeps,
args: string[],
group: DependencyGroup,
): Promise<string> {
const command = `issue ${group.command} list`;
const { positionals } = parseFlags(args, {}, command);
const number = parsePositionalNumber(positionals, command, "issue");
const context = await resolveRepoContext(deps);
const api = createClient(context);
const issues = await fetchRelationships(api, context, group, number);
const now = new Date();
const rows = issues.map((issue) => extractRow(issue, RELATIONSHIP_FIELDS, { now }));
return renderList({
noun: group.listNoun,
rows,
// The whole set is in hand, so its own size is the total and nothing is
// withheld by a limit.
countLine: formatCountLine(rows.length, rows.length, false),
help: [
suggestCommand(
context,
`issue ${group.command} add ${number} <${group.targetNoun}>`,
`to add a ${group.command} relationship`,
),
],
});
}
async function addRelationship(
deps: CliDeps,
args: string[],
group: DependencyGroup,
): Promise<string> {
const command = `issue ${group.command} add`;
const { positionals } = parseFlags(args, {}, command);
const { issue, target } = parseIssueAndTarget(positionals, command, group.targetNoun);
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Fetch-first idempotency check: an existing relationship is reported as a
// no-op rather than re-POSTed. A nonexistent issue surfaces here as its own
// ISSUE_NOT_FOUND, before any mutation is attempted.
const existing = await fetchRelationships(api, context, group, issue);
const help = [
suggestCommand(context, `issue ${group.command} list ${issue}`, "to see the current relationships"),
];
if (existing.some((related) => related.number === target)) {
return renderDetail({
noun: group.mutationNoun,
item: { issue, [group.targetKey]: target, already: true },
help,
});
}
// The body names the OTHER issue; the issue in the path is `issue`. Self-
// reference and cycle rejections come back from Gitea as 422, which
// classifyHttpError maps to VALIDATION_ERROR with the server's own message.
const meta: IssueMeta = { owner: context.owner, repo: context.name, index: target };
try {
await group.add(api, context, issue, meta);
} catch (error) {
throw classifyHttpError(error);
}
return renderDetail({
noun: group.mutationNoun,
item: { issue, [group.targetKey]: target },
help,
});
}
async function removeRelationship(
deps: CliDeps,
args: string[],
group: DependencyGroup,
): Promise<string> {
const command = `issue ${group.command} remove`;
const { positionals } = parseFlags(args, {}, command);
const { issue, target } = parseIssueAndTarget(positionals, command, group.targetNoun);
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Fetch-first: a relationship that is not present is an idempotent no-op —
// the caller's intent (relationship absent) already holds, so no DELETE is
// sent and the output marks it `already: true` (per the action/entity-block
// convention: a no-op reports the already-reached state rather than claiming
// an action it did not perform, mirroring `add`). A nonexistent issue still
// surfaces here as ISSUE_NOT_FOUND.
const existing = await fetchRelationships(api, context, group, issue);
const help = [
suggestCommand(context, `issue ${group.command} list ${issue}`, "to see the current relationships"),
];
if (!existing.some((related) => related.number === target)) {
return renderDetail({
noun: group.mutationNoun,
item: { issue, [group.targetKey]: target, already: true },
help,
});
}
const meta: IssueMeta = { owner: context.owner, repo: context.name, index: target };
try {
await group.remove(api, context, issue, meta);
} catch (error) {
throw classifyHttpError(error);
}
return renderDetail({
noun: group.mutationNoun,
item: { issue, [group.targetKey]: target, removed: true },
help,
});
}
/** Dispatch the `list`/`add`/`remove` sub-operation of a dependency group. */
async function issueDependencyGroup(
deps: CliDeps,
args: string[],
group: DependencyGroup,
): Promise<string> {
const [operation, ...rest] = args;
if (!operation || operation === "--help") {
return group.help;
}
if (operation === "list") {
return listRelationships(deps, rest, group);
}
if (operation === "add") {
return addRelationship(deps, rest, group);
}
if (operation === "remove") {
return removeRelationship(deps, rest, group);
}
throw axiError(`Unknown issue ${group.command} command: ${operation}`, "VALIDATION_ERROR", [
`Run \`gitea-axi issue ${group.command} --help\` to see available operations`,
]);
}
export function issueCommand(deps: CliDeps) { export function issueCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => { return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args; const [subcommand, ...rest] = args;
@@ -1019,6 +1353,12 @@ export function issueCommand(deps: CliDeps) {
if (subcommand === "comment") { if (subcommand === "comment") {
return issueComment(deps, rest); return issueComment(deps, rest);
} }
if (subcommand === "blocks") {
return issueDependencyGroup(deps, rest, BLOCKS_GROUP);
}
if (subcommand === "blocked-by") {
return issueDependencyGroup(deps, rest, BLOCKED_BY_GROUP);
}
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [ throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
"Run `gitea-axi issue --help` to see available issue commands", "Run `gitea-axi issue --help` to see available issue commands",
]); ]);

View File

@@ -129,6 +129,24 @@ function withArticle(noun: string): string {
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`; return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
} }
/**
* Validate a single raw argument as a positive issue number. `noun` names what
* the number identifies ("issue", "target") in the error; `suggestions` carries
* the caller's help line. Shared by the single-positional parser and the
* two-positional dependency parser so the rule and its message live in one place.
*/
export function parseIssueNumber(raw: string, noun: string, suggestions: string[]): number {
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
suggestions,
);
}
return number;
}
/** /**
* Parse the single positional number of a `<command> <number>` invocation. * Parse the single positional number of a `<command> <number>` invocation.
* `noun` names what the number identifies ("issue", "pull request") and appears * `noun` names what the number identifies ("issue", "pull request") and appears
@@ -150,16 +168,7 @@ export function parsePositionalNumber(
if (positionals.length > 1) { if (positionals.length > 1) {
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion); throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
} }
const raw = positionals[0]!; return parseIssueNumber(positionals[0]!, noun, helpSuggestion);
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
helpSuggestion,
);
}
return number;
} }
export function parseFlags( export function parseFlags(

211
test/issue-blocks.test.ts Normal file
View File

@@ -0,0 +1,211 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { postedBody, runCliTest, testModeEnv } from "./harness.js";
const ISSUE = 7;
const TARGET = 9;
const BLOCKS_PATH = `/api/v1/repos/testowner/testrepo/issues/${ISSUE}/blocks`;
const DEPENDENCIES_PATH = `/api/v1/repos/testowner/testrepo/issues/${ISSUE}/dependencies`;
let server: FixtureServer;
afterEach(async () => {
await server.close();
});
/** A minimal Issue as the relationship endpoints return one. */
function relatedIssue(number: number): Record<string, unknown> {
return { number, title: `Issue ${number}`, state: "open" };
}
/**
* Each dependency group is the same command shape over a different endpoint, so
* the behaviour is asserted once against a parameterised description. `path` is
* the endpoint the group's list/add/remove all share; `mutationNoun`/`targetKey`
* are the output field names the spec fixes for the group.
*/
interface GroupCase {
command: "blocks" | "blocked-by";
path: string;
listNoun: string;
mutationNoun: string;
targetKey: string;
}
const GROUPS: GroupCase[] = [
{
command: "blocks",
path: BLOCKS_PATH,
listNoun: "blocked_issues",
mutationNoun: "blocks",
targetKey: "blocks",
},
{
command: "blocked-by",
path: DEPENDENCIES_PATH,
listNoun: "blocking_issues",
mutationNoun: "blocked_by",
targetKey: "blocked_by",
},
];
for (const group of GROUPS) {
describe(`issue ${group.command}`, () => {
it(`list renders the ${group.listNoun} block with a count line`, async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "list", String(ISSUE)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// TOON renders list rows in compact tabular form (`9,Issue 9,open`), so
// the related issue shows up by its title, not a `number:` field line.
expect(stdout).toContain(`${group.listNoun}[1]`);
expect(stdout).toContain(`Issue ${TARGET}`);
expect(stdout).toContain("count: 1 of 1 total");
});
it("list renders an explicit empty state when there are none", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "list", String(ISSUE)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain(`${group.listNoun}[0]: (none)`);
expect(stdout).toContain("count: 0");
});
it(`add reports ${group.mutationNoun} and posts the target as IssueMeta`, async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [] },
{ method: "POST", path: group.path, body: relatedIssue(TARGET) },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "add", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain(`${group.mutationNoun}:`);
expect(stdout).toContain(`issue: ${ISSUE}`);
expect(stdout).toContain(`${group.targetKey}: ${TARGET}`);
expect(stdout).not.toContain("already");
expect(postedBody(server, group.path)).toEqual({
owner: "testowner",
repo: "testrepo",
index: TARGET,
});
});
it("add of an existing relationship returns already: true without posting", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "add", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("already: true");
expect(server.requests.some((request) => request.method === "POST")).toBe(false);
});
it("remove of an existing relationship deletes it", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [relatedIssue(TARGET)] },
{ method: "DELETE", path: group.path, body: relatedIssue(TARGET) },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "remove", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("removed: true");
expect(server.requests.some((request) => request.method === "DELETE")).toBe(true);
});
it("remove of a nonexistent relationship is an idempotent no-op without deleting", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "remove", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// A no-op remove reports the already-reached state, not a deletion it did
// not perform (mirrors add's `already: true`).
expect(stdout).toContain("already: true");
expect(stdout).not.toContain("removed: true");
expect(server.requests.some((request) => request.method === "DELETE")).toBe(false);
});
it("reports ISSUE_NOT_FOUND when the issue itself does not exist", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, status: 404, body: { message: "issue does not exist" } },
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "add", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(1);
expect(stdout).toContain("code: ISSUE_NOT_FOUND");
expect(server.requests.some((request) => request.method === "POST")).toBe(false);
});
it("surfaces a cycle rejection from Gitea as VALIDATION_ERROR", async () => {
server = await startFixtureServer([
{ method: "GET", path: group.path, body: [] },
{
method: "POST",
path: group.path,
status: 422,
body: { message: "circular dependencies are not allowed" },
},
]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "add", String(ISSUE), String(TARGET)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("circular dependencies are not allowed");
});
it("rejects a missing target before calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "add", String(ISSUE)],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(server.requests).toHaveLength(0);
});
it("prints help with --help without calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(
["issue", group.command, "--help"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain(`usage: gitea-axi issue ${group.command}`);
expect(server.requests).toHaveLength(0);
});
});
}