11import * 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
56const ISSUE_TITLE = "Lighthouse Performance Alert" ;
67const 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 . */
4041export 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