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
48 changes: 48 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
getCommandUsefulnessSummary,
getFreshOfficialMinerDetection,
getIssue,
getInstallation,
getInstallationHealth,
getLatestRepoGithubTotalsSnapshot,
getLatestScoringModelSnapshot,
Expand Down Expand Up @@ -77,6 +78,7 @@ import {
listLatestRepoGithubTotalsSnapshots,
listInstallationHealth,
listInstallations,
listInstalledRepoFullNamesForInstallation,
listIssues,
listGateOutcomeAuditEventRollups,
listIssueSignalSample,
Expand Down Expand Up @@ -946,6 +948,13 @@ const maintainerSettingsSchema = z
})
.partial();

// #7676 installation-scoped bulk pause/dry-run: the same two per-repo flags maintainerSettingsSchema already
// validates, picked out on their own so a tenant with many repos under one installation can flip both at once
// instead of one PUT /v1/repos/:owner/:repo/settings call per repo. Deliberately just these two fields -- not
// a general bulk settings merge -- and deliberately separate from the global operator kill-switch
// (getGlobalAgentFrozenState), which stays its own singleton untouched by this.
const installationBulkAgentSettingsSchema = maintainerSettingsSchema.pick({ agentPaused: true, agentDryRun: true }).strict();

// downgradeQualityGateMode (the settings-write-path "block" -> "advisory" downgrade for
// qualityGateMode/#2267) was removed here: qualityGateMode is config-as-code only now (Batch C,
// loopover#6444), so no write path sets it anymore. resolveEffectiveSettings's own downgrade logic
Expand Down Expand Up @@ -2713,6 +2722,45 @@ export function createApp() {
return c.json({ ...(await buildInstallationRepairDiagnostics(c.env, refreshed)), refreshed: true });
});

// #7676: a hosted tenant with multiple repos under one installation had no way to pause/dry-run all of
// them at once -- only the strictly-per-repo PUT /v1/repos/:owner/:repo/settings existed. Layers on top
// of it: applies the same agentPaused/agentDryRun flags across every currently-installed repo in the
// installation in one call. Distinct from the global operator kill-switch (getGlobalAgentFrozenState),
// which stays a deliberately separate singleton this never touches. Same tenant-vs-tenant isolation as
// this route family's siblings above (resolveAppInstallationScope / installationRecordInScope) -- an
// operator sees/writes any installation, a non-operator session only their own.
app.put("/v1/app/installations/:id/agent/bulk-settings", async (c) => {
const resolved = await resolveAppInstallationScope(c);
if (resolved instanceof Response) return resolved;
const installationId = Number(c.req.param("id"));
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
const installation = await getInstallation(c.env, installationId);
if (!installation) return c.json({ error: "installation_not_found" }, 404);
if (!installationRecordInScope(resolved.scope, { installationId: installation.id, accountLogin: installation.accountLogin })) {
return c.json({ error: "forbidden_installation" }, 403);
}
const body = await c.req.json().catch(() => null);
const parsed = installationBulkAgentSettingsSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_bulk_agent_settings", issues: parsed.error.issues }, 400);
const changes = Object.fromEntries(Object.entries(parsed.data).filter(([, value]) => value !== undefined)) as Partial<RepositorySettings>;
const repoFullNames = await listInstalledRepoFullNamesForInstallation(c.env, installationId);
await Promise.all(
repoFullNames.map(async (repoFullName) => {
const current = await getRepositorySettings(c.env, repoFullName);
await upsertRepositorySettings(c.env, { ...current, ...changes, repoFullName });
}),
);
await recordAuditEvent(c.env, {
eventType: "installation.agent_bulk_settings_updated",
actor: resolved.identity.actor,
targetKey: `installation#${installationId}`,
outcome: "completed",
detail: `Applied bulk agent settings across ${repoFullNames.length} repo(s).`,
metadata: { installationId, repoCount: repoFullNames.length, fields: Object.keys(changes) },
});
return c.json({ ok: true, installationId, repoCount: repoFullNames.length, repoFullNames, applied: changes });
});

