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
38 changes: 38 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../services/maintainer-activation";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy } from "../signals/focus-manifest";
import { loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
Expand Down Expand Up @@ -1790,6 +1791,38 @@ export function createApp() {
return c.json(await getRepositorySettings(c.env, fullName));
});

// Maintainer activation demo (#701): a repo-specific "here's what Gittensory would have surfaced" preview
// over recent PRs, plus a one-click advisory ramp. Maintainer-scoped + per-repo. Deterministic (no AI run).
app.get("/v1/repos/:owner/:repo/activation-preview", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoMaintainer(c, fullName);
if (gate instanceof Response) return gate;
const [repo, settings, pullRequests] = await Promise.all([
getRepository(c.env, fullName),
getRepositorySettings(c.env, fullName),
listPullRequests(c.env, fullName),
]);
return c.json(buildMaintainerActivationPreview({ repoFullName: fullName, repo, settings, pullRequests, generatedAt: nowIso() }));
});

// One-click "enable advisory mode" — turns on the gate + deterministic rules in advisory (non-blocking)
// mode. Merges onto current settings so unrelated fields are preserved.
app.post("/v1/repos/:owner/:repo/activation", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoMaintainer(c, fullName);
if (gate instanceof Response) return gate;
const current = await getRepositorySettings(c.env, fullName);
const updated = await upsertRepositorySettings(c.env, { ...current, ...recommendedAdvisoryActivationSettings() });
return c.json({
repoFullName: fullName,
gateCheckMode: updated.gateCheckMode,
checkRunMode: updated.checkRunMode,
linkedIssueGateMode: updated.linkedIssueGateMode,
duplicatePrGateMode: updated.duplicatePrGateMode,
qualityGateMode: updated.qualityGateMode,
});
});

// Maintainer self-serve AI-review config (non-secret: mode/byok/provider/model). Session-authenticated +
// scoped to repos the maintainer owns/maintains. The secret provider key goes through the ai-key route.
// Merges onto current settings so unrelated settings are preserved.
Expand Down Expand Up @@ -3901,6 +3934,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (path.startsWith("/v1/app/")) return true;
if (isIssueQualityPath(path)) return true;
if (isRepoSettingsPath(path)) return true;
if (isRepoActivationPath(path)) return true;
if (isRepoSettingsPreviewPath(path)) return true;
if (isRepoOnboardingPackPreviewPath(path)) return true;
if (isRepoFocusManifestPath(path)) return true;
Expand All @@ -3915,6 +3949,10 @@ function isRepoSettingsPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/settings$/.test(path);
}

function isRepoActivationPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/activation(?:-preview)?$/.test(path);
}

function isRepoSettingsPreviewPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/settings-preview$/.test(path);
}
Expand Down
115 changes: 115 additions & 0 deletions src/services/maintainer-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { buildPullRequestAdvisory } from "../rules/advisory";
import type { AdvisorySeverity, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../types";

export type MaintainerActivationFinding = {
code: string;
severity: AdvisorySeverity;
title: string;
};

export type MaintainerActivationSample = {
number: number;
title: string;
severity: AdvisorySeverity;
findingCount: number;
findings: MaintainerActivationFinding[];
};

export type MaintainerActivationPreview = {
repoFullName: string;
generatedAt: string;
// What's on today, so the UI can show the current state next to the one-click ramp.
currentGateMode: RepositorySettings["gateCheckMode"];
aiReviewConfigured: boolean;
evaluatedCount: number;
withFindingsCount: number;
// Distinct advisory finding codes seen across the sampled PRs, with counts (the "here's what we'd flag").
findingCodeCounts: Array<{ code: string; count: number }>;
samples: MaintainerActivationSample[];
// The single next action for the maintainer. null once advisory/blocking is already enabled.
recommendedAction: "enable_advisory" | null;
summary: string;
};

const DEFAULT_SAMPLE_SIZE = 10;

function recencyKey(pr: PullRequestRecord): string {
return pr.updatedAt ?? pr.createdAt ?? "";
}

/**
* Repo-specific install demo (#701): runs the deterministic advisory engine over the repo's most recent PRs
* so a newly-installed maintainer sees concrete "here's what Gittensory would have surfaced" evidence. Pure
* over already-loaded data; never runs AI (no surprise cost) — it only reports whether AI review is already
* configured. Maintainer-private (served behind requireRepoMaintainer); PR titles are already public on GitHub.
*/
export function buildMaintainerActivationPreview(args: {
repoFullName: string;
repo: RepositoryRecord | null;
settings: RepositorySettings;
pullRequests: PullRequestRecord[];
generatedAt: string;
sampleSize?: number;
}): MaintainerActivationPreview {
const sampleSize = Math.min(Math.max(args.sampleSize ?? DEFAULT_SAMPLE_SIZE, 1), 25);
const recent = [...args.pullRequests].sort((left, right) => recencyKey(right).localeCompare(recencyKey(left))).slice(0, sampleSize);

const codeCounts = new Map<string, number>();
const samples: MaintainerActivationSample[] = recent.map((pr) => {
const advisory = buildPullRequestAdvisory(args.repo, pr, {
otherOpenPullRequests: args.pullRequests.filter((other) => other.number !== pr.number),
requireLinkedIssue: true,
});
for (const finding of advisory.findings) codeCounts.set(finding.code, (codeCounts.get(finding.code) ?? 0) + 1);
return {
number: pr.number,
title: pr.title,
severity: advisory.severity,
findingCount: advisory.findings.length,
findings: advisory.findings.map((finding) => ({ code: finding.code, severity: finding.severity, title: finding.title })),
};
});

const withFindingsCount = samples.filter((sample) => sample.findingCount > 0).length;
const findingCodeCounts = [...codeCounts.entries()]
.map(([code, count]) => ({ code, count }))
.sort((left, right) => right.count - left.count || left.code.localeCompare(right.code));
const currentlyActive = args.settings.gateCheckMode === "enabled";

return {
repoFullName: args.repoFullName,
generatedAt: args.generatedAt,
currentGateMode: args.settings.gateCheckMode,
aiReviewConfigured: args.settings.aiReviewMode !== "off",
evaluatedCount: samples.length,
withFindingsCount,
findingCodeCounts,
samples,
recommendedAction: currentlyActive ? null : "enable_advisory",
summary: buildSummary(samples.length, withFindingsCount, currentlyActive),
};
}

function buildSummary(evaluated: number, withFindings: number, currentlyActive: boolean): string {
if (evaluated === 0) return "No recent pull requests are cached yet; Gittensory will start surfacing guidance as new PRs arrive.";
const base = `Gittensory reviewed your ${evaluated} most recent pull request(s) and would have surfaced guidance on ${withFindings} of them.`;
return currentlyActive ? `${base} The Gittensory gate is already enabled.` : `${base} Enable advisory mode to start surfacing this guidance automatically.`;
}

/**
* The one-click "enable advisory mode" patch. Advisory-first by design (#525 cross-cutting AC): turns on the
* gate check + the deterministic rules in ADVISORY mode (never blocking, never auto-merge). AI review stays
* off — it's opt-in via the ai-review route. Merged onto current settings so unrelated fields are preserved.
*/
export function recommendedAdvisoryActivationSettings(): Pick<
RepositorySettings,
"gateCheckMode" | "checkRunMode" | "linkedIssueGateMode" | "duplicatePrGateMode" | "qualityGateMode"
> {
return {
gateCheckMode: "enabled",
checkRunMode: "enabled",
linkedIssueGateMode: "advisory",
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
};
}
53 changes: 53 additions & 0 deletions test/integration/maintainer-activation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { createSessionForGitHubUser } from "../../src/auth/security";
import { getRepositorySettings } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";

const FULL_NAME = "owner/repo";
const PATH_PREVIEW = "/v1/repos/owner/repo/activation-preview";
const PATH_ACTIVATE = "/v1/repos/owner/repo/activation";

describe("maintainer activation routes", () => {
it("lets a maintainer preview activation and flip on advisory mode in one action", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-admin" });
const { token } = await createSessionForGitHubUser(env, { login: "operator-admin", id: 1 });
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };

const preview = await app.request(PATH_PREVIEW, { headers }, env);
expect(preview.status).toBe(200);
const previewBody = (await preview.json()) as { repoFullName: string; recommendedAction: string | null; currentGateMode: string; evaluatedCount: number };
expect(previewBody).toMatchObject({ repoFullName: FULL_NAME, recommendedAction: "enable_advisory", currentGateMode: "off", evaluatedCount: 0 });

const activate = await app.request(PATH_ACTIVATE, { method: "POST", headers, body: "{}" }, env);
expect(activate.status).toBe(200);
expect(await activate.json()).toMatchObject({
repoFullName: FULL_NAME,
gateCheckMode: "enabled",
linkedIssueGateMode: "advisory",
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
});

// The flip persisted, and the preview now reports nothing left to enable.
expect((await getRepositorySettings(env, FULL_NAME)).gateCheckMode).toBe("enabled");
const afterPreview = await app.request(PATH_PREVIEW, { headers }, env);
expect((await afterPreview.json() as { recommendedAction: string | null }).recommendedAction).toBeNull();
});

it("forbids a non-maintainer session from the activation preview", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-admin" });
const { token } = await createSessionForGitHubUser(env, { login: "random-user", id: 2 });
const response = await app.request(PATH_PREVIEW, { headers: { authorization: `Bearer ${token}` } }, env);
expect(response.status).toBe(403);
});

it("allows a server-to-server token", async () => {
const app = createApp();
const env = createTestEnv();
const response = await app.request(PATH_PREVIEW, { headers: { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}` } }, env);
expect(response.status).toBe(200);
});
});
Loading