Skip to content
Closed
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
22 changes: 22 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -1974,6 +2018,25 @@ export class GittensoryMcp {
};
}

private async simulateOpenPrPressure(input: { login: string; owner: string; repo: string }): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async checkSlopRisk(input: z.infer<z.ZodObject<typeof checkSlopRiskShape>>): Promise<ToolPayload> {
const assessment = buildSlopAssessment(input);
return {
Expand Down
60 changes: 60 additions & 0 deletions src/services/open-pr-pressure-response.ts
Original file line number Diff line number Diff line change
@@ -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<ContributorOpenPrPressureResponse | null> {
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,
};
}
65 changes: 65 additions & 0 deletions src/signals/extension-open-pr-pressure.ts
Original file line number Diff line number Diff line change
@@ -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)).`);
}
17 changes: 17 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
99 changes: 99 additions & 0 deletions test/unit/extension-open-pr-pressure.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading
Loading