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
27 changes: 27 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ const loginRepoShape = {
repo: z.string().min(1),
};

const validateLinkedIssueShape = {
owner: z.string().min(1),
repo: z.string().min(1),
issueNumber: z.number().int().positive(),
plannedChange: z
.object({
title: z.string().min(1).optional(),
changedFiles: z.array(z.string()).optional(),
contributorLogin: z.string().min(1).optional(),
})
.optional(),
};

const preflightShape = {
repoFullName: z.string().min(3),
contributorLogin: z.string().min(1).optional(),
Expand Down Expand Up @@ -261,6 +274,20 @@ server.registerTool(
async (input) => toolResult("Gittensory PR preflight.", await apiPost("/v1/preflight/pr", input)),
);

server.registerTool(
"gittensory_validate_linked_issue",
{
description:
"Report whether linking an issue will actually earn the standard linked-issue scoring multiplier for a planned PR — open, valid, single-owner, solvable by this PR — with the blocking reason if not. The raw multiplier value stays private.",
inputSchema: validateLinkedIssueShape,
},
async ({ owner, repo, issueNumber, plannedChange }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const body = { issueNumber, ...(plannedChange ? { plannedChange } : {}) };
return toolResult("Gittensory linked-issue validation.", await apiPost(`${prefix}/validate-linked-issue`, body));
},
);

server.registerTool(
"gittensory_preflight_local_diff",
{
Expand Down
32 changes: 32 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ import {
buildContributorIntakeHealth,
buildLabelAudit,
buildLaneAdvice,
buildLinkedIssueValidation,
buildLocalDiffPreflightResult,
buildMaintainerCutReadiness,
buildMaintainerLaneReport,
Expand Down Expand Up @@ -324,6 +325,17 @@ const localDiffPreflightSchema = preflightSchema.extend({
commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(),
});

const validateLinkedIssueSchema = z.object({
issueNumber: z.number().int().positive(),
plannedChange: z
.object({
title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(),
changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(),
})
.optional(),
});

const skippedPrAuditQuerySchema = z
.object({
limit: z.coerce.number().int().optional(),
Expand Down Expand Up @@ -1562,6 +1574,26 @@ export function createApp() {
return c.json(response);
});

app.post("/v1/repos/:owner/:repo/validate-linked-issue", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific repo guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const parsed = validateLinkedIssueSchema.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: "invalid_validate_linked_issue_request", issues: parsed.error.issues }, 400);
const [repo, issues, pullRequests, recentMergedPullRequests] = await Promise.all([
getRepository(c.env, fullName),
listIssueSignalSample(c.env, fullName),
listOpenPullRequests(c.env, fullName),
listRecentMergedPullRequests(c.env, fullName),
]);
if (identity.kind === "session") {
const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo);
if (forbidden) return forbidden;
}
return c.json(buildLinkedIssueValidation(repo, issues, pullRequests, recentMergedPullRequests, fullName, parsed.data.issueNumber, parsed.data.plannedChange ?? {}));
});

app.get("/v1/repos/:owner/:repo/registration-readiness", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
return c.json(await buildRegistrationReadinessResponse(c.env, fullName));
Expand Down
73 changes: 73 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
buildContributorProfile,
buildContributorScoringProfile,
buildLaneAdvice,
buildLinkedIssueValidation,
buildLocalDiffPreflightResult,
buildPreflightResult,
buildQueueHealth,
Expand Down Expand Up @@ -111,6 +112,19 @@ const bountyShape = {
id: z.string().min(1),
};

const validateLinkedIssueShape = {
owner: z.string().min(1),
repo: z.string().min(1),
issueNumber: z.number().int().positive(),
plannedChange: z
.object({
title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(),
changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(),
})
.optional(),
};

const preflightShape = {
repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars),
contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(),
Expand Down Expand Up @@ -367,6 +381,18 @@ const localStatusOutputSchema = {
supportedTools: z.unknown().optional(),
};

const validateLinkedIssueOutputSchema = {
status: z.string().optional(),
repoFullName: z.string().optional(),
issueNumber: z.number().optional(),
found: z.boolean().optional(),
multiplierStatus: z.string().optional(),
multiplierWouldApply: z.boolean().optional(),
blockingReason: z.string().optional(),
reasons: z.unknown().optional(),
report: z.unknown().optional(),
};

export async function handleMcpRequest(c: AppContext): Promise<Response> {
if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
const identity = await authenticateMcpRequest(c);
Expand Down Expand Up @@ -557,6 +583,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.getIssueQuality(input)),
);

server.registerTool(
"gittensory_validate_linked_issue",
{
description:
"Report whether linking a given issue will actually earn the standard linked-issue scoring multiplier for a planned PR — is it open, valid, single-owner, and solvable by this PR — with the precise blocking reason if not. Public-safe; the raw multiplier value stays private. No GitHub writes.",
inputSchema: validateLinkedIssueShape,
outputSchema: validateLinkedIssueOutputSchema,
},
async (input) => this.toolResult(await this.validateLinkedIssue(input)),
);

server.registerTool(
"gittensory_preflight_local_diff",
{
Expand Down Expand Up @@ -901,6 +938,42 @@ export class GittensoryMcp {
};
}

private async validateLinkedIssue(input: {
owner: string;
repo: string;
issueNumber: number;
plannedChange?: { title?: string | undefined; changedFiles?: string[] | undefined; contributorLogin?: string | undefined } | undefined;
}): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
if (!(await this.canAccessRepo(fullName))) {
return {
summary: `Forbidden: session cannot access linked-issue validation for ${fullName}.`,
data: { status: "forbidden", repoFullName: fullName },
};
}
const [repo, issues, pullRequests, recentMergedPullRequests] = await Promise.all([
getRepository(this.env, fullName),
listIssueSignalSample(this.env, fullName),
listOpenPullRequests(this.env, fullName),
listRecentMergedPullRequests(this.env, fullName),
]);
const report = buildLinkedIssueValidation(repo, issues, pullRequests, recentMergedPullRequests, fullName, input.issueNumber, input.plannedChange ?? {});
return {
summary: `Gittensory linked-issue validation for ${fullName}#${input.issueNumber}: multiplier ${report.multiplierWouldApply ? "would apply" : "would not apply"}.`,
data: {
status: "ok",
repoFullName: fullName,
issueNumber: report.issueNumber,
found: report.found,
multiplierStatus: report.multiplierStatus,
multiplierWouldApply: report.multiplierWouldApply,
...(report.blockingReason === undefined ? {} : { blockingReason: report.blockingReason }),
reasons: report.reasons,
report: report as unknown as Record<string, unknown>,
},
};
}

private async canAccessRepo(fullName: string): Promise<boolean> {
if (this.identity.kind !== "session") return true;
const [scope, repo] = await Promise.all([this.loadSessionAccessScope(), getRepository(this.env, fullName)]);
Expand Down
27 changes: 27 additions & 0 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,33 @@ function withValidatedLinkedIssueScenario(input: ScorePreviewInput): ScorePrevie
};
}

