Skip to content

Commit 306cee3

Browse files
committed
feat(triage): flag a pull request that duplicates another open one
1 parent 0fcd8a1 commit 306cee3

36 files changed

Lines changed: 640 additions & 51 deletions

‎apps/extension/src/session/page-reads.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,15 @@ test("a whole timeline's latest review is read on the commit the sidebar names",
3737
serve([approval, comment]);
3838
expect(JSON.parse((await pageReads().answer(`/repos/${REPO}/pulls/1/reviews`))!)).toEqual([{ user: { login: "x" }, state: "APPROVED", submitted_at: "2026-10-01T00:00:00Z", commit_id: HEAD }]);
3939
});
40+
41+
test("open pull requests into a base are listed from the dashboard filtered on it, and none open reads as none", async () => {
42+
const asked: string[] = [];
43+
globalThis.fetch = async function dashboard(url) {
44+
asked.push(decodeURIComponent(String(url)));
45+
const route = String(url).includes("base") ? { currentPage: 1, openCount: 0, closedCount: 0 } : { results: [{ number: 4, title: "T", displayState: "REPO_PULL_REQUEST_DISPLAY_STATE_OPEN", headSha: HEAD, labels: [] }], totalPages: 1, openCount: 1 };
46+
return new Response(JSON.stringify({ payload: { repoPullsDashboardContentRoute: route } }));
47+
} as typeof fetch;
48+
expect(JSON.parse((await pageReads().answer(`/repos/${REPO}/pulls?state=open&base=main&per_page=100`))!)).toEqual([]);
49+
expect(JSON.parse((await pageReads().answer(`/repos/${REPO}/pulls?state=open&per_page=100`))!)).toMatchObject([{ number: 4 }]);
50+
expect(asked[0]).toContain('q=is:pr is:open base:"main"');
51+
});

‎apps/extension/src/session/page-reads.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,14 @@ export function pageReads(): PageReads {
203203
}));
204204
}
205205

