Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/orb/apr-repo-transfer.ts
Original file line number Diff line number Diff line change
@@ -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<AprRepoTransferResult> {
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 };
}
76 changes: 76 additions & 0 deletions test/unit/orb-apr-repo-transfer.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../src/github/app")>()),
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<string, string>).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)" });
});
});