@env={{@model.data.environment}}
@cpuThrottle={{@model.data.args.CPU_THROTTLE}}
@timing={{@model.data.timing}}
+ @prs={{prsOf @model.data}}
/>
diff --git a/results/app/types.ts b/results/app/types.ts
index 502b2cb..2ea0160 100644
--- a/results/app/types.ts
+++ b/results/app/types.ts
@@ -40,6 +40,20 @@ export interface VersionOverride {
url: string;
}
+/**
+ * A PR recorded in a result set's notes. The runner writes these
+ * (`--include-prs`) from git history between the previous result set and
+ * the run; hand-added entries may also be plain URL strings.
+ */
+export interface PullRequestNote {
+ url: string;
+ /**
+ * The PR title, from the merge (or squash) commit. Absent on
+ * hand-added entries.
+ */
+ title?: string;
+}
+
/**
* Small labels a run records about a framework, collected by the runner
* from `frameworks//notes.json`.
@@ -107,8 +121,13 @@ export interface ResultSet {
* Optional per-framework notes, keyed by framework name. Collected by the
* runner from `frameworks//notes.json`, e.g.
* `{ vue: { variant: "Vapor" } }`.
+ *
+ * `prs` sits alongside the framework keys: the PRs that landed between
+ * the previous result set and this run (see {@link PullRequestNote}).
*/
- notes?: Record;
+ notes?: {
+ prs?: Array;
+ } & Record;
environment: {
machine: {
os: {
diff --git a/results/app/utils.ts b/results/app/utils.ts
index a5c67c3..18e8aac 100644
--- a/results/app/utils.ts
+++ b/results/app/utils.ts
@@ -56,6 +56,32 @@ export function variantOf(file: ResultSet, framework: string) {
return file.notes?.[framework]?.variant;
}
+export interface DisplayPr {
+ url: string;
+ title?: string;
+ /**
+ * `#` when the URL has one, the URL itself otherwise.
+ */
+ label: string;
+}
+
+/**
+ * The PRs a run recorded (the ones that landed between the previous
+ * result set and the run), normalized for display: the runner records
+ * `{ url, title }`, hand-added entries are plain URL strings.
+ */
+export function prsOf(file: ResultSet): DisplayPr[] {
+ const prs = file.notes?.prs ?? [];
+
+ return prs.map((pr) => {
+ const url = typeof pr === "string" ? pr : pr.url;
+ const title = typeof pr === "string" ? undefined : pr.title;
+ const number = url.match(/\/pull\/(\d+)/)?.[1];
+
+ return { url, title, label: number ? `#${number}` : url };
+ });
+}
+
/**
* How one framework did at one benchmark, or undefined when that run
* doesn't have the pair.
diff --git a/src/runner/arg.ts b/src/runner/arg.ts
index 0c6e935..fe42937 100644
--- a/src/runner/arg.ts
+++ b/src/runner/arg.ts
@@ -19,6 +19,7 @@ export const FRAMEWORK = str('--framework');
export const BENCH_NAME = str('--bench');
export const SKIP_BUILD = bool('--skip-build');
export const TIMEOUT = int('--timeout', 60_000);
+export const INCLUDE_PRS = bool('--include-prs');
export const VERSION_OVERRIDES = versionOverrides();
function col1(name: string) {
@@ -56,6 +57,11 @@ console.log(
row(col1('--timeout'), col2(TIMEOUT), col3('ms a single sample may take')),
row(col1('--framework'), col2(FRAMEWORK), col3(`or '${ALL}'`)),
row(col1('--bench'), col2(BENCH_NAME), col3(`or '${ALL}'`)),
+ row(
+ col1('--include-prs'),
+ col2(INCLUDE_PRS),
+ col3('record PRs merged since the previous result set'),
+ ),
...Object.entries(VERSION_OVERRIDES).map(([framework, override]) =>
row(
col1(`--${framework}`),
diff --git a/src/runner/bench-info.ts b/src/runner/bench-info.ts
index e602f3f..29445fb 100644
--- a/src/runner/bench-info.ts
+++ b/src/runner/bench-info.ts
@@ -6,14 +6,18 @@ import * as clack from '@clack/prompts';
import * as args from './arg.ts';
import { yyyymmdd } from './environment.ts';
+import { prsSinceLastResultSet } from './prs.ts';
import { frameworks } from './repo.ts';
import {
info,
saveBenchmarkInfo,
saveNotes,
+ savePrNotes,
saveVersionOverrides,
} from './results.ts';
+import type { PullRequestNote } from '../../results/app/types.ts';
+
export interface BenchmarkInfo {
/**
* The benchmark name.
@@ -299,6 +303,29 @@ export async function getBenchInfo() {
const selectedBenches = await getBenches();
const filePath = await getFilePath();
+ // resolved before the confirm below, so what will be recorded is part
+ // of the "does this look correct?" review
+ let prNotes: PullRequestNote[] = [];
+
+ if (args.INCLUDE_PRS) {
+ const found = await prsSinceLastResultSet(filePath);
+
+ if (found && found.prs.length > 0) {
+ prNotes = found.prs;
+
+ clack.log.info(
+ `PRs since the previous result set (${found.since}):\n` +
+ found.prs
+ .map((pr) => ` ${pr.url}${pr.title ? ` — ${pr.title}` : ''}`)
+ .join('\n'),
+ );
+ } else {
+ clack.log.warn(
+ `--include-prs: no PRs found since the previous result set`,
+ );
+ }
+ }
+
console.info(inspect(info, { showHidden: false, depth: null, colors: true }));
console.log(`
Results will be written to ${filePath}
@@ -328,6 +355,7 @@ export async function getBenchInfo() {
await saveVersionOverrides(args.VERSION_OVERRIDES, filePath);
await saveNotes(selectedFrameworks, filePath);
+ await savePrNotes(prNotes, filePath);
return {
apps,
diff --git a/src/runner/prs.ts b/src/runner/prs.ts
new file mode 100644
index 0000000..dfb9ed1
--- /dev/null
+++ b/src/runner/prs.ts
@@ -0,0 +1,100 @@
+import { readdir } from 'node:fs/promises';
+import { basename } from 'node:path';
+
+import { $ } from 'execa';
+
+import type { PullRequestNote } from '../../results/app/types.ts';
+
+const RESULTS_DIR = './results/public/results';
+
+/**
+ * Same base the results app links shas to.
+ */
+const REPO_URL = 'https://github.com/NullVoxPopuli/rere-benchmark';
+
+/**
+ * When the most recent result set (other than the one being written) was
+ * recorded. Result files are named with the run's ISO timestamp, so the
+ * directory listing is the history -- no need to open the files.
+ */
+async function previousResultSetDate(currentFilePath: string) {
+ const files = await readdir(RESULTS_DIR);
+ const current = basename(currentFilePath);
+
+ const dates = files
+ .filter((file) => file.endsWith('.json') && file !== current)
+ .map((file) => file.replace(/\.json$/, ''))
+ .filter((iso) => !Number.isNaN(Date.parse(iso)))
+ .sort();
+
+ return dates.at(-1);
+}
+
+/**
+ * The PRs that landed between the previous result set and now, from git
+ * history alone (no GitHub API):
+ *
+ * - a merge commit's subject is `Merge pull request #N from ...` and the
+ * first line of its body is the PR title
+ * - a squash-merge's subject is `The PR title (#N)`
+ *
+ * Newest first, like `git log`. Deduplicated by number, so a PR that
+ * appears both ways (or twice via --since's commit-date filter) is
+ * recorded once.
+ */
+export async function prsSinceLastResultSet(currentFilePath: string) {
+ const since = await previousResultSetDate(currentFilePath);
+
+ if (!since) return;
+
+ // NUL between subject and body, RS between commits: bodies span lines
+ const format = '%s%x00%b%x1e';
+ const { stdout } = await $`git log --since=${since} --format=${format}`;
+
+ const prs: PullRequestNote[] = [];
+ const seen = new Set();
+
+ for (const entry of stdout.split('\x1e')) {
+ const [subject = '', body = ''] = entry.trim().split('\0');
+
+ const pr = fromMergeCommit(subject, body) ?? fromSquashCommit(subject);
+
+ if (!pr) continue;
+ if (seen.has(pr.url)) continue;
+
+ seen.add(pr.url);
+ prs.push(pr);
+ }
+
+ return { since, prs };
+}
+
+function fromMergeCommit(
+ subject: string,
+ body: string,
+): PullRequestNote | undefined {
+ const merge = subject.match(/^Merge pull request #(?\d+) from /);
+
+ if (!merge?.groups) return;
+
+ const title = body
+ .split('\n')
+ .map((line) => line.trim())
+ .find(Boolean);
+
+ return {
+ url: `${REPO_URL}/pull/${merge.groups['number']}`,
+ ...(title ? { title } : {}),
+ };
+}
+
+function fromSquashCommit(subject: string): PullRequestNote | undefined {
+ const squash = subject.match(/^(?.+) \(#(?\d+)\)$/);
+
+ if (!squash?.groups) return;
+
+ return {
+ url: `${REPO_URL}/pull/${squash.groups['number']}`,
+ title: squash.groups['title'],
+ };
+}
diff --git a/src/runner/results.ts b/src/runner/results.ts
index dcb6857..d3a2fb5 100644
--- a/src/runner/results.ts
+++ b/src/runner/results.ts
@@ -12,7 +12,10 @@ import {
} from '../../results/app/frameworks.ts';
import { getInfo } from './environment.ts';
-import type { VersionOverride } from '../../results/app/types.ts';
+import type {
+ PullRequestNote,
+ VersionOverride,
+} from '../../results/app/types.ts';
import type { BenchmarkInfo } from './bench-info.ts';
const require = createRequire(import.meta.url);
@@ -150,6 +153,27 @@ export async function saveNotes(frameworks: string[], filePath: string) {
await write(file, filePath);
}
+/**
+ * The PRs that landed between the previous result set and this run
+ * (`--include-prs`, from git history). Merged in and deduplicated by URL,
+ * so hand-added entries (plain URL strings) and earlier appends survive.
+ */
+export async function savePrNotes(prs: PullRequestNote[], filePath: string) {
+ if (prs.length === 0) return;
+
+ const file = await read(filePath);
+
+ const existing: Array = file.notes?.prs ?? [];
+ const known = new Set(
+ existing.map((pr) => (typeof pr === 'string' ? pr : pr.url)),
+ );
+ const fresh = prs.filter((pr) => !known.has(pr.url));
+
+ file.notes = { ...file.notes, prs: existing.concat(fresh) };
+
+ await write(file, filePath);
+}
+
async function getVersion(framework: string, bench: BenchmarkInfo) {
const dir = join('frameworks', framework, bench.app);
const manifestPath = join(dir, 'package.json');