Skip to content

Commit 4fc0f3e

Browse files
committed
Add a PR check that comments on significant repo size changes
1 parent 67f4038 commit 4fc0f3e

5 files changed

Lines changed: 718 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Check repo size
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
defaults:
8+
run:
9+
shell: bash
10+
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
jobs:
16+
check-repo-size:
17+
name: Check repo size
18+
runs-on: ubuntu-slim
19+
# PRs from forks (and Dependabot, which behaves like a fork) get a
20+
# read-only GITHUB_TOKEN that can't post comments, so the job would only
21+
# ever fail. Skip them.
22+
if: >-
23+
github.event.pull_request.head.repo.full_name == github.repository &&
24+
github.triggering_actor != 'dependabot[bot]'
25+
timeout-minutes: 10
26+
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v6
30+
with:
31+
# Need full history so we have both the PR merge commit (HEAD) and
32+
# the base ref locally for `git archive` to work against either.
33+
fetch-depth: 0
34+
35+
- name: Set up Node.js
36+
uses: actions/setup-node@v6
37+
with:
38+
node-version: 24
39+
cache: 'npm'
40+
41+
- name: Install pr-checks dependencies
42+
working-directory: pr-checks
43+
run: npm ci
44+
45+
- name: Check repo size
46+
working-directory: pr-checks
47+
env:
48+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
BASE_REF: ${{ github.event.pull_request.base.ref }}
50+
PR_NUMBER: ${{ github.event.pull_request.number }}
51+
GITHUB_REPOSITORY: ${{ github.repository }}
52+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
53+
run: npx tsx check-repo-size.ts

package-lock.json

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

