Skip to content

Commit ab5cf6a

Browse files
T4rk1nclaude
andcommitted
Lighten PR comments: thumbnails, image budget, image-free emails
GitHub loads all inline comment images at once (closed <details> included, and width= doesn't reduce the download) and emails the body as created, so big reports were heavy. Now: - inline images are sharp-generated thumbnails (thumbnail-width, default 360px) hyperlinked to the full-size files on the baseline branch - max-comment-images is a total inline-image budget (changed row = 3, added = 1) spent on the worst diffs first; the rest degrade to baseline/new/diff text links - the comment is created with a links-only body and immediately edited to the full one: GitHub only emails on creation, so notification emails never embed images Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3fd31ad commit ab5cf6a

8 files changed

Lines changed: 207 additions & 44 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,28 @@ Only users with **write** access can approve; others get a 👎 reaction.
209209
| `fail-on-new` | `false` | new snapshots need approval too |
210210
| `fail-on-missing` | `false` | baselines not captured this run fail the check |
211211
| `fail-on-change` | `false` | also fail the step (the commit status is the primary signal) |
212-
| `comment` / `max-comment-images` | `true` / `20` | PR comment behavior |
212+
| `comment` / `max-comment-images` | `true` / `30` | PR comment behavior; the budget counts inline images (changed = 3, new = 1), worst diffs first, the rest degrade to links |
213+
| `thumbnail-width` | `360` | inline images are thumbnails linking to the full files; `0` embeds full-size |
213214
| `status-context` | `nitpix/visual` | the check name |
214215
| `github-token` | `github.token` | needs contents/PR/statuses write |
215216

216217
Outputs: `status`, `changed-count`, `added-count`, `missing-count`,
217218
`unapproved-count`.
218219

220+
### Comment weight
221+
222+
GitHub loads every inline image in a comment at once (even inside closed
223+
`<details>`, and `width=` doesn't shrink the download), and notification
224+
emails carry the comment body as created. nitpix keeps reports light three
225+
ways:
226+
227+
- inline images are **thumbnails** (`thumbnail-width`, default 360px)
228+
hyperlinked to the full-size files;
229+
- a total **image budget** (`max-comment-images`) is spent on the worst
230+
diffs first; snapshots beyond it render as `baseline · new · diff` links;
231+
- the comment is **created links-only and immediately edited** to the full
232+
version — GitHub only emails on creation, so emails never embed images.
233+
219234
## Notes & limitations
220235

221236
- **Fork PRs & dependabot**: fully supported via the `workflow_run` topology

‎action.yml‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,18 @@ inputs:
6565
required: false
6666
default: 'true'
6767
max-comment-images:
68-
description: 'Maximum number of snapshots rendered with inline images in the PR comment'
68+
description: >-
69+
Maximum number of inline images embedded in the PR comment (a changed
70+
snapshot costs 3, a new one costs 1; worst diffs first). Snapshots
71+
beyond the budget degrade to baseline/new/diff links.
72+
required: false
73+
default: '30'
74+
thumbnail-width:
75+
description: >-
76+
Width of the thumbnails embedded in the PR comment (they link to the
77+
full-size images). Set to '0' to embed full-size images instead.
6978
required: false
70-
default: '20'
79+
default: '360'
7180
status-context:
7281
description: 'Commit status context name (make this a required check)'
7382
required: false
@@ -117,12 +126,12 @@ runs:
117126
run-id: ${{ github.event.workflow_run.id || github.run_id }}
118127
github-token: ${{ inputs.github-token }}
119128

120-
- name: Install odiff
129+
- name: Install odiff and sharp
121130
shell: bash
122131
run: |
123-
if [ ! -x "$RUNNER_TEMP/nitpix-odiff/node_modules/.bin/odiff" ]; then
132+
if [ ! -d "$RUNNER_TEMP/nitpix-odiff/node_modules/odiff-bin" ] || [ ! -d "$RUNNER_TEMP/nitpix-odiff/node_modules/sharp" ]; then
124133
npm install --prefix "$RUNNER_TEMP/nitpix-odiff" --no-save --no-audit --no-fund \
125-
"odiff-bin@${{ inputs.odiff-version }}"
134+
"odiff-bin@${{ inputs.odiff-version }}" "sharp@^0.35.0"
126135
fi
127136
128137
- name: Diff and report
@@ -141,6 +150,7 @@ runs:
141150
INPUT_FAIL_ON_CHANGE: ${{ inputs.fail-on-change }}
142151
INPUT_COMMENT: ${{ inputs.comment }}
143152
INPUT_MAX_COMMENT_IMAGES: ${{ inputs.max-comment-images }}
153+
INPUT_THUMBNAIL_WIDTH: ${{ inputs.thumbnail-width }}
144154
INPUT_STATUS_CONTEXT: ${{ inputs.status-context }}
145155
INPUT_COMMAND_PREFIX: ${{ inputs.command-prefix }}
146156
INPUT_GITHUB_TOKEN: ${{ inputs.github-token }}

‎lib/github.js‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,14 @@ class GitHub {
9191
return data.permission; // admin | write | read | none
9292
}
9393

94-
/** Create or update the single nitpix comment (identified by marker) on a PR. */
95-
async upsertComment(prNumber, marker, body) {
94+
/**
95+
* Create or update the single nitpix comment (identified by marker) on a PR.
96+
* When creating and `createBody` is given, the comment is first posted with
97+
* that lighter body and immediately edited to the full one: GitHub only
98+
* sends notification emails for comment creation, so the email carries the
99+
* light (image-free) version while the PR page shows the full report.
100+
*/
101+
async upsertComment(prNumber, marker, body, createBody) {
96102
let existing = null;
97103
for (let page = 1; page <= 10 && !existing; page++) {
98104
const batch = await this.get(`/repos/${this.repo}/issues/${prNumber}/comments?per_page=100&page=${page}`);
@@ -102,7 +108,13 @@ class GitHub {
102108
if (existing) {
103109
return this.request('PATCH', `/repos/${this.repo}/issues/comments/${existing.id}`, { body });
104110
}
105-
return this.request('POST', `/repos/${this.repo}/issues/${prNumber}/comments`, { body });
111+
const created = await this.request('POST', `/repos/${this.repo}/issues/${prNumber}/comments`, {
112+
body: createBody || body,
113+
});
114+
if (createBody && createBody !== body) {
115+
return this.request('PATCH', `/repos/${this.repo}/issues/comments/${created.id}`, { body });
116+
}
117+
return created;
106118
}
107119

108120
createComment(prNumber, body) {

‎lib/main.js‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { input, boolInput, setOutput, stepSummary, eventPayload, listPngs, copyFi
88
const { GitHub } = require('./github');
99
const { remoteUrl, checkoutBaseline, commitAndPush } = require('./git');
1010
const { compareDirs, countByStatus } = require('./diffing');
11+
const { makeThumbs } = require('./thumbs');
1112
const { buildReport, rawUrls, MARKER } = require('./report');
1213

1314
async function main() {
@@ -201,6 +202,24 @@ async function diff({ gh, repo, remote, branch, baselineRepoDir, snapshotsDir, w
201202
results: results.map(({ name, status, hash, diffPercentage }) => ({ name, status, hash, diffPercentage })),
202203
};
203204

205+
// Small thumbnails for the comment (full-size PNGs are only a click away).
206+
// GitHub loads every inline image in a comment at once — even inside closed
207+
// <details> — so embedding full screenshots makes big reports very heavy.
208+
const thumbsDir = path.join(workDir, 'thumbs');
209+
const thumbJobs = [];
210+
for (const r of visual) {
211+
thumbJobs.push({ src: path.join(snapshotsDir, r.name), kind: 'snapshot', name: r.name });
212+
if (r.status === 'changed') {
213+
thumbJobs.push({ src: path.join(baselineDir, r.name), kind: 'baseline', name: r.name });
214+
const diffImg = path.join(diffDir, r.name);
215+
if (!r.layoutDiff && fs.existsSync(diffImg)) {
216+
thumbJobs.push({ src: diffImg, kind: 'diff', name: r.name });
217+
}
218+
}
219+
}
220+
const thumbWidth = parseInt(input('thumbnail-width', '360'), 10);
221+
const hasThumbs = await makeThumbs(thumbJobs, thumbsDir, thumbWidth);
222+
204223
let pushedSha = null;
205224
try {
206225
pushedSha = commitAndPush(
@@ -217,6 +236,11 @@ async function diff({ gh, repo, remote, branch, baselineRepoDir, snapshotsDir, w
217236
copyFile(diffImg, path.join(dir, pendingDir, 'diff', r.name));
218237
}
219238
}
239+
if (hasThumbs) {
240+
for (const { kind, name } of thumbJobs) {
241+
copyFile(path.join(thumbsDir, kind, name), path.join(dir, pendingDir, 'thumb', kind, name));
242+
}
243+
}
220244
fs.mkdirSync(path.join(dir, pendingDir), { recursive: true });
221245
fs.writeFileSync(path.join(dir, pendingDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
222246
},
@@ -233,25 +257,30 @@ async function diff({ gh, repo, remote, branch, baselineRepoDir, snapshotsDir, w
233257
? `${process.env.GITHUB_SERVER_URL}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`
234258
: null;
235259

236-
const urls = rawUrls({ repo, commitSha: pushedSha, baseRef, pendingDir });
237-
const body = buildReport({
260+
const urls = rawUrls({ repo, commitSha: pushedSha, baseRef, pendingDir, hasThumbs });
261+
const reportArgs = {
238262
results,
239263
baseRef,
240264
headSha,
241265
urls,
242266
approvedNames,
243267
unapproved,
244-
maxImages: parseInt(input('max-comment-images', '20'), 10),
268+
maxImages: parseInt(input('max-comment-images', '30'), 10),
245269
commandPrefix,
246270
runUrl,
247-
});
271+
};
272+
const body = buildReport(reportArgs);
273+
// Links-only variant used as the creation body: GitHub emails the comment
274+
// as created, so notifications stay light; the immediate edit to the full
275+
// body is what people see on the PR page.
276+
const emailBody = buildReport({ ...reportArgs, inlineImages: false });
248277

249278
stepSummary(body.replace(MARKER, '').trim());
250279

251280
let commentUrl = null;
252281
if (boolInput('comment', true)) {
253282
try {
254-
const comment = await gh.upsertComment(prNumber, MARKER, body);
283+
const comment = await gh.upsertComment(prNumber, MARKER, body, emailBody);
255284
commentUrl = comment && comment.html_url;
256285
} catch (err) {
257286
warn(`nitpix: could not post PR comment: ${err.message}`);

‎lib/report.js‎

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,20 @@ const MARKER = '<!-- nitpix-report -->';
88
/**
99
* Build the PR comment / step summary markdown.
1010
*
11-
* urls: { baseline(name), snapshot(name), diff(name) } returning a raw image
12-
* URL or null (e.g. fork PRs where we couldn't push pending images).
11+
* urls: { baseline(name), snapshot(name), diff(name), thumb(kind, name) }
12+
* returning a raw image URL, or null helpers when images couldn't be pushed
13+
* (e.g. fork PRs without the workflow_run topology). thumb() returns null
14+
* when thumbnails are disabled — full images are embedded instead.
15+
*
16+
* Comment-weight strategy (GitHub loads every <img> in a comment at once,
17+
* even inside closed <details>, and `width=` doesn't reduce the download):
18+
* - inline images are small thumbnails hyperlinked to the full-size files
19+
* - snapshots are ordered by severity and only `maxImages` inline images are
20+
* embedded (changed rows cost 3, added rows cost 1); the rest degrade to
21+
* plain baseline/new/diff links
22+
* - with `inlineImages: false` the whole report is links-only; main.js posts
23+
* that version first so notification emails never embed images, then
24+
* immediately edits in the full version (GitHub only emails on creation)
1325
*/
1426
function buildReport({
1527
results,
@@ -21,13 +33,16 @@ function buildReport({
2133
maxImages,
2234
commandPrefix,
2335
runUrl,
36+
inlineImages = true,
2437
}) {
2538
const counts = countByStatus(results);
26-
const changed = results.filter((r) => r.status === 'changed');
39+
const bySeverity = (a, b) =>
40+
(b.layoutDiff ? 100 : b.diffPercentage || 0) - (a.layoutDiff ? 100 : a.diffPercentage || 0);
41+
const changed = results.filter((r) => r.status === 'changed').sort(bySeverity);
2742
const added = results.filter((r) => r.status === 'added');
2843
const missing = results.filter((r) => r.status === 'missing');
2944

30-
const lines = ['<!-- nitpix-report -->', '## 👁 nitpix visual review', ''];
45+
const lines = [MARKER, '## 👁 nitpix visual review', ''];
3146

3247
const needsReview = unapproved.length > 0;
3348
const verdict = needsReview
@@ -43,7 +58,23 @@ function buildReport({
4358
);
4459

4560
let imagesUsed = 0;
46-
const imageBudgetExceeded = () => imagesUsed >= maxImages;
61+
const canEmbed = (cost) => inlineImages && urls.baseline && imagesUsed + cost <= maxImages;
62+
63+
// thumbnail-if-available <img> wrapped in a link to the full image
64+
const img = (kind, name) => {
65+
const full = urls[kind](name);
66+
const thumb = urls.thumb && urls.thumb(kind, name);
67+
return `<a href="${full}"><img src="${thumb || full}" width="260"></a>`;
68+
};
69+
const linkRow = (r) => {
70+
if (!urls.baseline) return '_images unavailable (no write access to the baseline branch)_';
71+
const parts = [`[new](${urls.snapshot(r.name)})`];
72+
if (r.status === 'changed') {
73+
parts.unshift(`[baseline](${urls.baseline(r.name)})`);
74+
if (!r.layoutDiff) parts.push(`[diff](${urls.diff(r.name)})`);
75+
}
76+
return parts.join(' · ');
77+
};
4778

4879
for (const r of changed) {
4980
const approved = approvedNames.has(r.name) ? ' — ✅ approved' : '';
@@ -53,32 +84,24 @@ function buildReport({
5384
? `${r.diffPercentage.toFixed(2)}% diff`
5485
: 'changed';
5586
lines.push(`<details><summary>🔄 <code>${escapeHtml(r.name)}</code> (${pct})${approved}</summary>`, '');
56-
if (urls.baseline && !imageBudgetExceeded()) {
57-
imagesUsed++;
58-
const cells = [
59-
`<img src="${urls.baseline(r.name)}" width="260">`,
60-
`<img src="${urls.snapshot(r.name)}" width="260">`,
61-
r.layoutDiff ? '—' : `<img src="${urls.diff(r.name)}" width="260">`,
62-
];
87+
if (canEmbed(3)) {
88+
imagesUsed += 3;
89+
const cells = [img('baseline', r.name), img('snapshot', r.name), r.layoutDiff ? '—' : img('diff', r.name)];
6390
lines.push('| baseline | new | diff |', '| --- | --- | --- |', `| ${cells.join(' | ')} |`);
64-
} else if (!urls.baseline) {
65-
lines.push('_images unavailable (no write access to the baseline branch — see the report artifact)_');
6691
} else {
67-
lines.push('_image budget exceeded — see the run artifacts for this diff_');
92+
lines.push(linkRow(r));
6893
}
6994
lines.push('', '</details>', '');
7095
}
7196

7297
for (const r of added) {
7398
const approved = approvedNames.has(r.name) ? ' — ✅ approved' : '';
7499
lines.push(`<details><summary>🆕 <code>${escapeHtml(r.name)}</code> (new snapshot)${approved}</summary>`, '');
75-
if (urls.snapshot && !imageBudgetExceeded()) {
76-
imagesUsed++;
77-
lines.push(`<img src="${urls.snapshot(r.name)}" width="260">`);
78-
} else if (!urls.snapshot) {
79-
lines.push('_image unavailable (no write access to the baseline branch)_');
100+
if (canEmbed(1)) {
101+
imagesUsed += 1;
102+
lines.push(img('snapshot', r.name));
80103
} else {
81-
lines.push('_image budget exceeded — see the run artifacts_');
104+
lines.push(linkRow(r));
82105
}
83106
lines.push('', '</details>', '');
84107
}
@@ -94,6 +117,10 @@ function buildReport({
94117
);
95118
}
96119

120+
if (inlineImages && urls.baseline && imagesUsed >= maxImages && changed.length + added.length > 0) {
121+
lines.push(`_Inline image budget (${maxImages}) reached — remaining snapshots are linked above._`, '');
122+
}
123+
97124
if (needsReview) {
98125
lines.push(`To approve these changes, comment \`${commandPrefix} approve\` (write access required).`);
99126
}
@@ -105,16 +132,22 @@ function escapeHtml(s) {
105132
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
106133
}
107134

108-
/** Build url helpers pointing at a pinned commit on the baseline branch. */
109-
function rawUrls({ repo, commitSha, baseRef, pendingDir }) {
110-
if (!commitSha) return { baseline: null, snapshot: null, diff: null };
135+
/**
136+
* Build url helpers pointing at a pinned commit on the baseline branch.
137+
* `hasThumbs` controls whether thumb() resolves into pending/<...>/thumb/.
138+
*/
139+
function rawUrls({ repo, commitSha, baseRef, pendingDir, hasThumbs = false }) {
140+
if (!commitSha) return { baseline: null, snapshot: null, diff: null, thumb: null };
111141
const server = process.env.GITHUB_SERVER_URL || 'https://github.com';
112142
const rawHost = server === 'https://github.com' ? 'https://raw.githubusercontent.com' : `${server}/raw`;
113143
const base = `${rawHost}/${repo}/${commitSha}`;
114144
return {
115145
baseline: (name) => `${base}/baselines/${encodePath(baseRef)}/${encodePath(name)}`,
116146
snapshot: (name) => `${base}/${pendingDir}/new/${encodePath(name)}`,
117147
diff: (name) => `${base}/${pendingDir}/diff/${encodePath(name)}`,
148+
thumb: hasThumbs
149+
? (kind, name) => `${base}/${pendingDir}/thumb/${kind}/${encodePath(name)}`
150+
: null,
118151
};
119152
}
120153

‎lib/thumbs.js‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const { pool, warn } = require('./util');
7+
8+
/**
9+
* Load sharp from the runtime-installed prefix (same dir the action installs
10+
* odiff-bin into) or a regular require (tests). Returns null when
11+
* unavailable — callers fall back to embedding full-size images.
12+
*/
13+
function loadSharp() {
14+
try {
15+
const dir = process.env.NITPIX_ODIFF_DIR;
16+
if (dir) return require(path.join(dir, 'node_modules', 'sharp'));
17+
return require('sharp');
18+
} catch (err) {
19+
warn(`nitpix: sharp unavailable, comment will embed full-size images (${err.message})`);
20+
return null;
21+
}
22+
}
23+
24+
/**
25+
* Generate thumbnails for the report comment. `jobs` is a list of
26+
* { src, kind, name }; thumbnails land in outDir/<kind>/<name>.
27+
* Returns true when thumbnails were generated.
28+
*/
29+
async function makeThumbs(jobs, outDir, width) {
30+
if (!width || jobs.length === 0) return false;
31+
const sharp = loadSharp();
32+
if (!sharp) return false;
33+
await pool(jobs, 4, async ({ src, kind, name }) => {
34+
const dest = path.join(outDir, kind, name);
35+
fs.mkdirSync(path.dirname(dest), { recursive: true });
36+
await sharp(src).resize({ width, withoutEnlargement: true }).png().toFile(dest);
37+
});
38+
return true;
39+
}
40+
41+
module.exports = { makeThumbs };

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"test": "node --test test/"
1212
},
1313
"devDependencies": {
14-
"odiff-bin": "4.3.8"
14+
"odiff-bin": "4.3.8",
15+
"sharp": "^0.35.0"
1516
}
1617
}

0 commit comments

Comments
 (0)