/**
* Project the standard linked-issue multiplier decision under the assumption that a planned PR
* becomes the merged solver of the given issue(s). Reuses {@link decideLinkedIssueMultiplier} — the
* same eligibility rule used by buildScorePreview — so standalone validators stay consistent with
* the scoring engine. The numeric multiplier on the returned decision is private; callers that are
* public-safe should surface only `eligible`/`status`/`reason`.
*/
export function projectLinkedIssueMultiplierForPlannedSolve(issueNumbers: number[]): LinkedIssueMultiplierDecision {
const branchEligibility: BranchEligibilityResult = {
required: true,
status: "eligible",
evidence: "provided",
source: "user_supplied",
stale: false,
warnings: [],
};
const context: ProjectedLinkedIssueMultiplierContext = {
status: "validated",
source: "user_supplied",
issueNumbers: uniquePositiveInts(issueNumbers),
solvedByPullRequests: [],
warnings: [],
[PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true,
};
return decideLinkedIssueMultiplier("standard", context, {}, branchEligibility);
}

function linkedIssueReason(
status: Exclude<LinkedIssueMultiplierStatus, "not_required">,
source: LinkedIssueMultiplierSource,
Expand Down
108 changes: 108 additions & 0 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type { PublicContributorProfile } from "../github/public";
import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer";
import type { GittensorContributorSnapshot } from "../gittensor/api";
import { nowIso } from "../utils/json";
import { sanitizePublicComment } from "../queue-intelligence";
import { projectLinkedIssueMultiplierForPlannedSolve, type LinkedIssueMultiplierStatus } from "../scoring/preview";
import { hasLocalTestEvidence } from "./test-evidence";
import { PREFLIGHT_LIMITS } from "./preflight-limits";

Expand Down Expand Up @@ -2811,6 +2813,112 @@ export function buildIssueDiscoveryLifecycleReport(
};
}

export type LinkedIssuePlannedChange = {
title?: string | undefined;
changedFiles?: string[] | undefined;
contributorLogin?: string | undefined;
};

export type LinkedIssueValidationReport = {
repoFullName: string;
generatedAt: string;
issueNumber: number;
found: boolean;
open: boolean;
lifecycle?: IssueDiscoveryLifecycleState | undefined;
/** Canonical linked-issue multiplier status from the scoring engine. The numeric multiplier value stays private. */
multiplierStatus: LinkedIssueMultiplierStatus;
multiplierWouldApply: boolean;
blockingReason?: string | undefined;
reasons: string[];
warnings: string[];
summary: string;
};

/**
* Validate whether linking a given issue will actually earn the standard linked-issue multiplier for
* a planned PR — open? valid? single-owner (uncontested)? solvable by this PR? — so miners stop
* chasing the bonus blind. Reuses {@link buildIssueDiscoveryLifecycleReport} for lifecycle truth and
* {@link projectLinkedIssueMultiplierForPlannedSolve} (buildScorePreview's eligibility rule) for the
* applies/does-not-apply decision. Public-safe: reasons routed through {@link sanitizePublicComment};
* only applies/does-not-apply + status are surfaced, never the raw multiplier value.
*/
export function buildLinkedIssueValidation(
repo: RepositoryRecord | null,
issues: IssueRecord[],
pullRequests: PullRequestRecord[],
recentMergedPullRequests: RecentMergedPullRequestRecord[],
fullName: string,
issueNumber: number,
plannedChange: LinkedIssuePlannedChange = {},
): LinkedIssueValidationReport {
const lifecycle = buildIssueDiscoveryLifecycleReport(repo, issues, pullRequests, fullName, recentMergedPullRequests);
const issue = issues.find((candidate) => candidate.number === issueNumber);
const lifecycleEntry = lifecycle.states.find((entry) => entry.number === issueNumber);
const open = issue?.state === "open";

const reasons: string[] = [];
const warnings: string[] = [];
let blockingReason: string | undefined;

// Other contributors' open PRs already pointing at the issue make the linkage contested — the
// multiplier follows whichever solving PR merges first, so it is not a single-owner target.
const contestingPullRequests = pullRequests.filter(
(pr) => pr.state === "open" && pr.linkedIssues.includes(issueNumber) && !sameLogin(pr.authorLogin, plannedChange.contributorLogin ?? ""),
);

if (!issue) {
blockingReason = `Issue #${issueNumber} was not found in cached open-issue metadata; confirm it exists and is open before linking it.`;
} else if (!open) {
blockingReason = `Issue #${issueNumber} is not open; the standard linked-issue multiplier requires an open issue.`;
} else if (lifecycleEntry?.state === "duplicate") {
blockingReason = `Issue #${issueNumber} is classified as a duplicate; it is not a valid linked-issue target.`;
} else if (lifecycleEntry?.state === "invalid") {
blockingReason = `Issue #${issueNumber} is classified as invalid or not-planned; it is not a valid linked-issue target.`;
} else if (lifecycleEntry?.state === "solved" || lifecycleEntry?.state === "valid_solved") {
blockingReason = `Issue #${issueNumber} is already solved by merged work; its solver holds the linkage, so linking it will not earn the multiplier.`;
} else if (contestingPullRequests.length > 0) {
blockingReason = `Another open PR already references issue #${issueNumber}; the linked-issue multiplier follows whichever solving PR merges first, so this is contested.`;
}

const multiplierWouldApply = blockingReason === undefined;
// Reuse the scoring engine's eligibility rule for the projected "this PR solves the issue" scenario.
const decision = multiplierWouldApply ? projectLinkedIssueMultiplierForPlannedSolve([issueNumber]) : undefined;
const multiplierStatus: LinkedIssueMultiplierStatus = decision
? decision.status
: lifecycleEntry?.state === "duplicate" || lifecycleEntry?.state === "invalid"
? "invalid"
: "unavailable";

if (multiplierWouldApply) {
reasons.push(`Issue #${issueNumber} is open, valid, and uncontested; linking it will earn the multiplier once your PR is the merged solver.`);
reasons.push("This assumes your PR becomes the merged solver of the issue (solved-by-PR validation).");
if (lifecycleEntry?.state === "stale") warnings.push(`Issue #${issueNumber} looks stale in cached metadata; confirm it is still wanted before investing effort.`);
if (!plannedChange.title && (plannedChange.changedFiles ?? []).length === 0) warnings.push("No planned-change detail was supplied; confirm the change actually resolves the issue so the linkage validates.");
} else {
reasons.push(blockingReason as string);
}

const summary = multiplierWouldApply
? `The linked-issue multiplier would apply for issue #${issueNumber} once your PR is the merged solver.`
: `The linked-issue multiplier would not apply for issue #${issueNumber}.`;

return {
repoFullName: fullName,
generatedAt: nowIso(),
issueNumber,
found: Boolean(issue),
open,
lifecycle: lifecycleEntry?.state,
multiplierStatus,
multiplierWouldApply,
blockingReason: blockingReason === undefined ? undefined : sanitizePublicComment(blockingReason),
reasons: [...new Set(reasons)].map((reason) => sanitizePublicComment(reason)),
warnings: [...new Set(warnings)].map((warning) => sanitizePublicComment(warning)),
summary: sanitizePublicComment(summary),
};
}

function buildIssueLinkageRecord(
issue: IssueRecord,
lifecycleEntry: IssueDiscoveryLifecycleReport["states"][number] | undefined,
Expand Down
24 changes: 24 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,23 @@ describe("api routes", () => {
expect(preflight.status).toBe(200);
await expect(preflight.json()).resolves.toMatchObject({ status: "needs_work" });

const validateLinkedIssue = await app.request(
"/v1/repos/entrius/allways-ui/validate-linked-issue",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ issueNumber: 7, plannedChange: { title: "Fix dashboard cache refresh" } }) },
env,
);
expect(validateLinkedIssue.status).toBe(200);
const validateLinkedIssueBody = await validateLinkedIssue.json();
expect(validateLinkedIssueBody).toMatchObject({ repoFullName: "entrius/allways-ui", issueNumber: 7, multiplierWouldApply: expect.any(Boolean) });
expect(JSON.stringify(validateLinkedIssueBody)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);

const invalidValidateLinkedIssue = await app.request(
"/v1/repos/entrius/allways-ui/validate-linked-issue",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ issueNumber: 0 }) },
env,
);
expect(invalidValidateLinkedIssue.status).toBe(400);

