diff --git a/src/api/routes.ts b/src/api/routes.ts index 2fb245501c..2d53bc620a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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"; @@ -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. @@ -3901,6 +3934,7 @@ function canSessionAccessPath(env: Env, identity: Extract; + 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(); + 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", + }; +} diff --git a/test/integration/maintainer-activation.test.ts b/test/integration/maintainer-activation.test.ts new file mode 100644 index 0000000000..5fa312008c --- /dev/null +++ b/test/integration/maintainer-activation.test.ts @@ -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); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts new file mode 100644 index 0000000000..9f467fa279 --- /dev/null +++ b/test/unit/maintainer-activation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../../src/services/maintainer-activation"; +import type { PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; + +const repo: RepositoryRecord = { + fullName: "owner/repo", + owner: "owner", + name: "repo", + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "owner/repo", + emissionShare: 0.02, + issueDiscoveryShare: 0.5, + maintainerCut: 0, + labelMultipliers: {}, + raw: {}, + }, +}; + +function settings(overrides: Partial = {}): RepositorySettings { + return { + repoFullName: repo.fullName, + commentMode: "detected_contributors_only", + publicAudienceMode: "oss_maintainer", + publicSignalLevel: "standard", + checkRunMode: "off", + checkRunDetailLevel: "standard", + gateCheckMode: "off", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + qualityGateMinScore: null, + autoLabelEnabled: true, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_and_label", + includeMaintainerAuthors: false, + requireLinkedIssue: false, + backfillEnabled: true, + privateTrustEnabled: true, + aiReviewMode: "off", + aiReviewByok: false, + ...overrides, + }; +} + +function pr(number: number, overrides: Partial = {}): PullRequestRecord { + return { + repoFullName: repo.fullName, + number, + title: `PR ${number}`, + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + labels: [], + linkedIssues: [number + 100], + updatedAt: "2026-06-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildMaintainerActivationPreview", () => { + it("summarizes advisory findings across recent PRs and recommends advisory enable when the gate is off", () => { + const preview = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings(), + pullRequests: [pr(1, { linkedIssues: [] }), pr(2, { linkedIssues: [5] })], + generatedAt: "2026-06-14T00:00:00.000Z", + }); + + expect(preview.evaluatedCount).toBe(2); + expect(preview.withFindingsCount).toBe(1); + expect(preview.recommendedAction).toBe("enable_advisory"); + expect(preview.aiReviewConfigured).toBe(false); + expect(preview.currentGateMode).toBe("off"); + expect(preview.findingCodeCounts).toContainEqual({ code: "missing_linked_issue", count: 1 }); + + const flagged = preview.samples.find((sample) => sample.number === 1)!; + expect(flagged.findingCount).toBeGreaterThanOrEqual(1); + expect(flagged.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); + + const clean = preview.samples.find((sample) => sample.number === 2)!; + expect(clean.findingCount).toBe(0); + expect(preview.summary).toContain("would have surfaced guidance on 1"); + }); + + it("orders finding codes by count, breaking ties by code name", () => { + const preview = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings(), + // PR 1 → missing_linked_issue; PR 2 (maintainer-authored, linked) → maintainer_authored_pr. Both count 1. + pullRequests: [pr(1, { linkedIssues: [] }), pr(2, { authorAssociation: "OWNER", linkedIssues: [5] })], + generatedAt: "2026-06-14T00:00:00.000Z", + }); + expect(preview.findingCodeCounts).toEqual([ + { code: "maintainer_authored_pr", count: 1 }, + { code: "missing_linked_issue", count: 1 }, + ]); + }); + + it("recommends no action and reflects AI config when the gate is already enabled", () => { + const preview = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings({ gateCheckMode: "enabled", aiReviewMode: "advisory" }), + pullRequests: [pr(1, { linkedIssues: [] })], + generatedAt: "2026-06-14T00:00:00.000Z", + }); + expect(preview.recommendedAction).toBeNull(); + expect(preview.aiReviewConfigured).toBe(true); + expect(preview.currentGateMode).toBe("enabled"); + expect(preview.summary).toContain("already enabled"); + }); + + it("handles a repo with no cached PRs", () => { + const preview = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings(), + pullRequests: [], + generatedAt: "2026-06-14T00:00:00.000Z", + }); + expect(preview.evaluatedCount).toBe(0); + expect(preview.withFindingsCount).toBe(0); + expect(preview.samples).toEqual([]); + expect(preview.recommendedAction).toBe("enable_advisory"); + expect(preview.summary).toContain("No recent pull requests"); + }); + + it("caps and orders the sample by recency", () => { + const many = Array.from({ length: 30 }, (_, index) => pr(index + 1, { updatedAt: `2026-06-${String((index % 28) + 1).padStart(2, "0")}T00:00:00.000Z` })); + const capped = buildMaintainerActivationPreview({ repoFullName: repo.fullName, repo, settings: settings(), pullRequests: many, generatedAt: "2026-06-14T00:00:00.000Z" }); + expect(capped.evaluatedCount).toBe(10); + // Most recent updatedAt first. + expect(capped.samples[0]!.number).toBe(28); + + const small = buildMaintainerActivationPreview({ repoFullName: repo.fullName, repo, settings: settings(), pullRequests: many, generatedAt: "2026-06-14T00:00:00.000Z", sampleSize: 3 }); + expect(small.evaluatedCount).toBe(3); + }); + + it("clamps the sample size to its bounds and falls back to createdAt (or nothing) for recency", () => { + const dated = Array.from({ length: 30 }, (_, index) => pr(index + 1, { updatedAt: undefined, createdAt: `2026-05-${String((index % 28) + 1).padStart(2, "0")}T00:00:00.000Z` })); + expect(buildMaintainerActivationPreview({ repoFullName: repo.fullName, repo, settings: settings(), pullRequests: dated, generatedAt: "2026-06-14T00:00:00.000Z", sampleSize: 50 }).evaluatedCount).toBe(25); + expect(buildMaintainerActivationPreview({ repoFullName: repo.fullName, repo, settings: settings(), pullRequests: dated, generatedAt: "2026-06-14T00:00:00.000Z", sampleSize: 0 }).evaluatedCount).toBe(1); + + // PRs with no cached timestamps at all still sort/evaluate without throwing. + const undatedPreview = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings(), + pullRequests: [pr(1, { updatedAt: undefined, createdAt: undefined }), pr(2, { updatedAt: undefined, createdAt: undefined })], + generatedAt: "2026-06-14T00:00:00.000Z", + }); + expect(undatedPreview.evaluatedCount).toBe(2); + }); +}); + +describe("recommendedAdvisoryActivationSettings", () => { + it("enables the gate + deterministic rules in advisory (non-blocking) mode", () => { + expect(recommendedAdvisoryActivationSettings()).toEqual({ + gateCheckMode: "enabled", + checkRunMode: "enabled", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + }); + }); +});