feat: add issue blocks and blocked-by (task 0007)
All checks were successful
CI / test (pull_request) Successful in 35s
CI / test (push) Successful in 35s

This commit was merged in pull request #10.
This commit is contained in:
2026-07-13 19:41:04 -04:00
parent 16d2f29f19
commit 6c15e6082f
4 changed files with 590 additions and 16 deletions

211
test/issue-blocks.test.ts Normal file
View File

@@ -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<string, unknown> {
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);
});
});
}