From 6c15e6082f573f69f73b8faed8f5ea0686c37d9e Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 13 Jul 2026 19:41:04 -0400 Subject: [PATCH] feat: add issue blocks and blocked-by (task 0007) --- .claude/tasks/0007-issue-blocks-blocked-by.md | 24 +- src/commands/issue.ts | 342 +++++++++++++++++- src/flags.ts | 29 +- test/issue-blocks.test.ts | 211 +++++++++++ 4 files changed, 590 insertions(+), 16 deletions(-) create mode 100644 test/issue-blocks.test.ts diff --git a/.claude/tasks/0007-issue-blocks-blocked-by.md b/.claude/tasks/0007-issue-blocks-blocked-by.md index 58ae6d2..584990d 100644 --- a/.claude/tasks/0007-issue-blocks-blocked-by.md +++ b/.claude/tasks/0007-issue-blocks-blocked-by.md @@ -13,8 +13,22 @@ No gh-axi equivalent exists — the interface shape follows this spec alone. ## Acceptance criteria -- [ ] `issue blocks list ` and `issue blocked-by list ` render their respective output blocks with count lines and explicit empty states -- [ ] `issue blocks add ` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add ` 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 -- [ ] 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] `issue blocks list ` and `issue blocked-by list ` render their respective output blocks with count lines and explicit empty states +- [x] `issue blocks add ` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add ` outputs `blocked_by: { issue: n, blocked_by: blocker }` +- [x] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully +- [x] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message +- [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 `: { issue, , 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. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 3553ae8..6997d82 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -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 { requireBodySource, resolveBodySource } from "../body-source.js"; import { createClient, type GiteaClient } from "../client.js"; @@ -19,6 +19,7 @@ import { flagValue, parseEnumFlag, parseFlags, + parseIssueNumber, parsePositionalNumber, parsePositiveInt, splitFlag, @@ -41,10 +42,56 @@ commands: pin Pin an issue to the repository unpin Unpin an issue 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 --help\` for the flags of a command. `; +export const ISSUE_BLOCKS_HELP = `usage: gitea-axi issue blocks [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 List the issues blocked by issue + add Make issue block issue + remove 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 Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const ISSUE_BLOCKED_BY_HELP = `usage: gitea-axi issue blocked-by [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 List the issues that block issue + add Make issue depend on issue + remove 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 Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + export const ISSUE_EDIT_HELP = `usage: gitea-axi issue edit [flags] 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 { }); } +/** + * 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; + add: ( + api: GiteaClient, + context: RepoContext, + index: number, + meta: IssueMeta, + ) => Promise; + remove: ( + api: GiteaClient, + context: RepoContext, + index: number, + meta: IssueMeta, + ) => Promise; +} + +// The identifying essentials of a related issue — enough to recognise it without +// the noise of a full issue listing. +const RELATIONSHIP_FIELDS: FieldDef[] = [ + 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 ` ` 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} <${targetNoun}>\``, + ]); + } + if (positionals.length === 1) { + throw axiError(`${command} requires a ${targetNoun} issue number`, "VALIDATION_ERROR", [ + `Run \`gitea-axi ${command} <${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 { + try { + const result = await fetchAllPages((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 { + 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 { + 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 { + 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 { + 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) { return async (args: string[]): Promise => { const [subcommand, ...rest] = args; @@ -1019,6 +1353,12 @@ export function issueCommand(deps: CliDeps) { if (subcommand === "comment") { 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", [ "Run `gitea-axi issue --help` to see available issue commands", ]); diff --git a/src/flags.ts b/src/flags.ts index 471e168..c1e4de2 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -129,6 +129,24 @@ function withArticle(noun: string): string { 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 ` ` invocation. * `noun` names what the number identifies ("issue", "pull request") and appears @@ -150,16 +168,7 @@ export function parsePositionalNumber( if (positionals.length > 1) { throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion); } - const raw = positionals[0]!; - 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; + return parseIssueNumber(positionals[0]!, noun, helpSuggestion); } export function parseFlags( diff --git a/test/issue-blocks.test.ts b/test/issue-blocks.test.ts new file mode 100644 index 0000000..4d5fbec --- /dev/null +++ b/test/issue-blocks.test.ts @@ -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 { + 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); + }); + }); +}