Skip to content

Commit 71a243b

Browse files
authored
ci: add pull request stats comment (#467)
1 parent 1c37959 commit 71a243b

3 files changed

Lines changed: 438 additions & 0 deletions

File tree

‎.cspell.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"numequal",
127127
"numequalverify",
128128
"numnotequal",
129+
"numstat",
129130
"NOTEQUAL",
130131
"op",
131132
"opcode",

‎.github/scripts/pr-stats.ts‎

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
// Breaks a pull request's changed lines down by package and by kind of change (source, tests, docs,
2+
// dependencies, ...), so reviewers can see at a glance how big a change is and where it lands.
3+
//
4+
// Usage: node .github/scripts/pr-stats.ts <base-revision> <head-revision>
5+
//
6+
// The script has no dependencies and only reads git history, so it is safe to run against untrusted
7+
// pull request revisions. Node runs the TypeScript directly (type stripping, Node 22.18+).
8+
9+
import { execFileSync } from 'node:child_process';
10+
11+
type Area = 'compiler' | 'sdk' | 'utils' | 'website' | 'examples' | 'repository';
12+
type Kind = 'source' | 'tests' | 'docs' | 'dependencies' | 'config' | 'version' | 'generated';
13+
14+
interface FileChange {
15+
filePath: string;
16+
area: Area;
17+
kind: Kind;
18+
added: number;
19+
removed: number;
20+
}
21+
22+
interface Churn {
23+
added: number;
24+
removed: number;
25+
}
26+
27+
const areaRules: { area: Area; prefix: string }[] = [
28+
{ area: 'compiler', prefix: 'packages/cashc/' },
29+
{ area: 'sdk', prefix: 'packages/cashscript/' },
30+
{ area: 'utils', prefix: 'packages/utils/' },
31+
{ area: 'website', prefix: 'website/' },
32+
{ area: 'examples', prefix: 'examples/' },
33+
];
34+
35+
// Order matters: the first matching rule wins, anything unmatched is source.
36+
const kindRules: { kind: Kind; matches: (filePath: string) => boolean }[] = [
37+
{
38+
kind: 'generated',
39+
matches: (filePath) => baseName(filePath) === 'yarn.lock' || isGeneratedGrammar(filePath),
40+
},
41+
{ kind: 'dependencies', matches: (filePath) => baseName(filePath) === 'package.json' },
42+
{ kind: 'tests', matches: (filePath) => filePath.includes('/test/') || filePath.endsWith('.test.ts') },
43+
// Agent instructions are tooling configuration rather than documentation for readers, despite being markdown.
44+
{
45+
kind: 'config',
46+
matches: (filePath) => filePath.startsWith('.github/')
47+
|| baseName(filePath).startsWith('.')
48+
|| /^(tsconfig.*\.json|vitest\.(config|setup)\.ts|lerna\.json|docusaurus\.config\.ts|sidebars\.ts)$/
49+
.test(baseName(filePath))
50+
|| ['AGENTS.md', 'CLAUDE.md'].includes(baseName(filePath)),
51+
},
52+
{
53+
kind: 'docs',
54+
matches: (filePath) => /\.mdx?$/.test(filePath) || /^website\/(docs|static)\//.test(filePath),
55+
},
56+
];
57+
58+
const areaLabels: Record<Area, string> = {
59+
compiler: 'Compiler (`cashc`)',
60+
sdk: 'SDK (`cashscript`)',
61+
utils: 'Utils (`@cashscript/utils`)',
62+
website: 'Website',
63+
examples: 'Examples',
64+
repository: 'Repository',
65+
};
66+
67+
const kindLabels: Record<Kind, string> = {
68+
source: 'Source',
69+
tests: 'Tests',
70+
docs: 'Docs',
71+
dependencies: 'Deps',
72+
config: 'Config',
73+
version: 'Version',
74+
generated: 'Generated',
75+
};
76+
77+
const areaOrder: Area[] = ['compiler', 'sdk', 'utils', 'website', 'examples', 'repository'];
78+
const kindOrder: Kind[] = ['source', 'tests', 'docs', 'dependencies', 'config', 'version', 'generated'];
79+
80+
// Version bumps and generated files are left out of the totals: there is nothing in them to review, and a
81+
// lock file update would otherwise dwarf everything else.
82+
const excludedFromTotal: Kind[] = ['version', 'generated'];
83+
84+
// Workspace packages are bumped together on every release. Their version lines count as version bumps
85+
// rather than dependency changes, and are summarised once instead of listed per package.json.
86+
const workspacePackages = ['cashc', 'cashscript', '@cashscript/utils'];
87+
const versionConstantPath = 'packages/cashc/src/index.ts';
88+
89+
const releaseNotesPath = 'website/docs/releases/release-notes.md';
90+
const publishedAreas: Area[] = ['compiler', 'sdk', 'utils'];
91+
const listedNewFiles = 10;
92+
93+
const [baseRevision, headRevision] = requireRevisions();
94+
// Diffing against the merge base keeps commits that landed on the base branch after the pull request
95+
// was opened out of the numbers.
96+
const mergeBase = git(['merge-base', baseRevision, headRevision]).trim();
97+
98+
const lineCounts = git(['diff', '--numstat', '--no-renames', mergeBase, headRevision]);
99+
console.log(renderReport(parseLineCounts(lineCounts).flatMap(splitVersionLines)));
100+
101+
function renderReport(changes: FileChange[]): string {
102+
if (changes.length === 0) return 'No line changes in this pull request.';
103+
104+
return [
105+
'### Pull request stats',
106+
'',
107+
...renderTable(changes),
108+
'',
109+
...renderSummary(changes),
110+
...renderVersionBump(changes),
111+
...renderDependencyChanges(changes),
112+
...renderNewFiles(),
113+
...renderReleaseNotesReminder(changes),
114+
].join('\n');
115+
}
116+
117+
function renderTable(changes: FileChange[]): string[] {
118+
const areas = areaOrder.filter((area) => changes.some((change) => change.area === area));
119+
const kinds = kindOrder.filter((kind) => changes.some((change) => change.kind === kind));
120+
const reviewableChurn = churnOf(reviewable(changes));
121+
122+
const header = `| | ${kinds.map((kind) => kindLabels[kind]).join(' | ')} | Total | Net | Share |`;
123+
const separator = `|---|${kinds.map(() => '---:|').join('')}---:|---:|---:|`;
124+
const rows = areas.map((area) => {
125+
const inArea = changes.filter((change) => change.area === area);
126+
return renderRow(areaLabels[area], inArea, kinds, reviewableChurn);
127+
});
128+
const totalRow = renderRow('**Total**', changes, kinds, reviewableChurn);
129+
130+
return [header, separator, ...rows, totalRow];
131+
}
132+
133+
function renderRow(label: string, changes: FileChange[], kinds: Kind[], reviewableChurn: Churn): string {
134+
const cells = kinds.map((kind) => formatChurn(churnOf(changes.filter((change) => change.kind === kind))));
135+
const total = churnOf(reviewable(changes));
136+
const share = sizeOf(reviewableChurn) === 0 ? '—' : formatPercentage(sizeOf(total) / sizeOf(reviewableChurn));
137+
return `| ${label} | ${cells.join(' | ')} | ${formatChurn(total)} | ${formatNet(total)} | ${share} |`;
138+
}
139+
140+
function renderSummary(changes: FileChange[]): string[] {
141+
const reviewableChurn = churnOf(reviewable(changes));
142+
const net = sizeOf(reviewableChurn) === 0 ? '' : ` (net ${formatNet(reviewableChurn)})`;
143+
const lines = [
144+
`Reviewable churn: ${sizeOf(reviewableChurn)} lines${net}, version bumps and generated files excluded.`,
145+
];
146+
147+
const sourceChurn = sizeOf(churnOf(changes.filter((change) => change.kind === 'source')));
148+
const testChurn = sizeOf(churnOf(changes.filter((change) => change.kind === 'tests')));
149+
if (sourceChurn > 0) lines.push(`Test lines per line of source: ${(testChurn / sourceChurn).toFixed(2)}.`);
150+
151+
// Only source files are read line by line, which keeps lock files and fixtures out of the comment share.
152+
const addedSourceLines = changes
153+
.filter((change) => change.kind === 'source')
154+
.flatMap((change) => addedLinesOf(change.filePath))
155+
.filter((line) => !isVersionLine(line));
156+
const comments = addedSourceLines.filter(isComment).length;
157+
const nonBlank = addedSourceLines.filter((line) => line.trim() !== '').length;
158+
if (nonBlank > 0) {
159+
lines.push(`Comments: ${comments} of ${nonBlank} added source lines, ${formatPercentage(comments / nonBlank)}.`);
160+
}
161+
162+
return lines;
163+
}
164+
165+
// A release bumps every package.json at once, which reads best as one line rather than one per file.
166+
function renderVersionBump(changes: FileChange[]): string[] {
167+
const bumps = new Map<string, string[]>();
168+
169+
for (const change of changes.filter((entry) => entry.kind === 'version' && baseName(entry.filePath) === 'package.json')) {
170+
const before = packageManifest(fileAt(mergeBase, change.filePath));
171+
const after = packageManifest(fileAt(headRevision, change.filePath));
172+
if (!before.version || !after.version || before.version === after.version) continue;
173+
174+
const bump = `${before.version} → ${after.version}`;
175+
bumps.set(bump, [...(bumps.get(bump) ?? []), `\`${after.name ?? change.filePath}\``]);
176+
}
177+
178+
return [...bumps].map(([bump, packages]) => `Version bump: ${bump} in ${packages.join(', ')}.`);
179+
}
180+
181+
// Which packages the pull request brings in or drops, as opposed to how many lines of package.json moved.
182+
// A new dependency is the part that warrants a look of its own.
183+
function renderDependencyChanges(changes: FileChange[]): string[] {
184+
const lines: string[] = [];
185+
186+
for (const change of changes.filter((entry) => entry.kind === 'dependencies')) {
187+
const before = declaredDependencies(fileAt(mergeBase, change.filePath));
188+
const after = declaredDependencies(fileAt(headRevision, change.filePath));
189+
const added = Object.entries(after)
190+
.filter(([name]) => !(name in before))
191+
.map(([name, version]) => `\`${name}@${version}\``);
192+
const removed = Object.keys(before).filter((name) => !(name in after)).map((name) => `\`${name}\``);
193+
const updated = Object.entries(after)
194+
.filter(([name, version]) => name in before && before[name] !== version && !workspacePackages.includes(name))
195+
.map(([name, version]) => `\`${name}\` ${before[name]} → ${version}`);
196+
197+
if (added.length > 0) lines.push(`- \`${change.filePath}\` adds ${added.join(', ')}`);
198+
if (removed.length > 0) lines.push(`- \`${change.filePath}\` removes ${removed.join(', ')}`);
199+
if (updated.length > 0) lines.push(`- \`${change.filePath}\` updates ${updated.join(', ')}`);
200+
}
201+
202+
return lines.length === 0 ? [] : ['', 'Dependency changes:', ...lines];
203+
}
204+
205+
// Files added by this pull request say how much of it is new surface rather than edits to code that
206+
// already had reviewers.
207+
function renderNewFiles(): string[] {
208+
const newFiles = git(['diff', '--name-status', '--no-renames', mergeBase, headRevision])
209+
.split('\n')
210+
.filter((line) => line.startsWith('A\t'))
211+
.map((line) => line.slice(2))
212+
.filter((filePath) => kindOf(filePath) !== 'generated');
213+
if (newFiles.length === 0) return [];
214+
215+
const shown = newFiles.slice(0, listedNewFiles).map((filePath) => `- \`${filePath}\``);
216+
const hidden = newFiles.length > listedNewFiles ? [`- and ${newFiles.length - listedNewFiles} more`] : [];
217+
return ['', `New files: ${newFiles.length}`, ...shown, ...hidden];
218+
}
219+
220+
// Source changes to a published package usually deserve a line in the release notes.
221+
function renderReleaseNotesReminder(changes: FileChange[]): string[] {
222+
const changesPublishedSource = changes
223+
.some((change) => change.kind === 'source' && publishedAreas.includes(change.area));
224+
const touchesReleaseNotes = changes.some((change) => change.filePath === releaseNotesPath);
225+
if (!changesPublishedSource || touchesReleaseNotes) return [];
226+
227+
return ['', `Package source changed without an update to \`${releaseNotesPath}\`.`];
228+
}
229+
230+
function requireRevisions(): [string, string] {
231+
const [base, head] = process.argv.slice(2);
232+
if (!base || !head) {
233+
console.error('Usage: node .github/scripts/pr-stats.ts <base-revision> <head-revision>');
234+
process.exit(1);
235+
}
236+
return [base, head];
237+
}
238+
239+
// Version lines in files that hold a version are split off from the rest of the file's changes, so a release
240+
// bump does not read as dependency or source changes.
241+
function splitVersionLines(change: FileChange): FileChange[] {
242+
if (!isVersionedFile(change.filePath)) return [change];
243+
244+
const changedLines = changedLinesOf(change.filePath);
245+
const versionLines = changedLines.filter((line) => isVersionLine(line.slice(1)));
246+
const otherLines = changedLines.filter((line) => !isVersionLine(line.slice(1)));
247+
248+
return [
249+
{ ...change, kind: 'version' as Kind, ...countLines(versionLines) },
250+
{ ...change, ...countLines(otherLines) },
251+
].filter((entry) => sizeOf(entry) > 0);
252+
}
253+
254+
function isVersionedFile(filePath: string): boolean {
255+
return ['package.json', 'lerna.json'].includes(baseName(filePath)) || filePath === versionConstantPath;
256+
}
257+
258+
// A package's own version, a workspace package's version in a dependency list, or the compiler's version constant.
259+
function isVersionLine(line: string): boolean {
260+
const workspaceDependency = workspacePackages.some((name) => line.trim().startsWith(`"${name}":`));
261+
return /^\s*"version":/.test(line) || /^export const version = /.test(line) || workspaceDependency;
262+
}
263+
264+
function countLines(changedLines: string[]): Churn {
265+
return {
266+
added: changedLines.filter((line) => line.startsWith('+')).length,
267+
removed: changedLines.filter((line) => line.startsWith('-')).length,
268+
};
269+
}
270+
271+
// Parses the output of `git diff --numstat`, one "<added>\t<removed>\t<path>" line per file.
272+
// Binary files report "-" for both counts and are left out.
273+
function parseLineCounts(lineCounts: string): FileChange[] {
274+
return lineCounts
275+
.split('\n')
276+
.filter((line) => line.trim() !== '')
277+
.map((line) => {
278+
const [added, removed, filePath] = line.split('\t');
279+
return { filePath, area: areaOf(filePath), kind: kindOf(filePath), added: Number(added), removed: Number(removed) };
280+
})
281+
.filter((change) => Number.isInteger(change.added) && Number.isInteger(change.removed));
282+
}
283+
284+
function areaOf(filePath: string): Area {
285+
return areaRules.find((rule) => filePath.startsWith(rule.prefix))?.area ?? 'repository';
286+
}
287+
288+
function kindOf(filePath: string): Kind {
289+
return kindRules.find((rule) => rule.matches(filePath))?.kind ?? 'source';
290+
}
291+
292+
// The ANTLR output next to the grammar is regenerated with `yarn antlr`, only the .g4 file is written by hand.
293+
function isGeneratedGrammar(filePath: string): boolean {
294+
return filePath.startsWith('packages/cashc/src/grammar/') && !filePath.endsWith('.g4');
295+
}
296+
297+
function baseName(filePath: string): string {
298+
return filePath.slice(filePath.lastIndexOf('/') + 1);
299+
}
300+
301+
function reviewable(changes: FileChange[]): FileChange[] {
302+
return changes.filter((change) => !excludedFromTotal.includes(change.kind));
303+
}
304+
305+
function churnOf(changes: FileChange[]): Churn {
306+
return {
307+
added: changes.reduce((sum, change) => sum + change.added, 0),
308+
removed: changes.reduce((sum, change) => sum + change.removed, 0),
309+
};
310+
}
311+
312+
function sizeOf(churn: Churn): number {
313+
return churn.added + churn.removed;
314+
}
315+
316+
function formatChurn(churn: Churn): string {
317+
return sizeOf(churn) === 0 ? '' : `+${churn.added} −${churn.removed}`;
318+
}
319+
320+
function formatNet(churn: Churn): string {
321+
const net = churn.added - churn.removed;
322+
if (sizeOf(churn) === 0) return '';
323+
if (net === 0) return '±0';
324+
return net > 0 ? `+${net}` : `−${-net}`;
325+
}
326+
327+
function formatPercentage(fraction: number): string {
328+
if (fraction > 0 && fraction < 0.01) return '<1%';
329+
return `${Math.round(fraction * 100)}%`;
330+
}
331+
332+
// Lines carrying nothing but a comment, in both TypeScript and CashScript: the line form, and the opening,
333+
// body and closing of the block form, which this codebase writes with a leading asterisk.
334+
function isComment(line: string): boolean {
335+
const trimmed = line.trim();
336+
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*');
337+
}
338+
339+
function addedLinesOf(filePath: string): string[] {
340+
return changedLinesOf(filePath).filter((line) => line.startsWith('+')).map((line) => line.slice(1));
341+
}
342+
343+
// The added and removed lines of a file, each still carrying its "+" or "-" prefix. Without context lines,
344+
// everything after the file header is a hunk header or a changed line.
345+
function changedLinesOf(filePath: string): string[] {
346+
const diffLines = git(['diff', '-U0', '--no-renames', mergeBase, headRevision, '--', filePath]).split('\n');
347+
const firstHunk = diffLines.findIndex((line) => line.startsWith('@@'));
348+
return firstHunk === -1 ? [] : diffLines.slice(firstHunk).filter((line) => /^[+-]/.test(line));
349+
}
350+
351+
interface PackageManifest {
352+
name?: string;
353+
version?: string;
354+
dependencies?: Record<string, string>;
355+
devDependencies?: Record<string, string>;
356+
peerDependencies?: Record<string, string>;
357+
}
358+
359+
function declaredDependencies(content: string): Record<string, string> {
360+
const manifest = packageManifest(content);
361+
return { ...manifest.dependencies, ...manifest.devDependencies, ...manifest.peerDependencies };
362+
}
363+
364+
// Returns an empty manifest for a missing or invalid package.json.
365+
function packageManifest(content: string): PackageManifest {
366+
if (content === '') return {};
367+
try {
368+
return JSON.parse(content) as PackageManifest;
369+
} catch {
370+
return {};
371+
}
372+
}
373+
374+
function git(args: string[]): string {
375+
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
376+
}
377+
378+
// Returns an empty string when the file does not exist at that revision, which is the case on either side
379+
// of an addition or a deletion. Git reports that on stderr, which is silenced so it does not read as a failure.
380+
function fileAt(revision: string, filePath: string): string {
381+
try {
382+
return execFileSync('git', ['show', `${revision}:${filePath}`], {
383+
encoding: 'utf8',
384+
maxBuffer: 64 * 1024 * 1024,
385+
stdio: ['ignore', 'pipe', 'ignore'],
386+
});
387+
} catch {
388+
return '';
389+
}
390+
}

0 commit comments

Comments
 (0)