diff --git a/.claude/tasks/0006-issue-delete-pin-unpin.md b/.claude/tasks/0006-issue-delete-pin-unpin.md index 13d95d5..0e26ccc 100644 --- a/.claude/tasks/0006-issue-delete-pin-unpin.md +++ b/.claude/tasks/0006-issue-delete-pin-unpin.md @@ -12,8 +12,21 @@ The remaining simple issue mutations: `issue delete`, `issue pin`, `issue unpin` ## Acceptance criteria -- [ ] `issue delete ` outputs `issue: { number, status: "deleted" }` on success -- [ ] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success -- [ ] `issue pin ` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0 -- [ ] `issue unpin ` mirrors pin with `message: "Already unpinned"` on the no-op -- [ ] Fixture-server tests cover delete success, delete-missing refusal, and both pin/unpin no-ops +- [x] `issue delete ` outputs `issue: { number, status: "deleted" }` on success +- [x] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success +- [x] `issue pin ` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0 +- [x] `issue unpin ` mirrors pin with `message: "Already unpinned"` on the no-op +- [x] Fixture-server tests cover delete success, delete-missing refusal, and both pin/unpin no-ops + +## Implementation Notes + +Pin state is read from the Gitea `pin_order` field, not a boolean: Gitea has no `pinned` flag on the issue struct, and records pin position as a positive integer (`0`/absent means unpinned). +A small `isPinned` helper wraps this so the two commands don't repeat the check. +The `state` field in the pin/unpin output is the issue's own open/closed state, taken from the fetched issue — pinning never changes it. + +`issue delete` runs no confirmation prompt. +The review's Risk axis rated the change High solely because of the irreversible hard delete and suggested a `--yes` guard, but this is an agent-facing CLI with structured TOON output where interactive prompts don't fit, and the spec deliberately specifies a hard, non-idempotent delete without one. +Left unguarded by design; the destructiveness is inherent to the operation, not a defect. + +`issuePin` and `issueUnpin` are near-identical mirrors (flagged as a judgement-call duplication by the Standards axis). +Kept as two functions per the repo's established one-function-per-subcommand convention, which the existing `issueClose`/`issueReopen` pair already follows. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 9f386b7..a2ee39d 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -37,6 +37,9 @@ commands: 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 Run \`gitea-axi issue --help\` for the flags of a command. @@ -87,6 +90,46 @@ global flags: --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. @@ -857,6 +900,111 @@ async function issueReopen(deps: CliDeps, args: string[]): Promise { }); } +/** + * 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")], + }); +} + export function issueCommand(deps: CliDeps) { return async (args: string[]): Promise => { const [subcommand, ...rest] = args; @@ -881,6 +1029,15 @@ export function issueCommand(deps: CliDeps) { 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); } diff --git a/test/issue-delete.test.ts b/test/issue-delete.test.ts new file mode 100644 index 0000000..8afa86a --- /dev/null +++ b/test/issue-delete.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUE_PATH = "/api/v1/repos/testowner/testrepo/issues/7"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +describe("issue delete", () => { + it("deletes an issue and reports the deletion", async () => { + server = await startFixtureServer([ + { method: "DELETE", path: ISSUE_PATH, status: 204 }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "delete", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("issue:"); + expect(stdout).toContain("number: 7"); + expect(stdout).toContain("status: deleted"); + expect(server.requests.some((request) => request.method === "DELETE")).toBe(true); + }); + + it("refuses to delete a nonexistent issue with ISSUE_NOT_FOUND", async () => { + server = await startFixtureServer([ + { method: "DELETE", path: ISSUE_PATH, status: 404, body: { message: "issue does not exist" } }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "delete", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: ISSUE_NOT_FOUND"); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "delete", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue delete"); + expect(server.requests).toHaveLength(0); + }); +}); diff --git a/test/issue-pin.test.ts b/test/issue-pin.test.ts new file mode 100644 index 0000000..66be3ca --- /dev/null +++ b/test/issue-pin.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUE_PATH = "/api/v1/repos/testowner/testrepo/issues/7"; +const PIN_PATH = "/api/v1/repos/testowner/testrepo/issues/7/pin"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +describe("issue pin", () => { + it("pins an unpinned issue and reports the pinned state", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: { number: 7, state: "open" } }, + { method: "POST", path: PIN_PATH, status: 204 }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "pin", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("issue:"); + expect(stdout).toContain("number: 7"); + expect(stdout).toContain("state: open"); + expect(stdout).toContain("pinned: true"); + expect(server.requests.some((request) => request.method === "POST")).toBe(true); + }); + + it("returns early with Already pinned on an already-pinned issue", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: { number: 7, state: "open", pin_order: 1 } }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "pin", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("message: Already pinned"); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "pin", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue pin"); + expect(server.requests).toHaveLength(0); + }); +}); + +describe("issue unpin", () => { + it("unpins a pinned issue and reports the unpinned state", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: { number: 7, state: "open", pin_order: 1 } }, + { method: "DELETE", path: PIN_PATH, status: 204 }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "unpin", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("number: 7"); + expect(stdout).toContain("pinned: false"); + expect(server.requests.some((request) => request.method === "DELETE")).toBe(true); + }); + + it("returns early with Already unpinned on an issue that is not pinned", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: { number: 7, state: "open" } }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "unpin", "7"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("message: Already unpinned"); + expect(server.requests.some((request) => request.method === "DELETE")).toBe(false); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "unpin", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue unpin"); + expect(server.requests).toHaveLength(0); + }); +});