Skip to content

Commit 91d352b

Browse files
committed
feat: redesign issue body w/ summary, matrix, CWV stats
Signed-off-by: Avish Jha <avish.j@protonmail.com>
1 parent de69b35 commit 91d352b

2 files changed

Lines changed: 293 additions & 31 deletions

File tree

src/issues.test.ts

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,65 @@ describe("buildIssueBody", () => {
4747
passed: false,
4848
};
4949
const body = buildIssueBody(analysis, 3);
50-
expect(body).toContain("## Lighthouse Performance Alert");
51-
expect(body).toContain("**Branch:** main");
52-
expect(body).toContain("**Commit:** abc1234");
53-
expect(body).toContain("auto-managed by AutoLighthouse");
50+
expect(body).toContain("Lighthouse Performance Alert");
51+
expect(body).toContain("`main`");
52+
expect(body).toContain("`abc1234`");
53+
expect(body).toContain("Auto-managed by AutoLighthouse");
5454
});
5555

56-
it("shows assertion failures table", () => {
56+
it("shows summary with counts", () => {
57+
const analysis: AnalysisResult = {
58+
urls: [
59+
{
60+
url: "https://example.com/",
61+
pathname: "/",
62+
profiles: [
63+
makeProfile({
64+
passed: false,
65+
assertions: [
66+
{ auditId: "fcp", level: "error", actual: 0.4, expected: 0.9, operator: ">=", passed: false },
67+
{ auditId: "si", level: "warn", actual: 0.6, expected: 0.8, operator: ">=", passed: false },
68+
],
69+
}),
70+
],
71+
passed: false,
72+
},
73+
],
74+
allRegressions: [],
75+
hasRegressions: false,
76+
passed: false,
77+
};
78+
const body = buildIssueBody(analysis, 3);
79+
expect(body).toContain("1 error");
80+
expect(body).toContain("1 warning");
81+
expect(body).toContain("1 failing");
82+
});
83+
84+
it("shows status matrix with profile columns", () => {
85+
const analysis: AnalysisResult = {
86+
urls: [
87+
{
88+
url: "https://example.com/",
89+
pathname: "/",
90+
profiles: [
91+
makeProfile({ profile: "mobile", passed: false, assertions: [{ auditId: "perf", level: "error", actual: 0.4, expected: 0.9, operator: ">=", passed: false }] }),
92+
makeProfile({ profile: "desktop", passed: true }),
93+
],
94+
passed: false,
95+
},
96+
],
97+
allRegressions: [],
98+
hasRegressions: false,
99+
passed: false,
100+
};
101+
const body = buildIssueBody(analysis, 3);
102+
expect(body).toContain("mobile");
103+
expect(body).toContain("desktop");
104+
expect(body).toContain("🔴");
105+
expect(body).toContain("🟢");
106+
});
107+
108+
it("shows assertion failures table with emoji levels", () => {
57109
const analysis: AnalysisResult = {
58110
urls: [
59111
{
@@ -76,9 +128,11 @@ describe("buildIssueBody", () => {
76128
passed: false,
77129
};
78130
const body = buildIssueBody(analysis, 3);
79-
expect(body).toContain("1 error(s), 1 warning(s)");
131+
expect(body).toContain("Assertion Failures");
80132
expect(body).toContain("first-contentful-paint");
81133
expect(body).toContain("speed-index");
134+
expect(body).toContain("🔴 error");
135+
expect(body).toContain("🟡 warn");
82136
});
83137

84138
it("shows regressions", () => {
@@ -174,7 +228,62 @@ describe("buildIssueBody", () => {
174228
passed: false,
175229
};
176230
const body = buildIssueBody(analysis, 3);
177-
expect(body).toContain("[View report](https://storage.example.com/report)");
231+
expect(body).toContain("View Report");
232+
expect(body).toContain("https://storage.example.com/report");
233+
});
234+
235+
it("shows core web vitals table with median and range when runMetrics provided", () => {
236+
const runs: Metrics[] = [
237+
{ "first-contentful-paint": 1000, "largest-contentful-paint": 2000, "cumulative-layout-shift": 0.05, "total-blocking-time": 100, "speed-index": 1500, interactive: 3000 },
238+
{ "first-contentful-paint": 1200, "largest-contentful-paint": 2200, "cumulative-layout-shift": 0.08, "total-blocking-time": 150, "speed-index": 1700, interactive: 3200 },
239+
{ "first-contentful-paint": 1100, "largest-contentful-paint": 2100, "cumulative-layout-shift": 0.06, "total-blocking-time": 120, "speed-index": 1600, interactive: 3100 },
240+
];
241+
const analysis: AnalysisResult = {
242+
urls: [
243+
{
244+
url: "https://example.com/",
245+
pathname: "/",
246+
profiles: [
247+
makeProfile({
248+
passed: false,
249+
metrics: runs[1],
250+
runMetrics: runs,
251+
assertions: [{ auditId: "perf", level: "error", actual: 0.4, expected: 0.9, operator: ">=", passed: false }],
252+
}),
253+
],
254+
passed: false,
255+
},
256+
],
257+
allRegressions: [],
258+
hasRegressions: false,
259+
passed: false,
260+
};
261+
const body = buildIssueBody(analysis, 3);
262+
expect(body).toContain("Core Web Vitals");
263+
expect(body).toContain("median of 3 runs");
264+
expect(body).toContain("First Contentful Paint");
265+
expect(body).toContain("Individual runs (3)");
266+
});
267+
268+
it("wraps each profile in a collapsible details section", () => {
269+
const analysis: AnalysisResult = {
270+
urls: [
271+
{
272+
url: "https://example.com/",
273+
pathname: "/",
274+
profiles: [
275+
makeProfile({ passed: false, assertions: [{ auditId: "perf", level: "error", actual: 0.4, expected: 0.9, operator: ">=", passed: false }] }),
276+
],
277+
passed: false,
278+
},
279+
],
280+
allRegressions: [],
281+
hasRegressions: false,
282+
passed: false,
283+
};
284+
const body = buildIssueBody(analysis, 3);
285+
expect(body).toContain("<details open>");
286+
expect(body).toContain("</details>");
178287
});
179288
});
180289

src/issues.ts

Lines changed: 177 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as github from "@actions/github";
2-
import type { AnalysisResult } from "./types";
3-
import { filterFailedAssertions, countAssertionLevels, buildAssertionTable, buildRegressionsList } from "./utils";
2+
import type { AnalysisResult, ProfileResult, Metrics, MetricKey } from "./types";
3+
import { METRIC_KEYS, METRIC_DISPLAY_NAMES, METRIC_SHORT_NAMES } from "./types";
4+
import { filterFailedAssertions, countAssertionLevels, buildRegressionsList, fmtMetricValue } from "./utils";
45

56
const ISSUE_TITLE = "Lighthouse Performance Alert";
67
const LABELS = ["lighthouse", "performance"];
@@ -36,7 +37,7 @@ export async function ensureLabels(octokit: Octokit): Promise<string[]> {
3637
return ensured;
3738
}
3839

39-
/** Build consolidated issue body — URL-first, profiles nested under each URL. */
40+
/** Build consolidated issue body — summary → URLs → profiles → individual runs. */
4041
export function buildIssueBody(
4142
analysis: AnalysisResult,
4243
consecutiveFailLimit: number,
@@ -45,11 +46,37 @@ export function buildIssueBody(
4546
const branch = process.env.GITHUB_REF?.replace("refs/heads/", "") ?? "unknown";
4647
const commit = process.env.GITHUB_SHA?.substring(0, 7) ?? "unknown";
4748

48-
let body = `## Lighthouse Performance Alert\n\n`;
49-
body += `**Timestamp:** ${timestamp}\n`;
50-
body += `**Branch:** ${branch}\n`;
51-
body += `**Commit:** ${commit}\n\n`;
49+
const failedUrls = analysis.urls.filter((u) => !u.passed);
50+
const failingProfiles = failedUrls.flatMap((u) => u.profiles.filter((p) => !p.passed));
51+
const totalProfiles = analysis.urls.reduce((s, u) => s + u.profiles.length, 0);
52+
const totalErrors = failingProfiles.reduce(
53+
(s, p) => s + countAssertionLevels(filterFailedAssertions(p.assertions)).errors, 0,
54+
);
55+
const totalWarnings = failingProfiles.reduce(
56+
(s, p) => s + countAssertionLevels(filterFailedAssertions(p.assertions)).warnings, 0,
57+
);
58+
const totalRegressions = failingProfiles.reduce((s, p) => s + p.regressions.length, 0);
5259

60+
// ── Header ──────────────────────────────────────────────────────────
61+
let body = `## 🔴 Lighthouse Performance Alert\n\n`;
62+
63+
const parts: string[] = [];
64+
if (totalErrors > 0) parts.push(`${totalErrors} error${pl(totalErrors)}`);
65+
if (totalWarnings > 0) parts.push(`${totalWarnings} warning${pl(totalWarnings)}`);
66+
if (totalRegressions > 0) parts.push(`${totalRegressions} regression${pl(totalRegressions)}`);
67+
68+
body += `> **${analysis.urls.length} URL${pl(analysis.urls.length)}** across `;
69+
body += `**${totalProfiles} profile${pl(totalProfiles)}** — `;
70+
body += `**${failingProfiles.length} failing** · ${parts.join(" · ")}\n\n`;
71+
72+
// ── Status matrix ───────────────────────────────────────────────────
73+
body += buildStatusMatrix(analysis);
74+
75+
// ── Metadata ────────────────────────────────────────────────────────
76+
body += `\`${branch}\` · \`${commit}\` · ${fmtDate(timestamp)}\n\n`;
77+
body += `---\n\n`;
78+
79+
// ── Per-URL sections ────────────────────────────────────────────────
5380
for (const url of analysis.urls) {
5481
if (url.passed) continue;
5582

@@ -58,31 +85,157 @@ export function buildIssueBody(
5885
for (const pr of url.profiles) {
5986
if (pr.passed) continue;
6087

61-
body += `#### ${pr.profile}\n\n`;
88+
body += buildProfileSection(pr, consecutiveFailLimit);
89+
}
90+
}
6291

63-
if (pr.reportLink) {
64-
body += `[View report](${pr.reportLink})\n\n`;
65-
}
92+
// ── Footer ──────────────────────────────────────────────────────────
93+
body += `---\n\n`;
94+
const runCount = failingProfiles[0]?.runMetrics?.length;
95+
body += `<sub>🤖 Auto-managed by AutoLighthouse`;
96+
if (runCount && runCount > 1) body += ` · ${runCount} runs per profile`;
97+
body += `</sub>`;
6698

67-
const failures = filterFailedAssertions(pr.assertions);
68-
if (failures.length > 0) {
69-
const { errors, warnings } = countAssertionLevels(failures);
70-
body += `**Assertion Failures:** ${errors} error(s), ${warnings} warning(s)\n\n`;
71-
body += buildAssertionTable(failures);
72-
}
99+
return body;
100+
}
73101

74-
if (pr.regressions.length > 0) {
75-
body += buildRegressionsList(pr.regressions);
76-
}
102+
// ── Helpers ─────────────────────────────────────────────────────────────
77103

78-
if (pr.consecutiveFailures >= consecutiveFailLimit) {
79-
body += `⚠️ **Persistent failure** — ${pr.consecutiveFailures} consecutive runs\n\n`;
104+
function pl(n: number): string { return n !== 1 ? "s" : ""; }
105+
106+
function fmtDate(iso: string): string {
107+
const d = new Date(iso);
108+
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
109+
const hh = String(d.getUTCHours()).padStart(2, "0");
110+
const mm = String(d.getUTCMinutes()).padStart(2, "0");
111+
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} ${hh}:${mm} UTC`;
112+
}
113+
114+
function profileStatusIcon(pr: ProfileResult): string {
115+
const failures = filterFailedAssertions(pr.assertions);
116+
const { errors } = countAssertionLevels(failures);
117+
if (errors > 0) return "🔴";
118+
if (failures.length > 0) return "🟡";
119+
if (pr.regressions.length > 0) return "📉";
120+
return "🟢";
121+
}
122+
123+
function buildStatusMatrix(analysis: AnalysisResult): string {
124+
const profileSet = new Set<string>();
125+
for (const url of analysis.urls) {
126+
for (const p of url.profiles) profileSet.add(p.profile);
127+
}
128+
const profiles = Array.from(profileSet);
129+
if (profiles.length === 0) return "";
130+
131+
const icons: Record<string, string> = { desktop: "🖥️", mobile: "📱", tablet: "📱" };
132+
133+
let md = `| URL |`;
134+
for (const p of profiles) md += ` ${icons[p] ?? ""} ${p} |`;
135+
md += `\n|-----|`;
136+
for (const _ of profiles) md += `:---:|`;
137+
md += `\n`;
138+
139+
for (const url of analysis.urls) {
140+
md += `| \`${url.pathname}\` |`;
141+
for (const name of profiles) {
142+
const pr = url.profiles.find((p) => p.profile === name);
143+
if (!pr) { md += ` — |`; continue; }
144+
md += ` ${profileStatusIcon(pr)} |`;
145+
}
146+
md += `\n`;
147+
}
148+
md += `\n`;
149+
return md;
150+
}
151+
152+
function buildProfileSection(pr: ProfileResult, consecutiveFailLimit: number): string {
153+
const icon = profileStatusIcon(pr);
154+
let md = `<details open>\n<summary><b>${icon} ${pr.profile}</b>`;
155+
156+
const failures = filterFailedAssertions(pr.assertions);
157+
if (failures.length > 0) {
158+
const { errors, warnings } = countAssertionLevels(failures);
159+
const counts: string[] = [];
160+
if (errors > 0) counts.push(`${errors} error${pl(errors)}`);
161+
if (warnings > 0) counts.push(`${warnings} warning${pl(warnings)}`);
162+
md += ` · ${counts.join(", ")}`;
163+
}
164+
if (pr.regressions.length > 0) {
165+
md += ` · ${pr.regressions.length} regression${pl(pr.regressions.length)}`;
166+
}
167+
if (pr.reportLink) {
168+
md += ` · <a href="${pr.reportLink}">View Report ↗</a>`;
169+
}
170+
md += `</summary>\n\n`;
171+
172+
// Assertion failures
173+
if (failures.length > 0) {
174+
md += `**Assertion Failures**\n\n`;
175+
md += `| Audit | Level | Actual | Threshold |\n`;
176+
md += `|-------|-------|--------|----------|\n`;
177+
for (const a of failures) {
178+
const lvl = a.level === "error" ? "🔴 error" : "🟡 warn";
179+
md += `| ${a.auditId} | ${lvl} | ${a.actual ?? "—"} | ${a.operator ?? ""} ${a.expected ?? "—"} |\n`;
180+
}
181+
md += "\n";
182+
}
183+
184+
// Regressions
185+
if (pr.regressions.length > 0) {
186+
md += buildRegressionsList(pr.regressions);
187+
}
188+
189+
// Core Web Vitals
190+
const runs = pr.runMetrics;
191+
if (runs && runs.length > 0) {
192+
md += buildMetricsTable(pr.metrics, runs);
193+
}
194+
195+
// Persistent failure
196+
if (pr.consecutiveFailures >= consecutiveFailLimit) {
197+
md += `> ⚠️ **Persistent failure** — ${pr.consecutiveFailures} consecutive runs\n\n`;
198+
}
199+
200+
md += `</details>\n\n`;
201+
return md;
202+
}
203+
204+
function buildMetricsTable(median: Metrics, runs: Metrics[]): string {
205+
let md = `**Core Web Vitals** _(median of ${runs.length} run${pl(runs.length)})_\n\n`;
206+
md += `| Metric | Median | Range |\n`;
207+
md += `|--------|-------:|------:|\n`;
208+
209+
for (const key of METRIC_KEYS) {
210+
const medVal = median[key];
211+
if (medVal === undefined) continue;
212+
const sorted = runs.map((r) => r[key]).filter((v): v is number => v !== undefined).sort((a, b) => a - b);
213+
if (sorted.length === 0) continue;
214+
const range = `${fmtMetricValue(key, sorted[0])}${fmtMetricValue(key, sorted[sorted.length - 1])}`;
215+
md += `| ${METRIC_DISPLAY_NAMES[key]} | ${fmtMetricValue(key, medVal)} | ${range} |\n`;
216+
}
217+
md += "\n";
218+
219+
// Individual runs (collapsible)
220+
if (runs.length > 1) {
221+
md += `<details>\n<summary>📊 Individual runs (${runs.length})</summary>\n\n`;
222+
md += `| # |`;
223+
for (const key of METRIC_KEYS) md += ` ${METRIC_SHORT_NAMES[key]} |`;
224+
md += `\n|---|`;
225+
for (const _ of METRIC_KEYS) md += `---:|`;
226+
md += `\n`;
227+
for (let i = 0; i < runs.length; i++) {
228+
md += `| ${i + 1} |`;
229+
for (const key of METRIC_KEYS) {
230+
const v = runs[i][key];
231+
md += ` ${v !== undefined ? fmtMetricValue(key, v) : "—"} |`;
80232
}
233+
md += `\n`;
81234
}
235+
md += `\n</details>\n\n`;
82236
}
83237

84-
body += `---\n_This issue is auto-managed by AutoLighthouse._`;
85-
return body;
238+
return md;
86239
}
87240

88241
/** Create, comment on, or close the Lighthouse Performance Alert issue. */

0 commit comments

Comments
 (0)