const contributorProfile = await app.request("/v1/contributors/oktofeesh1/profile", { headers: apiHeaders(env) }, env);
expect(contributorProfile.status).toBe(200);
await expect(contributorProfile.json()).resolves.toMatchObject({ login: "oktofeesh1", github: { topLanguages: ["TypeScript", "Python"] } });
Expand Down Expand Up @@ -1457,6 +1474,13 @@ describe("api routes", () => {
expect(forbiddenIssueQuality.status).toBe(403);
await expect(forbiddenIssueQuality.json()).resolves.toMatchObject({ error: "forbidden_repo" });

const forbiddenValidateLinkedIssue = await app.request(
"/v1/repos/entrius/allways-ui/validate-linked-issue",
{ method: "POST", headers: { authorization: `Bearer ${unrelatedIssueQualityToken}` }, body: JSON.stringify({ issueNumber: 7 }) },
env,
);
expect(forbiddenValidateLinkedIssue.status).toBe(403);

await upsertRepositoryFromGitHub(env, { name: "uncached", full_name: "entrius/uncached", private: false, owner: { login: "entrius" }, default_branch: "main" });
const computedIssueQuality = await app.request("/v1/repos/entrius/uncached/issue-quality", { headers: apiHeaders(env) }, env);
expect(computedIssueQuality.status).toBe(200);
Expand Down
Loading