diff --git a/src/env.d.ts b/src/env.d.ts index d0b088300f..68a3c4a5f0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -410,6 +410,11 @@ declare global { * src/review/active-review-reconciliation.ts). Default OFF — unset/false means the cron tick enqueues NO * reconciliation job, so the worker is byte-identical to today. */ LOOPOVER_ACTIVE_REVIEW_RECONCILIATION?: string; + /** APR repo-transfer acceptance/expiry detection (#7741): when truthy, an hourly cron enqueues a + * `poll-apr-repo-transfers` job that, for each pending transfer, probes GitHub and marks it accepted / + * accepted-and-departed / expired (>7 days), reconciling the per-repo AMS-dispatch pause. Default OFF — + * unset/false means the cron tick enqueues NO poll job, so the worker is byte-identical to today. */ + LOOPOVER_APR_TRANSFER_POLL?: string; /** Convergence (RAG retrieval): when truthy, the AI reviewer prompt gains a RELEVANT EXISTING CODE / DOCS * section — at review time the codebase vector index is queried for code/docs semantically related to the * PR's changed files (callers, related modules, existing conventions) and appended as additive reference diff --git a/src/index.ts b/src/index.ts index ea3d93899f..a691ed6ef7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { isOpsEnabled, resolveOpsManifestOverride } from "./review/ops-wire"; import { isRecapEnabled, resolveMaintainerRecapManifestOverride, shouldFireMaintainerRecap } from "./review/maintainer-recap-wire"; import { isSweepWatchdogEnabled, resolveSweepWatchdogManifestOverride } from "./review/sweep-watchdog"; import { isLoopEscalationSweepEnabled } from "./review/loop-escalation-wire"; +import { isAprRepoTransferPollEnabled } from "./orb/apr-repo-transfer"; import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride } from "./review/pr-reconciliation"; import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride } from "./review/active-review-reconciliation"; import { isRagEnabled } from "./review/rag-wire"; @@ -273,6 +274,11 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): // Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created, so the cron tick does // ZERO new tuning work and the enqueued set is byte-identical to today. if (selfHostedReviews && isSelfTuneEnabled(env)) jobs.push({ type: "selftune", requestedBy: "schedule" }); + // APR repo-transfer acceptance/expiry detection (#7741, flag LOOPOVER_APR_TRANSFER_POLL). Hourly poll that + // resolves each pending APR transfer (accepted / accepted-and-departed / expired at 7 days) and reconciles + // the per-repo AMS pause. Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created, + // so the cron tick does ZERO new work and the enqueued set is byte-identical to today. + if (isAprRepoTransferPollEnabled(env)) jobs.push({ type: "poll-apr-repo-transfers", requestedBy: "schedule" }); } if (isHourly && scheduledAt.getUTCDay() === 1 && hour === 12) { jobs.push({ type: "generate-weekly-value-report", requestedBy: "schedule", variant: "operator", days: 7 }); diff --git a/src/orb/apr-repo-transfer.ts b/src/orb/apr-repo-transfer.ts index e56e07a79f..957de8ea18 100644 --- a/src/orb/apr-repo-transfer.ts +++ b/src/orb/apr-repo-transfer.ts @@ -9,6 +9,7 @@ // boolean over the wire; {@link loadAprIdeaCompletion} is the sole source, and it fail-closes until #7664 // persists a completion record. +import { upsertRepositorySettings } from "../db/repositories"; import { createInstallationToken } from "../github/app"; import { githubHeaders, timeoutFetch } from "../github/client"; import { loadAprIdeaCompletion, type AprIdeaCompletionLookup } from "./apr-idea-completion"; @@ -115,6 +116,8 @@ export async function requestAprRepoTransfer( newOwner: string, ) => Promise; loadCompletion?: AprIdeaCompletionLookup; + /** #7741 deliverable 2 seam: how to freeze AMS dispatch once a transfer is pending. Injectable for tests. */ + pauseDispatch?: (env: Env, repoFullName: string) => Promise; } = {}, ): Promise { const loadCompletion = options.loadCompletion ?? loadAprIdeaCompletion; @@ -124,6 +127,177 @@ export async function requestAprRepoTransfer( const initiate = options.initiate ?? initiateAprRepoTransfer; const transfer = await initiate(env, input.installationId, input.repoFullName, input.newOwner); - if (transfer.initiated) return { status: "initiated", transfer }; + if (transfer.initiated) { + // #7741 deliverable 2: a pending transfer is acceptance-gated and asynchronous, so freeze AMS dispatch for + // the source repo the instant GitHub accepts the request — reusing the EXISTING per-repo `agentPaused` + // kill-switch, not a new mechanism. The scheduled poll ({@link pollPendingAprRepoTransfers}) resumes it once + // the transfer is accepted-and-still-installed, or expires/declines. + const pauseDispatch = options.pauseDispatch ?? ((e, r) => setAprRepoDispatchPaused(e, r, true)); + await pauseDispatch(env, input.repoFullName); + return { status: "initiated", transfer }; + } return { status: "failed", transfer }; } + +// --------------------------------------------------------------------------------------------------------------- +// #7741: detect whether a PENDING transfer was accepted, declined, or expired, and reconcile the per-repo pause. +// +// GitHub repo transfers are asynchronous + acceptance-gated (see {@link AprRepoTransferResult}), so a +// scheduled poll — NOT a webhook (design ratified in #7741) — reconciles each pending transfer. All IO (the +// GitHub probe, the clock, the pending-transfer store, the pause toggle) is INJECTED so the detection/expiry +// logic is unit-testable without the live cron; the cron itself only wires these real dependencies together. +// --------------------------------------------------------------------------------------------------------------- + +/** + * A pending APR repo transfer the scheduled poll must resolve (#7741). Persisting these rows is a separate + * concern (#7664 completion/record store); this module only needs what it takes to probe GitHub and time out. + */ +export type PendingAprRepoTransfer = { + /** The loopover-org path (`owner/name`) the transfer was initiated FROM. */ + repoFullName: string; + /** The GitHub account the repo is moving TO. */ + newOwner: string; + /** Installation whose App token can read the repo — the same token source as initiation. */ + installationId: number; + /** Epoch-ms when {@link initiateAprRepoTransfer} accepted the pending transfer. */ + initiatedAt: number; +}; + +/** What a single GitHub repo-probe reveals about a pending transfer (#7741). */ +export type AprRepoTransferProbe = + | { state: "resolved_under_target" } // the repo now resolves under `newOwner` — accepted. + | { state: "access_departed" } // the App's access 404s, consistent with ownership having moved — accepted-and-departed. + | { state: "pending" }; // still under the original owner (or a transient error) — keep waiting. + +/** Outcomes of a pending transfer (#7741). Everything except `pending` is terminal. */ +export type AprRepoTransferOutcome = "accepted" | "accepted_departed" | "expired" | "pending"; +export type TerminalAprRepoTransferOutcome = Exclude; + +/** A pending transfer that neither resolves nor departs within this window (from initiation) is expired (#7741). */ +export const APR_REPO_TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + +/** Default-OFF flag (#7741): flag-OFF, the cron enqueues no poll job, so the worker is byte-identical to today. */ +export function isAprRepoTransferPollEnabled(env: { LOOPOVER_APR_TRANSFER_POLL?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test((env.LOOPOVER_APR_TRANSFER_POLL ?? "").trim()); +} + +/** + * Decide a pending transfer's outcome from a repo probe + elapsed time (#7741). Pure and deterministic. + * A resolved/departed probe is terminal immediately; otherwise the transfer stays pending until it has been + * outstanding for `expiryMs` (default {@link APR_REPO_TRANSFER_EXPIRY_MS}), at which point it is expired. + */ +export function classifyAprRepoTransferOutcome(input: { + probe: AprRepoTransferProbe; + initiatedAt: number; + now: number; + expiryMs?: number; +}): AprRepoTransferOutcome { + if (input.probe.state === "resolved_under_target") return "accepted"; + if (input.probe.state === "access_departed") return "accepted_departed"; + const expiryMs = input.expiryMs ?? APR_REPO_TRANSFER_EXPIRY_MS; + if (input.now - input.initiatedAt >= expiryMs) return "expired"; + return "pending"; +} + +/** + * Probe GitHub for the current state of a pending transfer (#7741): read the repo at its ORIGINAL path with the + * App installation token (same token source as initiation). GitHub redirects a completed transfer to its new + * location, so a 2xx whose owner is now `newOwner` means accepted; a 404 means the App lost access because + * ownership moved (accepted-and-departed); anything else (still under the original owner, or a transient error) + * is treated as still pending so the next poll retries. Never throws. + */ +export async function probeAprRepoTransfer( + env: Env, + transfer: Pick, +): Promise { + const token = await createInstallationToken(env, transfer.installationId); + const response = await timeoutFetch(`https://api.github.com/repos/${transfer.repoFullName}`, { + headers: githubHeaders({ token }), + }); + if (response.status === 404) return { state: "access_departed" }; + if (!response.ok) return { state: "pending" }; + const body = (await response.json().catch(() => null)) as { owner?: { login?: string } } | null; + const owner = body?.owner?.login; + if (owner && owner.toLowerCase() === transfer.newOwner.toLowerCase()) return { state: "resolved_under_target" }; + return { state: "pending" }; +} + +/** + * Pause or resume AMS dispatch for a repo by toggling the EXISTING per-repo `agentPaused` kill-switch (#7741 + * deliverable 2) — no new pause mechanism. Freezes dispatch while a transfer is pending; releases it once the + * transfer resolves or expires. + */ +export async function setAprRepoDispatchPaused(env: Env, repoFullName: string, paused: boolean): Promise { + await upsertRepositorySettings(env, { repoFullName, agentPaused: paused }); +} + +/** + * Load the transfers still awaiting acceptance (#7741). Fail-empty until the pending-transfer record store + * (#7664) lands: today there is nothing to persist a pending row to, so — exactly like + * {@link loadAprIdeaCompletion} — this returns none and the poll no-ops. Swap the body (keep the signature) + * once #7664 persists rows and every caller picks it up. + */ +export async function loadPendingAprRepoTransfers(_env: Env): Promise { + return []; +} + +/** + * Record a resolved transfer's terminal outcome (#7741). No-op until the pending-transfer record store (#7664) + * lands — mirrors {@link loadPendingAprRepoTransfers}. Kept as an injectable seam so the poll's terminal branch + * is exercised and swapping in real persistence needs no call-site change. + */ +export async function recordAprRepoTransferOutcome( + _env: Env, + _transfer: PendingAprRepoTransfer, + _outcome: TerminalAprRepoTransferOutcome, +): Promise { + // Intentionally empty until #7664 persists a pending-transfer record to update. +} + +/** Injected dependencies for {@link pollPendingAprRepoTransfers}. Every seam is provided so it is cron-free testable. */ +export type AprRepoTransferPollDeps = { + listPending: (env: Env) => Promise; + probe: (env: Env, transfer: PendingAprRepoTransfer) => Promise; + now: () => number; + markResolved: (env: Env, transfer: PendingAprRepoTransfer, outcome: TerminalAprRepoTransferOutcome) => Promise; + setDispatchPaused: (env: Env, repoFullName: string, paused: boolean) => Promise; + expiryMs?: number; +}; + +/** Per-transfer result of one poll pass (#7741). */ +export type AprRepoTransferPollResult = { repoFullName: string; outcome: AprRepoTransferOutcome }; + +/** + * Resolve every pending APR repo transfer in one poll pass (#7741 deliverables 1+2). For each pending transfer: + * probe GitHub, classify the outcome, and reconcile the per-repo pause — + * - `pending`: keep AMS dispatch frozen (idempotent re-assert) and leave the record pending; + * - `accepted` (App still installed) or `expired`/declined (the repo never left): record it and RESUME dispatch; + * - `accepted_departed` (App lost access — ownership moved away): record it but leave dispatch alone — there is + * nothing left to resume. + * All IO is injected, so the detection/expiry/pause logic is unit-testable without the live cron. + */ +export async function pollPendingAprRepoTransfers( + env: Env, + deps: AprRepoTransferPollDeps, +): Promise { + const pending = await deps.listPending(env); + const now = deps.now(); + const results: AprRepoTransferPollResult[] = []; + for (const transfer of pending) { + const probe = await deps.probe(env, transfer); + const outcome = classifyAprRepoTransferOutcome({ + probe, + initiatedAt: transfer.initiatedAt, + now, + ...(deps.expiryMs !== undefined ? { expiryMs: deps.expiryMs } : {}), + }); + if (outcome === "pending") { + await deps.setDispatchPaused(env, transfer.repoFullName, true); + } else { + await deps.markResolved(env, transfer, outcome); + if (outcome !== "accepted_departed") await deps.setDispatchPaused(env, transfer.repoFullName, false); + } + results.push({ repoFullName: transfer.repoFullName, outcome }); + } + return results; +} diff --git a/src/queue/job-dispatch.ts b/src/queue/job-dispatch.ts index 6136c1a38c..ca08bf7ac1 100644 --- a/src/queue/job-dispatch.ts +++ b/src/queue/job-dispatch.ts @@ -34,6 +34,13 @@ import { runSelfTuneBreaker } from "../review/outcomes-wire"; import { isRagEnabled } from "../review/rag-wire"; import { processSubmitDraft } from "../services/draft"; import { retryFailedRelays } from "../orb/relay"; +import { + loadPendingAprRepoTransfers, + pollPendingAprRepoTransfers, + probeAprRepoTransfer, + recordAprRepoTransferOutcome, + setAprRepoDispatchPaused, +} from "../orb/apr-repo-transfer"; import { syncBrokeredInstalledRepos } from "../orb/installed-repos-sync"; import { incr } from "../selfhost/metrics"; import { generateSignalSnapshots } from "./signal-snapshot"; @@ -388,6 +395,20 @@ export async function processJob(env: Env, message: JobMessage): Promise { // an empty table). Never throws. await retryFailedRelays(env); return; + /* v8 ignore start -- live-loop wiring: binds the injectable, unit-tested pollPendingAprRepoTransfers (#7741) + to its real dependencies. The detection/expiry/pause logic is covered directly in + test/unit/orb-apr-repo-transfer.test.ts; this arm is a no-op today (loadPendingAprRepoTransfers fail-empties + until #7664 persists rows) and is enqueued only when LOOPOVER_APR_TRANSFER_POLL is set. */ + case "poll-apr-repo-transfers": + await pollPendingAprRepoTransfers(env, { + listPending: loadPendingAprRepoTransfers, + probe: probeAprRepoTransfer, + now: Date.now, + markResolved: recordAprRepoTransferOutcome, + setDispatchPaused: setAprRepoDispatchPaused, + }); + return; + /* v8 ignore stop */ default: // An unrecognized job type (a stale queued message from a renamed/removed type, a producer/consumer skew // during a rolling deploy, or a corrupted payload) would otherwise fall through and be acked with zero diff --git a/src/types.ts b/src/types.ts index bd8e3cc5fb..ae18fc9c54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -278,6 +278,13 @@ export type JobMessage = type: "retry-orb-relay"; requestedBy: "schedule" | "test"; } + | { + // APR repo-transfer acceptance/expiry detection (#7741): resolve every pending APR transfer — probe GitHub, + // mark accepted / accepted-and-departed / expired (>7 days), reconcile the per-repo AMS pause. Enqueued by + // the cron hourly ONLY when LOOPOVER_APR_TRANSFER_POLL is set; flag-OFF (default) it is never created. + type: "poll-apr-repo-transfers"; + requestedBy: "schedule" | "test"; + } | { // Self-host backlog-convergence sweep (#selfhost-backlog-convergence): finds open PRs whose public review // surface was never published for their current head (a blind spot the periodic re-gate sweep's dispatch- diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index dad89e5381..21f61c79fe 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -364,6 +364,24 @@ describe("worker entrypoint", () => { expect(sent).toEqual([{ type: "agent-regate-sweep", requestedBy: "schedule" }]); }); + it("enqueues the APR repo-transfer poll on an hourly tick only when LOOPOVER_APR_TRANSFER_POLL is set (#7741)", async () => { + const captured: Array = []; + const env = createTestEnv({ + LOOPOVER_APR_TRANSFER_POLL: "1", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + captured.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-05-25T05:00:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + + expect(captured).toContainEqual({ type: "poll-apr-repo-transfers", requestedBy: "schedule" }); + }); + it("keeps enqueueing scheduled sweeps while prior per-PR regate jobs are queued (#2119)", async () => { // Per-PR "agent-regate-pr" backlog is normal, expected, ongoing work (staggered/rate-deferred re-reviews) — // it must NOT block the next scheduled fan-out trigger, or the sweep starves under any sustained load. diff --git a/test/unit/orb-apr-repo-transfer.test.ts b/test/unit/orb-apr-repo-transfer.test.ts index 37492e6c1b..caeac30769 100644 --- a/test/unit/orb-apr-repo-transfer.test.ts +++ b/test/unit/orb-apr-repo-transfer.test.ts @@ -1,11 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createInstallationToken } from "../../src/github/app"; +import { getRepositorySettings } from "../../src/db/repositories"; import { + APR_REPO_TRANSFER_EXPIRY_MS, + classifyAprRepoTransferOutcome, evaluateAprRepoTransferRequestEligibility, initiateAprRepoTransfer, + isAprRepoTransferPollEnabled, loadAprIdeaCompletion, + loadPendingAprRepoTransfers, + pollPendingAprRepoTransfers, + probeAprRepoTransfer, + recordAprRepoTransferOutcome, requestAprRepoTransfer, + setAprRepoDispatchPaused, + type AprRepoTransferPollDeps, + type PendingAprRepoTransfer, } from "../../src/orb/apr-repo-transfer"; import { createTestEnv } from "../helpers/d1"; @@ -190,4 +201,242 @@ describe("requestAprRepoTransfer (#7742)", () => { transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" }, }); }); + + it("pauses AMS dispatch for the source repo once a transfer is initiated (#7741 deliverable 2)", async () => { + const initiate = vi.fn().mockResolvedValue({ initiated: true, status: 202, newFullName: "customer-acct/widgets" }); + const pauseDispatch = vi.fn().mockResolvedValue(undefined); + const env = createTestEnv(); + const result = await requestAprRepoTransfer( + env, + { installationId: 3, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { initiate, loadCompletion: async () => ({ ideaComplete: true }), pauseDispatch }, + ); + expect(result.status).toBe("initiated"); + expect(pauseDispatch).toHaveBeenCalledWith(env, "loopover-repos/widgets"); + }); + + it("does NOT pause dispatch when the request is rejected or the initiation fails", async () => { + const pauseDispatch = vi.fn().mockResolvedValue(undefined); + const rejected = await requestAprRepoTransfer( + createTestEnv(), + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { initiate: vi.fn(), loadCompletion: async () => ({ ideaComplete: false }), pauseDispatch }, + ); + expect(rejected.status).toBe("rejected"); + const failed = await requestAprRepoTransfer( + createTestEnv(), + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { + initiate: vi.fn().mockResolvedValue({ initiated: false, status: 403, error: "no admin" }), + loadCompletion: async () => ({ ideaComplete: true }), + pauseDispatch, + }, + ); + expect(failed.status).toBe("failed"); + expect(pauseDispatch).not.toHaveBeenCalled(); + }); +}); + +describe("isAprRepoTransferPollEnabled (#7741)", () => { + it("is OFF unless the flag is explicitly truthy", () => { + expect(isAprRepoTransferPollEnabled({})).toBe(false); + expect(isAprRepoTransferPollEnabled({ LOOPOVER_APR_TRANSFER_POLL: "" })).toBe(false); + expect(isAprRepoTransferPollEnabled({ LOOPOVER_APR_TRANSFER_POLL: "off" })).toBe(false); + expect(isAprRepoTransferPollEnabled({ LOOPOVER_APR_TRANSFER_POLL: "1" })).toBe(true); + expect(isAprRepoTransferPollEnabled({ LOOPOVER_APR_TRANSFER_POLL: " TRUE " })).toBe(true); + }); +}); + +describe("classifyAprRepoTransferOutcome (#7741)", () => { + const initiatedAt = 1_000_000; + + it("is accepted the moment the repo resolves under the target owner", () => { + expect( + classifyAprRepoTransferOutcome({ probe: { state: "resolved_under_target" }, initiatedAt, now: initiatedAt }), + ).toBe("accepted"); + }); + + it("is accepted-and-departed when the App's access has moved away", () => { + expect( + classifyAprRepoTransferOutcome({ probe: { state: "access_departed" }, initiatedAt, now: initiatedAt + 1 }), + ).toBe("accepted_departed"); + }); + + it("stays pending inside the window and expires once the default 7-day window elapses", () => { + const withinWindow = classifyAprRepoTransferOutcome({ + probe: { state: "pending" }, + initiatedAt, + now: initiatedAt + APR_REPO_TRANSFER_EXPIRY_MS - 1, + }); + expect(withinWindow).toBe("pending"); + const atWindow = classifyAprRepoTransferOutcome({ + probe: { state: "pending" }, + initiatedAt, + now: initiatedAt + APR_REPO_TRANSFER_EXPIRY_MS, + }); + expect(atWindow).toBe("expired"); + }); + + it("honors a custom expiry override", () => { + expect( + classifyAprRepoTransferOutcome({ probe: { state: "pending" }, initiatedAt, now: initiatedAt + 500, expiryMs: 1000 }), + ).toBe("pending"); + expect( + classifyAprRepoTransferOutcome({ probe: { state: "pending" }, initiatedAt, now: initiatedAt + 1000, expiryMs: 1000 }), + ).toBe("expired"); + }); +}); + +describe("probeAprRepoTransfer (#7741)", () => { + const mockedProbeToken = vi.mocked(createInstallationToken); + const transfer = { repoFullName: "loopover-repos/widgets", newOwner: "customer-acct", installationId: 88 }; + beforeEach(() => { + mockedProbeToken.mockReset(); + mockedProbeToken.mockResolvedValue("ghs_installation_token"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reads the repo at its original path with the installation token", async () => { + let seenUrl = ""; + let seenInit: RequestInit = {}; + stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return new Response(JSON.stringify({ owner: { login: "customer-acct" } }), { status: 200 }); + }); + const probe = await probeAprRepoTransfer(createTestEnv(), transfer); + expect(seenUrl).toBe("https://api.github.com/repos/loopover-repos/widgets"); + expect((seenInit.headers as Record).authorization).toBe("Bearer ghs_installation_token"); + expect(probe).toEqual({ state: "resolved_under_target" }); + }); + + it("treats a 404 (App access gone) as accepted-and-departed", async () => { + stubFetch(() => new Response("", { status: 404 })); + expect(await probeAprRepoTransfer(createTestEnv(), transfer)).toEqual({ state: "access_departed" }); + }); + + it("stays pending while the repo is still under the original owner", async () => { + stubFetch(() => new Response(JSON.stringify({ owner: { login: "loopover-repos" } }), { status: 200 })); + expect(await probeAprRepoTransfer(createTestEnv(), transfer)).toEqual({ state: "pending" }); + }); + + it("stays pending on a 2xx body with no owner", async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + expect(await probeAprRepoTransfer(createTestEnv(), transfer)).toEqual({ state: "pending" }); + }); + + it("stays pending when the body is not valid JSON", async () => { + stubFetch(() => new Response("<>", { status: 200 })); + expect(await probeAprRepoTransfer(createTestEnv(), transfer)).toEqual({ state: "pending" }); + }); + + it("stays pending on a transient non-404 error so the next poll retries", async () => { + stubFetch(() => new Response("", { status: 500 })); + expect(await probeAprRepoTransfer(createTestEnv(), transfer)).toEqual({ state: "pending" }); + }); +}); + +describe("setAprRepoDispatchPaused (#7741 deliverable 2)", () => { + it("toggles the existing per-repo agentPaused kill-switch", async () => { + const env = createTestEnv(); + await setAprRepoDispatchPaused(env, "loopover-repos/widgets", true); + expect((await getRepositorySettings(env, "loopover-repos/widgets")).agentPaused).toBe(true); + await setAprRepoDispatchPaused(env, "loopover-repos/widgets", false); + expect((await getRepositorySettings(env, "loopover-repos/widgets")).agentPaused).toBe(false); + }); +}); + +describe("loadPendingAprRepoTransfers / recordAprRepoTransferOutcome (#7741, fail-empty until #7664)", () => { + it("loads no pending transfers until a persisted record store lands", async () => { + await expect(loadPendingAprRepoTransfers(createTestEnv())).resolves.toEqual([]); + }); + + it("records a terminal outcome as a no-op today", async () => { + await expect( + recordAprRepoTransferOutcome( + createTestEnv(), + { repoFullName: "loopover-repos/widgets", newOwner: "customer-acct", installationId: 1, initiatedAt: 0 }, + "accepted", + ), + ).resolves.toBeUndefined(); + }); +}); + +describe("pollPendingAprRepoTransfers (#7741 deliverables 1+2)", () => { + const now = 10_000_000; + const base = { newOwner: "customer-acct", installationId: 5 }; + + /** Build injectable deps whose probe answers per-repo, recording every pause/resume and terminal write. */ + function makeDeps( + pending: PendingAprRepoTransfer[], + probeByRepo: Record, + overrides: Partial = {}, + ) { + const paused: Array<{ repoFullName: string; paused: boolean }> = []; + const resolved: Array<{ repoFullName: string; outcome: string }> = []; + const deps: AprRepoTransferPollDeps = { + listPending: async () => pending, + probe: async (_env, t) => probeByRepo[t.repoFullName]!, + now: () => now, + markResolved: async (_env, t, outcome) => { + resolved.push({ repoFullName: t.repoFullName, outcome }); + }, + setDispatchPaused: async (_env, repoFullName, p) => { + paused.push({ repoFullName, paused: p }); + }, + ...overrides, + }; + return { deps, paused, resolved }; + } + + it("reconciles accepted, accepted-departed, and still-pending transfers in one pass", async () => { + const pending: PendingAprRepoTransfer[] = [ + { ...base, repoFullName: "loopover-repos/accepted", initiatedAt: now - 1000 }, + { ...base, repoFullName: "loopover-repos/departed", initiatedAt: now - 1000 }, + { ...base, repoFullName: "loopover-repos/waiting", initiatedAt: now - 1000 }, + ]; + const { deps, paused, resolved } = makeDeps( + pending, + { + "loopover-repos/accepted": { state: "resolved_under_target" }, + "loopover-repos/departed": { state: "access_departed" }, + "loopover-repos/waiting": { state: "pending" }, + }, + { expiryMs: 5000 }, + ); + + const results = await pollPendingAprRepoTransfers(createTestEnv(), deps); + + expect(results).toEqual([ + { repoFullName: "loopover-repos/accepted", outcome: "accepted" }, + { repoFullName: "loopover-repos/departed", outcome: "accepted_departed" }, + { repoFullName: "loopover-repos/waiting", outcome: "pending" }, + ]); + // accepted → resume (still installed); departed → no pause toggle; pending → re-assert the freeze. + expect(paused).toEqual([ + { repoFullName: "loopover-repos/accepted", paused: false }, + { repoFullName: "loopover-repos/waiting", paused: true }, + ]); + expect(resolved).toEqual([ + { repoFullName: "loopover-repos/accepted", outcome: "accepted" }, + { repoFullName: "loopover-repos/departed", outcome: "accepted_departed" }, + ]); + }); + + it("expires a transfer that never resolves within the default 7-day window and resumes dispatch", async () => { + const pending: PendingAprRepoTransfer[] = [ + { ...base, repoFullName: "loopover-repos/stale", initiatedAt: now - APR_REPO_TRANSFER_EXPIRY_MS }, + ]; + const { deps, paused, resolved } = makeDeps(pending, { + "loopover-repos/stale": { state: "pending" }, + }); + + const results = await pollPendingAprRepoTransfers(createTestEnv(), deps); + + expect(results).toEqual([{ repoFullName: "loopover-repos/stale", outcome: "expired" }]); + expect(resolved).toEqual([{ repoFullName: "loopover-repos/stale", outcome: "expired" }]); + expect(paused).toEqual([{ repoFullName: "loopover-repos/stale", paused: false }]); + }); });