diff --git a/src/orb/apr-repo-transfer.ts b/src/orb/apr-repo-transfer.ts new file mode 100644 index 0000000000..ef5f97ceb3 --- /dev/null +++ b/src/orb/apr-repo-transfer.ts @@ -0,0 +1,53 @@ +// APR (auto-provisioned repo) transfer-to-customer initiation (#7638, decision #7590). An APR repo is created +// under a loopover-controlled GitHub org (#7637) and can later be transferred, on explicit customer request, to +// the customer's own account via GitHub's standard repository-transfer flow. +// +// This module owns ONLY the initiation call. Detecting when a pending transfer is accepted or expires, any +// customer-facing UI, and the policy of *when* a transfer should be offered are deliberately out of scope +// (separate follow-ons per #7638). No provisioning or repo-creation logic lives here. + +import { createInstallationToken } from "../github/app"; +import { githubHeaders, timeoutFetch } from "../github/client"; +// `Env` is the ambient Cloudflare Worker binding interface (worker-configuration.d.ts) — a global, not imported. + +/** + * Result of initiating an APR repo transfer. + * + * IMPORTANT: `initiated: true` means GitHub ACCEPTED the transfer request, NOT that the transfer is complete. + * GitHub's transfer flow is asynchronous and acceptance-gated — the recipient must accept via a confirmation + * email within a time window — so the repo does not actually move when this call returns. Anything built on top + * of this must treat a successful result as "transfer pending", never "transfer done". + */ +export type AprRepoTransferResult = + | { initiated: true; status: number; newFullName: string | null } + | { initiated: false; status: number; error: string }; + +/** + * Initiate a transfer of `repoFullName` (a loopover-org APR repo, `owner/name`) to the GitHub account `newOwner`, + * using the App installation token — the same token source as APR repo creation (#7637). + * + * Calls GitHub's `POST /repos/{owner}/{repo}/transfer` with `new_owner`. Returns the initiation outcome WITHOUT + * throwing on an API error (a non-existent target account, or missing admin access to the repo, come back as a + * structured `{ initiated: false }` result), so callers get a total function they can branch on. A successful + * result models the transfer as INITIATED, not complete — see {@link AprRepoTransferResult}. + */ +export async function initiateAprRepoTransfer( + env: Env, + installationId: number, + repoFullName: string, + newOwner: string, +): Promise { + const token = await createInstallationToken(env, installationId); + const response = await timeoutFetch(`https://api.github.com/repos/${repoFullName}/transfer`, { + method: "POST", + headers: githubHeaders({ token, json: true }), + body: JSON.stringify({ new_owner: newOwner }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + return { initiated: false, status: response.status, error: detail.slice(0, 200) || `transfer request failed (${response.status})` }; + } + // GitHub returns 202 Accepted with the repository object; `full_name` reflects the pending destination path. + const payload = (await response.json().catch(() => null)) as { full_name?: string } | null; + return { initiated: true, status: response.status, newFullName: payload?.full_name ?? null }; +} diff --git a/test/unit/orb-apr-repo-transfer.test.ts b/test/unit/orb-apr-repo-transfer.test.ts new file mode 100644 index 0000000000..8ec85d6059 --- /dev/null +++ b/test/unit/orb-apr-repo-transfer.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createInstallationToken } from "../../src/github/app"; +import { initiateAprRepoTransfer } from "../../src/orb/apr-repo-transfer"; +import { createTestEnv } from "../helpers/d1"; + +// The transfer initiation mints an App installation token. Mock that mint to return a plain opaque token string +// — NEVER a PEM/private-key block. A prior attempt at this issue was auto-closed by the secret scanner for a +// key-shaped fixture in the diff; the token is opaque to this module, so a bare string is a faithful stand-in. +vi.mock("../../src/github/app", async (importOriginal) => ({ + ...(await importOriginal()), + createInstallationToken: vi.fn(), +})); +const mockedToken = vi.mocked(createInstallationToken); + +/** Capture the outbound request so we can assert the endpoint, method, auth, and body. */ +function stubFetch(handler: (url: string, init: RequestInit) => Response): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init ?? {})); +} + +describe("initiateAprRepoTransfer (#7638)", () => { + beforeEach(() => { + mockedToken.mockReset(); + mockedToken.mockResolvedValue("ghs_installation_token"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("POSTs to the transfer endpoint with new_owner and the installation token, returning the pending destination", async () => { + let seenUrl = ""; + let seenInit: RequestInit = {}; + stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return new Response(JSON.stringify({ full_name: "customer-acct/widgets" }), { status: 202 }); + }); + + const env = createTestEnv(); + const result = await initiateAprRepoTransfer(env, 4242, "loopover-repos/widgets", "customer-acct"); + + expect(mockedToken).toHaveBeenCalledWith(env, 4242); + expect(seenUrl).toBe("https://api.github.com/repos/loopover-repos/widgets/transfer"); + expect(seenInit.method).toBe("POST"); + expect((seenInit.headers as Record).authorization).toBe("Bearer ghs_installation_token"); + expect(JSON.parse(String(seenInit.body))).toEqual({ new_owner: "customer-acct" }); + expect(result).toEqual({ initiated: true, status: 202, newFullName: "customer-acct/widgets" }); + }); + + it("models a successful response with no repo body as initiated with an unknown destination", async () => { + stubFetch(() => new Response("", { status: 202 })); + const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); + // A 202 with an unparseable/empty body still means "initiated" — the destination path is simply not known yet. + expect(result).toEqual({ initiated: true, status: 202, newFullName: null }); + }); + + it("treats a 2xx body that omits full_name as initiated with a null destination", async () => { + stubFetch(() => new Response(JSON.stringify({ id: 99 }), { status: 202 })); + const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); + expect(result).toEqual({ initiated: true, status: 202, newFullName: null }); + }); + + it("returns a structured error (never throws) when the target account does not exist (422)", async () => { + stubFetch(() => new Response(JSON.stringify({ message: "Could not resolve to a User with the login of 'ghost'." }), { status: 422 })); + const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "ghost"); + expect(result.initiated).toBe(false); + expect(result).toMatchObject({ initiated: false, status: 422 }); + if (!result.initiated) expect(result.error).toContain("Could not resolve"); + }); + + it("returns a structured error when the caller lacks admin access (403), with a fallback message on an empty body", async () => { + stubFetch(() => new Response("", { status: 403 })); + const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); + expect(result).toEqual({ initiated: false, status: 403, error: "transfer request failed (403)" }); + }); +});