diff --git a/src/api/routes.ts b/src/api/routes.ts index 2bf8a04e17..b70b04be57 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -175,6 +175,7 @@ import { loadWeeklyValueReport, } from "../services/weekly-value-report"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; +import { buildContributorOpenPrPressureResponse } from "../services/open-pr-pressure-response"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; @@ -214,6 +215,7 @@ import { buildExtensionIssueBadges, buildExtensionPrStatus, } from "../signals/extension-contributor-context"; +import { buildExtensionOpenPrPressure } from "../signals/extension-open-pr-pressure"; import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; @@ -2573,6 +2575,26 @@ export function createApp() { return c.json(buildExtensionPrStatus({ repoFullName, pullNumber, readiness })); }); + // #348 open-PR pressure strategy: self-only contributor simulation of whether to open more work, + // wait, or clean up first. Public-safe bands only — no payout/reward/scoreability language. + app.get("/v1/extension/contributors/:login/open-pr-pressure", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const owner = c.req.query("owner") ?? ""; + const repoName = c.req.query("repo") ?? ""; + if (!owner || !repoName) return c.json({ error: "valid_owner_repo_required" }, 400); + const repoFullName = `${owner}/${repoName}`; + const repo = await getRepository(c.env, repoFullName); + if (!repo) return c.json({ error: "repo_not_found" }, 404); + const repoForbidden = await requireContributorRepoAccess(c, repoFullName, repo); + if (repoForbidden) return repoForbidden; + const context = await loadContributorFastContext(c.env, login); + const response = await buildContributorOpenPrPressureResponse(c.env, login, repoFullName, context.profile); + if (!response) return c.json({ error: "repo_not_found" }, 404); + return c.json(buildExtensionOpenPrPressure(response)); + }); + app.post("/v1/local/branch-analysis", async (c) => { const contentLength = parsePositiveInt(c.req.header("content-length")); if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bddf7263de..29329e42f4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -69,6 +69,8 @@ import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration"; +import { buildContributorOpenPrPressureResponse } from "../services/open-pr-pressure-response"; +import { buildExtensionOpenPrPressure, extensionOpenPrPressureHeadline } from "../signals/extension-open-pr-pressure"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { applyMcpPlanningChoices, @@ -636,6 +638,17 @@ const openPrMonitorOutputSchema = { pullRequests: z.unknown().optional(), }; +const openPrPressureOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + contributorOpenPrCount: z.number().optional(), + queuePressure: z.string().optional(), + recommendedOption: z.string().optional(), + summary: z.string().optional(), + scenarios: z.unknown().optional(), +}; + const notificationsOutputSchema = { login: z.string().optional(), unreadCount: z.number().optional(), @@ -1085,6 +1098,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.monitorOpenPullRequests(input.login)), ); + server.registerTool( + "gittensory_simulate_open_pr_pressure", + { + description: + "Simulate open-PR pressure strategy options for a contributor on a repo: open new work, wait, or clean up first. Self-scoped, public-safe, advisory only.", + inputSchema: loginRepoShape, + outputSchema: openPrPressureOutputSchema, + }, + async (input) => this.toolResult(await this.simulateOpenPrPressure(input)), + ); + server.registerTool( "gittensory_predict_gate", { @@ -1644,6 +1668,26 @@ export class GittensoryMcp { }), ); + server.registerPrompt( + "gittensory_plan_open_pr_strategy", + { + title: "Plan open-PR strategy", + description: "Compare open-new-work, wait, and cleanup-first options for a contributor on a repo using queue pressure signals. Advisory only.", + argsSchema: { ...ownerRepoShape, login: z.string().min(1) }, + }, + ({ owner, repo, login }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Use gittensory_simulate_open_pr_pressure for ${login} on ${owner}/${repo} to compare strategy options before opening another PR. Summarize the recommended option, queue pressure band, and the top facts/blockers for each scenario. Do not open a PR or take any GitHub action — present guidance for the contributor to decide manually.`, + }, + }, + ], + }), + ); + server.registerPrompt( "gittensory_plan_cleanup_first", { @@ -1974,6 +2018,25 @@ export class GittensoryMcp { }; } + private async simulateOpenPrPressure(input: { login: string; owner: string; repo: string }): Promise { + this.requireContributorAccess(input.login); + const fullName = `${input.owner}/${input.repo}`; + await this.requireWatchableRepo(input.login, fullName); + const response = await buildContributorOpenPrPressureResponse(this.env, input.login, fullName); + if (!response) { + return { + summary: `Gittensory has no cached metadata for ${fullName}.`, + data: { status: "not_found", repoFullName: fullName }, + }; + } + const simulation = response.simulation; + const extension = buildExtensionOpenPrPressure(response); + return { + summary: extensionOpenPrPressureHeadline(extension), + data: extension as unknown as Record, + }; + } + private async checkSlopRisk(input: z.infer>): Promise { const assessment = buildSlopAssessment(input); return { diff --git a/src/services/open-pr-pressure-response.ts b/src/services/open-pr-pressure-response.ts new file mode 100644 index 0000000000..f32fc72cbb --- /dev/null +++ b/src/services/open-pr-pressure-response.ts @@ -0,0 +1,60 @@ +import { getRepository, listIssues, listPullRequests } from "../db/repositories"; +import type { ContributorProfile } from "../signals/engine"; +import { buildCollisionReport, buildQueueHealth, buildRoleContext } from "../signals/engine"; +import { nowIso } from "../utils/json"; +import { simulateOpenPrPressure, type OpenPrPressureSimulation } from "./open-pr-pressure-scenarios"; + +export type ContributorOpenPrPressureResponse = { + login: string; + repoFullName: string; + generatedAt: string; + contributorOpenPrCount: number; + simulation: OpenPrPressureSimulation; +}; + +/** + * Build a contributor-scoped open-PR pressure strategy simulation for one repo. + * Mirrors the simulation wired into local-branch analysis (#348) but exposes it as a + * dedicated read-only surface for extension clients and MCP agents. + */ +export async function buildContributorOpenPrPressureResponse( + env: Env, + login: string, + repoFullName: string, + profile: ContributorProfile | null = null, +): Promise { + const repo = await getRepository(env, repoFullName); + if (!repo) return null; + + const [issues, pullRequests] = await Promise.all([listIssues(env, repoFullName), listPullRequests(env, repoFullName)]); + const roleContext = buildRoleContext({ + login, + repo, + repoFullName, + pullRequests, + issues, + profile, + }); + const collisions = buildCollisionReport(repoFullName, issues, pullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); + const normalizedLogin = login.toLowerCase(); + const contributorOpenPrCount = pullRequests.filter( + (pr) => pr.state === "open" && (pr.authorLogin ?? "").toLowerCase() === normalizedLogin, + ).length; + const generatedAt = nowIso(); + const simulation = simulateOpenPrPressure({ + repoFullName, + generatedAt, + queueHealth, + roleContext, + contributorOpenPrCount, + }); + + return { + login, + repoFullName, + generatedAt, + contributorOpenPrCount, + simulation, + }; +} diff --git a/src/signals/extension-open-pr-pressure.ts b/src/signals/extension-open-pr-pressure.ts new file mode 100644 index 0000000000..b106113946 --- /dev/null +++ b/src/signals/extension-open-pr-pressure.ts @@ -0,0 +1,65 @@ +import type { ContributorOpenPrPressureResponse } from "../services/open-pr-pressure-response"; +import type { OpenPrPressureSimulation, OpenPrStrategyOption, OpenPrStrategyScenario } from "../services/open-pr-pressure-scenarios"; +import { redactExtensionText } from "./extension-contributor-context"; + +// ─── Extension contributor open-PR pressure (#348 exposure) ─────────────────────────────────── +// Public-safe overlay for the open-PR pressure simulator. Reuses the extension redaction helper so +// any free-form scenario text is scrubbed before it reaches a contributor browser session or MCP agent. + +export type ExtensionOpenPrStrategyScenario = { + option: OpenPrStrategyOption; + label: string; + rank: number; + recommended: boolean; + facts: string[]; + assumptions: string[]; + tradeoffs: string[]; + blockers: string[]; +}; + +export type ExtensionOpenPrPressure = { + login: string; + repoFullName: string; + generatedAt: string; + contributorOpenPrCount: number; + queuePressure: OpenPrPressureSimulation["queuePressure"]; + recommendedOption: OpenPrStrategyOption; + summary: string; + scenarios: ExtensionOpenPrStrategyScenario[]; +}; + +function redactScenarioStrings(values: string[]): string[] { + return values.map((value) => redactExtensionText(value)); +} + +function toExtensionScenario(scenario: OpenPrStrategyScenario): ExtensionOpenPrStrategyScenario { + return { + option: scenario.option, + label: redactExtensionText(scenario.label), + rank: scenario.rank, + recommended: scenario.recommended, + facts: redactScenarioStrings(scenario.facts), + assumptions: redactScenarioStrings(scenario.assumptions), + tradeoffs: redactScenarioStrings(scenario.tradeoffs), + blockers: redactScenarioStrings(scenario.blockers), + }; +} + +export function buildExtensionOpenPrPressure(response: ContributorOpenPrPressureResponse): ExtensionOpenPrPressure { + const { simulation } = response; + return { + login: response.login, + repoFullName: response.repoFullName, + generatedAt: response.generatedAt, + contributorOpenPrCount: response.contributorOpenPrCount, + queuePressure: simulation.queuePressure, + recommendedOption: simulation.recommendedOption, + summary: redactExtensionText(simulation.summary), + scenarios: simulation.scenarios.map(toExtensionScenario), + }; +} + +export function extensionOpenPrPressureHeadline(pressure: ExtensionOpenPrPressure): string { + const option = pressure.scenarios.find((entry) => entry.recommended)?.label ?? pressure.recommendedOption; + return redactExtensionText(`${pressure.repoFullName}: ${option} (${pressure.queuePressure} queue pressure, ${pressure.contributorOpenPrCount} open PR(s)).`); +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index f91cad1263..9f625f9777 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -3863,6 +3863,20 @@ describe("api routes", () => { expect(JSON.stringify(prStatusBody)).not.toMatch(/"(?:score|total|max)":/); expect(JSON.stringify(prStatusBody)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); + const pressure = await app.request("/v1/extension/contributors/contributor-dev/open-pr-pressure?owner=octo&repo=demo", { headers: bearer }, env); + expect(pressure.status).toBe(200); + const pressureBody = (await pressure.json()) as { + contributorOpenPrCount: number; + queuePressure: string; + recommendedOption: string; + scenarios: Array<{ option: string; recommended: boolean }>; + }; + expect(pressureBody.contributorOpenPrCount).toBeGreaterThanOrEqual(2); + expect(["low", "medium", "high", "critical", "unknown"]).toContain(pressureBody.queuePressure); + expect(["open_new_work", "wait", "cleanup_first"]).toContain(pressureBody.recommendedOption); + expect(pressureBody.scenarios.some((entry) => entry.recommended)).toBe(true); + expect(JSON.stringify(pressureBody)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); + // The contributor's OWN bodyless PR still resolves to a public-safe readiness band (body defaults cleanly). const bodylessPrStatus = await app.request("/v1/extension/contributors/contributor-dev/pr-status?owner=octo&repo=demo&pullNumber=14", { headers: bearer }, env); expect(bodylessPrStatus.status).toBe(200); @@ -3889,6 +3903,9 @@ describe("api routes", () => { expect((await app.request("/v1/extension/contributors/contributor-dev/pr-status?repo=demo&pullNumber=12", { headers: bearer }, env)).status).toBe(400); expect((await app.request("/v1/extension/contributors/contributor-dev/pr-status?owner=octo&pullNumber=12", { headers: bearer }, env)).status).toBe(400); expect((await app.request("/v1/extension/contributors/contributor-dev/pr-status?owner=octo&repo=demo&pullNumber=abc", { headers: bearer }, env)).status).toBe(400); + expect((await app.request("/v1/extension/contributors/contributor-dev/open-pr-pressure?owner=octo", { headers: bearer }, env)).status).toBe(400); + expect((await app.request("/v1/extension/contributors/contributor-dev/open-pr-pressure?repo=demo", { headers: bearer }, env)).status).toBe(400); + expect((await app.request("/v1/extension/contributors/contributor-dev/open-pr-pressure?owner=victim-org&repo=secret", { headers: bearer }, env)).status).toBe(403); // Repo not found → 404. expect((await app.request("/v1/extension/contributors/contributor-dev/issue-fit?owner=no&repo=such&issueNumber=1", { headers: bearer }, env)).status).toBe(404); expect((await app.request("/v1/extension/contributors/contributor-dev/issue-badges?owner=no&repo=such", { headers: bearer }, env)).status).toBe(404); diff --git a/test/unit/extension-open-pr-pressure.test.ts b/test/unit/extension-open-pr-pressure.test.ts new file mode 100644 index 0000000000..ffe44ca406 --- /dev/null +++ b/test/unit/extension-open-pr-pressure.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { buildExtensionOpenPrPressure, extensionOpenPrPressureHeadline } from "../../src/signals/extension-open-pr-pressure"; +import type { ContributorOpenPrPressureResponse } from "../../src/services/open-pr-pressure-response"; + +const FORBIDDEN_PUBLIC_LANGUAGE = + /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|reward estimate|raw trust|trust score|scoreability|private reviewability|estimated score|score estimate|farming/i; + +function sampleResponse(overrides: Partial = {}): ContributorOpenPrPressureResponse { + return { + login: "miner-a", + repoFullName: "octo/demo", + generatedAt: "2026-06-03T00:00:00.000Z", + contributorOpenPrCount: 2, + simulation: { + repoFullName: "octo/demo", + generatedAt: "2026-06-03T00:00:00.000Z", + lane: "contributor", + queuePressure: "high", + recommendedOption: "cleanup_first", + summary: "Clean up existing work before opening another PR.", + scenarios: [ + { + option: "cleanup_first", + label: "Clean up existing work first", + rank: 1, + recommended: true, + facts: ["Repo queue pressure is high.", "You have 2 open PR(s) on this repo."], + assumptions: ["Maintainers review oldest work first."], + tradeoffs: ["Delays starting new work."], + blockers: ["Multiple open PRs increase review friction."], + }, + { + option: "wait", + label: "Wait before opening more", + rank: 2, + recommended: false, + facts: ["Repo queue pressure is high."], + assumptions: ["Queue may clear soon."], + tradeoffs: ["May miss a timely issue."], + blockers: [], + }, + { + option: "open_new_work", + label: "Open another PR now", + rank: 3, + recommended: false, + facts: ["Repo queue pressure is high."], + assumptions: ["New work is small and reviewable."], + tradeoffs: ["Adds more queue pressure."], + blockers: ["Review backlog is already elevated."], + }, + ], + }, + ...overrides, + }; +} + +describe("extension open-PR pressure shaping", () => { + it("redacts forbidden private terms from scenario text", () => { + const shaped = buildExtensionOpenPrPressure( + sampleResponse({ + simulation: { + ...sampleResponse().simulation, + summary: "Avoid wallet language and raw trust score claims.", + scenarios: [ + { + option: "wait", + label: "Wait before opening more", + rank: 1, + recommended: true, + facts: ["Do not mention payout estimates."], + assumptions: ["No hotkey material in public comments."], + tradeoffs: ["scoreability stays private"], + blockers: ["reward estimate language"], + }, + ], + }, + }), + ); + expect(JSON.stringify(shaped)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + expect(shaped.summary).toContain("[redacted]"); + }); + + it("preserves strategy structure and recommended option", () => { + const shaped = buildExtensionOpenPrPressure(sampleResponse()); + expect(shaped.recommendedOption).toBe("cleanup_first"); + expect(shaped.contributorOpenPrCount).toBe(2); + expect(shaped.scenarios).toHaveLength(3); + expect(shaped.scenarios.find((entry) => entry.recommended)?.option).toBe("cleanup_first"); + }); + + it("builds a public-safe headline from the recommended scenario label", () => { + const shaped = buildExtensionOpenPrPressure(sampleResponse()); + const headline = extensionOpenPrPressureHeadline(shaped); + expect(headline).toContain("octo/demo"); + expect(headline).toContain("Clean up existing work first"); + expect(headline).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 02eb3c9626..40464c1014 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -17,6 +17,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_contributor_profile", "gittensory_get_decision_pack", "gittensory_monitor_open_prs", + "gittensory_simulate_open_pr_pressure", "gittensory_explain_repo_decision", "gittensory_get_issue_quality", "gittensory_validate_linked_issue", @@ -406,6 +407,30 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(data.recommendations).toBeTruthy(); expect(Array.isArray(data.signals)).toBe(true); }); + + it("gittensory_simulate_open_pr_pressure returns strategy options for a contributor repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertPullRequestFromGitHub(env, "octo/demo", { + number: 1, + title: "Open work", + state: "open", + user: { login: "alice" }, + head: { sha: "abc", ref: "feat" }, + base: { ref: "main" }, + }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_simulate_open_pr_pressure", + arguments: { login: "alice", owner: "octo", repo: "demo" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + expect(data.contributorOpenPrCount).toBe(1); + expect(data.recommendedOption).toMatch(/^(open_new_work|wait|cleanup_first)$/); + expect(Array.isArray(data.scenarios)).toBe(true); + }); }); // ── Public/private safety ───────────────────────────────────────────────────── diff --git a/test/unit/open-pr-pressure-response.test.ts b/test/unit/open-pr-pressure-response.test.ts new file mode 100644 index 0000000000..30bd7d0eed --- /dev/null +++ b/test/unit/open-pr-pressure-response.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { buildContributorOpenPrPressureResponse } from "../../src/services/open-pr-pressure-response"; +import { createTestEnv } from "../helpers/d1"; + +const FORBIDDEN_PUBLIC_LANGUAGE = + /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|reward estimate|raw trust|trust score|scoreability|private reviewability|estimated score|score estimate|farming/i; + +describe("buildContributorOpenPrPressureResponse", () => { + it("returns strategy simulation with contributor open PR count for a registered repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Queue issue", state: "open", user: { login: "octo" }, labels: [{ name: "feature" }] }); + await upsertPullRequestFromGitHub(env, "octo/demo", { + number: 10, + title: "Contributor work", + state: "open", + user: { login: "miner-a" }, + labels: [], + head: { sha: "abc", ref: "feat" }, + base: { ref: "main" }, + }); + await upsertPullRequestFromGitHub(env, "octo/demo", { + number: 11, + title: "More contributor work", + state: "open", + user: { login: "miner-a" }, + labels: [], + head: { sha: "def", ref: "feat2" }, + base: { ref: "main" }, + }); + + const response = await buildContributorOpenPrPressureResponse(env, "miner-a", "octo/demo"); + expect(response).toMatchObject({ + login: "miner-a", + repoFullName: "octo/demo", + contributorOpenPrCount: 2, + simulation: { + repoFullName: "octo/demo", + lane: "contributor", + recommendedOption: expect.stringMatching(/^(open_new_work|wait|cleanup_first)$/), + scenarios: expect.arrayContaining([ + expect.objectContaining({ + option: expect.any(String), + recommended: expect.any(Boolean), + facts: expect.any(Array), + }), + ]), + }, + }); + expect(JSON.stringify(response)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + }); + + it("returns null when the repo is unknown", async () => { + const env = createTestEnv(); + await expect(buildContributorOpenPrPressureResponse(env, "miner-a", "missing/repo")).resolves.toBeNull(); + }); + + it("recommends cleanup_first when the contributor has multiple open PRs on a busy queue", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "busy", full_name: "octo/busy", private: false, owner: { login: "octo" }, default_branch: "main" }); + for (let issue = 1; issue <= 6; issue += 1) { + await upsertIssueFromGitHub(env, "octo/busy", { number: issue, title: `Issue ${issue}`, state: "open", user: { login: "octo" }, labels: [{ name: "feature" }] }); + } + for (let pr = 1; pr <= 14; pr += 1) { + await upsertPullRequestFromGitHub(env, "octo/busy", { + number: pr, + title: `PR ${pr}`, + state: "open", + user: { login: pr <= 3 ? "miner-a" : "other" }, + labels: [], + head: { sha: `sha${pr}`, ref: `branch-${pr}` }, + base: { ref: "main" }, + ...(pr > 10 ? { updated_at: "2020-01-01T00:00:00.000Z" } : {}), + }); + } + + const response = await buildContributorOpenPrPressureResponse(env, "miner-a", "octo/busy"); + expect(response?.contributorOpenPrCount).toBe(3); + expect(response?.simulation.recommendedOption).toBe("cleanup_first"); + expect(response?.simulation.scenarios.find((entry) => entry.recommended)?.option).toBe("cleanup_first"); + }); +});