app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env)));

app.get("/v1/repos/:owner/:repo", async (c) => {
Expand Down
181 changes: 180 additions & 1 deletion test/integration/app-installations-selfservice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../src/api/routes";
import { createSessionForGitHubUser } from "../../src/auth/security";
import { upsertInstallation, upsertInstallationHealth } from "../../src/db/repositories";
import { getRepositorySettings, upsertInstallation, upsertInstallationHealth, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
import type { InstallationHealthRecord } from "../../src/types";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -263,6 +263,185 @@ describe("tenant self-service installation health/repair (#7661)", () => {
});
});

// #7676: installation-scoped bulk pause/dry-run. Same tenant-vs-tenant isolation as the health/repair siblings
// above (resolveAppInstallationScope / installationRecordInScope), layered on top of the existing strictly-
// per-repo PUT /v1/repos/:owner/:repo/settings.
describe("installation-scoped bulk agent pause/dry-run (#7676)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

async function seedInstallation(env: Env, id: number, login: string): Promise<void> {
await upsertInstallation(env, {
installation: {
id,
account: { login, id, type: "User" },
repository_selection: "selected",
permissions: { metadata: "read", pull_requests: "read" },
events: ["issues", "pull_request", "repository"],
},
});
}

async function seedInstalledRepo(env: Env, installationId: number, owner: string, name: string): Promise<void> {
await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, installationId);
}

function cookie(token: string): Record<string, string> {
return { cookie: `loopover_session=${token}` };
}

function apiHeaders(env: Env): Record<string, string> {
return { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" };
}

async function bulkRequest(app: ReturnType<typeof createApp>, env: Env, installationId: number | string, headers: Record<string, string>, body: unknown) {
return app.request(
`/v1/app/installations/${installationId}/agent/bulk-settings`,
{ method: "PUT", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) },
env,
);
}

it("rejects an unauthenticated caller and a session with no maintainer/owner/operator role", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");

expect((await bulkRequest(app, env, 700, {}, { agentPaused: true })).status).toBe(401);

const { token } = await createSessionForGitHubUser(env, { login: "nobody", id: 71 });
const forbidden = await bulkRequest(app, env, 700, cookie(token), { agentPaused: true });
expect(forbidden.status).toBe(403);
await expect(forbidden.json()).resolves.toMatchObject({ error: "insufficient_role" });
});

it("rejects a non-numeric installation id, an unknown installation, and a malformed body", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");
const { token } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5701 });

const invalidId = await bulkRequest(app, env, "not-a-number", cookie(token), { agentPaused: true });
expect(invalidId.status).toBe(400);
await expect(invalidId.json()).resolves.toMatchObject({ error: "invalid_installation_id" });

const missing = await bulkRequest(app, env, 999, cookie(token), { agentPaused: true });
expect(missing.status).toBe(404);
await expect(missing.json()).resolves.toMatchObject({ error: "installation_not_found" });

const malformed = await bulkRequest(app, env, 700, cookie(token), { agentPaused: "not-a-boolean" });
expect(malformed.status).toBe(400);
await expect(malformed.json()).resolves.toMatchObject({ error: "invalid_bulk_agent_settings" });

// An unrecognized field is rejected outright (.strict()), not silently dropped.
const extraField = await bulkRequest(app, env, 700, cookie(token), { agentPaused: true, gatePack: "oss-anti-slop" });
expect(extraField.status).toBe(400);

// Genuinely unparseable JSON (not just schema-invalid) -- the .catch(() => null) arm.
const unparseable = await app.request(
"/v1/app/installations/700/agent/bulk-settings",
{ method: "PUT", headers: { "content-type": "application/json", ...cookie(token) }, body: "{not valid json" },
env,
);
expect(unparseable.status).toBe(400);
await expect(unparseable.json()).resolves.toMatchObject({ error: "invalid_bulk_agent_settings" });
});

