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
28 changes: 28 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import {
type LoopOverMentionCommandName,
} from "../github/commands";
import { handleGitHubWebhook, handleOrbRelay } from "../github/webhook";
import { requestAprRepoTransfer } from "../orb/apr-repo-transfer";
import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest";
import { handleAmsIngest } from "../ams/ingest";
import { handleOrbWebhook } from "../orb/webhook";
Expand Down Expand Up @@ -532,6 +533,18 @@ const evaluateEscalationSchema = z.object({
killRequested: z.boolean().optional(),
});

// #7742: customer-facing APR transfer request. Completion is resolved SERVER-SIDE via loadAprIdeaCompletion —
// never accepted from the body (that was the #8000 Superagent P1). `.strict()` rejects any attempt to smuggle
// `ideaComplete` (or other unknown keys). Plan/payment fields are deliberately absent.
const requestAprTransferSchema = z
.object({
installationId: z.number().int().positive(),
repoFullName: z.string().min(1).max(200),
newOwner: z.string().min(1).max(100),
ideaId: z.string().min(1).max(200).optional(),
})
.strict();

// #6744: mirrors proposeActionShape in src/mcp/server.ts VERBATIM, minus owner/repo (they are path params), so
// POST /v1/repos/:owner/:repo/agent/pending-actions can never stage an action the loopover_propose_action MCP
// tool would reject, or vice versa. actionClass stays the 7-value propose set (a subset of AgentActionClass).
Expand Down Expand Up @@ -3727,6 +3740,21 @@ export function createApp() {
return c.json(evaluateEscalation(parsed.data));
});

// #7742: customer-facing "request transfer" for an APR repo. Request-only (nothing auto-offers). Completion is
// resolved SERVER-SIDE by requestAprRepoTransfer → loadAprIdeaCompletion (fail-closed until #7591/#7664 persist
// a record) — the body must NOT carry ideaComplete (`.strict()` schema rejects smuggling attempts; that was
// the #8000 Superagent P1). Rejected gate → 409 without touching GitHub; initiation is still pending-acceptance
// (202), never "transfer done".
app.post("/v1/loop/request-apr-transfer", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = requestAprTransferSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_request_apr_transfer_request", issues: parsed.error.issues }, 400);
const result = await requestAprRepoTransfer(c.env, parsed.data);
if (result.status === "rejected") return c.json(result, 409);
if (result.status === "failed") return c.json(result, 502);
return c.json(result, 202);
});

