import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, } from "bun:test"; import { Command } from "@effect/cli"; import { NodeContext } from "@effect/platform-node"; import { Effect, Layer, Option } from "effect"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; const BASE = "http://issue-cmd-test.local"; const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; const ISSUES = [ { id: "i1", sequence_id: 29, name: "Migrate Button", priority: "high", state: "s1", }, { id: "i2", sequence_id: 30, name: "Migrate Input", priority: "medium", state: "s2", }, ]; const STATES = [ { id: "s-done", name: "Done", group: "completed" }, { id: "s-todo", name: "Todo", group: "unstarted" }, ]; const MEMBERS = [ { id: "m-alice", display_name: "Alice", email: "alice@example.com", }, { id: "m-bob", display_name: "Bob", email: "bob@example.com" }, ]; const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () => HttpResponse.json({ results: STATES }), ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () => HttpResponse.json({ results: [{ id: "l-bug", name: "Bug", color: "#ff0000" }], }), ), http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () => HttpResponse.json(MEMBERS), ), ); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); process.env["PLANE_HOST"] = BASE; process.env["PLANE_WORKSPACE"] = WS; process.env["PLANE_API_TOKEN"] = "test-token"; }); afterEach(() => { server.resetHandlers(); delete process.env["PLANE_HOST"]; delete process.env["PLANE_WORKSPACE"]; delete process.env["PLANE_API_TOKEN"]; }); describe("issueGet", () => { it("prints full JSON for an issue", async () => { const { issueGetHandler } = await import("@/commands/issue"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise(issueGetHandler({ ref: "ACME-29" })); } finally { console.log = orig; } const output = logs.join("\n"); const parsed = JSON.parse(output); expect(parsed.id).toBe("i1"); expect(parsed.name).toBe("Migrate Button"); }); }); describe("issuesList", () => { it("filters by state group", async () => { server.use( http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: [ { id: "i-state-1", sequence_id: 1, name: "Done issue", priority: "medium", state: { id: "s-done", name: "Done", group: "completed" }, assignees: ["m-alice"], }, { id: "i-state-2", sequence_id: 2, name: "Todo issue", priority: "medium", state: { id: "s-todo", name: "Todo", group: "unstarted" }, assignees: ["m-bob"], }, ], }), ), ); const { issuesListHandler } = await import("@/commands/issues"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issuesListHandler({ project: "ACME", state: Option.some("completed"), assignee: Option.none(), priority: Option.none(), }), ); } finally { console.log = orig; } const output = logs.join("\n"); expect(output).toContain("Done issue"); expect(output).not.toContain("Todo issue"); }); it("filters by assignee (email)", async () => { server.use( http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: [ { id: "i-assignee-1", sequence_id: 3, name: "Alice issue", priority: "medium", state: { id: "s-done", name: "Done", group: "completed" }, assignees: ["m-alice"], }, { id: "i-assignee-2", sequence_id: 4, name: "Bob issue", priority: "medium", state: { id: "s-todo", name: "Todo", group: "unstarted" }, assignees: ["m-bob"], }, ], }), ), ); const { issuesListHandler } = await import("@/commands/issues"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issuesListHandler({ project: "ACME", state: Option.none(), assignee: Option.some("alice@example.com"), priority: Option.none(), }), ); } finally { console.log = orig; } const output = logs.join("\n"); expect(output).toContain("Alice issue"); expect(output).not.toContain("Bob issue"); }); it("filters by priority", async () => { server.use( http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: [ { id: "i-priority-1", sequence_id: 5, name: "Urgent fix", priority: "urgent", state: { id: "s-done", name: "Done", group: "completed" }, assignees: ["m-alice"], }, { id: "i-priority-2", sequence_id: 6, name: "Low cleanup", priority: "low", state: { id: "s-todo", name: "Todo", group: "unstarted" }, assignees: ["m-bob"], }, ], }), ), ); const { issuesListHandler } = await import("@/commands/issues"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issuesListHandler({ project: "ACME", state: Option.none(), assignee: Option.none(), priority: Option.some("urgent"), }), ); } finally { console.log = orig; } const output = logs.join("\n"); expect(output).toContain("Urgent fix"); expect(output).not.toContain("Low cleanup"); }); }); describe("issueUpdate", () => { it("updates state", async () => { server.use( http.patch( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, async ({ request }) => { const body = (await request.json()) as { state?: string }; return HttpResponse.json({ id: "i1", sequence_id: 29, name: "Migrate Button", priority: "high", state: body.state ?? "s1", }); }, ), ); const { issueUpdateHandler } = await import("@/commands/issue"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issueUpdateHandler({ ref: "ACME-29", state: Option.some("completed"), priority: Option.none(), title: Option.none(), description: Option.none(), assignee: Option.none(), label: Option.none(), noAssignee: false, }), ); } finally { console.log = orig; } expect(logs.join("\n")).toContain("Updated ACME-29"); }); it("updates priority", async () => { server.use( http.patch( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, async ({ request }) => { const body = (await request.json()) as { priority?: string }; return HttpResponse.json({ id: "i1", sequence_id: 29, name: "Migrate Button", priority: body.priority, state: "s1", }); }, ), ); const { issueUpdateHandler } = await import("@/commands/issue"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issueUpdateHandler({ ref: "ACME-29", state: Option.none(), priority: Option.some("urgent"), title: Option.none(), description: Option.none(), assignee: Option.none(), label: Option.none(), noAssignee: false, }), ); } finally { console.log = orig; } expect(logs.join("\n")).toContain("urgent"); }); it("fails when nothing to update", async () => { const { issueUpdateHandler } = await import("@/commands/issue"); const result = await Effect.runPromise( Effect.either( issueUpdateHandler({ ref: "ACME-29", state: Option.none(), priority: Option.none(), title: Option.none(), description: Option.none(), assignee: Option.none(), label: Option.none(), noAssignee: false, }), ), ); expect(result._tag).toBe("Left"); if (result._tag === "Left") { expect((result.left as Error).message).toContain("Nothing to update"); } }); it("updates title", async () => { let patchedBody: unknown; server.use( http.patch( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, async ({ request }) => { patchedBody = await request.json(); return HttpResponse.json({ id: "i1", sequence_id: 29, name: "New title", priority: "high", state: "s1", }); }, ), ); const { issueUpdateHandler } = await import("@/commands/issue"); await Effect.runPromise( issueUpdateHandler({ ref: "ACME-29", state: Option.none(), priority: Option.none(), title: Option.some("New title"), description: Option.none(), assignee: Option.none(), label: Option.none(), noAssignee: false, }), ); expect((patchedBody as { name?: string }).name).toBe("New title"); }); }); describe("issueComment", () => { it("adds a comment", async () => { let postedBody: unknown; server.use( http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, async ({ request }) => { postedBody = await request.json(); return HttpResponse.json({ id: "c1" }, { status: 201 }); }, ), ); const { issueCommentHandler } = await import("@/commands/issue"); const logs: string[] = []; const orig = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); try { await Effect.runPromise( issueCommentHandler({ ref: "ACME-29", text: "Fixed in latest build", }), ); } finally { console.log = orig; } expect((postedBody as { comment_html?: string }).comment_html).toContain( "Fixed in latest build", ); expect(logs.join("\n")).toContain("Comment added to ACME-29"); }); it("HTML-escapes angle brackets in comment text", async () => { let postedBody: unknown; server.use( http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, async ({ request }) => { postedBody = await request.json(); return HttpResponse.json({ id: "c2" }, { status: 201 }); }, ), ); const { issueCommentHandler } = await import("@/commands/issue"); try { await Effect.runPromise( issueCommentHandler({ ref: "ACME-29", text: "", }), ); } finally { } expect((postedBody as { comment_html?: string }).comment_html).toContain( "<script>", ); expect( (postedBody as { comment_html?: string }).comment_html, ).not.toContain("