From 2015d20aa536277eef09db0fc4c42a1d23388946 Mon Sep 17 00:00:00 2001 From: Joshua Coles Date: Wed, 29 Jul 2026 09:24:20 +0000 Subject: [PATCH] Enumerate failing specs/E2E from CI artifacts; mute all github-actions comments Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ --- README.md | 10 ++ config.json | 13 +- package.json | 1 + pnpm-lock.yaml | 8 ++ server/config.ts | 30 +++++ server/db.ts | 92 +++++++++++++ server/failures.ts | 192 +++++++++++++++++++++++++++ server/github/rest.ts | 29 ++++ server/poller.ts | 7 + server/routes.ts | 6 + shared/types.ts | 20 +++ web/src/api.ts | 5 +- web/src/components/FailuresPanel.tsx | 91 +++++++++++++ web/src/components/PrCard.tsx | 13 +- web/src/components/PrList.tsx | 1 + web/src/components/Timeline.tsx | 2 + web/src/styles/app.css | 132 ++++++++++++++++++ web/src/useSSE.ts | 1 + 18 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 server/failures.ts create mode 100644 server/github/rest.ts create mode 100644 web/src/components/FailuresPanel.tsx diff --git a/README.md b/README.md index abaa930..484daa9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,16 @@ and per-repo check-name → category mappings (exact names or `*` globs, e.g. Auth: `GITHUB_TOKEN` env var, falling back to `gh auth token`. +`timeline.mute` hides timeline events by actor (optionally kind / body substring) +at read time — data is kept, so unmuting is retroactive. + +`failureReports` (per repo) maps failing check names to workflow artifacts that +contain structured results: `rspec` parser reads RSpec JSON-formatter output +(`rspec_results_*.json`), `playwright` reads the `data/*.md` failure attachments +inside Playwright HTML-report artifacts. Failing specs are stored (capped at +1000 per report), summarized on PR cards, and enumerated in the per-PR focus +view grouped by file. Reports re-fetch only when the workflow run id changes. + ## Commands ```sh diff --git a/config.json b/config.json index 0898338..e579cdb 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "timeline": { "mute": [ { "actor": "qltysh" }, - { "actor": "github-actions", "kind": "review_comment" } + { "actor": "github-actions" } ] }, "repos": [ @@ -27,6 +27,17 @@ "e2e": ["Preview E2E *"] }, "ignoreChecks": ["Preview E2E ${{ matrix.folder }}"], + "failureReports": [ + { "check": "specs", "artifact": "rspec_results", "parser": "rspec", "label": "specs" }, + { "check": "rspec", "artifact": "rspec_results", "parser": "rspec", "label": "specs" }, + { "check": "rstest", "artifact": "rstest_results", "parser": "rspec", "label": "rstest" }, + { + "check": "Preview E2E *", + "artifact": "preview-report-pr-{number}-*", + "parser": "playwright", + "label": "e2e" + } + ], "preview": { "label": "preview", "urlTemplate": "https://pr-{number}.argocd.testmd.co.uk/" diff --git a/package.json b/package.json index 3c06cb9..507aded 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "express": "^5.1.0", + "fflate": "^0.8.3", "picomatch": "^4.0.2", "zod": "^4.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5fcf2..fdc61ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 picomatch: specifier: ^4.0.2 version: 4.0.5 @@ -650,6 +653,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1500,6 +1506,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + finalhandler@2.1.1: dependencies: debug: 4.4.3 diff --git a/server/config.ts b/server/config.ts index 63a2f71..254a37c 100644 --- a/server/config.ts +++ b/server/config.ts @@ -6,6 +6,18 @@ import type { CheckCategory } from '../shared/types.js'; const categorySchema = z.array(z.string()); +const failureReportSchema = z.object({ + /** Check-run name this applies to; supports `*` globs. */ + check: z.string(), + /** Artifact name on the workflow run; `{number}` = PR number, supports `*` globs. */ + artifact: z.string(), + parser: z.enum(['rspec', 'playwright']), + /** Grouping label shown in the UI (e.g. "specs", "e2e"). */ + label: z.string(), +}); + +export type FailureReportRule = z.infer; + const repoSchema = z.object({ name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), checkCategories: z @@ -16,6 +28,7 @@ const repoSchema = z.object({ }) .default({}), ignoreChecks: z.array(z.string()).default([]), + failureReports: z.array(failureReportSchema).default([]), preview: z .object({ label: z.string(), @@ -52,11 +65,21 @@ interface GlobMatcher { category: CheckCategory; } +export interface CompiledFailureRule { + matchesCheck: (name: string) => boolean; + artifact: string; + parser: 'rspec' | 'playwright'; + label: string; + /** Stable identity for caching: the raw check pattern. */ + key: string; +} + export interface RepoRules { exact: Map; globs: GlobMatcher[]; ignore: Set; preview?: { label: string; urlTemplate: string }; + failureRules: CompiledFailureRule[]; } export interface AppConfig extends RawConfig { @@ -113,6 +136,13 @@ export function loadConfig(path = join(rootDir, 'config.json')): AppConfig { globs, ignore: new Set(repo.ignoreChecks), preview: repo.preview, + failureRules: repo.failureReports.map((r) => ({ + matchesCheck: GLOB_CHARS.test(r.check) ? compileNameGlob(r.check) : (n) => n === r.check, + artifact: r.artifact, + parser: r.parser, + label: r.label, + key: r.check, + })), }); } diff --git a/server/db.ts b/server/db.ts index 742e1fe..4ea8bbc 100644 --- a/server/db.ts +++ b/server/db.ts @@ -38,11 +38,32 @@ CREATE TABLE IF NOT EXISTS events ( read_at TEXT ); +CREATE TABLE IF NOT EXISTS failure_reports ( + pr_id TEXT NOT NULL, + key TEXT NOT NULL, + label TEXT NOT NULL, + run_id TEXT NOT NULL, + fetched_at TEXT NOT NULL, + failed INTEGER NOT NULL, + truncated INTEGER NOT NULL DEFAULT 0, + failures TEXT NOT NULL, + PRIMARY KEY (pr_id, key) +); + CREATE INDEX IF NOT EXISTS idx_events_created ON events (created_at DESC); CREATE INDEX IF NOT EXISTS idx_events_pr ON events (pr_id); CREATE INDEX IF NOT EXISTS idx_events_unread ON events (read) WHERE read = 0; `; +export interface FailureReportRow { + key: string; + label: string; + fetched_at: string; + failed: number; + truncated: number; + failures: string; +} + export interface EventInsert { id: string; prId: string; @@ -184,6 +205,77 @@ export class Db { ); } + getFailureReportRunId(prId: string, key: string): string | null { + const row = this.db + .prepare('SELECT run_id FROM failure_reports WHERE pr_id = ? AND key = ?') + .get(prId, key) as { run_id: string } | undefined; + return row?.run_id ?? null; + } + + upsertFailureReport( + prId: string, + key: string, + label: string, + runId: string, + report: { failed: number; truncated: boolean; failures: unknown[] }, + ): void { + this.db + .prepare( + `INSERT INTO failure_reports (pr_id, key, label, run_id, fetched_at, failed, truncated, failures) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pr_id, key) DO UPDATE SET + label = excluded.label, run_id = excluded.run_id, fetched_at = excluded.fetched_at, + failed = excluded.failed, truncated = excluded.truncated, failures = excluded.failures`, + ) + .run( + prId, + key, + label, + runId, + new Date().toISOString(), + report.failed, + report.truncated ? 1 : 0, + JSON.stringify(report.failures), + ); + } + + /** Remove reports whose rule no longer has failing checks. Returns true if any removed. */ + pruneFailureReports(prId: string, activeKeys: string[]): boolean { + const placeholders = activeKeys.map(() => '?').join(', '); + const sql = activeKeys.length + ? `DELETE FROM failure_reports WHERE pr_id = ? AND key NOT IN (${placeholders})` + : 'DELETE FROM failure_reports WHERE pr_id = ?'; + return Number(this.db.prepare(sql).run(prId, ...activeKeys).changes) > 0; + } + + failureReportRows(prId: string): FailureReportRow[] { + return this.db + .prepare( + 'SELECT key, label, fetched_at, failed, truncated, failures FROM failure_reports WHERE pr_id = ? ORDER BY label', + ) + .all(prId) as unknown as FailureReportRow[]; + } + + failureSummaries(): Record { + const rows = this.db + .prepare( + `SELECT f.pr_id, f.key, f.label, f.failed, f.truncated + FROM failure_reports f JOIN prs p ON p.id = f.pr_id AND p.state = 'OPEN' + ORDER BY f.label`, + ) + .all() as { pr_id: string; key: string; label: string; failed: number; truncated: number }[]; + const out: Record = {}; + for (const r of rows) { + (out[r.pr_id] ??= []).push({ + key: r.key, + label: r.label, + failed: r.failed, + truncated: r.truncated === 1, + }); + } + return out; + } + getSnapshot(id: string): PrSnapshot | null { const row = this.db.prepare('SELECT snapshot FROM prs WHERE id = ?').get(id) as | { snapshot: string } diff --git a/server/failures.ts b/server/failures.ts new file mode 100644 index 0000000..35dc938 --- /dev/null +++ b/server/failures.ts @@ -0,0 +1,192 @@ +import { unzipSync, strFromU8 } from 'fflate'; +import type { FailureReport, PrSnapshot, SpecFailure } from '../shared/types.js'; +import type { CompiledFailureRule, RepoRules } from './config.js'; +import type { Db } from './db.js'; +import { restJson, restZip } from './github/rest.js'; + +const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024; +const MAX_STORED_FAILURES = 1000; +const MSG_LEN = 240; + +interface ArtifactInfo { + id: number; + name: string; + expired: boolean; + size_in_bytes: number; +} + +function firstLine(text: string): string { + const line = text.split('\n').find((l) => l.trim() !== '') ?? ''; + return line.trim().slice(0, MSG_LEN); +} + +/* ---------- parsers ---------- */ + +interface RspecJson { + examples?: { + status: string; + file_path: string; + line_number: number; + full_description: string; + exception?: { message?: string }; + }[]; +} + +function parseRspecZip(zip: Uint8Array): SpecFailure[] { + const files = unzipSync(zip, { filter: (f) => f.name.endsWith('.json') }); + const failures: SpecFailure[] = []; + for (const [, data] of Object.entries(files)) { + let parsed: RspecJson; + try { + parsed = JSON.parse(strFromU8(data)) as RspecJson; + } catch { + continue; + } + for (const ex of parsed.examples ?? []) { + if (ex.status !== 'failed') continue; + failures.push({ + path: ex.file_path.replace(/^\.\//, ''), + line: ex.line_number ?? null, + name: ex.full_description, + message: firstLine(ex.exception?.message ?? ''), + }); + } + } + return failures; +} + +/** Playwright HTML-report artifacts carry one `data/.md` per failed test + * (the "copy prompt" attachment) with Name / Location / Error details blocks. */ +function parsePlaywrightZip(zip: Uint8Array): SpecFailure[] { + const files = unzipSync(zip, { + filter: (f) => f.name.startsWith('data/') && f.name.endsWith('.md'), + }); + const failures: SpecFailure[] = []; + const seen = new Set(); + for (const [, data] of Object.entries(files)) { + const text = strFromU8(data); + const name = /^- Name: (.+)$/m.exec(text)?.[1]?.trim(); + const location = /^- Location: (.+)$/m.exec(text)?.[1]?.trim(); + if (!name || seen.has(name + location)) continue; + seen.add(name + location); + const errorBlock = /# Error details\s*```([\s\S]*?)```/.exec(text)?.[1] ?? ''; + const locMatch = location ? /^(.*?):(\d+)(?::\d+)?$/.exec(location) : null; + failures.push({ + path: locMatch?.[1] ?? location ?? '', + line: locMatch?.[2] ? Number(locMatch[2]) : null, + name: name.replace(/ >> /g, ' › '), + message: firstLine(errorBlock), + }); + } + return failures; +} + +/* ---------- orchestration ---------- */ + +function runIdFromUrl(url: string | null): string | null { + if (!url) return null; + return /\/actions\/runs\/(\d+)\//.exec(url)?.[1] ?? null; +} + +function artifactMatcher(pattern: string, prNumber: number): (name: string) => boolean { + const concrete = pattern.replaceAll('{number}', String(prNumber)); + if (!concrete.includes('*')) return (n) => n === concrete; + const regex = new RegExp( + `^${concrete.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, + ); + return (n) => regex.test(n); +} + +/** Sync failure reports for one PR: for each configured rule with at least one + * failing check, download + parse that run's artifacts unless the stored report + * already covers the same run. Reports for now-passing rules are deleted. */ +export async function syncFailureReports( + db: Db, + rules: RepoRules | undefined, + repo: string, + snapshot: PrSnapshot, +): Promise { + if (!rules || rules.failureRules.length === 0) return false; + + const failingChecks = snapshot.categories + .flatMap((c) => c.checks) + .filter((c) => c.state === 'fail'); + + const activeKeys: string[] = []; + let changed = false; + + for (const rule of rules.failureRules) { + const matches = failingChecks.filter((c) => rule.matchesCheck(c.name)); + if (matches.length === 0) continue; + activeKeys.push(rule.key); + + const runId = matches.map((m) => runIdFromUrl(m.url)).find((r) => r !== null); + if (!runId) continue; + const existing = db.getFailureReportRunId(snapshot.id, rule.key); + if (existing === runId) continue; + + try { + const failures = await fetchAndParse(repo, runId, rule, snapshot.number); + db.upsertFailureReport(snapshot.id, rule.key, rule.label, runId, { + failed: failures.length, + truncated: failures.length > MAX_STORED_FAILURES, + failures: failures.slice(0, MAX_STORED_FAILURES), + }); + changed = true; + console.log( + `[failures] ${repo}#${snapshot.number} ${rule.label}: ${failures.length} failing`, + ); + } catch (err) { + console.error( + `[failures] ${repo}#${snapshot.number} ${rule.key}: ${err instanceof Error ? err.message : err}`, + ); + } + } + + changed = db.pruneFailureReports(snapshot.id, activeKeys) || changed; + return changed; +} + +async function fetchAndParse( + repo: string, + runId: string, + rule: CompiledFailureRule, + prNumber: number, +): Promise { + const { artifacts } = await restJson<{ artifacts: ArtifactInfo[] }>( + `/repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, + ); + const match = artifactMatcher(rule.artifact, prNumber); + const wanted = artifacts.filter((a) => match(a.name) && !a.expired); + + const failures: SpecFailure[] = []; + for (const artifact of wanted) { + if (artifact.size_in_bytes > MAX_ARTIFACT_BYTES) { + console.warn(`[failures] skipping oversized artifact ${artifact.name}`); + continue; + } + const zip = await restZip(`/repos/${repo}/actions/artifacts/${artifact.id}/zip`, MAX_ARTIFACT_BYTES); + if (!zip) continue; + failures.push(...(rule.parser === 'rspec' ? parseRspecZip(zip) : parsePlaywrightZip(zip))); + } + failures.sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0)); + return failures; +} + +export function buildFailureReport(row: { + key: string; + label: string; + fetched_at: string; + failed: number; + truncated: number; + failures: string; +}): FailureReport { + return { + key: row.key, + label: row.label, + fetchedAt: row.fetched_at, + failed: row.failed, + truncated: row.truncated === 1, + failures: JSON.parse(row.failures) as SpecFailure[], + }; +} diff --git a/server/github/rest.ts b/server/github/rest.ts new file mode 100644 index 0000000..bede39c --- /dev/null +++ b/server/github/rest.ts @@ -0,0 +1,29 @@ +import { getToken } from './token.js'; +import { GithubError } from './client.js'; + +const BASE = 'https://api.github.com'; + +export async function restJson(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { + authorization: `bearer ${getToken()}`, + accept: 'application/vnd.github+json', + 'user-agent': 'pr-monitor', + }, + }); + if (!res.ok) { + throw new GithubError(`GitHub REST ${path}: ${res.status}`); + } + return res.json() as Promise; +} + +/** Download an artifact zip (follows the storage redirect). */ +export async function restZip(path: string, maxBytes: number): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { authorization: `bearer ${getToken()}`, 'user-agent': 'pr-monitor' }, + }); + if (!res.ok) throw new GithubError(`GitHub REST ${path}: ${res.status}`); + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength > maxBytes) return null; + return buf; +} diff --git a/server/poller.ts b/server/poller.ts index c2d62ee..4ab18c3 100644 --- a/server/poller.ts +++ b/server/poller.ts @@ -126,6 +126,12 @@ export async function runPoll(config: AppConfig, db: Db): Promise { db.upsertPr(snapshot, signature(pr), detail ? now : null); if (detail) { newEventCount += ingest(db, detail, config.user, backfillAsRead); + await syncFailureReports( + db, + config.rulesByRepo.get(snapshot.repo), + snapshot.repo, + snapshot, + ); } } @@ -137,6 +143,7 @@ export async function runPoll(config: AppConfig, db: Db): Promise { // Thin wrapper so poller stays the single import for index.ts / sync-once. import { ingestTimeline } from './timeline.js'; +import { syncFailureReports } from './failures.js'; function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number { return ingestTimeline(db, detail, { user, backfillAsRead }); } diff --git a/server/routes.ts b/server/routes.ts index e8e254f..81cdf2c 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -5,6 +5,7 @@ import type { StatePayload, SyncEventPayload } from '../shared/types.js'; import type { Db } from './db.js'; import { rootDir } from './config.js'; import { addClient } from './sse.js'; +import { buildFailureReport } from './failures.js'; export interface RouteDeps { db: Db; @@ -33,10 +34,15 @@ export function createApp({ db, triggerSync }: RouteDeps): Express { lastSyncError: db.getMeta('last_sync_error'), unreadCountsByPr: byPr, unreadTotal: total, + failuresByPr: db.failureSummaries(), }; res.json(payload); }); + app.get('/api/prs/:id/failures', (req, res) => { + res.json(db.failureReportRows(req.params.id).map(buildFailureReport)); + }); + app.get('/api/timeline', (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const before = typeof req.query.before === 'string' ? req.query.before : undefined; diff --git a/shared/types.ts b/shared/types.ts index 490e8ed..c3773f0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -68,12 +68,32 @@ export interface TimelineEvent { inReplyTo: { actor: string; bodyExcerpt: string } | null; } +export interface SpecFailure { + path: string; + line: number | null; + name: string; + message: string; +} + +export interface FailureReportSummary { + key: string; + label: string; + failed: number; + truncated: boolean; +} + +export interface FailureReport extends FailureReportSummary { + fetchedAt: string; + failures: SpecFailure[]; +} + export interface StatePayload { prs: PrSnapshot[]; lastSyncAt: string | null; lastSyncError: string | null; unreadCountsByPr: Record; unreadTotal: number; + failuresByPr: Record; } export interface TimelinePage { diff --git a/web/src/api.ts b/web/src/api.ts index aa0dde3..874b005 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { StatePayload, TimelinePage } from '../../shared/types'; +import type { FailureReport, StatePayload, TimelinePage } from '../../shared/types'; async function request(path: string, init?: RequestInit): Promise { const res = await fetch(path, init); @@ -20,6 +20,9 @@ export const fetchTimeline = (opts: { return request(`/api/timeline?${params}`); }; +export const fetchFailures = (prId: string): Promise => + request(`/api/prs/${encodeURIComponent(prId)}/failures`); + export const markPrRead = (prId: string): Promise => request(`/api/prs/${encodeURIComponent(prId)}/read-all`, { method: 'POST' }); diff --git a/web/src/components/FailuresPanel.tsx b/web/src/components/FailuresPanel.tsx new file mode 100644 index 0000000..9de7dd6 --- /dev/null +++ b/web/src/components/FailuresPanel.tsx @@ -0,0 +1,91 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { FailureReport, SpecFailure } from '../../../shared/types'; +import { fetchFailures } from '../api'; + +const FLAT_LIMIT = 15; +const FILE_GROUPS_SHOWN = 50; + +function FailureRow({ f }: { f: SpecFailure }) { + return ( +
+
+ {f.name} + {f.line != null && :{f.line}} +
+ {f.message &&
{f.message}
} +
+ ); +} + +function ReportSection({ report }: { report: FailureReport }) { + const [showAllFiles, setShowAllFiles] = useState(false); + + const byFile = new Map(); + for (const f of report.failures) { + if (!byFile.has(f.path)) byFile.set(f.path, []); + byFile.get(f.path)!.push(f); + } + const files = [...byFile.entries()].sort((a, b) => b[1].length - a[1].length); + const visibleFiles = showAllFiles ? files : files.slice(0, FILE_GROUPS_SHOWN); + const flat = report.failures.length <= FLAT_LIMIT; + + return ( +
+
+ ✗ {report.failed} + {report.label} failing + {report.truncated && ( + showing first {report.failures.length} + )} +
+ {flat ? ( + files.map(([path, fs]) => ( +
+
{path}
+ {fs.map((f, i) => ( + + ))} +
+ )) + ) : ( + <> + {visibleFiles.map(([path, fs]) => ( +
+ + {path} + {fs.length} + + {fs.map((f, i) => ( + + ))} +
+ ))} + {!showAllFiles && files.length > FILE_GROUPS_SHOWN && ( + + )} + + )} +
+ ); +} + +export default function FailuresPanel({ prId }: { prId: string }) { + const query = useQuery({ + queryKey: ['failures', prId], + queryFn: () => fetchFailures(prId), + }); + + const reports = query.data ?? []; + if (reports.length === 0) return null; + + return ( +
+ {reports.map((r) => ( + + ))} +
+ ); +} diff --git a/web/src/components/PrCard.tsx b/web/src/components/PrCard.tsx index 0f166c1..9155271 100644 --- a/web/src/components/PrCard.tsx +++ b/web/src/components/PrCard.tsx @@ -1,4 +1,4 @@ -import type { PrSnapshot } from '../../../shared/types'; +import type { FailureReportSummary, PrSnapshot } from '../../../shared/types'; import CategoryChips from './CategoryChips'; import ReviewBadge from './ReviewBadge'; import { relativeTime } from '../format'; @@ -6,14 +6,20 @@ import { relativeTime } from '../format'; export default function PrCard({ pr, unread, + failures, selected, onSelect, }: { pr: PrSnapshot; unread: number; + failures: FailureReportSummary[]; selected: boolean; onSelect: () => void; }) { + const failureText = failures + .filter((f) => f.failed > 0) + .map((f) => `${f.failed}${f.truncated ? '+' : ''} ${f.label}`) + .join(' · '); return (
+ {failureText && ( +
+ ✗ {failureText} failing +
+ )} ); } diff --git a/web/src/components/PrList.tsx b/web/src/components/PrList.tsx index 6ed2d03..3771d16 100644 --- a/web/src/components/PrList.tsx +++ b/web/src/components/PrList.tsx @@ -58,6 +58,7 @@ export default function PrList({ onSelect(pr.id)} /> diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx index 5342107..1405e47 100644 --- a/web/src/components/Timeline.tsx +++ b/web/src/components/Timeline.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { TimelineEvent } from '../../../shared/types'; import type { PrSnapshot } from '../../../shared/types'; import { fetchTimeline, markAllRead, markPrRead, markRead } from '../api'; +import FailuresPanel from './FailuresPanel'; import { relativeTime, shortRepo } from '../format'; const GROUP_WINDOW_MS = 10 * 60 * 1000; @@ -162,6 +163,7 @@ export default function Timeline({ )} + {selectedPr && }
{query.isLoading &&
Loading…
} {!query.isLoading && groups.length === 0 && ( diff --git a/web/src/styles/app.css b/web/src/styles/app.css index fda6b5d..296f9eb 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -566,6 +566,138 @@ a.check-row:hover { white-space: nowrap; } +.fail-callout { + margin-top: 7px; + padding: 4px 9px; + border-radius: 7px; + background: var(--chip-fail-bg); + color: var(--status-fail-text); + font-size: 12px; + font-weight: 600; +} + +/* ---------- failures panel ---------- */ + +.failures-panel { + margin: 0 14px 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.failure-section { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-raised); + padding: 8px 10px; +} + +.failure-section-head { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 4px; +} + +.failure-count { + color: var(--status-fail-text); + font-weight: 700; + font-size: 14px; +} + +.failure-label { + font-weight: 650; + font-size: 13px; +} + +.failure-truncated { + color: var(--ink-muted); + font-size: 11px; +} + +.failure-file summary { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 3px 4px; + border-radius: 6px; + list-style: none; +} + +.failure-file summary::-webkit-details-marker { + display: none; +} + +.failure-file summary::before { + content: '▸'; + color: var(--ink-muted); + font-size: 10px; + flex: none; +} + +.failure-file[open] summary::before { + content: '▾'; +} + +.failure-file summary:hover { + background: var(--chip-pending-bg); +} + +.failure-path { + font-family: ui-monospace, monospace; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + flex: 1; + min-width: 0; +} + +.failure-file-count { + background: var(--chip-fail-bg); + color: var(--status-fail-text); + border-radius: 9px; + font-size: 11px; + font-weight: 650; + padding: 0 6px; + flex: none; +} + +.failure-file-flat { + margin-bottom: 6px; +} + +.failure-file-flat .failure-path { + direction: ltr; + margin: 2px 4px; +} + +.failure-row { + padding: 3px 4px 3px 18px; +} + +.failure-name { + font-size: 12px; + overflow-wrap: anywhere; +} + +.failure-line { + color: var(--ink-muted); + font-family: ui-monospace, monospace; + font-size: 11px; +} + +.failure-msg { + font-size: 11px; + color: var(--status-fail-text); + font-family: ui-monospace, monospace; + overflow-wrap: anywhere; + opacity: 0.9; +} + .timeline-list { padding: 0 8px 24px; } diff --git a/web/src/useSSE.ts b/web/src/useSSE.ts index 1cf6b68..47685f2 100644 --- a/web/src/useSSE.ts +++ b/web/src/useSSE.ts @@ -21,6 +21,7 @@ export function useSSE(): ConnectionState { if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) { void queryClient.invalidateQueries({ queryKey: ['state'] }); void queryClient.invalidateQueries({ queryKey: ['timeline'] }); + void queryClient.invalidateQueries({ queryKey: ['failures'] }); } }); return () => source.close();