diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index aad10527ba..66738c5af6 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -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(), @@ -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", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 1b3b6b7173..06b36d80d4 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -172,6 +172,7 @@ import { buildContributorIntakeHealth, buildLabelAudit, buildLaneAdvice, + buildLinkedIssueValidation, buildLocalDiffPreflightResult, buildMaintainerCutReadiness, buildMaintainerLaneReport, @@ -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(), @@ -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)); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 51389d5068..d815250c20 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -66,6 +66,7 @@ import { buildContributorProfile, buildContributorScoringProfile, buildLaneAdvice, + buildLinkedIssueValidation, buildLocalDiffPreflightResult, buildPreflightResult, buildQueueHealth, @@ -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(), @@ -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 { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); const identity = await authenticateMcpRequest(c); @@ -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", { @@ -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 { + 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, + }, + }; + } + private async canAccessRepo(fullName: string): Promise { if (this.identity.kind !== "session") return true; const [scope, repo] = await Promise.all([this.loadSessionAccessScope(), getRepository(this.env, fullName)]); diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 7d8cb5d663..ff031437e2 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -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, source: LinkedIssueMultiplierSource, diff --git a/src/signals/engine.ts b/src/signals/engine.ts index cd2b193588..11cb5bb983 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -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"; @@ -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, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3f88f774e5..4b5ff0765e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -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"] } }); @@ -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); diff --git a/test/unit/linked-issue-validation.test.ts b/test/unit/linked-issue-validation.test.ts new file mode 100644 index 0000000000..f727eca423 --- /dev/null +++ b/test/unit/linked-issue-validation.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { buildLinkedIssueValidation, type LinkedIssueValidationReport } from "../../src/signals/engine"; +import type { IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; + +const FORBIDDEN_PUBLIC_TERMS = /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|reward|farming|raw trust|trust score|scoreability|reviewability/i; + +function repo(fullName: string, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/") as [string, string]; + return { + fullName, + owner, + name, + isInstalled: true, + isRegistered: true, + isPrivate: false, + defaultBranch: "main", + registryConfig: { repo: fullName, emissionShare: 0.02, issueDiscoveryShare: 1, labelMultipliers: {}, trustedLabelPipeline: false, maintainerCut: 0, raw: {}, ...overrides }, + }; +} + +function issue(number: number, title: string, overrides: Partial = {}): IssueRecord { + return { + repoFullName: "owner/repo", + number, + title, + state: "open", + authorLogin: "reporter", + authorAssociation: "NONE", + labels: [], + linkedPrs: [], + body: "A clear issue body with reproduction steps and expected behaviour.", + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function pr(number: number, title: string, overrides: Partial = {}): PullRequestRecord { + return { repoFullName: "owner/repo", number, title, state: "open", authorLogin: "dev", authorAssociation: "NONE", labels: [], linkedIssues: [], body: "", updatedAt: new Date().toISOString(), ...overrides }; +} + +function mergedPr(number: number, title: string, overrides: Partial = {}): RecentMergedPullRequestRecord { + return { repoFullName: "owner/repo", number, title, authorLogin: "solver", mergedAt: new Date().toISOString(), labels: [], linkedIssues: [], changedFiles: [], payload: {}, ...overrides }; +} + +function assertPublicSafe(report: LinkedIssueValidationReport): void { + for (const line of [...report.reasons, ...report.warnings, report.summary, report.blockingReason ?? ""]) { + expect(line).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + } +} + +describe("buildLinkedIssueValidation", () => { + it("reports the multiplier would apply for a clean, open, uncontested issue", () => { + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(1, "Fix crash on empty input")], [], [], "owner/repo", 1, { title: "Fix crash", changedFiles: ["src/parser.ts"] }); + expect(report.multiplierWouldApply).toBe(true); + expect(report.multiplierStatus).toBe("validated"); + expect(report.found).toBe(true); + expect(report.open).toBe(true); + expect(report.blockingReason).toBeUndefined(); + expect(report.reasons.some((r) => /open, valid, and uncontested/i.test(r))).toBe(true); + assertPublicSafe(report); + }); + + it("warns when no planned-change detail is supplied", () => { + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(1, "Fix crash on empty input")], [], [], "owner/repo", 1); + expect(report.multiplierWouldApply).toBe(true); + expect(report.warnings.some((w) => /No planned-change detail/i.test(w))).toBe(true); + assertPublicSafe(report); + }); + + it("warns when the target issue is stale", () => { + const stale = issue(1, "Fix crash on empty input", { updatedAt: "2024-01-01T00:00:00.000Z" }); + const report = buildLinkedIssueValidation(repo("owner/repo"), [stale], [], [], "owner/repo", 1, { title: "x", changedFiles: ["a.ts"] }); + expect(report.lifecycle).toBe("stale"); + expect(report.multiplierWouldApply).toBe(true); + expect(report.warnings.some((w) => /stale/i.test(w))).toBe(true); + assertPublicSafe(report); + }); + + it("does not apply when the issue is closed", () => { + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(1, "Already handled", { state: "closed" })], [], [], "owner/repo", 1); + expect(report.open).toBe(false); + expect(report.multiplierWouldApply).toBe(false); + expect(report.blockingReason).toMatch(/is not open/i); + assertPublicSafe(report); + }); + + it("does not apply for duplicate or invalid issues", () => { + const dup = buildLinkedIssueValidation(repo("owner/repo"), [issue(1, "Dup", { labels: ["duplicate"] })], [], [], "owner/repo", 1); + expect(dup.lifecycle).toBe("duplicate"); + expect(dup.multiplierWouldApply).toBe(false); + expect(dup.multiplierStatus).toBe("invalid"); + + const invalid = buildLinkedIssueValidation(repo("owner/repo"), [issue(2, "Nope", { labels: ["wontfix"] })], [], [], "owner/repo", 2); + expect(invalid.lifecycle).toBe("invalid"); + expect(invalid.multiplierWouldApply).toBe(false); + expect(invalid.multiplierStatus).toBe("invalid"); + assertPublicSafe(dup); + assertPublicSafe(invalid); + }); + + it("does not apply when the issue is already solved by merged work", () => { + const merged = [mergedPr(20, "Fix it", { linkedIssues: [3] })]; + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(3, "Improve retry backoff")], [], merged, "owner/repo", 3); + expect(report.lifecycle).toBe("valid_solved"); + expect(report.multiplierWouldApply).toBe(false); + expect(report.blockingReason).toMatch(/already solved/i); + assertPublicSafe(report); + }); + + it("does not apply when the issue is self-solved by the reporter's own merged PR", () => { + const merged = [mergedPr(21, "Self fix", { linkedIssues: [5], authorLogin: "reporter" })]; + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(5, "Tidy logging", { authorLogin: "reporter" })], [], merged, "owner/repo", 5); + expect(report.lifecycle).toBe("solved"); + expect(report.multiplierWouldApply).toBe(false); + expect(report.multiplierStatus).toBe("unavailable"); + expect(report.blockingReason).toMatch(/already solved/i); + assertPublicSafe(report); + }); + + it("does not apply when another contributor's open PR contests the issue", () => { + const prs = [ + pr(10, "WIP fix", { linkedIssues: [4], authorLogin: "someone-else" }), + pr(11, "Abandoned attempt", { linkedIssues: [4], authorLogin: "third-party", state: "closed" }), + ]; + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(4, "Add pagination")], prs, [], "owner/repo", 4, { contributorLogin: "me" }); + expect(report.multiplierWouldApply).toBe(false); + expect(report.blockingReason).toMatch(/another open PR already references/i); + assertPublicSafe(report); + }); + + it("ignores the contributor's own open PR when checking for contention", () => { + const prs = [pr(10, "My fix", { linkedIssues: [4], authorLogin: "me" })]; + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(4, "Add pagination")], prs, [], "owner/repo", 4, { contributorLogin: "me", title: "fix", changedFiles: ["a.ts"] }); + expect(report.multiplierWouldApply).toBe(true); + assertPublicSafe(report); + }); + + it("does not apply when the issue is not in cached metadata", () => { + const report = buildLinkedIssueValidation(repo("owner/repo"), [issue(1, "Real issue")], [], [], "owner/repo", 999); + expect(report.found).toBe(false); + expect(report.multiplierWouldApply).toBe(false); + expect(report.blockingReason).toMatch(/#999 was not found/i); + assertPublicSafe(report); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index bd9bc65dcc..5e1fad7bcf 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; -import { persistSignalSnapshot, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { persistSignalSnapshot, upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { GittensoryMcp } from "../../src/mcp/server"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; @@ -18,6 +18,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_monitor_open_prs", "gittensory_explain_repo_decision", "gittensory_get_issue_quality", + "gittensory_validate_linked_issue", "gittensory_get_registry_changes", "gittensory_get_upstream_drift", "gittensory_local_status", @@ -131,6 +132,36 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(data.repoFullName).toBe("octo/demo"); }); + it("gittensory_validate_linked_issue reports multiplier eligibility for an uncached issue", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_validate_linked_issue", arguments: { owner: "octo", repo: "demo", issueNumber: 1 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + expect(data.repoFullName).toBe("octo/demo"); + expect(data.issueNumber).toBe(1); + expect(data.found).toBe(false); + expect(data.multiplierWouldApply).toBe(false); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + + it("gittensory_validate_linked_issue reports the multiplier would apply for a clean open issue", 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: 5, title: "Fix flaky retry backoff", state: "open", user: { login: "reporter" }, labels: [], body: "Reproduction steps and expected behaviour are described in detail." }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_validate_linked_issue", + arguments: { owner: "octo", repo: "demo", issueNumber: 5, plannedChange: { title: "Fix retry backoff", changedFiles: ["src/queue.ts"] } }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.found).toBe(true); + expect(data.multiplierWouldApply).toBe(true); + expect(data.multiplierStatus).toBe("validated"); + expect(data.blockingReason).toBeUndefined(); + }); + it("gittensory_get_repo_outcome_patterns reports not-found, computed, and cached outcomes", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "computed", full_name: "owner/computed", private: false, owner: { login: "owner" }, default_branch: "main" });