pr-checks/check-repo-size.test.ts

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import * as assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { randomBytes } from "node:crypto";
4+
import * as fs from "node:fs";
5+
import * as os from "node:os";
6+
import * as path from "node:path";
7+
import { afterEach, beforeEach, describe, it } from "node:test";
8+
9+
import { getOctokit } from "@actions/github";
10+
import * as sinon from "sinon";
11+
12+
import {
13+
COMMENT_MARKER,
14+
buildCommentBody,
15+
formatBytes,
16+
formatPercent,
17+
isDeltaSignificant,
18+
measureArchiveSize,
19+
upsertSizeComment,
20+
} from "./check-repo-size";
21+
22+
describe("formatBytes", async () => {
23+
const cases: Array<[number, boolean, string]> = [
24+
// Unsigned: bytes / KiB / MiB boundaries.
25+
[0, false, "0 B"],
26+
[1, false, "1 B"],
27+
[1023, false, "1023 B"],
28+
[1024, false, "1.00 KiB"],
29+
[2048, false, "2.00 KiB"],
30+
[1024 * 1024 - 1, false, "1024.00 KiB"],
31+
[1024 * 1024, false, "1.00 MiB"],
32+
[2.5 * 1024 * 1024, false, "2.50 MiB"],
33+
// Negative values always use a leading minus.
34+
[-512, false, "-512 B"],
35+
[-2048, false, "-2.00 KiB"],
36+
[-2 * 1024 * 1024, false, "-2.00 MiB"],
37+
// signed=true prepends a + to non-negative values.
38+
[0, true, "+0 B"],
39+
[512, true, "+512 B"],
40+
[2048, true, "+2.00 KiB"],
41+
[-512, true, "-512 B"],
42+
];
43+
for (const [bytes, signed, expected] of cases) {
44+
await it(`formats ${bytes} (signed=${signed}) as ${expected}`, () => {
45+
assert.equal(formatBytes(bytes, signed), expected);
46+
});
47+
}
48+
});
49+
50+
describe("formatPercent", async () => {
51+
await it("formats positive fractions with a leading +", () => {
52+
assert.equal(formatPercent(0.1), "+10.00%");
53+
assert.equal(formatPercent(0.0123), "+1.23%");
54+
});
55+
56+
await it("formats negative fractions with a leading -", () => {
57+
assert.equal(formatPercent(-0.1), "-10.00%");
58+
});
59+
60+
await it("formats zero without a sign", () => {
61+
assert.equal(formatPercent(0), "0.00%");
62+
});
63+
});
64+
65+
describe("isDeltaSignificant", async () => {
66+
const cases: Array<[number, number, number, boolean]> = [
67+
// At and above threshold (both signs).
68+
[100, 1000, 0.1, true],
69+
[101, 1000, 0.1, true],
70+
[-100, 1000, 0.1, true],
71+
// Below threshold (both signs, plus exact zero).
72+
[99, 1000, 0.1, false],
73+
[-99, 1000, 0.1, false],
74+
[0, 1000, 0.1, false],
75+
];
76+
for (const [delta, base, fraction, expected] of cases) {
77+
await it(`returns ${expected} for delta=${delta}, base=${base}, fraction=${fraction}`, () => {
78+
assert.equal(isDeltaSignificant(delta, base, fraction), expected);
79+
});
80+
}
81+
});
82+
83+
describe("buildCommentBody", async () => {
84+
await it("includes the marker, the base/PR/delta rows, and the run URL", () => {
85+
const body = buildCommentBody({
86+
baseRef: "main",
87+
baseSize: 2_000_000,
88+
prSize: 2_300_000,
89+
runUrl: "https://example.test/run",
90+
});
91+
92+
assert.match(body, new RegExp(`^${escapeRegExp(COMMENT_MARKER)}`));
93+
assert.match(body, /Base \(`main`\) \| 1\.91 MiB \(2000000 bytes\)/);
94+
assert.match(body, /This PR \| 2\.19 MiB \(2300000 bytes\)/);
95+
assert.match(
96+
body,
97+
/\*\*Delta\*\* \| \*\*\+292\.97 KiB \(\+300000 bytes, \+15\.00%\)\*\*/,
98+
);
99+
assert.match(body, /\[workflow run\]\(https:\/\/example\.test\/run\)/);
100+
});
101+
102+
await it("formats negative deltas with a leading minus and omits the run URL when missing", () => {
103+
const body = buildCommentBody({
104+
baseRef: "main",
105+
baseSize: 2_000_000,
106+
prSize: 1_800_000,
107+
});
108+
assert.match(
109+
body,
110+
/\*\*Delta\*\* \| \*\*-195\.31 KiB \(-200000 bytes, -10\.00%\)\*\*/,
111+
);
112+
assert.doesNotMatch(body, /workflow run/);
113+
});
114+
});
115+
116+
let repoDir: string;
117+
118+
beforeEach(() => {
119+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "check-repo-size-test-"));
120+
execFileSync("git", ["init", "--initial-branch=main", "-q"], {
121+
cwd: repoDir,
122+
});
123+
execFileSync("git", ["config", "user.email", "test@example.test"], {
124+
cwd: repoDir,
125+
});
126+
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
127+
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir });
128+
});
129+
130+
afterEach(() => {
131+
fs.rmSync(repoDir, { recursive: true, force: true });
132+
});
133+
134+
function commit(name: string, content: string, message: string) {
135+
fs.writeFileSync(path.join(repoDir, name), content);
136+
execFileSync("git", ["add", name], { cwd: repoDir });
137+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: repoDir });
138+
}
139+
140+
describe("measureArchiveSize", async () => {
141+
await it("returns a positive byte count for a non-empty repo", async () => {
142+
commit("a.txt", "hello world\n", "first");
143+
const size = await measureArchiveSize("HEAD", repoDir);
144+
assert.ok(size > 0, `expected size > 0, got ${size}`);
145+
});
146+
147+
await it("returns the same size on repeated runs (deterministic)", async () => {
148+
commit("a.txt", "hello world\n", "first");
149+
const a = await measureArchiveSize("HEAD", repoDir);
150+
const b = await measureArchiveSize("HEAD", repoDir);
151+
assert.equal(a, b);
152+
});
153+
154+
await it("returns a larger size when more content is added", async () => {
155+
commit("a.txt", "hello world\n", "first");
156+
const small = await measureArchiveSize("HEAD", repoDir);
157+
158+
// Use random bytes so the new content is incompressible and the archive
159+
// is guaranteed to grow even after gzip.
160+
commit("b.bin", randomBytes(8192).toString("base64"), "second");
161+
const big = await measureArchiveSize("HEAD", repoDir);
162+
assert.ok(
163+
big > small,
164+
`expected ${big} > ${small} after adding more content`,
165+
);
166+
});
167+
168+
await it("ignores untracked files (e.g. node_modules)", async () => {
169+
commit("a.txt", "hello\n", "first");
170+
commit(".gitignore", "node_modules/\n", "ignore node_modules");
171+
const sizeBefore = await measureArchiveSize("HEAD", repoDir);
172+
173+
fs.mkdirSync(path.join(repoDir, "node_modules"));
174+
fs.writeFileSync(
175+
path.join(repoDir, "node_modules", "huge.bin"),
176+
"x".repeat(1_000_000),
177+
);
178+
179+
const sizeAfter = await measureArchiveSize("HEAD", repoDir);
180+
assert.equal(
181+
sizeAfter,
182+
sizeBefore,
183+
"untracked node_modules should not affect the archive size",
184+
);
185+
});
186+
187+
await it("rejects when the ref does not exist", async () => {
188+
commit("a.txt", "hello\n", "first");
189+
await assert.rejects(
190+
() => measureArchiveSize("does-not-exist", repoDir),
191+
/git archive does-not-exist exited with code/,
192+
);
193+
});
194+
});
195+
196+
describe("upsertSizeComment", async () => {
197+
const owner = "test-owner";
198+
const repo = "test-repo";
199+
const prNumber = 42;
200+
201+
let octokit: ReturnType<typeof getOctokit>;
202+
203+
beforeEach(() => {
204+
octokit = getOctokit("test-token");
205+
});
206+
207+
afterEach(() => {
208+
sinon.restore();
209+
});
210+
211+
function stubExistingComments(comments: Array<{ id: number; body: string }>) {
212+
// upsertSizeComment calls `octokit.paginate(octokit.rest.issues.listComments, ...)`,
213+
// so stubbing `paginate` directly mocks the listing without depending on how
214+
// paginate walks Octokit's response (link headers etc.).
215+
return sinon.stub(octokit, "paginate").resolves(comments);
216+
}
217+
218+
await it("creates a new comment when none exists and the delta is significant", async () => {
219+
stubExistingComments([]);
220+
const createStub = sinon
221+
.stub(octokit.rest.issues, "createComment")
222+
.resolves({ data: { id: 999 } } as never);
223+
224+
const result = await upsertSizeComment({
225+
octokit,
226+
owner,
227+
repo,
228+
prNumber,
229+
body: `${COMMENT_MARKER}\nhello`,
230+
delta: 200,
231+
baseSize: 1000,
232+
});
233+
234+
assert.deepEqual(result, { action: "created", commentId: 999 });
235+
sinon.assert.calledOnce(createStub);
236+
const createArgs = createStub.firstCall.args[0]!;
237+
assert.equal(createArgs.owner, owner);
238+
assert.equal(createArgs.repo, repo);
239+
assert.equal(createArgs.issue_number, prNumber);
240+
assert.ok(createArgs.body.includes(COMMENT_MARKER));
241+
});
242+
243+
await it("creates a new comment for a significant size decrease", async () => {
244+
// Shrinkage matters too: it might indicate accidentally deleted tracked
245+
// files. The full pipeline (not just isDeltaSignificant) needs to post on
246+
// negative deltas.
247+
stubExistingComments([]);
248+
const createStub = sinon
249+
.stub(octokit.rest.issues, "createComment")
250+
.resolves({ data: { id: 999 } } as never);
251+
252+
const result = await upsertSizeComment({
253+
octokit,
254+
owner,
255+
repo,
256+
prNumber,
257+
body: `${COMMENT_MARKER}\nhello`,
258+
delta: -200,
259+
baseSize: 1000,
260+
});
261+
262+
assert.deepEqual(result, { action: "created", commentId: 999 });
263+
sinon.assert.calledOnce(createStub);
264+
});
265+
266+
await it("skips when no existing comment and delta is below threshold", async () => {
267+
stubExistingComments([]);
268+
const createStub = sinon.stub(octokit.rest.issues, "createComment");
269+
const updateStub = sinon.stub(octokit.rest.issues, "updateComment");
270+
271+
const result = await upsertSizeComment({
272+
octokit,
273+
owner,
274+
repo,
275+
prNumber,
276+
body: `${COMMENT_MARKER}\nhello`,
277+
delta: 50,
278+
baseSize: 1000,
279+
});
280+
281+
assert.equal(result.action, "skipped");
282+
sinon.assert.notCalled(createStub);
283+
sinon.assert.notCalled(updateStub);
284+
});
285+
286+
await it("updates the existing comment when the delta is significant", async () => {
287+
stubExistingComments([{ id: 7, body: `${COMMENT_MARKER}\nold body` }]);
288+
const updateStub = sinon
289+
.stub(octokit.rest.issues, "updateComment")
290+
.resolves({ data: { id: 7 } } as never);
291+
292+
const result = await upsertSizeComment({
293+
octokit,
294+
owner,
295+
repo,
296+
prNumber,
297+
body: `${COMMENT_MARKER}\nnew body`,
298+
delta: 200,
299+
baseSize: 1000,
300+
});
301+
302+
assert.deepEqual(result, { action: "updated", commentId: 7 });
303+
sinon.assert.calledOnce(updateStub);
304+
const updateArgs = updateStub.firstCall.args[0]!;
305+
assert.equal(updateArgs.comment_id, 7);
306+
assert.ok(updateArgs.body.includes("new body"));
307+
});
308+
309+
await it("updates an existing comment even when the delta is below threshold", async () => {
310+
// This keeps the comment in sync after a PR that initially had a big diff
311+
// gets reduced below the threshold by a follow-up commit.
312+
stubExistingComments([{ id: 7, body: `${COMMENT_MARKER}\nold body` }]);
313+
const updateStub = sinon
314+
.stub(octokit.rest.issues, "updateComment")
315+
.resolves({ data: { id: 7 } } as never);
316+
317+
const result = await upsertSizeComment({
318+
octokit,
319+
owner,
320+
repo,
321+
prNumber,
322+
body: `${COMMENT_MARKER}\nnew body`,
323+
delta: 1,
324+
baseSize: 1000,
325+
});
326+
327+
assert.deepEqual(result, { action: "updated", commentId: 7 });
328+
sinon.assert.calledOnce(updateStub);
329+
});
330+
});
331+
332+
function escapeRegExp(s: string): string {
333+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
334+
}

0 commit comments

Comments
 (0)