import { afterEach, describe, expect, test } from "bun:test"; import { withGhDefaultRendered } from "./gh/default-render"; import { ghScopedHelp } from "./gh/help"; import { prReady } from "./gh/pr-ready"; import type { GitHubPullRequest } from "./gh/types"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); function pullRequest(overrides: Partial = {}): GitHubPullRequest { return { id: 1748, node_id: "PR_kwDO_ready_1748", number: 1748, title: "PaC source artifact", body: "body", state: "open", html_url: "https://github.com/pikasTech/unidesk/pull/1748", draft: true, head: { ref: "fix/1746-pac-source-artifact", sha: "a".repeat(40) }, base: { ref: "master", sha: "b".repeat(40) }, ...overrides, }; } function installFetch(handler: (url: URL, init: RequestInit | undefined) => Response | Promise): string[] { const calls: string[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url); calls.push(`${init?.method ?? "GET"} ${url.pathname}`); return handler(url, init); }) as typeof fetch; return calls; } describe("gh pr ready", () => { test("marks an open draft PR ready through the shared GraphQL client", async () => { const calls = installFetch(async (url, init) => { if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); expect(url.pathname).toBe("/graphql"); expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token"); const payload = JSON.parse(String(init?.body)) as { query: string; variables: { pullRequestId: string } }; expect(payload.query).toContain("markPullRequestReadyForReview"); expect(payload.variables.pullRequestId).toBe("PR_kwDO_ready_1748"); return Response.json({ data: { markPullRequestReadyForReview: { pullRequest: { id: "PR_kwDO_ready_1748", number: 1748, title: "PaC source artifact", state: "OPEN", isDraft: false, url: "https://github.com/pikasTech/unidesk/pull/1748", headRefName: "fix/1746-pac-source-artifact", baseRefName: "master", updatedAt: "2026-07-11T04:30:00Z", }, }, }, }); }); const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); expect(result.ok).toBe(true); expect(result.action).toBe("marked-ready"); expect(result.changed).toBe(true); expect(result.graphQl).toBe(true); expect((result.pullRequest as { draft?: boolean }).draft).toBe(false); expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748", "POST /graphql"]); }); test("is idempotent when the open PR is already ready", async () => { const calls = installFetch(() => Response.json(pullRequest({ draft: false }))); const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); expect(result.ok).toBe(true); expect(result.action).toBe("already-ready"); expect(result.changed).toBe(false); expect(result.graphQl).toBe(false); expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); const rendered = withGhDefaultRendered(["pr", "ready", "1748"], result) as { renderedText: string }; expect(rendered.renderedText).toContain("gh pr ready (already-ready)"); }); test("dry-run reads the PR and exposes a bounded mutation plan without writing", async () => { const calls = installFetch(() => Response.json(pullRequest())); const result = await prReady("pikasTech/unidesk", "test-token", 1748, true); expect(result.ok).toBe(true); expect(result.action).toBe("would-mark-ready"); expect(result.planned).toBe(true); expect(result.changed).toBe(false); expect(result.request).toEqual({ method: "POST", path: "/graphql", mutation: "markPullRequestReadyForReview", variables: { pullRequestId: "present" }, }); expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); }); test("rejects a closed PR before mutation with a typed validation error", async () => { const calls = installFetch(() => Response.json(pullRequest({ state: "closed" }))); const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); expect(result.ok).toBe(false); expect(result.degradedReason).toBe("validation-failed"); expect(result.runnerDisposition).toBe("business-failed"); expect(result.phase).toBe("validate-pr-state"); expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); }); test("preserves the typed pr-not-found REST failure", async () => { installFetch(() => Response.json({ message: "Not Found" }, { status: 404 })); const result = await prReady("pikasTech/unidesk", "test-token", 404, false); expect(result.ok).toBe(false); expect(result.degradedReason).toBe("pr-not-found"); expect(result.runnerDisposition).toBe("business-failed"); expect(result.phase).toBe("fetch-pr"); }); test("preserves typed mutation permission failures", async () => { installFetch((url) => { if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); return Response.json( { message: "Resource not accessible by integration" }, { status: 403, headers: { "x-accepted-oauth-scopes": "repo" } }, ); }); const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); expect(result.ok).toBe(false); expect(result.degradedReason).toBe("scope-insufficient"); expect(result.runnerDisposition).toBe("infra-blocked"); expect(result.phase).toBe("mark-ready"); }); test("publishes bounded scoped help for the command", () => { const help = ghScopedHelp(["pr", "ready", "--help"]) as { usage: string[]; notes: string[] }; expect(help.usage).toEqual([ "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", ]); expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview"); expect(help.notes.join("\n")).toContain("不改变 GitHub 状态"); }); });