// #6752: REST mirror of the loopover_build_results_payload MCP tool, bringing it to the same REST/CLI parity
// its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Both are pure, source-free
// composers over caller-supplied, already-computed iteration metadata, so this route delegates to the same
Expand Down
28 changes: 28 additions & 0 deletions src/orb/apr-idea-completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Trusted server-side idea-completion lookup for APR transfer gating (#7742).
//
// Until #7591/#7664 persist a completion record, this ALWAYS returns incomplete (fail closed). A client
// boolean must never substitute for this — that was the #8000 Superagent P1. When a persisted lookup lands,
// replace the body of this function (keep the signature) so every caller picks it up.

export type AprIdeaCompletionLookupInput = {
repoFullName: string;
/** Optional idea/submission id for the eventual #7664 record lookup. */
ideaId?: string | undefined;
};

export type AprIdeaCompletionLookup = (
env: Env,
input: AprIdeaCompletionLookupInput,
) => Promise<{ ideaComplete: boolean }>;

/**
* Resolve whether an APR idea's task-graph is complete (#7591). Fail-closed until a persisted record exists.
* Declared return is `{ ideaComplete: boolean }` so a future persisted lookup (and test doubles) can return true;
* today's body always returns false.
*/
export async function loadAprIdeaCompletion(
_env: Env,
_input: AprIdeaCompletionLookupInput,
): Promise<{ ideaComplete: boolean }> {
return { ideaComplete: false };
}
82 changes: 79 additions & 3 deletions src/orb/apr-repo-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
// 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.
// Initiation (#7638) lives here; the customer-facing request gate (#7742) does too. Detecting when a pending
// transfer is accepted or expires (#7741) remains out of scope. No provisioning or repo-creation logic lives
// here. Transfer is NEVER offered or nudged proactively in v1 — request-only, and only once a TRUSTED
// server-side idea-completion signal (#7591) says the task-graph is done. Callers must NEVER supply that
// boolean over the wire; {@link loadAprIdeaCompletion} is the sole source, and it fail-closes until #7664
// persists a completion record.

import { createInstallationToken } from "../github/app";
import { githubHeaders, timeoutFetch } from "../github/client";
import { loadAprIdeaCompletion, type AprIdeaCompletionLookup } from "./apr-idea-completion";
// `Env` is the ambient Cloudflare Worker binding interface (worker-configuration.d.ts) — a global, not imported.

export type { AprIdeaCompletionLookup, AprIdeaCompletionLookupInput } from "./apr-idea-completion";
export { loadAprIdeaCompletion } from "./apr-idea-completion";

/**
* Result of initiating an APR repo transfer.
*
Expand All @@ -22,6 +29,42 @@ export type AprRepoTransferResult =
| { initiated: true; status: number; newFullName: string | null }
| { initiated: false; status: number; error: string };

/**
* #7742 policy gate: a transfer may be requested only after the idea's completion signal (#7591) is true.
* Plan/payment tiers are deliberately NOT consulted — this stays clear of the billing track. Pure: no IO.
* The boolean MUST come from {@link loadAprIdeaCompletion} (or a test double of it), never from a client body.
*/
export type AprRepoTransferRequestEligibility =
| { allowed: true }
| { allowed: false; reason: "idea_not_complete" };

export type RequestAprRepoTransferInput = {
installationId: number;
repoFullName: string;
newOwner: string;
ideaId?: string | undefined;
};

/**
* Outcome of a customer-initiated transfer request (#7742).
*
* - `rejected` — the completion gate blocked the call; GitHub was never contacted.
* - `initiated` / `failed` — the gate passed and {@link initiateAprRepoTransfer} ran; `initiated` still means
* GitHub accepted a *pending* transfer (see {@link AprRepoTransferResult}), never "transfer done".
*/
export type RequestAprRepoTransferResult =
| { status: "rejected"; reason: "idea_not_complete" }
| { status: "initiated"; transfer: Extract<AprRepoTransferResult, { initiated: true }> }
| { status: "failed"; transfer: Extract<AprRepoTransferResult, { initiated: false }> };

/** Decide whether a customer may request an APR repo transfer right now (#7742). Pure and deterministic. */
export function evaluateAprRepoTransferRequestEligibility(input: {
ideaComplete: boolean;
}): AprRepoTransferRequestEligibility {
if (input.ideaComplete !== true) return { allowed: false, reason: "idea_not_complete" };
return { allowed: true };
}

/**
* 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).
Expand All @@ -30,6 +73,9 @@ export type AprRepoTransferResult =
* 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}.
*
* Prefer {@link requestAprRepoTransfer} for the customer-facing path — it applies the #7742 completion gate
* before calling this. Direct callers are for tests / internal seams that already enforced the gate.
*/
export async function initiateAprRepoTransfer(
env: Env,
Expand All @@ -51,3 +97,33 @@ export async function initiateAprRepoTransfer(
const payload = (await response.json().catch(() => null)) as { full_name?: string } | null;
return { initiated: true, status: response.status, newFullName: payload?.full_name ?? null };
}

/**
* Customer-facing "request transfer" action (#7742): resolve idea completion via a trusted server lookup
* ({@link loadAprIdeaCompletion}), gate on that result, then call {@link initiateAprRepoTransfer}. Never
* initiates when incomplete — and nothing in this module (or its REST mirror) auto-offers or nudges a transfer,
* or accepts a client-supplied completion boolean.
*/
export async function requestAprRepoTransfer(
env: Env,
input: RequestAprRepoTransferInput,
options: {
initiate?: (
env: Env,
installationId: number,
repoFullName: string,
newOwner: string,
) => Promise<AprRepoTransferResult>;
loadCompletion?: AprIdeaCompletionLookup;
} = {},
): Promise<RequestAprRepoTransferResult> {
const loadCompletion = options.loadCompletion ?? loadAprIdeaCompletion;
const { ideaComplete } = await loadCompletion(env, { repoFullName: input.repoFullName, ideaId: input.ideaId });
const eligibility = evaluateAprRepoTransferRequestEligibility({ ideaComplete });
if (!eligibility.allowed) return { status: "rejected", reason: eligibility.reason };

const initiate = options.initiate ?? initiateAprRepoTransfer;
const transfer = await initiate(env, input.installationId, input.repoFullName, input.newOwner);
if (transfer.initiated) return { status: "initiated", transfer };
return { status: "failed", transfer };
}
121 changes: 119 additions & 2 deletions test/unit/orb-apr-repo-transfer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createInstallationToken } from "../../src/github/app";
import { initiateAprRepoTransfer } from "../../src/orb/apr-repo-transfer";
import {
evaluateAprRepoTransferRequestEligibility,
initiateAprRepoTransfer,
loadAprIdeaCompletion,
requestAprRepoTransfer,
} 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
Expand Down Expand Up @@ -50,7 +55,6 @@ describe("initiateAprRepoTransfer (#7638)", () => {
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 });
});

