diff --git a/src/env.d.ts b/src/env.d.ts index 800c221de8..f7540d07e1 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -310,6 +310,13 @@ declare global { * unchanged). Once a winner closes, the next-lowest OPEN sibling becomes the winner on re-eval. See * src/signals/duplicate-winner.ts. */ GITTENSORY_DUPLICATE_WINNER?: string; + /** Open-PR file-path collision (#2653): when truthy, a live PR review enriches its own and its open + * siblings' `changedFiles` from the `pull_request_files` cache (a plain D1 read — no extra GitHub calls) + * before building the collision report, so two independently-open PRs touching the same file are flagged + * the same way two title-similar PRs already are. A contributor's own two PRs sharing a file are never + * flagged (see the same-author guard in buildCollisionReport). Default OFF — unset/false leaves every + * PullRequestRecord's changedFiles unset, byte-identical to today. See src/signals/engine.ts prItem. */ + GITTENSORY_OPEN_PR_FILE_COLLISION?: string; } } diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e3005cae00..ba9278b08d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5795,6 +5795,33 @@ export function reputationOutcomeFromTerminalState( return undefined; } +/** + * Open-PR file-path collision (#2653): enrich `changedFiles` on the reviewed PR and its open siblings from the + * `pull_request_files` cache, so `buildCollisionReport`'s existing termOverlap heuristic (which already tokenizes + * `changedFiles` for merged PRs, see recentMergedItem) gets real path signal for open-vs-open pairs too — not + * just title/label/linked-issue text. A single bounded D1 read (no GitHub API calls): siblings are populated by + * the routine detail-sync backfill independent of this flag, so this is a cache read, not a live fetch. Only + * `PullRequestRecord`s already carrying no `changedFiles` are overwritten; entries missing from the cache (e.g. a + * brand-new PR reviewed before its first detail-sync) are left as-is and simply carry no path signal this pass — + * a fail-safe degrade, not an error, and the next scheduled re-gate sweep picks it up once synced. + */ +export async function enrichOpenPullRequestsWithChangedFiles(env: Env, repoFullName: string, pullRequests: PullRequestRecord[]): Promise { + const openPullNumbers = pullRequests.filter((candidate) => candidate.state === "open").map((candidate) => candidate.number); + if (openPullNumbers.length === 0) return pullRequests; + const filePaths = await listRepoPullRequestFilePaths(env, repoFullName, { pullNumbers: openPullNumbers }); + if (filePaths.length === 0) return pullRequests; + const pathsByPullNumber = new Map(); + for (const row of filePaths) { + const paths = pathsByPullNumber.get(row.pullNumber) ?? []; + paths.push(row.path); + pathsByPullNumber.set(row.pullNumber, paths); + } + return pullRequests.map((candidate) => { + const paths = pathsByPullNumber.get(candidate.number); + return paths ? { ...candidate, changedFiles: paths } : candidate; + }); +} + async function maybePublishPrPublicSurface( env: Env, installationId: number, @@ -6180,15 +6207,22 @@ async function maybePublishPrPublicSurface( listPullRequests(env, repoFullName), listBountiesByRepo(env, repoFullName), ]); + // Open-PR file-path collision (#2653): flag-gated, byte-identical when OFF (see enrichOpenPullRequestsWithChangedFiles). + // Scoped to collision/preflight/queue-health inputs only — every OTHER use of repoPullRequests below (e.g. the + // duplicate-winner adjudication, which is same-linked-issue-based, not path-based) keeps reading the un-enriched array. + const collisionPullRequests = + env.GITTENSORY_OPEN_PR_FILE_COLLISION === "true" + ? await enrichOpenPullRequestsWithChangedFiles(env, repoFullName, repoPullRequests) + : repoPullRequests; collisions = buildCollisionReport( repoFullName, repoIssues, - repoPullRequests, + collisionPullRequests, ); queueHealth = buildQueueHealth( repo, repoIssues, - repoPullRequests, + collisionPullRequests, collisions, ); preflight = buildPreflightResult( @@ -6203,7 +6237,7 @@ async function maybePublishPrPublicSurface( }, repo, repoIssues, - repoPullRequests, + collisionPullRequests, repoBounties, ); // Duplicate-winner adjudication (#dup-winner): compute the winner ONCE for this review run from the SAME diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 734b0d228b..99081c2139 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -862,6 +862,17 @@ export function buildCollisionReport( } const overlap = termOverlap(itemTerms.get(itemKey(left)) ?? collisionTerms(left), itemTerms.get(itemKey(right)) ?? collisionTerms(right)); if (overlap.score < 0.58 || overlap.shared < 2) continue; + // A contributor iterating on their own work (e.g. a follow-up PR touching the same file as their still-open + // prior PR) is not duplicate effort. Title/label overlap between a contributor's own items is today's + // established behavior (unchanged, e.g. a self-filed issue and its own PR); what's new here is that + // `changedFiles` now also feeds this same heuristic, and two of a contributor's own PRs sharing a file is + // exactly the false-positive path-overlap creates. Re-score without paths: if the pair only clears the bar + // WITH file-path terms, paths alone drove the match — self-authored, so skip it. If title/label terms alone + // already clear the bar, this is pre-existing behavior and still clusters. + if (isPullRequestShapedItem(left) && isPullRequestShapedItem(right) && Boolean(left.authorLogin) && sameLogin(left.authorLogin, right.authorLogin ?? "")) { + const titleOnlyOverlap = termOverlap(collisionTerms(left, false), collisionTerms(right, false)); + if (titleOnlyOverlap.score < 0.58 || titleOnlyOverlap.shared < 2) continue; + } const key = [itemKey(left), itemKey(right)].sort().join("--"); if (clusters.has(key)) continue; clusters.set(key, { @@ -5156,6 +5167,7 @@ function prItem(pr: PullRequestRecord): CollisionItem { labels: pr.labels, linkedIssues: pr.linkedIssues, linkedIssueClaimedAt: pr.linkedIssueClaimedAt, + changedFiles: pr.changedFiles, body: pr.body, }; } @@ -5217,8 +5229,8 @@ type CollisionTerms = { const collisionReportTermCache = new WeakMap>(); -function collisionTerms(item: CollisionItem): CollisionTerms { - const terms = new Set(tokenize(collisionItemText(item))); +function collisionTerms(item: CollisionItem, includePaths = true): CollisionTerms { + const terms = new Set(tokenize(collisionItemText(item, includePaths))); return { terms, size: terms.size }; } @@ -5251,11 +5263,11 @@ function termOverlap(left: CollisionTerms, right: CollisionTerms): { score: numb return { score: shared / Math.min(left.size, right.size), shared }; } -function collisionItemText(item: CollisionItem): string { +function collisionItemText(item: CollisionItem, includePaths = true): string { return [ truncateText(item.title, PREFLIGHT_LIMITS.titleChars), ...boundedTextItems(item.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars), - ...boundedTextItems(item.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars), + ...(includePaths ? boundedTextItems(item.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars) : []), ] .filter(Boolean) .join(" "); @@ -5390,6 +5402,10 @@ function sameLogin(value: string | null | undefined, login: string): boolean { return value?.toLowerCase() === login.toLowerCase(); } +function isPullRequestShapedItem(item: CollisionItem): boolean { + return item.type === "pull_request" || item.type === "recent_merged_pull_request"; +} + function sameRepo(left: string | null | undefined, right: string | null | undefined): boolean { return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); } diff --git a/src/types.ts b/src/types.ts index a4b5c887b3..f2440d5e68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -470,6 +470,11 @@ export type PullRequestRecord = { * stale-surface diagnostics, not as a hard re-review skip: GitHub comments/checks can still be stale or partial * while this marker matches headSha. Publish-written; read straight from the row. */ lastPublishedSurfaceSha?: string | null | undefined; + /** File paths changed by this open PR, when the caller has already resolved them (e.g. from the + * `pull_request_files` cache). Absent/undefined when not resolved — callers must not assume an empty array + * means "no files changed". Mirrors {@link RecentMergedPullRequestRecord.changedFiles} so the same + * collision/preflight path-overlap scoring works for open PRs, not just merged history. */ + changedFiles?: string[] | undefined; }; export type IssueRecord = { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index aaf2139318..8bcdec757b 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -47,7 +47,8 @@ import { upsertRepositoryFromGitHub, putCachedAiReview, } from "../../src/db/repositories"; -import { agentMaintenanceHeadMatchesGate, changedPathsForGuardrail, claimAiReviewLock, claimPrActuationLock, contributorEvidenceBatchSize, processJob, reconcileLiveDuplicateSiblings, releaseAiReviewLock, releasePrActuationLock } from "../../src/queue/processors"; +import { agentMaintenanceHeadMatchesGate, changedPathsForGuardrail, claimAiReviewLock, claimPrActuationLock, contributorEvidenceBatchSize, enrichOpenPullRequestsWithChangedFiles, processJob, reconcileLiveDuplicateSiblings, releaseAiReviewLock, releasePrActuationLock } from "../../src/queue/processors"; +import type { PullRequestRecord } from "../../src/types"; import { aiReviewCacheInputFingerprint } from "../../src/review/ai-review-cache-input"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; @@ -2274,6 +2275,144 @@ describe("queue processors", () => { expect(stickyComment.current?.body).not.toContain("is reviewing"); }); + it("flags an open-PR file-path collision against a sibling PR when GITTENSORY_OPEN_PR_FILE_COLLISION is on (#2653)", async () => { + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + GITTENSORY_OPEN_PR_FILE_COLLISION: "true", + }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "off", + aiReviewMode: "off", + }); + // A sibling PR (different author, unrelated title) already open and already detail-synced — its files are + // in the pull_request_files cache, the same way routine backfill would have populated them. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 8, + title: "Document logging output", + state: "open", + user: { login: "other-author" }, + head: { sha: "b8" }, + labels: [], + body: "", + }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 8, path: "src/shared/util.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + // The PR under review (#7) was ALSO already detail-synced against the same file before this rerun. + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 7, path: "src/shared/util.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + const stickyComment: { current: { id: number; body: string } | null } = { current: null }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") { + return Response.json([{ uid: 7, githubUsername: "contributor", githubId: "123", totalPrs: 4, totalMergedPrs: 3, totalOpenPrs: 1, totalClosedPrs: 0, totalOpenIssues: 0, totalClosedIssues: 0, totalSolvedIssues: 0, totalValidSolvedIssues: 0, isEligible: true, credibility: 1, eligibleRepoCount: 1 }]); + } + if (url === "https://api.gittensor.io/miners/123") return Response.json({ repositories: [] }); + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor", public_repos: 2, followers: 1 }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/7/files")) return Response.json([{ filename: "src/shared/util.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/7")) return Response.json({ number: 7, title: "Improve widget rendering", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "" }); + if (url.includes("/commits/a7/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a7/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/7/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/7/comments") && method === "POST") { + const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + stickyComment.current = { id: 1, body }; + return Response.json({ id: 1 }, { status: 201 }); + } + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "open-pr-file-collision", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 7, title: "Improve widget rendering", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "" }, + }, + }); + + // The sibling PR #8 (different author, same file, unrelated title) surfaces in the related-work panel — + // proof the enriched changedFiles flowed through buildCollisionReport's existing termOverlap scoring. + expect(stickyComment.current?.body).toContain("#8"); + }); + + it("does NOT flag an open-PR file-path collision when GITTENSORY_OPEN_PR_FILE_COLLISION is unset (byte-identical default)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "off", + aiReviewMode: "off", + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 8, + title: "Document logging output", + state: "open", + user: { login: "other-author" }, + head: { sha: "b8" }, + labels: [], + body: "", + }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 8, path: "src/shared/util.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 7, path: "src/shared/util.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + const stickyComment: { current: { id: number; body: string } | null } = { current: null }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") { + return Response.json([{ uid: 7, githubUsername: "contributor", githubId: "123", totalPrs: 4, totalMergedPrs: 3, totalOpenPrs: 1, totalClosedPrs: 0, totalOpenIssues: 0, totalClosedIssues: 0, totalSolvedIssues: 0, totalValidSolvedIssues: 0, isEligible: true, credibility: 1, eligibleRepoCount: 1 }]); + } + if (url === "https://api.gittensor.io/miners/123") return Response.json({ repositories: [] }); + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor", public_repos: 2, followers: 1 }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/7/files")) return Response.json([{ filename: "src/shared/util.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/7")) return Response.json({ number: 7, title: "Improve widget rendering", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "" }); + if (url.includes("/commits/a7/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a7/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/7/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/7/comments") && method === "POST") { + const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + stickyComment.current = { id: 1, body }; + return Response.json({ id: 1 }, { status: 201 }); + } + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "open-pr-file-collision-flag-off", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 7, title: "Improve widget rendering", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "" }, + }, + }); + + expect(stickyComment.current?.body).not.toContain("#8"); + }); + it("computes the AI review cache fingerprint with a self-host reviewer plan and converged grounding/enrichment on (#2119)", async () => { let aiCalls = 0; const env = createTestEnv({ @@ -15388,3 +15527,54 @@ describe("installation app_id capture + dual-app webhook filter (#selfhost-app-i }); }); }); + +describe("enrichOpenPullRequestsWithChangedFiles (#2653)", () => { + const pr = (number: number, overrides: Partial = {}): PullRequestRecord => ({ + repoFullName: "owner/repo", + number, + title: `PR ${number}`, + state: "open", + labels: [], + linkedIssues: [], + ...overrides, + }); + + it("populates changedFiles for open PRs from the pull_request_files cache", async () => { + const env = createTestEnv(); + await upsertPullRequestFile(env, { repoFullName: "owner/repo", pullNumber: 10, path: "src/a.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + await upsertPullRequestFile(env, { repoFullName: "owner/repo", pullNumber: 10, path: "src/b.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + await upsertPullRequestFile(env, { repoFullName: "owner/repo", pullNumber: 11, path: "src/c.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + + const result = await enrichOpenPullRequestsWithChangedFiles(env, "owner/repo", [pr(10), pr(11)]); + + expect(result.find((candidate) => candidate.number === 10)?.changedFiles?.sort()).toEqual(["src/a.ts", "src/b.ts"]); + expect(result.find((candidate) => candidate.number === 11)?.changedFiles).toEqual(["src/c.ts"]); + }); + + it("leaves a PR's changedFiles untouched when the cache has no rows for it (fail-safe degrade, not an error)", async () => { + const env = createTestEnv(); + await upsertPullRequestFile(env, { repoFullName: "owner/repo", pullNumber: 10, path: "src/a.ts", additions: 1, deletions: 0, changes: 1, payload: {} }); + + const result = await enrichOpenPullRequestsWithChangedFiles(env, "owner/repo", [pr(10), pr(12)]); + + expect(result.find((candidate) => candidate.number === 12)?.changedFiles).toBeUndefined(); + }); + + it("does not query the cache and returns the same array reference when there are no open PRs", async () => { + const env = createTestEnv(); + const input = [pr(20, { state: "closed" })]; + + const result = await enrichOpenPullRequestsWithChangedFiles(env, "owner/repo", input); + + expect(result).toBe(input); + }); + + it("returns the same array reference when the cache has no rows for any open PR", async () => { + const env = createTestEnv(); + const input = [pr(30)]; + + const result = await enrichOpenPullRequestsWithChangedFiles(env, "owner/repo", input); + + expect(result).toBe(input); + }); +}); diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index a4942f50ee..4dd795d95f 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -147,6 +147,173 @@ describe("v2 signal builders", () => { expect(edges[0]).toMatchObject({ repoFullName: repo.fullName, risk: expect.any(String) }); }); + describe("open-PR file-path collision (#2653)", () => { + const findCluster = (report: ReturnType, left: number, right: number) => + report.clusters.find((cluster) => cluster.items.some((item) => item.number === left) && cluster.items.some((item) => item.number === right)); + + it("flags two open PRs from different authors that touch the same file, even with unrelated titles", () => { + const alicePr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 201, + title: "Improve widget rendering", + state: "open", + authorLogin: "alice", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const bobPr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 202, + title: "Document logging output", + state: "open", + authorLogin: "bob", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const report = buildCollisionReport(repo.fullName, [], [alicePr, bobPr]); + const cluster = findCluster(report, 201, 202); + expect(cluster).toBeDefined(); + expect(cluster?.reason).toMatch(/meaningful terms/i); + }); + + it("does not flag two open PRs by the SAME author sharing only a file path (regression: self-supersession is not a collision)", () => { + const authorPr1: PullRequestRecord = { + repoFullName: repo.fullName, + number: 203, + title: "Improve widget rendering", + state: "open", + authorLogin: "carol", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const authorPr2: PullRequestRecord = { + repoFullName: repo.fullName, + number: 204, + title: "Document logging output", + state: "open", + authorLogin: "carol", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const report = buildCollisionReport(repo.fullName, [], [authorPr1, authorPr2]); + expect(findCluster(report, 203, 204)).toBeUndefined(); + }); + + it("still flags two open PRs by the SAME author when their titles alone already overlap enough (pre-existing behavior preserved)", () => { + const authorPr1: PullRequestRecord = { + repoFullName: repo.fullName, + number: 205, + title: "Fix authentication retry backoff handler", + state: "open", + authorLogin: "dave", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + }; + const authorPr2: PullRequestRecord = { + repoFullName: repo.fullName, + number: 206, + title: "Fix authentication retry backoff logic", + state: "open", + authorLogin: "dave", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + }; + const report = buildCollisionReport(repo.fullName, [], [authorPr1, authorPr2]); + expect(findCluster(report, 205, 206)).toBeDefined(); + }); + + it("still flags overlapping titles between an issue and a PR regardless of authorship (path-overlap guard is scoped to PR-shaped pairs only)", () => { + const websocketIssue: IssueRecord = { + repoFullName: repo.fullName, + number: 210, + title: "Websocket cache reconnect handler crashes", + state: "open", + authorLogin: "erin", + labels: [], + linkedPrs: [], + }; + const websocketPr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 211, + title: "Fix websocket cache reconnect crash handler", + state: "open", + authorLogin: "erin", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + }; + const report = buildCollisionReport(repo.fullName, [websocketIssue], [websocketPr]); + const cluster = report.clusters.find((c) => c.items.some((item) => item.type === "issue" && item.number === 210) && c.items.some((item) => item.number === 211)); + expect(cluster).toBeDefined(); + }); + + it("flags an open PR against a recently-merged PR from a different author sharing a file (extends to merged history)", () => { + const openPr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 220, + title: "Improve widget rendering", + state: "open", + authorLogin: "frank", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const mergedPr: RecentMergedPullRequestRecord = { + repoFullName: repo.fullName, + number: 219, + title: "Document logging output", + authorLogin: "grace", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + mergedAt: "2026-06-01T00:00:00.000Z", + payload: {}, + }; + const report = buildCollisionReport(repo.fullName, [], [openPr], [mergedPr]); + const cluster = report.clusters.find((c) => c.items.some((item) => item.number === 220) && c.items.some((item) => item.number === 219)); + expect(cluster).toBeDefined(); + }); + + it("does not flag an open PR against the SAME author's own recently-merged PR sharing only a file path", () => { + const openPr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 221, + title: "Improve widget rendering", + state: "open", + authorLogin: "heidi", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + }; + const mergedPr: RecentMergedPullRequestRecord = { + repoFullName: repo.fullName, + number: 222, + title: "Document logging output", + authorLogin: "heidi", + labels: [], + linkedIssues: [], + changedFiles: ["src/queue/processors.ts"], + mergedAt: "2026-06-01T00:00:00.000Z", + payload: {}, + }; + const report = buildCollisionReport(repo.fullName, [], [openPr], [mergedPr]); + const cluster = report.clusters.find((c) => c.items.some((item) => item.number === 221) && c.items.some((item) => item.number === 222)); + expect(cluster).toBeUndefined(); + }); + }); + it("keeps collision radar bounded for huge issue queues while preserving queue totals", () => { const manyIssues = Array.from({ length: 1000 }, (_, index) => ({ repoFullName: repo.fullName, diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 62ff743b2c..5a2686f78b 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 99df6ab8b6cfcf7a4671508211a42dda) +// Generated by Wrangler by running `wrangler types` (hash: ed445a7a260a431e4c5988e63c82a35a) // Runtime types generated with workerd@1.20260617.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -35,6 +35,7 @@ interface __BaseEnv_Env { GITTENSORY_PUBLIC_STATS: "true"; GITTENSORY_PUBLIC_STATS_REPOS: "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed"; GITTENSORY_DUPLICATE_WINNER: "true"; + GITTENSORY_OPEN_PR_FILE_COLLISION: "false"; RATE_LIMITER: DurableObjectNamespace; } declare namespace Cloudflare { @@ -49,7 +50,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index 3222bd01fd..de64d4fc22 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -137,6 +137,11 @@ // #1695 both closed) — the opposite of "spare one good PR". With it ON, the earliest open PR in a cluster // is judged on its merits and only the true duplicates close. "GITTENSORY_DUPLICATE_WINNER": "true", + // Open-PR file-path collision (#2653): enrich changedFiles on the reviewed PR and its open siblings from + // the pull_request_files cache before building the collision report, so two independently-open PRs on the + // same file get flagged the way two title-similar PRs already are. Default OFF — unset/false leaves every + // PullRequestRecord's changedFiles unset (byte-identical to today, no extra D1 reads). + "GITTENSORY_OPEN_PR_FILE_COLLISION": "false", }, "routes": [ {