diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 66738c5af6..e73eac857d 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -131,6 +131,14 @@ const validateLinkedIssueShape = { .optional(), }; +const checkBeforeStartShape = { + owner: z.string().min(1), + repo: z.string().min(1), + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).optional(), + plannedPaths: z.array(z.string()).optional(), +}; + const preflightShape = { repoFullName: z.string().min(3), contributorLogin: z.string().min(1).optional(), @@ -288,6 +296,24 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_check_before_start", + { + description: + "Before writing any code, check whether an issue is already claimed or solved, whether a duplicate cluster is forming, and whether it is a valid target. Returns a go/raise/avoid recommendation with public-safe reasons from cached metadata.", + inputSchema: checkBeforeStartShape, + }, + async ({ owner, repo, issueNumber, title, plannedPaths }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const body = { + ...(issueNumber != null ? { issueNumber } : {}), + ...(title ? { title } : {}), + ...(plannedPaths ? { plannedPaths } : {}), + }; + return toolResult("Gittensory pre-start check.", await apiPost(`${prefix}/check-before-start`, body)); + }, +); + server.registerTool( "gittensory_preflight_local_diff", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 06b36d80d4..5fbe1ca56b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -177,6 +177,7 @@ import { buildMaintainerCutReadiness, buildMaintainerLaneReport, buildPullRequestMaintainerPacket, + buildPreStartCheck, buildRoleContext, buildPreflightResult, buildQueueHealth, @@ -336,6 +337,12 @@ const validateLinkedIssueSchema = z.object({ .optional(), }); +const checkBeforeStartSchema = z.object({ + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), +}); + const skippedPrAuditQuerySchema = z .object({ limit: z.coerce.number().int().optional(), @@ -1594,6 +1601,27 @@ export function createApp() { return c.json(buildLinkedIssueValidation(repo, issues, pullRequests, recentMergedPullRequests, fullName, parsed.data.issueNumber, parsed.data.plannedChange ?? {})); }); + app.post("/v1/repos/:owner/:repo/check-before-start", 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 body = await c.req.json().catch(() => ({})); + const parsed = checkBeforeStartSchema.safeParse(body ?? {}); + if (!parsed.success) return c.json({ error: "invalid_check_before_start_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(buildPreStartCheck(repo, issues, pullRequests, recentMergedPullRequests, fullName, parsed.data)); + }); + 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 d815250c20..3535179f26 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -69,6 +69,7 @@ import { buildLinkedIssueValidation, buildLocalDiffPreflightResult, buildPreflightResult, + buildPreStartCheck, buildQueueHealth, buildRegistryChangeReport, buildRoleContext, @@ -125,6 +126,14 @@ const validateLinkedIssueShape = { .optional(), }; +const checkBeforeStartShape = { + owner: z.string().min(1), + repo: z.string().min(1), + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), +}; + const preflightShape = { repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -393,6 +402,18 @@ const validateLinkedIssueOutputSchema = { report: z.unknown().optional(), }; +const checkBeforeStartOutputSchema = { + status: z.string().optional(), + repoFullName: z.string().optional(), + found: z.boolean().optional(), + claimStatus: z.string().optional(), + duplicateClusterRisk: z.string().optional(), + recommendation: z.string().optional(), + reasons: z.unknown().optional(), + blockers: 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); @@ -594,6 +615,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.validateLinkedIssue(input)), ); + server.registerTool( + "gittensory_check_before_start", + { + description: + "Before any code is written, check whether an issue is already claimed or solved, whether a duplicate cluster is forming, and whether it is a valid target. Returns a go/raise/avoid recommendation with public-safe reasons from cached metadata. No GitHub writes.", + inputSchema: checkBeforeStartShape, + outputSchema: checkBeforeStartOutputSchema, + }, + async (input) => this.toolResult(await this.checkBeforeStart(input)), + ); + server.registerTool( "gittensory_preflight_local_diff", { @@ -974,6 +1006,41 @@ export class GittensoryMcp { }; } + private async checkBeforeStart(input: { owner: string; repo: string; issueNumber?: number | undefined; title?: string | undefined; plannedPaths?: string[] | undefined }): Promise { + const fullName = `${input.owner}/${input.repo}`; + if (!(await this.canAccessRepo(fullName))) { + return { + summary: `Forbidden: session cannot access pre-start checks 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 = buildPreStartCheck(repo, issues, pullRequests, recentMergedPullRequests, fullName, { + issueNumber: input.issueNumber, + title: input.title, + plannedPaths: input.plannedPaths, + }); + return { + summary: `Gittensory pre-start check for ${fullName}: ${report.recommendation.toUpperCase()}.`, + data: { + status: "ok", + repoFullName: fullName, + found: report.found, + claimStatus: report.claimStatus, + duplicateClusterRisk: report.duplicateClusterRisk, + recommendation: report.recommendation, + reasons: report.reasons, + blockers: report.blockers, + 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/signals/engine.ts b/src/signals/engine.ts index 11cb5bb983..5667536354 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -2919,6 +2919,190 @@ export function buildLinkedIssueValidation( }; } +export type PreStartCheckTarget = { + issueNumber?: number | undefined; + title?: string | undefined; + plannedPaths?: string[] | undefined; +}; + +export type PreStartCheckClaimStatus = "unclaimed" | "claimed" | "solved" | "unknown"; +export type PreStartCheckRecommendation = "go" | "raise" | "avoid"; +export type DuplicateClusterRisk = "none" | "low" | "medium" | "high"; + +export type PreStartCheckReport = { + repoFullName: string; + generatedAt: string; + lane: LaneAdvice; + target: { + requested: { issueNumber?: number | undefined; title?: string | undefined; plannedPaths?: string[] | undefined }; + matchedBy: "issue_number" | "title" | "planned_paths" | "none"; + resolvedIssueNumber?: number | undefined; + resolvedIssueTitle?: string | undefined; + }; + found: boolean; + claimStatus: PreStartCheckClaimStatus; + lifecycle?: IssueDiscoveryLifecycleState | undefined; + issueQualityStatus?: "ready" | "needs_proof" | "hold" | "do_not_use" | undefined; + duplicateClusterRisk: DuplicateClusterRisk; + recommendation: PreStartCheckRecommendation; + reasons: string[]; + blockers: string[]; + summary: string; +}; + +const DUPLICATE_RISK_RANK: Record = { none: 0, low: 1, medium: 2, high: 3 }; +// Minimum Jaccard token overlap for a supplied title to resolve to a cached open issue. +const TITLE_MATCH_MIN_JACCARD = 0.5; +// Cap the title-matching scan so it stays cheap on repos with very large open-issue counts +// (matches the bound used by the issue lifecycle report). +const TITLE_MATCH_MAX_ISSUES = 300; + +/** + * Pre-start duplicate/solvability check. Answers, before any branch exists, whether an issue is + * already claimed/solved, whether a duplicate cluster is forming, and whether it is a valid target — + * composing the existing collision, issue-quality, and lifecycle reports. Public-safe by construction: + * every reason/blocker is routed through {@link sanitizePublicComment}; no reward/score/trust language. + */ +export function buildPreStartCheck( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[], + fullName: string, + target: PreStartCheckTarget, +): PreStartCheckReport { + const lane = buildLaneAdvice(repo, fullName); + const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const quality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, recentMergedPullRequests); + const lifecycle = buildIssueDiscoveryLifecycleReport(repo, issues, pullRequests, fullName, recentMergedPullRequests); + const openIssues = issues.filter((issue) => issue.state === "open"); + + let resolvedIssue: IssueRecord | undefined; + let matchedBy: PreStartCheckReport["target"]["matchedBy"] = "none"; + if (typeof target.issueNumber === "number") { + resolvedIssue = openIssues.find((issue) => issue.number === target.issueNumber); + if (resolvedIssue) matchedBy = "issue_number"; + } else if (target.title) { + const wanted = new Set(tokenize(target.title)); + let best: { number: number; score: number } | undefined; + // An all-stopword/short title has no meaningful tokens to match against. Bound the scan to a + // fixed number of open issues so title matching stays cheap on repos with very large queues. + if (wanted.size > 0) { + for (const issue of openIssues.slice(0, TITLE_MATCH_MAX_ISSUES)) { + const have = new Set(tokenize(issue.title)); + const shared = [...wanted].filter((term) => have.has(term)).length; + const score = shared / new Set([...wanted, ...have]).size; + if (!best || score > best.score) best = { number: issue.number, score }; + } + } + if (best && best.score >= TITLE_MATCH_MIN_JACCARD) { + resolvedIssue = openIssues.find((issue) => issue.number === best!.number); + matchedBy = "title"; + } + } + + const resolvedNumber = resolvedIssue?.number; + const qualityEntry = resolvedNumber == null ? undefined : quality.issues.find((entry) => entry.number === resolvedNumber); + const lifecycleEntry = resolvedNumber == null ? undefined : lifecycle.states.find((entry) => entry.number === resolvedNumber); + + const plannedPaths = (target.plannedPaths ?? []).map((path) => path.toLowerCase()); + if (matchedBy === "none" && plannedPaths.length > 0) matchedBy = "planned_paths"; + + const issueClusters = + resolvedNumber == null ? [] : collisions.clusters.filter((cluster) => cluster.items.some((item) => item.type === "issue" && item.number === resolvedNumber)); + // Open PR records carry no file metadata in the cache, so planned-path overlap is evaluated against recently merged work. + const pathOverlapMergedPullRequests = + plannedPaths.length === 0 ? [] : recentMergedPullRequests.filter((pr) => pr.changedFiles.some((file) => plannedPaths.includes(file.toLowerCase()))); + + let duplicateClusterRisk: DuplicateClusterRisk = "none"; + const riskCandidates: DuplicateClusterRisk[] = [...issueClusters.map((cluster) => cluster.risk), ...(pathOverlapMergedPullRequests.length > 0 ? (["medium"] as const) : [])]; + for (const risk of riskCandidates) { + if (DUPLICATE_RISK_RANK[risk] > DUPLICATE_RISK_RANK[duplicateClusterRisk]) duplicateClusterRisk = risk; + } + + const found = resolvedNumber != null || matchedBy === "planned_paths"; + + let claimStatus: PreStartCheckClaimStatus = "unknown"; + if (resolvedNumber != null) { + const linkageStatus = qualityEntry?.linkage?.status; + const state = lifecycleEntry?.state; + if (state === "solved" || state === "valid_solved" || linkageStatus === "validated") claimStatus = "solved"; + else if (linkageStatus === "plausible") claimStatus = "claimed"; + else claimStatus = "unclaimed"; + } else if (matchedBy === "planned_paths") { + claimStatus = pathOverlapMergedPullRequests.length > 0 ? "claimed" : "unclaimed"; + } + + const reasons: string[] = []; + const blockers: string[] = []; + + if (!found) { + blockers.push( + target.issueNumber != null + ? `Issue #${target.issueNumber} was not found in cached open-issue metadata; confirm it exists and is open before starting.` + : "No matching open issue or overlapping work was found in cached metadata; confirm the target before starting.", + ); + } + if (claimStatus === "solved") blockers.push("This issue already has merged or validated solving work; new work would likely duplicate it."); + if (claimStatus === "claimed") { + blockers.push( + resolvedNumber != null + ? "Open PR work already references this issue; coordinate or pick a different target to avoid a collision." + : "Recently merged work already touched one or more of these paths; confirm this is not a duplicate before starting.", + ); + } + if (duplicateClusterRisk === "high") blockers.push("A high-risk duplicate or overlapping work cluster already exists for this target."); + if (lifecycleEntry?.state === "duplicate") blockers.push("This issue is classified as a duplicate in cached metadata."); + if (lifecycleEntry?.state === "invalid") blockers.push("This issue is classified as invalid in cached metadata."); + // Issue quality is "uncertain" when the cached report places it anywhere short of ready (needs_proof/hold), but not at the do_not_use floor (handled as an avoid blocker). + const qualityUncertain = qualityEntry != null && qualityEntry.status !== "ready" && qualityEntry.status !== "do_not_use"; + if (duplicateClusterRisk === "medium") reasons.push("A possible duplicate or overlapping work cluster exists; confirm it before starting."); + if (qualityUncertain) reasons.push("Issue quality is not yet a confident go; verify the scope and proof before committing effort."); + if (lane.lane === "direct_pr") reasons.push("This repository is direct-PR first; issue filing is not its primary contribution path."); + + let recommendation: PreStartCheckRecommendation; + if (claimStatus === "solved" || qualityEntry?.status === "do_not_use" || lifecycleEntry?.state === "duplicate" || lifecycleEntry?.state === "invalid" || duplicateClusterRisk === "high") { + recommendation = "avoid"; + } else if (!found || duplicateClusterRisk === "medium" || claimStatus === "claimed" || qualityUncertain || lane.lane === "direct_pr") { + recommendation = "raise"; + } else { + recommendation = "go"; + } + if (recommendation === "go") reasons.push("No claim, duplicate, or solvability blocker was detected in cached metadata; this looks safe to start."); + + const summary = + recommendation === "go" + ? "Go: no blocking claim, duplicate, or solvability signal in cached metadata." + : recommendation === "raise" + ? "Raise: proceed only after confirming the flagged concerns." + : "Avoid: this target is already claimed, solved, duplicate, or high-risk."; + + return { + repoFullName: fullName, + generatedAt: nowIso(), + lane, + target: { + requested: { + ...(target.issueNumber != null ? { issueNumber: target.issueNumber } : {}), + ...(target.title ? { title: target.title } : {}), + ...(plannedPaths.length > 0 ? { plannedPaths: target.plannedPaths } : {}), + }, + matchedBy, + resolvedIssueNumber: resolvedNumber, + resolvedIssueTitle: resolvedIssue?.title, + }, + found, + claimStatus, + lifecycle: lifecycleEntry?.state, + issueQualityStatus: qualityEntry?.status, + duplicateClusterRisk, + recommendation, + reasons: [...new Set(reasons)].map((reason) => sanitizePublicComment(reason)), + blockers: [...new Set(blockers)].map((blocker) => sanitizePublicComment(blocker)), + 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 4b5ff0765e..fdfee8ed67 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -725,6 +725,24 @@ describe("api routes", () => { ); expect(invalidValidateLinkedIssue.status).toBe(400); + const checkBeforeStart = await app.request( + "/v1/repos/entrius/allways-ui/check-before-start", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ issueNumber: 7 }) }, + env, + ); + expect(checkBeforeStart.status).toBe(200); + const checkBeforeStartBody = await checkBeforeStart.json(); + expect(checkBeforeStartBody).toMatchObject({ repoFullName: "entrius/allways-ui", recommendation: expect.any(String) }); + expect(["go", "raise", "avoid"]).toContain((checkBeforeStartBody as { recommendation: string }).recommendation); + expect(JSON.stringify(checkBeforeStartBody)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + + const invalidCheckBeforeStart = await app.request( + "/v1/repos/entrius/allways-ui/check-before-start", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ issueNumber: -3 }) }, + env, + ); + expect(invalidCheckBeforeStart.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"] } }); @@ -1481,6 +1499,13 @@ describe("api routes", () => { ); expect(forbiddenValidateLinkedIssue.status).toBe(403); + const forbiddenCheckBeforeStart = await app.request( + "/v1/repos/entrius/allways-ui/check-before-start", + { method: "POST", headers: { authorization: `Bearer ${unrelatedIssueQualityToken}` }, body: JSON.stringify({ issueNumber: 7 }) }, + env, + ); + expect(forbiddenCheckBeforeStart.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); @@ -1499,6 +1524,21 @@ describe("api routes", () => { } }); + it("allows an authorized operator session to run pre-start checks", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-admin" }); + await upsertRepositoryFromGitHub(env, { name: "widget", full_name: "operator-admin/widget", private: false, owner: { login: "operator-admin" }, default_branch: "main" }); + const { token } = await createSessionForGitHubUser(env, { login: "operator-admin", id: 99 }); + const res = await app.request( + "/v1/repos/operator-admin/widget/check-before-start", + { method: "POST", headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ issueNumber: 1 }) }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { recommendation: string }; + expect(["go", "raise", "avoid"]).toContain(body.recommendation); + }); + it("serves installation repair diagnostics and refreshes installation health", async () => { const app = createApp(); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 5e1fad7bcf..290e80c330 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -19,6 +19,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_explain_repo_decision", "gittensory_get_issue_quality", "gittensory_validate_linked_issue", + "gittensory_check_before_start", "gittensory_get_registry_changes", "gittensory_get_upstream_drift", "gittensory_local_status", @@ -162,6 +163,18 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(data.blockingReason).toBeUndefined(); }); + it("gittensory_check_before_start returns a recommendation for a clean repo", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_check_before_start", 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(["go", "raise", "avoid"]).toContain(data.recommendation); + expect(data.found).toBe(false); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + 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" }); diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index c3aa59ad3e..418fcc92de 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -44,6 +44,22 @@ describe("MCP contributor access", () => { expect(JSON.stringify(payload)).not.toContain("SECRET private issue"); }); + it("blocks session actors from pre-start checks for inaccessible repos", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim/private-repo", private: true, owner: { login: "victim" }, default_branch: "main" }); + const { token } = await createSessionForGitHubUser(env, { login: "attacker", id: 7 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + + const payload = await ( + new GittensoryMcp(env, identity) as unknown as { + checkBeforeStart(input: { owner: string; repo: string; issueNumber?: number }): Promise<{ data: Record }>; + } + ).checkBeforeStart({ owner: "victim", repo: "private-repo", issueNumber: 1 }); + + expect(payload.data).toEqual({ status: "forbidden", repoFullName: "victim/private-repo" }); + }); + it("does not reveal inaccessible bounty ids through advisory errors", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim/private-repo", private: true, owner: { login: "victim" }, default_branch: "main" }); diff --git a/test/unit/pre-start-check.test.ts b/test/unit/pre-start-check.test.ts new file mode 100644 index 0000000000..f015b703fd --- /dev/null +++ b/test/unit/pre-start-check.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { buildPreStartCheck, type PreStartCheckReport } 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; +const LONG_BODY = "This issue has a thorough description with reproduction steps, expected behaviour, actual behaviour, and enough surrounding context that the issue-quality report treats it as actionable rather than thin."; + +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: LONG_BODY, + 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: PreStartCheckReport): void { + for (const line of [...report.reasons, ...report.blockers, report.summary]) { + expect(line).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + } +} + +describe("buildPreStartCheck", () => { + it("returns go for a clean, unclaimed, actionable issue", () => { + const r = repo("owner/repo"); + const issues = [issue(1, "Fix parser crash on empty input handling")]; + const report = buildPreStartCheck(r, issues, [], [], "owner/repo", { issueNumber: 1 }); + expect(report.recommendation).toBe("go"); + expect(report.claimStatus).toBe("unclaimed"); + expect(report.found).toBe(true); + expect(report.duplicateClusterRisk).toBe("none"); + expect(report.target.matchedBy).toBe("issue_number"); + expect(report.target.resolvedIssueNumber).toBe(1); + expect(report.summary.startsWith("Go")).toBe(true); + expect(report.reasons.length).toBeGreaterThan(0); + assertPublicSafe(report); + }); + + it("avoids an issue already claimed by an open linked PR", () => { + const r = repo("owner/repo"); + const issues = [issue(1, "Fix parser crash on empty input handling")]; + const prs = [pr(10, "Fix parser crash", { linkedIssues: [1] })]; + const report = buildPreStartCheck(r, issues, prs, [], "owner/repo", { issueNumber: 1 }); + expect(report.recommendation).toBe("avoid"); + expect(report.claimStatus).toBe("claimed"); + expect(report.issueQualityStatus).toBe("do_not_use"); + expect(report.blockers.some((b) => /already references this issue/i.test(b))).toBe(true); + assertPublicSafe(report); + }); + + it("avoids an issue already solved by merged work", () => { + const r = repo("owner/repo"); + const issues = [issue(2, "Improve retry backoff in the queue worker")]; + const merged = [mergedPr(20, "Improve retry backoff", { linkedIssues: [2] })]; + const report = buildPreStartCheck(r, issues, [], merged, "owner/repo", { issueNumber: 2 }); + expect(report.claimStatus).toBe("solved"); + expect(report.lifecycle).toBe("valid_solved"); + expect(report.recommendation).toBe("avoid"); + expect(report.blockers.some((b) => /merged or validated/i.test(b))).toBe(true); + assertPublicSafe(report); + }); + + it("flags high duplicate-cluster risk when multiple PRs target one issue", () => { + const r = repo("owner/repo"); + const issues = [issue(3, "Add pagination to the labels endpoint")]; + const prs = [pr(31, "Paginate labels", { linkedIssues: [3] }), pr(32, "Labels pagination", { linkedIssues: [3] })]; + const report = buildPreStartCheck(r, issues, prs, [], "owner/repo", { issueNumber: 3 }); + expect(report.duplicateClusterRisk).toBe("high"); + expect(report.recommendation).toBe("avoid"); + expect(report.blockers.some((b) => /high-risk duplicate/i.test(b))).toBe(true); + assertPublicSafe(report); + }); + + it("avoids issues labelled duplicate or invalid", () => { + const r = repo("owner/repo"); + const duplicate = buildPreStartCheck(r, [issue(4, "Crash on startup", { labels: ["duplicate"] })], [], [], "owner/repo", { issueNumber: 4 }); + expect(duplicate.lifecycle).toBe("duplicate"); + expect(duplicate.recommendation).toBe("avoid"); + expect(duplicate.blockers.some((b) => /duplicate in cached metadata/i.test(b))).toBe(true); + + const invalid = buildPreStartCheck(r, [issue(5, "Please add feature", { labels: ["wontfix"] })], [], [], "owner/repo", { issueNumber: 5 }); + expect(invalid.lifecycle).toBe("invalid"); + expect(invalid.recommendation).toBe("avoid"); + assertPublicSafe(duplicate); + assertPublicSafe(invalid); + }); + + it("raises for a thin issue that needs more proof", () => { + const r = repo("owner/repo"); + const issues = [issue(6, "Something is broken somewhere", { body: "broken" })]; + const report = buildPreStartCheck(r, issues, [], [], "owner/repo", { issueNumber: 6 }); + expect(report.issueQualityStatus).toBe("needs_proof"); + expect(report.recommendation).toBe("raise"); + assertPublicSafe(report); + }); + + it("raises when the requested issue number is not in cached metadata", () => { + const r = repo("owner/repo"); + const report = buildPreStartCheck(r, [issue(1, "Real issue")], [], [], "owner/repo", { issueNumber: 999 }); + expect(report.found).toBe(false); + expect(report.recommendation).toBe("raise"); + expect(report.blockers.some((b) => /#999 was not found/i.test(b))).toBe(true); + assertPublicSafe(report); + }); + + it("resolves a target by fuzzy title match", () => { + const r = repo("owner/repo"); + const issues = [issue(7, "Fix flaky retry backoff in queue worker"), issue(8, "Unrelated documentation polish")]; + const report = buildPreStartCheck(r, issues, [], [], "owner/repo", { title: "flaky retry backoff queue worker" }); + expect(report.target.matchedBy).toBe("title"); + expect(report.target.resolvedIssueNumber).toBe(7); + expect(report.found).toBe(true); + assertPublicSafe(report); + }); + + it("raises when a supplied title matches no cached issue", () => { + const r = repo("owner/repo"); + const report = buildPreStartCheck(r, [issue(1, "Fix parser crash")], [], [], "owner/repo", { title: "qqqq wwww eeee rrrr" }); + expect(report.found).toBe(false); + expect(report.target.matchedBy).toBe("none"); + expect(report.recommendation).toBe("raise"); + assertPublicSafe(report); + }); + + it("raises when the supplied title has no meaningful tokens to match", () => { + const r = repo("owner/repo"); + const report = buildPreStartCheck(r, [issue(1, "Fix parser crash on empty input handling")], [], [], "owner/repo", { title: "the and for with" }); + expect(report.found).toBe(false); + expect(report.target.matchedBy).toBe("none"); + expect(report.recommendation).toBe("raise"); + assertPublicSafe(report); + }); + + it("raises on a direct-PR-first repository", () => { + const r = repo("owner/repo", { issueDiscoveryShare: 0 }); + const report = buildPreStartCheck(r, [issue(1, "Fix parser crash on empty input handling")], [], [], "owner/repo", { issueNumber: 1 }); + expect(report.lane.lane).toBe("direct_pr"); + expect(report.recommendation).toBe("raise"); + expect(report.reasons.some((reason) => /direct-PR first/i.test(reason))).toBe(true); + assertPublicSafe(report); + }); + + it("flags planned-path overlap with recently merged work", () => { + const r = repo("owner/repo"); + const merged = [mergedPr(40, "Refactor labels module", { changedFiles: ["src/github/labels.ts"] })]; + const report = buildPreStartCheck(r, [], [], merged, "owner/repo", { plannedPaths: ["src/github/labels.ts"] }); + expect(report.target.matchedBy).toBe("planned_paths"); + expect(report.claimStatus).toBe("claimed"); + expect(report.duplicateClusterRisk).toBe("medium"); + expect(report.recommendation).toBe("raise"); + assertPublicSafe(report); + }); + + it("returns go for planned paths with no overlapping merged work", () => { + const r = repo("owner/repo"); + const merged = [mergedPr(41, "Unrelated change", { changedFiles: ["src/other/thing.ts"] })]; + const report = buildPreStartCheck(r, [], [], merged, "owner/repo", { plannedPaths: ["src/github/labels.ts"] }); + expect(report.target.matchedBy).toBe("planned_paths"); + expect(report.claimStatus).toBe("unclaimed"); + expect(report.duplicateClusterRisk).toBe("none"); + expect(report.recommendation).toBe("go"); + assertPublicSafe(report); + }); + + it("raises a clean issue when planned paths overlap recently merged work", () => { + const r = repo("owner/repo"); + const issues = [issue(9, "Improve label caching in the sync worker")]; + const merged = [mergedPr(50, "Tune cache", { changedFiles: ["src/cache.ts"] })]; + const report = buildPreStartCheck(r, issues, [], merged, "owner/repo", { issueNumber: 9, plannedPaths: ["src/cache.ts"] }); + expect(report.target.matchedBy).toBe("issue_number"); + expect(report.claimStatus).toBe("unclaimed"); + expect(report.duplicateClusterRisk).toBe("medium"); + expect(report.recommendation).toBe("raise"); + expect(report.reasons.some((reason) => /possible duplicate/i.test(reason))).toBe(true); + assertPublicSafe(report); + }); + + it("keeps the highest risk when issue and planned-path signals both apply", () => { + const r = repo("owner/repo"); + const issues = [issue(3, "Add pagination to the labels endpoint")]; + const prs = [pr(31, "Paginate labels", { linkedIssues: [3] }), pr(32, "Labels pagination", { linkedIssues: [3] })]; + const merged = [mergedPr(33, "Touch labels", { changedFiles: ["src/github/labels.ts"] })]; + const report = buildPreStartCheck(r, issues, prs, merged, "owner/repo", { issueNumber: 3, plannedPaths: ["src/github/labels.ts"] }); + expect(report.duplicateClusterRisk).toBe("high"); + expect(report.recommendation).toBe("avoid"); + assertPublicSafe(report); + }); + + it("raises when no target is supplied at all", () => { + const r = repo("owner/repo"); + const report = buildPreStartCheck(r, [issue(1, "Real issue")], [], [], "owner/repo", {}); + expect(report.found).toBe(false); + expect(report.target.matchedBy).toBe("none"); + expect(report.recommendation).toBe("raise"); + expect(report.claimStatus).toBe("unknown"); + assertPublicSafe(report); + }); +});