feat: add issue delete, pin, and unpin (task 0006) #7
@@ -12,8 +12,21 @@ The remaining simple issue mutations: `issue delete`, `issue pin`, `issue unpin`
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `issue delete <n>` outputs `issue: { number, status: "deleted" }` on success
|
- [x] `issue delete <n>` outputs `issue: { number, status: "deleted" }` on success
|
||||||
- [ ] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success
|
- [x] 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
|
- [x] `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
|
- [x] `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] 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.
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ commands:
|
|||||||
edit Edit an issue's title, body, labels, assignees, or milestone
|
edit Edit an issue's title, body, labels, assignees, or milestone
|
||||||
close Close an issue
|
close Close an issue
|
||||||
reopen Reopen a closed 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
|
comment Post a comment on an issue or pull request
|
||||||
|
|
||||||
Run \`gitea-axi issue <command> --help\` for the flags of a command.
|
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
|
--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]
|
export const ISSUE_CREATE_HELP = `usage: gitea-axi issue create --title <text> [flags]
|
||||||
|
|
||||||
Create an issue in the current repository.
|
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) {
|
export function issueCommand(deps: CliDeps) {
|
||||||
return async (args: string[]): Promise<string> => {
|
return async (args: string[]): Promise<string> => {
|
||||||
const [subcommand, ...rest] = args;
|
const [subcommand, ...rest] = args;
|
||||||
@@ -881,6 +1029,15 @@ export function issueCommand(deps: CliDeps) {
|
|||||||
if (subcommand === "reopen") {
|
if (subcommand === "reopen") {
|
||||||
return issueReopen(deps, rest);
|
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") {
|
if (subcommand === "comment") {
|
||||||
return issueComment(deps, rest);
|
return issueComment(deps, rest);
|
||||||
}
|
}
|
||||||
|
|||||||
51
test/issue-delete.test.ts
Normal file
51
test/issue-delete.test.ts
Normal 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
96
test/issue-pin.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user