it("never lets tenant A bulk-pause tenant B's installation", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");
await seedInstallation(env, 800, "tenant-b");
await seedInstalledRepo(env, 800, "tenant-b", "repo-1");
const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5701 });

const foreign = await bulkRequest(app, env, 800, cookie(tokenA), { agentPaused: true });
expect(foreign.status).toBe(403);
await expect(foreign.json()).resolves.toMatchObject({ error: "forbidden_installation" });

// Confirms the 403 is enforced BEFORE any write: tenant-b's repo settings are completely untouched.
expect((await getRepositorySettings(env, "tenant-b/repo-1")).agentPaused).toBe(false);
});

it("applies agentPaused/agentDryRun across every installed repo in the tenant's own installation, and only those repos", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");
await seedInstalledRepo(env, 700, "tenant-a", "repo-1");
await seedInstalledRepo(env, 700, "tenant-a", "repo-2");
// A second, unrelated installation -- proves the bulk write is scoped to installation 700 only.
await seedInstallation(env, 800, "tenant-b");
await seedInstalledRepo(env, 800, "tenant-b", "repo-3");
const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5701 });

const res = await bulkRequest(app, env, 700, cookie(tokenA), { agentPaused: true, agentDryRun: true });
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; installationId: number; repoCount: number; repoFullNames: string[]; applied: object };
expect(body).toMatchObject({ ok: true, installationId: 700, repoCount: 2, applied: { agentPaused: true, agentDryRun: true } });
expect(body.repoFullNames.sort()).toEqual(["tenant-a/repo-1", "tenant-a/repo-2"]);

expect((await getRepositorySettings(env, "tenant-a/repo-1")).agentPaused).toBe(true);
expect((await getRepositorySettings(env, "tenant-a/repo-1")).agentDryRun).toBe(true);
expect((await getRepositorySettings(env, "tenant-a/repo-2")).agentPaused).toBe(true);
// The unrelated installation's repo is completely untouched.
expect((await getRepositorySettings(env, "tenant-b/repo-3")).agentPaused).toBe(false);
});

it("supports setting only one of the two flags, preserving the other's existing value", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");
await seedInstalledRepo(env, 700, "tenant-a", "repo-1");
// repo-1 already has agentDryRun on from an earlier per-repo edit, before any bulk call.
const current = await getRepositorySettings(env, "tenant-a/repo-1");
await upsertRepositorySettings(env, { ...current, agentDryRun: true, repoFullName: "tenant-a/repo-1" });
const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5701 });

const res = await bulkRequest(app, env, 700, cookie(tokenA), { agentPaused: true });
expect(res.status).toBe(200);

const updated = await getRepositorySettings(env, "tenant-a/repo-1");
expect(updated.agentPaused).toBe(true);
expect(updated.agentDryRun).toBe(true); // untouched -- the omitted field was never overwritten to false
});

it("succeeds as a no-op for an installation with zero currently-installed repos", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 700, "tenant-a");
const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5701 });

const res = await bulkRequest(app, env, 700, cookie(tokenA), { agentPaused: true });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({ ok: true, repoCount: 0, repoFullNames: [] });
});

it("lets an operator (static api token) bulk-pause any tenant's installation, with a distinct audit event", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
await seedInstallation(env, 800, "tenant-b");
await seedInstalledRepo(env, 800, "tenant-b", "repo-3");

const res = await bulkRequest(app, env, 800, apiHeaders(env), { agentDryRun: true });
expect(res.status).toBe(200);
expect((await getRepositorySettings(env, "tenant-b/repo-3")).agentDryRun).toBe(true);

const events = await env.DB.prepare("SELECT event_type, target_key, actor FROM audit_events WHERE event_type = ?")
.bind("installation.agent_bulk_settings_updated")
.all<{ event_type: string; target_key: string; actor: string }>();
expect(events.results).toHaveLength(1);
expect(events.results[0]).toMatchObject({ target_key: "installation#800", actor: "api" });
});
});

function generateRsaPrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
return privateKey.export({ type: "pkcs1", format: "pem" }).toString();
Expand Down