Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions bench/pr-review-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// What `threads.pullRequestFiles` hands a reader for a real pull request.
// The GitHub API is mocked: each squash commit given (default: this repo's
// #614, #621 and #524) is turned into the files API pages GitHub would serve
// (30 files a page, the patch from the first `@@`, none for a binary file),
// and every page is read through PullRequestReviews. No network, no login.
//
// bun bench/pr-review-context.ts [commit ...]
// BENCH_REVIEW_MODULE=<path to another pull-request-review.ts> bun bench/pr-review-context.ts
//
// Prints, per pull request, the JSON bytes of all pages, of the largest page,
// the patch characters of the largest page and how many files of the PR come
// back with less than their whole patch.
import { resolve } from 'node:path';

const commits = process.argv.slice(2).length ? process.argv.slice(2) : ['38d5ca2f7', '9ea83fda5', '060cb4528'];
const modulePath = resolve(process.env.BENCH_REVIEW_MODULE ?? `${import.meta.dir}/../packages/core/src/pull-request-review.ts`);
const { PullRequestReviews } = await import(modulePath) as typeof import('../packages/core/src/pull-request-review.ts');

function git(args: string[]): string {
const run = Bun.spawnSync(['git', ...args], { cwd: import.meta.dir, stdout: 'pipe', stderr: 'pipe' });
if (run.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${run.stderr.toString()}`);
return run.stdout.toString();
}

interface ApiFile { filename: string; previous_filename?: string; status: string; additions: number; deletions: number; patch?: string }

function apiFiles(commit: string): ApiFile[] {
const diff = git(['diff', '--find-renames', '--no-color', '--no-ext-diff', `${commit}^`, commit]);
return diff.split(/^(?=diff --git )/m).filter(Boolean).map(section => {
const lines = section.replace(/\n$/, '').split('\n'), at = lines.findIndex(line => line.startsWith('@@'));
const header = at < 0 ? lines : lines.slice(0, at), body = at < 0 ? [] : lines.slice(at);
const pick = (prefix: string) => header.find(line => line.startsWith(prefix))?.slice(prefix.length);
const to = pick('rename to ') ?? pick('+++ b/') ?? / b\/(.+)$/.exec(lines[0]!)![1]!, from = pick('rename from ') ?? pick('--- a/');
const status = header.some(l => l.startsWith('new file')) ? 'added' : header.some(l => l.startsWith('deleted file')) ? 'removed' : pick('rename from ') ? 'renamed' : 'modified';
return { filename: status === 'removed' ? from! : to, ...(status === 'renamed' ? { previous_filename: from } : {}), status,
additions: body.filter(l => l.startsWith('+')).length, deletions: body.filter(l => l.startsWith('-')).length, ...(body.length ? { patch: body.join('\n') } : {}) };
});
}

const url = 'https://github.com/example/repo/pull/1';
for (const commit of commits) {
const files = apiFiles(commit), title = git(['log', '-1', '--format=%s', commit]).trim();
const service = new PullRequestReviews(async (_thread, args) => {
const page = Number(/[?&]page=(\d+)/.exec(args.at(-1)!)![1]);
return JSON.stringify(files.slice((page - 1) * 30, page * 30));
});
let bytes = 0, largest = 0, largestPatch = 0, cut = 0, pages = 0;
for (let page = 1; ; page++) {
const result = await service.files('bench', url, page), size = Buffer.byteLength(JSON.stringify(result));
bytes += size; pages++;
if (size > largest) { largest = size; largestPatch = result.files.reduce((n, f) => n + (f.patch?.length ?? 0), 0); }
cut += result.files.filter((f, i) => f.patch !== (files[(page - 1) * 30 + i]?.patch ?? null)).length;
if (!result.hasMore) break;
}
const raw = files.reduce((n, f) => n + (f.patch?.length ?? 0), 0);
console.log(`${commit} ${title}`);
console.log(` ${files.length} files, ${pages} pages, ${raw} patch chars from git; all pages ${bytes} B, largest page ${largest} B (${largestPatch} patch chars), files not whole ${cut}`);
}
24 changes: 24 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -1612,6 +1612,29 @@ A lower threshold costs a summary and a rewritten cache at each extra
compaction, which is why the cost falls far less than the context. Automatic
compaction is the owner's setting ([context](context.md#automatic-compaction)).

### Pull request file pages

`threads.pullRequestFiles` returns 30 changed files a page. Their patches
share 60,000 characters, about 15,000 tokens: a file under its fair share
stays whole and leaves the rest to bigger ones, and a bigger file is cut the
same way across its hunks, so small hunks stay whole and long ones keep their
first lines. A cut patch ends with a note giving its page and path; the same
call with that `path` returns the file alone, up to 100,000 characters. Lock
files and generated files, known by their path, keep one stat line. Before, each patch kept up to
100,000 characters, so a page could reach 3 MB and one large file could fill
a reader's context ahead of the small files after it.

`bench/pr-review-context.ts` rebuilds the files API pages of squash commits of
this repository from git and reads every page through the service, with no
network. Measured on 2026-11-07, before against the previous
`pull-request-review.ts` with `BENCH_REVIEW_MODULE`:

| Pull request | Files | All pages before | All pages after | Largest page before | Largest page after |
| --- | --- | --- | --- | --- | --- |
| #614, one strings file per part | 185 | 628,053 B | 398,304 B | 271,909 B | 65,752 B |
| #621, one contracts file per domain | 44 | 413,740 B | 126,800 B | 306,462 B | 64,037 B |
| #524, with a 51,000-line results file | 62 | 250,241 B | 131,179 B | 174,421 B | 63,469 B |

## Benches

```sh
Expand All @@ -1635,6 +1658,7 @@ bun run bench/queue-admission.ts 1024 16 # one scheduler pass o
bun bench/ui-heap.ts --machines --load 4 --tick 25 --switch 0 --minutes 9 # heap, DOM and private bytes of a page left open on busy agents
bun bench/process-log-replay.ts <logs directory> # process records a log directory holds, and what this store writes of them
bun run bench/checkpoint-store.ts --source <checkpoints> # store passes and retention on a copy: bytes, inodes, 20 rewinds
bun bench/pr-review-context.ts [commit ...] # bytes of each pull request file page, from real commits, no network
```

`bench/bandwidth.ts` puts a TCP relay between the client and the core, counts
Expand Down
13 changes: 11 additions & 2 deletions packages/contracts/src/pull-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export interface LinkedPullRequest {
}
export interface PullRequestsRpcMethods {
'threads.pullRequestReview': { params: { threadId: string; url: string }; result: PullRequestReview };
'threads.pullRequestFiles': { params: { threadId: string; url: string; page: number }; result: PullRequestFiles };
/**
* One page of up to 30 changed files. Their patches share a fixed budget, so
* a big patch comes back cut with a note; `path` returns that file of the
* page alone with up to 100,000 characters of its patch.
*/
'threads.pullRequestFiles': { params: { threadId: string; url: string; page: number; path?: string }; result: PullRequestFiles };
'threads.pullRequests': { params: { threadId: string; refresh?: boolean }; result: LinkedPullRequest[] };
'threads.linkPullRequest': { params: { threadId: string; url: string }; result: LinkedPullRequest[] };
'threads.unlinkPullRequest': { params: { threadId: string; url: string }; result: LinkedPullRequest[] };
Expand Down Expand Up @@ -81,7 +86,11 @@ export interface PullRequestReview {
url: string; title: string; body: string; state: string; head: string; base: string; checkedAt: number;
checks: PullRequestCheck[]; comments: PullRequestComment[]; reviews: PullRequestComment[]; truncated: boolean;
}
export interface PullRequestFile { path: string; previousPath?: string; status: string; additions: number; deletions: number; patch: string | null }
export interface PullRequestFile {
path: string; previousPath?: string; status: string; additions: number; deletions: number; patch: string | null;
/** Characters of the patch left out to fit the page's budget; absent when the patch is whole. */
omitted?: number;
}
export interface PullRequestFiles { files: PullRequestFile[]; page: number; hasMore: boolean; truncated: boolean }
export interface PullRequestsRpcEvents {
'threads.pullRequestsChanged': { threadId: string; pullRequests: LinkedPullRequest[] };
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/pull-request-diff-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Fits one page of pull request patches into a fixed budget of characters.
* Files share it first: a file under its fair share keeps its whole patch and
* leaves the rest to bigger ones. Inside a file over its share the hunks share
* that file's part the same way: small hunks stay whole, big ones keep their
* first lines. Each cut patch ends with a note naming what it lost and the page
* and path that return it alone, up to REVIEW_FILE_LIMIT characters. Lock files and generated files, known by
* their path, keep only a stat line. Notes and stat lines are paid for before
* the split, so a page never exceeds the budget.
*/

/** Characters of patch text in one page of files, notes included. About 15,000 tokens; it holds 30 notes many times over. */
export const REVIEW_DIFF_BUDGET = 60_000;
/** A file read alone by path keeps up to this much of its patch. */
export const REVIEW_FILE_LIMIT = 100_000;

export interface BudgetFile { path: string; additions: number; deletions: number; patch: string | null }
export interface BudgetedPatch { patch: string | null; omitted: number }

const LOCK_FILES = new Set(['bun.lock', 'bun.lockb', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'cargo.lock', 'gemfile.lock',
'poetry.lock', 'pipfile.lock', 'composer.lock', 'go.sum', 'flake.lock', 'uv.lock', 'packages.lock.json', 'pubspec.lock', 'mix.lock', 'podfile.lock']);
const GENERATED_PATH = /(^|\/)(dist|vendor|node_modules|__generated__)\/|\.(lock|min\.js|min\.css|map|snap|pb\.go|g\.dart)$|_pb2\.py$|[._-]generated\.[a-z0-9]+$/i;

export function generatedFile(path: string): boolean {
return LOCK_FILES.has(path.slice(path.lastIndexOf('/') + 1).toLowerCase()) || GENERATED_PATH.test(path);
}

/** A path past 200 characters is not repeated: the file entry carries it, and the note stays short. */
const where = (page: number, path: string) => `read it alone, up to ${REVIEW_FILE_LIMIT} characters, with threads.pullRequestFiles page ${page} path ${path.length > 200 ? 'of this file' : JSON.stringify(path)}`;

/**
* Max-min fair split: the smallest asks are served whole and what they leave
* goes to the rest. Never hands out more than `budget` in total.
*/
export function fairShares(sizes: number[], budget: number): number[] {
const shares = sizes.map(() => 0), order = sizes.map((_, i) => i).sort((a, b) => sizes[a]! - sizes[b]!);
let left = Math.max(0, budget), waiting = sizes.length;
for (const i of order) { shares[i] = Math.min(sizes[i]!, Math.floor(left / waiting)); left -= shares[i]!; waiting--; }
return shares;
}

/** Each part first gets its floor, the least it can be shown in; the rest is split fairly over what each still asks. */
function flooredShares(sizes: number[], floors: number[], budget: number): number[] {
const extra = fairShares(sizes.map((size, i) => size - floors[i]!), budget - floors.reduce((n, f) => n + f, 0));
return floors.map((floor, i) => floor + extra[i]!);
}

/** Splits a patch before each `@@` header. Text before the first header stays with the first hunk. */
function hunks(patch: string): string[][] {
const out: string[][] = [];
for (const line of patch.split('\n')) {
if (line.startsWith('@@') || !out.length) out.push([line]); else out.at(-1)!.push(line);
}
return out;
}

/** One hunk cut to `limit` characters at a line boundary; the header always stays. */
const marker = (n: number) => `[${n} more line${n === 1 ? '' : 's'} of this hunk left out]`;

function cutHunk(lines: string[], limit: number): { lines: string[]; omitted: number } {
const kept = [lines[0]!];
let used = lines[0]!.length + 1;
const reserve = marker(lines.length).length + 1;
for (const line of lines.slice(1)) { if (used + line.length + 1 + reserve > limit) break; kept.push(line); used += line.length + 1; }
const dropped = lines.slice(kept.length);
return { lines: [...kept, marker(dropped.length)], omitted: dropped.reduce((n, line) => n + line.length + 1, 0) };
}

/** The least a cut patch can take: its note alone. */
function noteOnly(patch: string, page: number, path: string): string {
const count = hunks(patch).length;
return `[patch cut to fit the review budget: ${count} of ${count} hunk${count === 1 ? '' : 's'} shortened, ${patch.length} characters in all; ${where(page, path)}]`;
}

/** One patch within `limit` characters, cut inside hunks when one of them does not fit whole. Never longer than `limit` once `limit` holds the note alone. */
export function cutPatch(patch: string, limit: number, page: number, path: string): BudgetedPatch {
if (patch.length <= limit) return { patch, omitted: 0 };
const parts = hunks(patch), sizes = parts.map(lines => lines.join('\n').length + 1);
const note = (shortened: number) => `[patch cut to fit the review budget: ${shortened} of ${parts.length} hunk${parts.length === 1 ? '' : 's'} shortened, ${patch.length} characters in all; ${where(page, path)}]`;
const room = limit - note(parts.length).length - 1;
// A hunk shows at least its header and a marker; with no room for all of them, the note alone says where the patch is.
const floors = parts.map((lines, i) => Math.min(sizes[i]!, lines[0]!.length + 1 + marker(lines.length).length + 1));
if (room < floors.reduce((n, f) => n + f, 0)) return { patch: note(parts.length), omitted: patch.length };
const shares = flooredShares(sizes, floors, room), body: string[] = [];
let shortened = 0, omitted = 0;
parts.forEach((lines, i) => {
if (shares[i]! >= sizes[i]!) { body.push(...lines); return; }
const cut = cutHunk(lines, shares[i]!);
shortened++; omitted += cut.omitted; body.push(...cut.lines);
});
return { patch: [...body, note(shortened)].join('\n'), omitted };
}

/**
* Patches of one page fitted into `budget`. `omitted` counts the characters of
* the original patch that the reader does not get.
*/
export function budgetPatches(files: BudgetFile[], page: number, budget = REVIEW_DIFF_BUDGET): BudgetedPatch[] {
const stats = files.map(f => f.patch !== null && generatedFile(f.path)
? `[generated or lock file: +${f.additions} -${f.deletions}, patch of ${f.patch.length} characters left out; ${where(page, f.path)}]` : null);
const sizes = files.map((f, i) => stats[i]?.length ?? f.patch?.length ?? 0);
const floors = files.map((f, i) => stats[i] !== null || f.patch === null ? sizes[i]! : Math.min(sizes[i]!, noteOnly(f.patch, page, f.path).length));
const shares = flooredShares(sizes, floors, budget);
return files.map((f, i) => {
if (f.patch === null) return { patch: null, omitted: 0 };
return stats[i] !== null ? { patch: stats[i]!, omitted: f.patch.length } : cutPatch(f.patch, shares[i]!, page, f.path);
});
}
25 changes: 20 additions & 5 deletions packages/core/src/pull-request-review.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { pullRequestAddress, type PullRequestReview, type PullRequestFiles, type PullRequestComment } from '@boite/contracts';
import { refused } from './errors.ts';
import { budgetPatches, REVIEW_FILE_LIMIT } from './pull-request-diff-budget.ts';

type Row = Record<string, unknown>;
const row = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {};
Expand Down Expand Up @@ -46,15 +47,29 @@ export class PullRequestReviews {
truncated: comments.length > 150 || reviews.length >= 100 || checks.length >= 100 || inline.length >= 100 || list(item.comments).length >= 100 || text(item.body, 40001).length > 40000 || [...comments, ...reviews].some(c => text(row(c).body, 12001).length > 12000) };
});
}
files(threadId: string, url: string, page: number): Promise<PullRequestFiles> {
/** A page of files whose patches share one budget, or with `path`, that file of the page alone with up to REVIEW_FILE_LIMIT characters of its patch. */
files(threadId: string, url: string, page: number, path?: string): Promise<PullRequestFiles> {
const a = this.address(url);
if (!Number.isSafeInteger(page) || page < 1 || page > 100) throw refused('pull request files page must be between 1 and 100');
if (path !== undefined && (typeof path !== 'string' || !path || path.length > 2000)) throw refused('pull request file path must be a file path of the page', { field: 'path', expected: 'a path of 1 to 2000 characters' });
return this.cached(`${threadId}:${a.url}:files:${page}`, async () => {
const values = json(await this.run(threadId, ['api', '--hostname', a.host, `repos/${a.repository}/pulls/${a.number}/files?per_page=30&page=${page}`]));
if (!Array.isArray(values)) throw refused('GitHub files must be an array');
return { page, hasMore: values.length === 30 && page < 100, truncated: (page === 100 && values.length === 30) || values.some(v => text(row(v).patch, 100001).length > 100000),
files: values.slice(0, 30).map(value => { const f = row(value); return { path: text(f.filename, 2000), previousPath: f.previous_filename ? text(f.previous_filename, 2000) : undefined,
status: text(f.status, 50), additions: Math.max(0, Number(f.additions) || 0), deletions: Math.max(0, Number(f.deletions) || 0), patch: typeof f.patch === 'string' ? f.patch.slice(0, 100000) : null }; }) };
});
return values;
}).then(values => filesPage(values, page, path));
}
}

function filesPage(values: unknown[], page: number, path: string | undefined): PullRequestFiles {
const hasMore = values.length === 30 && page < 100, all = values.slice(0, 30).map(value => { const f = row(value); return { path: text(f.filename, 2000), previousPath: f.previous_filename ? text(f.previous_filename, 2000) : undefined,
status: text(f.status, 50), additions: Math.max(0, Number(f.additions) || 0), deletions: Math.max(0, Number(f.deletions) || 0), patch: typeof f.patch === 'string' ? f.patch : null }; });
if (path !== undefined) {
const file = all.find(f => f.path === path);
if (!file) throw refused(`page ${page} has no file ${path}`, { field: 'path', expected: all.map(f => f.path).join(', ') || 'a page with files' });
const omitted = Math.max(0, (file.patch?.length ?? 0) - REVIEW_FILE_LIMIT);
return { page, hasMore, truncated: omitted > 0 || (page === 100 && values.length === 30), files: [{ ...file, patch: file.patch?.slice(0, REVIEW_FILE_LIMIT) ?? null, ...(omitted ? { omitted } : {}) }] };
}
const patches = budgetPatches(all, page);
return { page, hasMore, truncated: (page === 100 && values.length === 30) || patches.some(p => p.omitted > 0),
files: all.map((f, i) => ({ ...f, patch: patches[i]!.patch, ...(patches[i]!.omitted ? { omitted: patches[i]!.omitted } : {}) })) };
}
2 changes: 1 addition & 1 deletion packages/core/src/threads/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function registerThreadMethods(core: Core, probe: ProviderProbe): void {
linked.requireLink(p.threadId, url); return result;
});
core.router.register('threads.pullRequestFiles', async p => {
const url = linked.requireLink(p.threadId, p.url), result = await pullRequests.reviews.files(p.threadId, url, p.page);
const url = linked.requireLink(p.threadId, p.url), result = await pullRequests.reviews.files(p.threadId, url, p.page, p.path);
linked.requireLink(p.threadId, url); return result;
});
core.router.register('threads.pullRequests', p => linked.list(p.threadId, p.refresh === true));
Expand Down
Loading
Loading