206-
/** Every open pull request, or only the newest page of GitHub's list when `newest` asks for no more than one page. */
207-
async function openPulls(repo: string, newest: boolean) {
206+
/** Every open pull request, or only those into `base` when it is set, or only the newest page of GitHub's list when `newest` asks for no more than one page. */
207+
async function openPulls(repo: string, newest: boolean, base: string | null) {
208208
const rows: Array<{ number: number; title: string }> = [];
209+
const query = base === null ? "is:pr is:open" : `is:pr is:open base:"${base}"`;
209210
for (let page = 1; ; page++) {
210-
const route = (await json(`/${repo}/pulls?q=${encodeURIComponent("is:pr is:open")}&page=${page}`)).payload.repoPullsDashboardContentRoute;
211+
const route = (await json(`/${repo}/pulls?q=${encodeURIComponent(query)}&page=${page}`)).payload.repoPullsDashboardContentRoute;
212+
// With nothing open the route leaves out its results and pages.
213+
if (route.openCount === 0) return rows;
211214
rows.push(...(route.results as any[]).map((row) => ({ number: row.number, title: row.title, draft: row.displayState === "REPO_PULL_REQUEST_DISPLAY_STATE_DRAFT", head: { sha: row.headSha }, labels: row.labels })));
212215
if (newest || page >= route.totalPages) return rows;
213216
}
@@ -254,7 +257,7 @@ export function pageReads(): PageReads {
254257
if ((match = COMMITS.exec(pathname))) return commits(match[1]!, Number(match[2]));
255258
if ((match = ISSUE_COMMENTS.exec(pathname))) return issueComments(match[1]!, Number(match[2]));
256259
// The dashboard filters by neither branch, so a stack's filtered lists are left to the API.
257-
if ((match = OPEN_PULLS.exec(pathname)) && searchParams.get("state") === "open" && !searchParams.has("head") && !searchParams.has("base")) return openPulls(match[1]!, Number(searchParams.get("per_page") ?? 30) <= DASHBOARD_PAGE);
260+
if ((match = OPEN_PULLS.exec(pathname)) && searchParams.get("state") === "open" && !searchParams.has("head")) return openPulls(match[1]!, Number(searchParams.get("per_page") ?? 30) <= DASHBOARD_PAGE, searchParams.get("base"));
258261
if ((match = CHECK_RUNS.exec(pathname))) return checkRuns(match[1]!, match[2]!);
259262
if ((match = FOLDER.exec(pathname)) && !searchParams.has("ref")) return folder(match[1]!, decodeURIComponent(match[2] ?? ""));
260263
return null;

‎apps/hub/src/code-review-runs.postgres.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { expect, test } from "bun:test";
33
import { randomBytes } from "node:crypto";
44
import { sql } from "drizzle-orm";
55
import { createDB, runMigrations, schema } from "@intx/db";
6-
import { diffHash, type PullRequestToReview } from "./pull-request-reader.js";
6+
import { diffHash } from "@corbits/common/diff-hash";
7+
import type { PullRequestToReview } from "./pull-request-reader.js";
78
import { createCodeReviewRuns } from "./code-review-runs.js";
89
import { recordEvent, type PullRequestRef } from "./manager-state.js";
910
import { migrateTriageTables } from "./triage-db.js";

‎apps/hub/src/code-review-runs.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { eq } from "drizzle-orm";
55
import { type } from "arktype";
66
import { schema, type DB } from "@intx/db";
77
import { CodeReviewComment, DiffCut, REPO_SOURCES, repoSourceOf, StageResult, type RepoSource } from "@corbits/triage-contracts";
8-
import { diffHash, type PullRequestToReview } from "./pull-request-reader.js";
8+
import { diffHash } from "@corbits/common/diff-hash";
9+
import type { PullRequestToReview } from "./pull-request-reader.js";
910
import { repoRecords, sourceOf, triageNs, type RepoRecord } from "./github/tenant-config.js";
1011
import type { Member } from "./integrations/app.js";
1112
import { codeReviews, type CodeReviewRecord } from "./manager-state.js";

‎apps/hub/src/pull-request-reader.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from "bun:test";
2-
import { createPullRequestReader, cutDiff, diffHash, reviewRequestOf, type PullRequestToReview } from "./pull-request-reader.js";
2+
import { diffHash } from "@corbits/common/diff-hash";
3+
import { createPullRequestReader, cutDiff, reviewRequestOf, type PullRequestToReview } from "./pull-request-reader.js";
34
import type { RepoRecord } from "./github/tenant-config.js";
45

56
const RECORD: RepoRecord = { name: "acme/widgets", connected: true };

‎apps/hub/src/pull-request-reader.ts‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Reads a pull request as its host shows it, alone or with its diff, and builds the code review request a manager's
22
// review reads: the diff cut to the limit and hashed whole, so a later head with the same diff can take a review over
33
// (code-review-runs.ts).
4-
import { createHash } from "node:crypto";
54
import * as bitbucket from "@corbits/bitbucket-datacenter-reads";
65
import { readPr, readPrDiff, type GithubFetch } from "@corbits/github-reads";
76
import { shownBody, visibleText, type RepoSource } from "@corbits/triage-contracts";
87
import type { TenantBitbucket } from "./bitbucket-datacenter/tenant-bitbucket.js";
98
import { filesFrom } from "@corbits/common/diff-files";
9+
import { diffHash } from "@corbits/common/diff-hash";
1010
import { forInstallation } from "./github/open-pulls.js";
1111
import { sourceOf, type RepoRecord } from "./github/tenant-config.js";
1212

@@ -21,15 +21,6 @@ export type PullRequestHead = { title: string; description: string; headSha: str
2121

2222
export type PullRequestToReview = PullRequestHead & { diff: string };
2323

24-
/**
25-
* The diff's hash, its `index` lines' blob ids included, so a base change to a file the diff touches changes it; undefined
26-
* for a diff with no `index` line, such as one rebuilt from the file list, which can't show such a change.
27-
*/
28-
export function diffHash(diff: string): string | undefined {
29-
if (!diff.split("\n").some((line) => line.startsWith("index "))) return undefined;
30-
return createHash("sha256").update(diff).digest("hex");
31-
}
32-
3324
function lineCount(text: string): number {
3425
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
3526
}

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/ARCHITECTURE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,21 @@ label off that head, so it never stands on a head not yet triaged.
389389
own changes and focused runs on them, the comment noting what it is
390390
stacked on. A branch other pull requests were merged into holds their
391391
changes too, so focused is skipped and the comment says why.
392+
With a duplicate rule on, it compares the open pull requests opened before
393+
this one on its base branch, drafts left out (`list_open_prs` with `base`,
394+
one call per base branch); past 100 of them it reads none and both rules
395+
are unconfirmed. It reads the first page of each one's changed paths
396+
(`list_pr_files` without patches, ten at a time), so its cost is one call
397+
per earlier open pull request. One with the same head commit repeats this
398+
one with no further read. For no-duplicate it reads the diff hash
399+
(`pr_diff_hash`, the hash the hub's code review takeover uses,
400+
`@corbits/common/diff-hash`) and commits only of those whose first page
401+
lists no path this one does not change, and of this one when any does: two
402+
calls each, usually none. For duplicates-open-pull-request it keeps only
403+
the shortlist the judge is asked about (`overlapping`,
404+
`logic/duplicates.ts`): up to five that share a path on that first page,
405+
lockfiles left out, or two title words, most shared paths first
406+
(`openPulls`).
392407
2. **rules**: runs the hard rules (built-in rules over host data, text
393408
rules and code-pattern rules) and, unless one failed or the pull request is a draft or closed,
394409
builds the judged rules' questions, or the verdict question when there are

0 commit comments

Comments
 (0)