Skip to content

Commit 0119764

Browse files
HazATclaude
andcommitted
feat(lighthouse-ci): add post-comment.mjs to render Lighthouse results as PR comment
Add a script that reads LHCI artifact directories (manifest.json + lhr-*.json), extracts median-run performance scores for each app × mode combination, and renders a markdown table with columns for No Sentry, Init Only, Tracing+Replay, plus computed deltas (SDK overhead = init-only minus no-sentry, feature overhead = tracing-replay minus init-only). Mirrors the find-and-update PR comment pattern from size-limit-gh-action: searches for an existing comment by heading prefix ('## 🔦 Lighthouse Report'), then creates or updates accordingly. Handles 403 gracefully on fork PRs where GITHUB_TOKEN from pull_request events is read-only — logs a warning and exits 0 instead of failing. For nightly cron runs (IS_PR !== 'true'), the table is logged to stdout. A <50% data completeness guard skips posting when too many cells are missing. Co-Authored-By: Claude claude-opus-4-6 <noreply@anthropic.com>
1 parent 2a7c378 commit 0119764

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

dev-packages/lighthouse-tests/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,10 @@
88
},
99
"scripts": {
1010
"generate-matrix": "node lighthouse-matrix.mjs"
11+
},
12+
"dependencies": {
13+
"@actions/core": "1.10.1",
14+
"@actions/github": "^5.0.0",
15+
"markdown-table": "3.0.3"
1116
}
1217
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
import * as core from '@actions/core';
4+
import { context, getOctokit } from '@actions/github';
5+
import { markdownTable } from 'markdown-table';
6+
7+
const HEADING = '## 🔦 Lighthouse Report';
8+
const MODES = ['no-sentry', 'init-only', 'tracing-replay'];
9+
10+
/**
11+
* Apps and their human-readable SDK labels, matching lighthouse-matrix.mjs.
12+
* Order here determines row order in the table.
13+
*/
14+
const APPS = [
15+
{ app: 'default-browser', sdk: 'browser' },
16+
{ app: 'react-19', sdk: 'react' },
17+
{ app: 'ember-classic', sdk: 'ember' },
18+
{ app: 'create-remix-app-express', sdk: 'remix' },
19+
{ app: 'angular-21', sdk: 'angular' },
20+
{ app: 'vue-3', sdk: 'vue' },
21+
{ app: 'svelte-5', sdk: 'svelte' },
22+
{ app: 'sveltekit-2', sdk: 'sveltekit' },
23+
{ app: 'astro-5', sdk: 'astro' },
24+
{ app: 'react-router-7-spa', sdk: 'react-router' },
25+
{ app: 'solidstart-spa', sdk: 'solidstart' },
26+
{ app: 'tanstackstart-react', sdk: 'tanstack-start' },
27+
{ app: 'nextjs-16', sdk: 'nextjs' },
28+
{ app: 'nuxt-5', sdk: 'nuxt' },
29+
];
30+
31+
/**
32+
* Read the median-run LHR from an LHCI artifact directory.
33+
* Returns { score, url } or null if missing/invalid.
34+
*/
35+
async function readResult(resultsDir, app, mode) {
36+
const dir = path.join(resultsDir, `lighthouse-${app}-${mode}`);
37+
let manifest;
38+
try {
39+
manifest = JSON.parse(await fs.readFile(path.join(dir, 'manifest.json'), 'utf8'));
40+
} catch {
41+
return null;
42+
}
43+
44+
// LHCI manifest is an array of entries. Pick the representative one
45+
// (aggregationMethod: median-run) or fall back to the first entry.
46+
const entry = manifest.find(e => e.isRepresentativeRun) || manifest[0];
47+
if (!entry) return null;
48+
49+
const lhrPath = path.join(dir, path.basename(entry.jsonPath));
50+
let lhr;
51+
try {
52+
lhr = JSON.parse(await fs.readFile(lhrPath, 'utf8'));
53+
} catch {
54+
return null;
55+
}
56+
57+
const score = lhr.categories?.performance?.score;
58+
if (score == null) return null;
59+
60+
const url = entry.htmlPath ? entry.htmlPath : undefined;
61+
62+
return { score: Math.round(score * 100), url };
63+
}
64+
65+
function formatCell(result) {
66+
if (!result) return '⚠️';
67+
if (result.url) return `[${result.score}](${result.url})`;
68+
return `${result.score}`;
69+
}
70+
71+
function formatDelta(a, b) {
72+
if (!a || !b) return '—';
73+
const diff = b.score - a.score;
74+
const sign = diff > 0 ? '+' : '';
75+
return `${sign}${diff}`;
76+
}
77+
78+
async function run() {
79+
const resultsDir = process.env.LIGHTHOUSE_RESULTS_DIR || 'lighthouse-results';
80+
const isPR = process.env.IS_PR === 'true';
81+
const prNumber = process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : undefined;
82+
83+
// Collect all results
84+
const rows = [];
85+
let totalCells = 0;
86+
let filledCells = 0;
87+
88+
for (const { app, sdk } of APPS) {
89+
const results = {};
90+
for (const mode of MODES) {
91+
totalCells++;
92+
results[mode] = await readResult(resultsDir, app, mode);
93+
if (results[mode]) filledCells++;
94+
}
95+
rows.push({ sdk, results });
96+
}
97+
98+
// If <50% of cells have data, warn and skip
99+
if (totalCells > 0 && filledCells / totalCells < 0.5) {
100+
core.warning(`Only ${filledCells}/${totalCells} Lighthouse cells have results (< 50%). Skipping comment.`);
101+
return;
102+
}
103+
104+
// Build markdown table
105+
const header = ['App', 'No Sentry', 'Init Only', 'Δ (SDK)', 'Tracing+Replay', 'Δ (Features)'];
106+
const tableRows = rows.map(({ sdk, results }) => [
107+
sdk,
108+
formatCell(results['no-sentry']),
109+
formatCell(results['init-only']),
110+
formatDelta(results['no-sentry'], results['init-only']),
111+
formatCell(results['tracing-replay']),
112+
formatDelta(results['init-only'], results['tracing-replay']),
113+
]);
114+
115+
const table = markdownTable([header, ...tableRows]);
116+
117+
if (!isPR || !prNumber) {
118+
// Nightly / non-PR: just log to stdout
119+
// eslint-disable-next-line no-console
120+
console.log(`${HEADING}\n\n${table}`);
121+
return;
122+
}
123+
124+
// Post or update PR comment
125+
const token = process.env.GITHUB_TOKEN;
126+
if (!token) {
127+
core.warning('GITHUB_TOKEN not set — cannot post PR comment.');
128+
return;
129+
}
130+
131+
const octokit = getOctokit(token);
132+
const repo = context.repo;
133+
134+
// Find existing comment
135+
const { data: comments } = await octokit.rest.issues.listComments({
136+
...repo,
137+
issue_number: prNumber,
138+
});
139+
const existing = comments.find(c => c.body?.startsWith(HEADING));
140+
141+
const body = `${HEADING}\n\n${table}`;
142+
143+
try {
144+
if (existing) {
145+
await octokit.rest.issues.updateComment({
146+
...repo,
147+
comment_id: existing.id,
148+
body,
149+
});
150+
core.info('Updated existing Lighthouse comment.');
151+
} else {
152+
await octokit.rest.issues.createComment({
153+
...repo,
154+
issue_number: prNumber,
155+
body,
156+
});
157+
core.info('Created Lighthouse PR comment.');
158+
}
159+
} catch (err) {
160+
if (err.status === 403) {
161+
core.warning(
162+
'Could not post PR comment (403 Forbidden). This is expected for fork PRs where GITHUB_TOKEN is read-only.',
163+
);
164+
// eslint-disable-next-line no-console
165+
console.log(`\n${body}`);
166+
return;
167+
}
168+
throw err;
169+
}
170+
}
171+
172+
run().catch(err => {
173+
core.setFailed(err.message);
174+
process.exit(1);
175+
});

0 commit comments

Comments
 (0)