feat: add issue delete, pin, and unpin (task 0006)
All checks were successful
CI / test (pull_request) Successful in 29s
CI / test (push) Successful in 32s

Add the remaining simple issue mutations. `issue delete` hard-deletes via
the DELETE endpoint and is deliberately not idempotent — a nonexistent
issue yields ISSUE_NOT_FOUND rather than reporting success. `issue pin`
and `issue unpin` read the current pin state (Gitea's pin_order field) and
short-circuit to an idempotent no-op with an Already pinned/unpinned
message when there is nothing to do.
This commit was merged in pull request #7.
This commit is contained in:
2026-07-12 19:02:02 -04:00
parent f526acbb7b
commit 7a41807a8a
4 changed files with 322 additions and 5 deletions

View File

@@ -12,8 +12,21 @@ The remaining simple issue mutations: `issue delete`, `issue pin`, `issue unpin`
## Acceptance criteria
- [ ] `issue delete <n>` outputs `issue: { number, status: "deleted" }` on success
- [ ] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success
- [ ] `issue pin <n>` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0
- [ ] `issue unpin <n>` 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 <n>` outputs `issue: { number, status: "deleted" }` on success
- [x] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success
- [x] `issue pin <n>` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0
- [x] `issue unpin <n>` 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.

View File

@@ -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 <command> --help\` for the flags of a command.
@@ -87,6 +90,46 @@ global flags:
--login <name> Select a tea login profile by name
`;
export const ISSUE_DELETE_HELP = `usage: gitea-axi issue delete <number>
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 <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_PIN_HELP = `usage: gitea-axi issue pin <number>
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 <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_UNPIN_HELP = `usage: gitea-axi issue unpin <number>
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 <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const ISSUE_CREATE_HELP = `usage: gitea-axi issue create --title <text> [flags]
Create an issue in the current repository.
@@ -857,6 +900,111 @@ async function issueReopen(deps: CliDeps, args: string[]): Promise<string> {
});
}
/**
* 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<string> {
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<string> {
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<string> {
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<string> => {
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);
}

51
test/issue-delete.test.ts Normal file
View File

@@ -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);
});
});

96
test/issue-pin.test.ts Normal file
View File

@@ -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);
});
});