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"; import { COMMENT_FLAGS, commentItem, commentRows } from "../comment.js"; import { resolveRepoContext, type RepoContext } from "../context.js"; import type { CliDeps } from "../deps.js"; import { axiError, classifyHttpError, httpStatus } from "../errors.js"; import { extractRow, joined, lowercased, pluck, relativeTimeField, selectExtraFields, type FieldDef, } from "../fields.js"; import { flagValue, parseEnumFlag, parseFlags, parseIssueNumber, parsePositionalNumber, parsePositiveInt, splitFlag, } from "../flags.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { fetchAllPages, readTotalCount } from "../paginate.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; import { suggestCommand } from "../suggestions.js"; export const ISSUE_HELP = `usage: gitea-axi issue [flags] commands: list List issues in the current repository view Show a single issue's details create Create an issue edit Edit an issue's title, body, labels, assignees, or milestone close Close an issue reopen Reopen a closed issue delete Permanently delete an issue 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. flags: --title New title --body New body --body-file Read the new body from a file (mutually exclusive with --body) --add-label Add a label by name (repeatable) --remove-label Remove a label by name (repeatable, case-insensitive) --add-assignee Add an assignee (repeatable) --remove-assignee Remove an assignee (repeatable) --milestone Assign a milestone by name (case-insensitive) --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_CLOSE_HELP = `usage: gitea-axi issue close [flags] Close an issue in the current repository. flags: --comment Post a comment when closing --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_REOPEN_HELP = `usage: gitea-axi issue reopen Reopen a closed issue in the current repository. 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_DELETE_HELP = `usage: gitea-axi issue delete Permanently delete an issue in the current repository. This is a hard delete and requires admin or owner permissions. Deleting a nonexistent issue is an error, not a silent success. 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_PIN_HELP = `usage: gitea-axi issue pin Pin an issue to the top of the repository's issue list. Pinning an already-pinned issue is a no-op. 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_UNPIN_HELP = `usage: gitea-axi issue unpin Unpin an issue from the repository's issue list. Unpinning an issue that is not pinned is a no-op. 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_CREATE_HELP = `usage: gitea-axi issue create --title [flags] Create an issue in the current repository. flags: --title Issue title (required) --body Issue body --body-file Read the issue body from a file (mutually exclusive with --body) --assignee Assign the issue to a user --label Apply a label by name (repeatable, case-insensitive) --milestone Assign a milestone by name (case-insensitive) --fields Append extra fields: labels, assignees, milestone, body --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_COMMENT_HELP = `usage: gitea-axi issue comment [flags] Post a comment on an issue. Pull request numbers are accepted — issues and pull requests share the comment endpoint. flags: --body Comment body (required unless --body-file is given) --body-file Read the comment body from a file (mutually exclusive with --body) --full Echo the posted body in full, without truncating it at 800 chars --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_VIEW_HELP = `usage: gitea-axi issue view [flags] Show a single issue. Pull request numbers are rejected — use \`pr view\` instead. flags: --comments Render every comment in full (bodies truncated at 800 chars) --full Suppress all truncation of the issue body and comment bodies --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_LIST_HELP = `usage: gitea-axi issue list [flags] List issues in the current repository. Pull requests are never included. flags: --state Filter by state (default: open) --label Filter by label name (comma-separated) --assignee Filter by assignee --author Filter by author --milestone Filter by milestone name --sort Sort descending (client-side) --limit Maximum number of issues to return (default: 30) --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url --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 `; const ISSUE_LIST_FIELDS: FieldDef[] = [ pluck("number"), pluck("title"), lowercased("state"), pluck("author", "user.login"), relativeTimeField("created", "created_at"), ]; // Appended to the defaults on request via `--fields`, never replacing them. const ISSUE_LIST_EXTRA_FIELDS: Record> = { body: pluck("body"), closedAt: relativeTimeField("closedAt", "closed_at"), labels: joined("labels", "labels", "name"), milestone: pluck("milestone", "milestone.title"), updatedAt: relativeTimeField("updatedAt", "updated_at"), url: pluck("url", "html_url"), }; const ISSUE_STATES = ["open", "closed", "all"] as const; type IssueState = (typeof ISSUE_STATES)[number]; const ISSUE_SORTS = ["created", "updated", "comments"] as const; type IssueSort = (typeof ISSUE_SORTS)[number]; /** Sort keys, read descending. A missing or unparseable value sorts last. */ const ISSUE_SORT_KEYS: Record number> = { created: (issue) => timestamp(issue.created_at), updated: (issue) => timestamp(issue.updated_at), comments: (issue) => issue.comments ?? 0, }; const DEFAULT_LIMIT = 30; const ISSUE_LIST_HELP_SUGGESTION = [ "Run `gitea-axi issue list --help` to see available flags", ]; function timestamp(iso: string | undefined): number { const value = Date.parse(iso ?? ""); return Number.isNaN(value) ? 0 : value; } function parseState(value: string | true | undefined): IssueState { return parseEnumFlag(value, "--state", ISSUE_STATES, ISSUE_LIST_HELP_SUGGESTION) ?? "open"; } function parseSort(value: string | true | undefined): IssueSort | undefined { return parseEnumFlag(value, "--sort", ISSUE_SORTS, ISSUE_LIST_HELP_SUGGESTION); } /** * Gitea's issue list has no sort parameter, so ordering happens here, over the * fully paginated set (see ADR 0005). `sort` is stable, so equal keys keep the * order the API returned them in. */ function sortIssues(issues: Issue[], sort: IssueSort): Issue[] { const key = ISSUE_SORT_KEYS[sort]; return [...issues].sort((a, b) => key(b) - key(a)); } /** * The filters Gitea's issue list accepts as query params, under its own names. * All four filter server-side; none of them needs the client-side policy. */ function issueListFilters(flags: Record): Record { const filters: Record = {}; const label = flagValue(flags, "--label"); if (label !== undefined) { filters.labels = label; } const assignee = flagValue(flags, "--assignee"); if (assignee !== undefined) { filters.assigned_by = assignee; } const author = flagValue(flags, "--author"); if (author !== undefined) { filters.created_by = author; } const milestone = flagValue(flags, "--milestone"); if (milestone !== undefined) { filters.milestones = milestone; } return filters; } /** * `--search` is refused rather than quietly forwarded to the API's `q` param: * full-text search is `search issues`, and a flag that half-worked here would be * the wrong thing to learn. Checked ahead of `parseFlags` so every form of the * flag — valued, inline, bare — lands on the redirect instead of a generic * unknown-flag or missing-value error. */ function refuseSearchFlag(args: string[]): void { if (!args.some((arg) => splitFlag(arg).name === "--search")) { return; } throw axiError("issue list does not support --search", "VALIDATION_ERROR", [ 'Use `gitea-axi search issues ""` for full-text search', ]); } function parseLimit(value: string | true | undefined): number { if (value === undefined) { return DEFAULT_LIMIT; } return parsePositiveInt(value, "--limit", ISSUE_LIST_HELP_SUGGESTION); } function issueListSuggestions( context: RepoContext, state: IssueState, shown: number, total: number | undefined, ): string[] { const help: string[] = []; if (state !== "all") { help.push(suggestCommand(context, "issue list --state all", "to list issues in any state")); } if (total !== undefined && shown < total) { help.push( suggestCommand(context, "issue list --limit ", `to fetch more of the ${total} issues`), ); } if (help.length === 0) { help.push( suggestCommand(context, "issue list --help", "to see all issue list flags"), ); } return help; } async function issueList(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_LIST_HELP; } refuseSearchFlag(args); const { flags, positionals } = parseFlags( args, { "--state": { takesValue: true }, "--label": { takesValue: true }, "--assignee": { takesValue: true }, "--author": { takesValue: true }, "--milestone": { takesValue: true }, "--sort": { takesValue: true }, "--limit": { takesValue: true }, "--fields": { takesValue: true }, }, "issue list", ); if (positionals.length > 0) { throw axiError( `Unexpected argument: ${positionals[0]}`, "VALIDATION_ERROR", ISSUE_LIST_HELP_SUGGESTION, ); } const state = parseState(flags["--state"]); const sort = parseSort(flags["--sort"]); const limit = parseLimit(flags["--limit"]); const extraFields = selectExtraFields( flagValue(flags, "--fields"), ISSUE_LIST_EXTRA_FIELDS, "issue list", ); const query = { state, type: "issues" as const, ...issueListFilters(flags) }; const context = await resolveRepoContext(deps); const api = createClient(context); let issues: Issue[]; let total: number | undefined; try { if (sort === undefined) { const response = await api.repos.issueListIssues(context.owner, context.name, { ...query, limit, page: 1, }); issues = response.data ?? []; total = readTotalCount(response.headers); } else { // Sorting client-side means holding the whole set first: the top `limit` // by the sort key is only knowable once every page is in (see ADR 0005). const result = await fetchAllPages((page, pageLimit) => api.repos.issueListIssues(context.owner, context.name, { ...query, page, limit: pageLimit, }), ); issues = sortIssues(result.items, sort).slice(0, limit); // Sorting reorders without changing membership, so the API's own total // still describes this result set and the count line keeps reporting it. // Having paginated everything, the set's own size is the fallback when an // instance omits the header — a total is always reported (Principle 4). total = result.total ?? result.items.length; } } catch (error) { throw classifyHttpError(error); } const now = new Date(); const rows = issues.map((issue) => extractRow(issue, [...ISSUE_LIST_FIELDS, ...extraFields], { now }), ); return renderList({ noun: "issues", rows, countLine: formatCountLine(rows.length, total, rows.length >= limit), help: issueListSuggestions(context, state, rows.length, total), }); } // The default detail fields reuse the same declarative extraction as the list // path; only `body` (truncation) and `comment_count` need bespoke handling. const ISSUE_VIEW_FIELDS: FieldDef[] = [ pluck("number"), pluck("title"), lowercased("state"), pluck("author", "user.login"), relativeTimeField("created", "created_at"), ]; interface IssueDetailOptions { host: string; full: boolean; withComments: boolean; now: Date; } function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record { const row = extractRow(issue, ISSUE_VIEW_FIELDS, { now: options.now }); const body = issue.body ?? ""; row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host); if (!options.withComments) { const count = issue.comments ?? 0; row.comment_count = count > 0 ? `${count} — use --comments to see full comments` : 0; } return row; } /** Fetch a single issue, mapping any HTTP failure to an AxiError. */ async function getIssue(api: GiteaClient, context: RepoContext, number: number): Promise { try { const response = await api.repos.issueGetIssue(context.owner, context.name, number); return response.data; } catch (error) { throw classifyHttpError(error); } } function issueViewSuggestions( context: RepoContext, number: number, options: { withComments: boolean; commentCount: number; bodyAbbreviated: boolean }, ): string[] { const help: string[] = []; if (!options.withComments && options.commentCount > 0) { help.push(suggestCommand(context, `issue view ${number} --comments`, "to see full comments")); } if (options.bodyAbbreviated) { help.push(suggestCommand(context, `issue view ${number} --full`, "to see the complete body")); } if (help.length === 0) { help.push(suggestCommand(context, `issue view ${number} --help`, "to see all issue view flags")); } return help; } async function issueView(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_VIEW_HELP; } const { flags, positionals } = parseFlags( args, { "--comments": { takesValue: false }, "--full": { takesValue: false } }, "issue view", ); const number = parsePositionalNumber(positionals, "issue view", "issue"); const full = flags["--full"] === true; const withComments = flags["--comments"] === true; const context = await resolveRepoContext(deps); const api = createClient(context); const issue = await getIssue(api, context, number); if (issue.pull_request) { throw axiError(`issue #${number} is a pull request`, "VALIDATION_ERROR", [ suggestCommand(context, `pr view ${number}`, "to view this pull request"), ]); } const now = new Date(); const item = buildIssueDetail(issue, { host: context.host, full, withComments, now }); const blocks: DetailBlock[] = []; if (withComments) { let comments: Comment[]; try { const response = await api.repos.issueGetComments(context.owner, context.name, number); comments = response.data ?? []; } catch (error) { throw classifyHttpError(error); } blocks.push({ noun: "comments", rows: commentRows(comments, { host: context.host, full, now }) }); } const commentCount = issue.comments ?? 0; // Suggest --full whenever the rendered body differs from the raw one — cleaned // or truncated alike — read straight off the rendered output so the two never // drift from truncateBody's own limit decision. const bodyAbbreviated = item.body !== (issue.body ?? ""); return renderDetail({ noun: "issue", item, blocks, help: issueViewSuggestions(context, number, { withComments, commentCount, bodyAbbreviated }), }); } const ISSUE_CREATE_HELP_SUGGESTION = [ "Run `gitea-axi issue create --help` to see available flags", ]; // Default create output, per the spec: number, title, state, url (= html_url). const ISSUE_CREATE_FIELDS: FieldDef[] = [ pluck("number"), pluck("title"), lowercased("state"), pluck("url", "html_url"), ]; // Appended to the defaults on request via `--fields`, never replacing them. const ISSUE_CREATE_EXTRA_FIELDS: Record> = { labels: joined("labels", "labels", "name"), assignees: joined("assignees", "assignees", "login"), milestone: pluck("milestone", "milestone.title"), body: pluck("body"), }; async function issueCreate(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_CREATE_HELP; } const { flags, lists, positionals } = parseFlags( args, { "--title": { takesValue: true }, "--body": { takesValue: true }, "--body-file": { takesValue: true }, "--assignee": { takesValue: true }, "--label": { takesValue: true, repeatable: true }, "--milestone": { takesValue: true }, "--fields": { takesValue: true }, }, "issue create", ); if (positionals.length > 0) { throw axiError( `Unexpected argument: ${positionals[0]}`, "VALIDATION_ERROR", ISSUE_CREATE_HELP_SUGGESTION, ); } // Everything that can fail on the caller's own input is settled before any // request goes out, so a rejected invocation never half-creates an issue. const title = flagValue(flags, "--title"); if (title === undefined) { throw axiError("issue create requires --title ", "VALIDATION_ERROR", [ "Run `gitea-axi issue create --title `", ]); } const body = resolveBodySource(deps, flags, "issue create"); const extraFields = selectExtraFields( flagValue(flags, "--fields"), ISSUE_CREATE_EXTRA_FIELDS, "issue create", ); const assignee = flagValue(flags, "--assignee"); const milestoneName = flagValue(flags, "--milestone"); const labelNames = lists["--label"] ?? []; const context = await resolveRepoContext(deps); const api = createClient(context); const payload: CreateIssueOption = { title }; if (body !== undefined) { payload.body = body; } if (assignee !== undefined) { payload.assignees = [assignee]; } const labelIds = await resolveLabelIds(api, context, labelNames); if (labelIds.length > 0) { payload.labels = labelIds; } if (milestoneName !== undefined) { payload.milestone = await resolveMilestoneId(api, context, milestoneName); } let issue: Issue; try { const response = await api.repos.issueCreateIssue(context.owner, context.name, payload); issue = response.data; } catch (error) { throw classifyHttpError(error); } const item = extractRow(issue, [...ISSUE_CREATE_FIELDS, ...extraFields], { now: new Date() }); return renderDetail({ noun: "issue", item, help: [ suggestCommand(context, `issue view ${issue.number}`, "to see the issue in full"), ], }); } async function issueComment(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_COMMENT_HELP; } const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "issue comment"); const number = parsePositionalNumber(positionals, "issue comment", "issue"); const body = requireBodySource(deps, flags, "issue comment"); const full = flags["--full"] === true; // No type guard here: issues and pull requests genuinely share this endpoint, // so a PR number is a valid target and is never fetched to be checked. const context = await resolveRepoContext(deps); const api = createClient(context); let comment: Comment; try { const response = await api.repos.issueCreateComment(context.owner, context.name, number, { body, }); comment = response.data; } catch (error) { throw classifyHttpError(error); } const item = commentItem(comment, { number, full, host: context.host, now: new Date() }); // Gitea marks a comment posted on a pull request with `pull_request_url`. Only // an issue target gets the `issue view` suggestion: `issue view` type-guards // pull requests, so suggesting it after commenting on one would hand back a // command that is guaranteed to fail. const help = comment.pull_request_url ? [suggestCommand(context, "issue comment --help", "to see all issue comment flags")] : [suggestCommand(context, `issue view ${number} --comments`, "to see the full thread")]; return renderDetail({ noun: "comment", item, help }); } /** * The assignee list to PATCH: the issue's current assignees with the requested * additions appended and removals dropped (fetch-then-patch, ADR 0007). Matching * is case-insensitive and the result is de-duplicated, order-preserving, so a * login that is already assigned never lands in the list twice. */ async function resolveAssignees( api: GiteaClient, context: RepoContext, number: number, add: string[], remove: string[], ): Promise { const issue = await getIssue(api, context, number); const removeSet = new Set(remove.map((login) => login.toLowerCase())); const seen = new Set(); const result: string[] = []; const push = (login: string): void => { const key = login.toLowerCase(); if (removeSet.has(key) || seen.has(key)) { return; } seen.add(key); result.push(login); }; for (const assignee of issue.assignees ?? []) { if (assignee.login) { push(assignee.login); } } for (const login of add) { push(login); } return result; } async function issueEdit(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_EDIT_HELP; } const { flags, lists, positionals } = parseFlags( args, { "--title": { takesValue: true }, "--body": { takesValue: true }, "--body-file": { takesValue: true }, "--add-label": { takesValue: true, repeatable: true }, "--remove-label": { takesValue: true, repeatable: true }, "--add-assignee": { takesValue: true, repeatable: true }, "--remove-assignee": { takesValue: true, repeatable: true }, "--milestone": { takesValue: true }, }, "issue edit", ); const number = parsePositionalNumber(positionals, "issue edit", "issue"); const title = flagValue(flags, "--title"); const body = resolveBodySource(deps, flags, "issue edit"); const milestoneName = flagValue(flags, "--milestone"); const addLabels = lists["--add-label"] ?? []; const removeLabels = lists["--remove-label"] ?? []; const addAssignees = lists["--add-assignee"] ?? []; const removeAssignees = lists["--remove-assignee"] ?? []; const changesAssignees = addAssignees.length > 0 || removeAssignees.length > 0; const nothingToDo = title === undefined && body === undefined && milestoneName === undefined && addLabels.length === 0 && removeLabels.length === 0 && !changesAssignees; if (nothingToDo) { throw axiError("issue edit requires at least one change", "VALIDATION_ERROR", [ "Run `gitea-axi issue edit --help` to see the fields you can change", ]); } const context = await resolveRepoContext(deps); const api = createClient(context); // Name resolution runs before any mutation: whether a milestone or label name // is real does not depend on the issue's state, so a typo is reported before a // single change lands, never leaving the issue half-edited. const milestoneId = milestoneName !== undefined ? await resolveMilestoneId(api, context, milestoneName) : undefined; const removeLabelIds = await resolveLabelIds(api, context, removeLabels); // Title, body, milestone, and the recomputed assignee list travel in one PATCH. const payload: EditIssueOption = {}; if (title !== undefined) { payload.title = title; } if (body !== undefined) { payload.body = body; } if (milestoneId !== undefined) { payload.milestone = milestoneId; } if (changesAssignees) { payload.assignees = await resolveAssignees(api, context, number, addAssignees, removeAssignees); } if (Object.keys(payload).length > 0) { try { await api.repos.issueEditIssue(context.owner, context.name, number, payload); } catch (error) { throw classifyHttpError(error); } } // Label mutations use Gitea's dedicated endpoints (idempotent). `--add-label` // passes names straight through — Gitea accepts them there, no lookup needed. if (addLabels.length > 0) { try { await api.repos.issueAddLabel(context.owner, context.name, number, { labels: addLabels }); } catch (error) { throw classifyHttpError(error); } } for (const id of removeLabelIds) { try { await api.repos.issueRemoveLabel(context.owner, context.name, number, id); } catch (error) { // The label exists in the repo but is not applied to this issue: Gitea // answers 404, and the caller's intent (label absent) already holds, so it // is silent success rather than an error. if (httpStatus(error) === 404) { continue; } throw classifyHttpError(error); } } // The mutation ran, so the block is named for the action, not the entity — a // deliberate departure from gh-axi's `issue:` block (see ADR/spec). return renderDetail({ noun: "edited", item: { number, status: "ok" }, help: [suggestCommand(context, `issue view ${number}`, "to see the issue in full")], }); } async function issueClose(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_CLOSE_HELP; } const { flags, positionals } = parseFlags( args, { "--comment": { takesValue: true } }, "issue close", ); const number = parsePositionalNumber(positionals, "issue close", "issue"); const comment = flagValue(flags, "--comment"); const context = await resolveRepoContext(deps); const api = createClient(context); // Read the current state first: an already-closed issue short-circuits to the // idempotent no-op below rather than issuing a redundant PATCH. const issue = await getIssue(api, context, number); if (issue.state === "closed") { return renderDetail({ noun: "issue", item: { number, state: "closed", message: "Already closed" }, help: [suggestCommand(context, `issue reopen ${number}`, "to reopen this issue")], }); } try { await api.repos.issueEditIssue(context.owner, context.name, number, { state: "closed" }); } catch (error) { throw classifyHttpError(error); } // The comment is a second call after the close lands. A failure here is // surfaced, never swallowed: the issue is closed, but the caller must learn // that the comment they asked for did not post. if (comment !== undefined) { try { await api.repos.issueCreateComment(context.owner, context.name, number, { body: comment }); } catch (error) { throw classifyHttpError(error); } } return renderDetail({ noun: "closed", item: { number, status: "ok" }, help: [suggestCommand(context, `issue reopen ${number}`, "to reopen this issue")], }); } async function issueReopen(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_REOPEN_HELP; } const { positionals } = parseFlags(args, {}, "issue reopen"); const number = parsePositionalNumber(positionals, "issue reopen", "issue"); const context = await resolveRepoContext(deps); const api = createClient(context); // Read the current state first: an already-open issue short-circuits to the // idempotent no-op below rather than issuing a redundant PATCH. const issue = await getIssue(api, context, number); if (issue.state === "open") { return renderDetail({ noun: "issue", item: { number, state: "open", message: "Already open" }, help: [suggestCommand(context, `issue close ${number}`, "to close this issue")], }); } try { await api.repos.issueEditIssue(context.owner, context.name, number, { state: "open" }); } catch (error) { throw classifyHttpError(error); } return renderDetail({ noun: "reopened", item: { number, status: "ok" }, help: [suggestCommand(context, `issue view ${number}`, "to see the issue in full")], }); } /** * Whether an issue is pinned. Gitea records pin position in `pin_order`, a * positive integer for a pinned issue and 0 (or absent) for an unpinned one, so * there is no boolean flag to read — the position is the state. */ function isPinned(issue: Issue): boolean { return (issue.pin_order ?? 0) > 0; } async function issueDelete(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_DELETE_HELP; } const { positionals } = parseFlags(args, {}, "issue delete"); const number = parsePositionalNumber(positionals, "issue delete", "issue"); const context = await resolveRepoContext(deps); const api = createClient(context); // A hard delete, deliberately not idempotent (ADR 0010): a nonexistent issue // is a 404, which classify404 maps to ISSUE_NOT_FOUND rather than reporting a // deletion that never happened. try { await api.repos.issueDelete(context.owner, context.name, number); } catch (error) { throw classifyHttpError(error); } return renderDetail({ noun: "issue", item: { number, status: "deleted" }, help: [suggestCommand(context, "issue list", "to see the remaining issues")], }); } async function issuePin(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_PIN_HELP; } const { positionals } = parseFlags(args, {}, "issue pin"); const number = parsePositionalNumber(positionals, "issue pin", "issue"); const context = await resolveRepoContext(deps); const api = createClient(context); // Read the current pin state first: an already-pinned issue short-circuits to // the idempotent no-op below rather than issuing a redundant POST. const issue = await getIssue(api, context, number); const state = issue.state ?? "open"; if (isPinned(issue)) { return renderDetail({ noun: "issue", item: { number, state, pinned: true, message: "Already pinned" }, help: [suggestCommand(context, `issue unpin ${number}`, "to unpin this issue")], }); } try { await api.repos.pinIssue(context.owner, context.name, number); } catch (error) { throw classifyHttpError(error); } return renderDetail({ noun: "issue", item: { number, state, pinned: true }, help: [suggestCommand(context, `issue unpin ${number}`, "to unpin this issue")], }); } async function issueUnpin(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_UNPIN_HELP; } const { positionals } = parseFlags(args, {}, "issue unpin"); const number = parsePositionalNumber(positionals, "issue unpin", "issue"); const context = await resolveRepoContext(deps); const api = createClient(context); // Read the current pin state first: an issue that is not pinned short-circuits // to the idempotent no-op below rather than issuing a redundant DELETE. const issue = await getIssue(api, context, number); const state = issue.state ?? "open"; if (!isPinned(issue)) { return renderDetail({ noun: "issue", item: { number, state, pinned: false, message: "Already unpinned" }, help: [suggestCommand(context, `issue pin ${number}`, "to pin this issue")], }); } try { await api.repos.unpinIssue(context.owner, context.name, number); } catch (error) { throw classifyHttpError(error); } return renderDetail({ noun: "issue", item: { number, state, pinned: false }, help: [suggestCommand(context, `issue pin ${number}`, "to pin this issue")], }); } /** * 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; if (!subcommand || subcommand === "--help") { return ISSUE_HELP; } if (subcommand === "list") { return issueList(deps, rest); } if (subcommand === "view") { return issueView(deps, rest); } if (subcommand === "create") { return issueCreate(deps, rest); } if (subcommand === "edit") { return issueEdit(deps, rest); } if (subcommand === "close") { return issueClose(deps, rest); } if (subcommand === "reopen") { return issueReopen(deps, rest); } if (subcommand === "delete") { return issueDelete(deps, rest); } if (subcommand === "pin") { return issuePin(deps, rest); } if (subcommand === "unpin") { return issueUnpin(deps, rest); } 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", ]); }; }