Expand All @@ -73,4 +77,117 @@ describe("initiateAprRepoTransfer (#7638)", () => {
const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct");
expect(result).toEqual({ initiated: false, status: 403, error: "transfer request failed (403)" });
});

it("falls back to a status message when response.text() rejects on a non-OK reply", async () => {
stubFetch(
() =>
({
ok: false,
status: 500,
text: async () => {
throw new Error("body unread");
},
}) as unknown as Response,
);
const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct");
expect(result).toEqual({ initiated: false, status: 500, error: "transfer request failed (500)" });
});
});

describe("evaluateAprRepoTransferRequestEligibility (#7742)", () => {
it("allows a request only when the idea-completion signal is true", () => {
expect(evaluateAprRepoTransferRequestEligibility({ ideaComplete: true })).toEqual({ allowed: true });
});

it("rejects when the idea is not complete — including an explicit false", () => {
expect(evaluateAprRepoTransferRequestEligibility({ ideaComplete: false })).toEqual({
allowed: false,
reason: "idea_not_complete",
});
});
});

describe("loadAprIdeaCompletion (#7742)", () => {
it("fail-closes to incomplete until a persisted #7591/#7664 record exists", async () => {
await expect(loadAprIdeaCompletion(createTestEnv(), { repoFullName: "loopover-repos/widgets" })).resolves.toEqual({
ideaComplete: false,
});
await expect(
loadAprIdeaCompletion(createTestEnv(), { repoFullName: "loopover-repos/widgets", ideaId: "idea-1" }),
).resolves.toEqual({ ideaComplete: false });
});
});

describe("requestAprRepoTransfer (#7742)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("rejects without calling initiate when the trusted lookup reports incomplete (default fail-closed)", async () => {
const initiate = vi.fn();
const result = await requestAprRepoTransfer(
createTestEnv(),
{ installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" },
{ initiate },
);
expect(result).toEqual({ status: "rejected", reason: "idea_not_complete" });
expect(initiate).not.toHaveBeenCalled();
});

it("rejects when an injectable lookup reports incomplete, without calling initiate", async () => {
const initiate = vi.fn();
const loadCompletion = vi.fn().mockResolvedValue({ ideaComplete: false });
const env = createTestEnv();
const result = await requestAprRepoTransfer(
env,
{ installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct", ideaId: "idea-9" },
{ initiate, loadCompletion },
);
expect(loadCompletion).toHaveBeenCalledWith(env, { repoFullName: "loopover-repos/widgets", ideaId: "idea-9" });
expect(result).toEqual({ status: "rejected", reason: "idea_not_complete" });
expect(initiate).not.toHaveBeenCalled();
});

it("initiates when a trusted lookup reports complete and the GitHub call succeeds", async () => {
const initiate = vi.fn().mockResolvedValue({ initiated: true, status: 202, newFullName: "customer-acct/widgets" });
const loadCompletion = vi.fn().mockResolvedValue({ ideaComplete: true });
const env = createTestEnv();
const result = await requestAprRepoTransfer(
env,
{ installationId: 7, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" },
{ initiate, loadCompletion },
);
expect(initiate).toHaveBeenCalledWith(env, 7, "loopover-repos/widgets", "customer-acct");
expect(result).toEqual({
status: "initiated",
transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" },
});
});

it("surfaces a structured failure when initiate returns initiated:false after a trusted complete lookup", async () => {
const initiate = vi.fn().mockResolvedValue({ initiated: false, status: 403, error: "no admin" });
const result = await requestAprRepoTransfer(
createTestEnv(),
{ installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" },
{ initiate, loadCompletion: async () => ({ ideaComplete: true }) },
);
expect(result).toEqual({
status: "failed",
transfer: { initiated: false, status: 403, error: "no admin" },
});
});

it("defaults to initiateAprRepoTransfer when no initiate hook is supplied and completion is trusted-complete", async () => {
mockedToken.mockResolvedValue("ghs_installation_token");
stubFetch(() => new Response(JSON.stringify({ full_name: "customer-acct/widgets" }), { status: 202 }));
const result = await requestAprRepoTransfer(
createTestEnv(),
{ installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" },
{ loadCompletion: async () => ({ ideaComplete: true }) },
);
expect(result).toEqual({
status: "initiated",
transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" },
});
});
});
Loading