Enumerate failing specs/E2E from CI artifacts; mute all github-actions comments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
Joshua Coles 2026-07-29 09:24:20 +00:00
parent 3b7850fe1a
commit 2015d20aa5
18 changed files with 650 additions and 3 deletions

View File

@ -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

View File

@ -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/"

View File

@ -13,6 +13,7 @@
},
"dependencies": {
"express": "^5.1.0",
"fflate": "^0.8.3",
"picomatch": "^4.0.2",
"zod": "^4.0.0"
},

8
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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<typeof failureReportSchema>;
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<string, CheckCategory>;
globs: GlobMatcher[];
ignore: Set<string>;
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,
})),
});
}

View File

@ -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<string, { key: string; label: string; failed: number; truncated: boolean }[]> {
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<string, { key: string; label: string; failed: number; truncated: boolean }[]> = {};
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 }

192
server/failures.ts Normal file
View File

@ -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/<hash>.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<string>();
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<boolean> {
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<SpecFailure[]> {
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[],
};
}

29
server/github/rest.ts Normal file
View File

@ -0,0 +1,29 @@
import { getToken } from './token.js';
import { GithubError } from './client.js';
const BASE = 'https://api.github.com';
export async function restJson<T>(path: string): Promise<T> {
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<T>;
}
/** Download an artifact zip (follows the storage redirect). */
export async function restZip(path: string, maxBytes: number): Promise<Uint8Array | null> {
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;
}

View File

@ -126,6 +126,12 @@ export async function runPoll(config: AppConfig, db: Db): Promise<PollResult> {
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<PollResult> {
// 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 });
}

View File

@ -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;

View File

@ -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<string, number>;
unreadTotal: number;
failuresByPr: Record<string, FailureReportSummary[]>;
}
export interface TimelinePage {

View File

@ -1,4 +1,4 @@
import type { StatePayload, TimelinePage } from '../../shared/types';
import type { FailureReport, StatePayload, TimelinePage } from '../../shared/types';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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<FailureReport[]> =>
request(`/api/prs/${encodeURIComponent(prId)}/failures`);
export const markPrRead = (prId: string): Promise<unknown> =>
request(`/api/prs/${encodeURIComponent(prId)}/read-all`, { method: 'POST' });

View File

@ -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 (
<div className="failure-row">
<div className="failure-name">
{f.name}
{f.line != null && <span className="failure-line">:{f.line}</span>}
</div>
{f.message && <div className="failure-msg">{f.message}</div>}
</div>
);
}
function ReportSection({ report }: { report: FailureReport }) {
const [showAllFiles, setShowAllFiles] = useState(false);
const byFile = new Map<string, SpecFailure[]>();
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 (
<div className="failure-section">
<div className="failure-section-head">
<span className="failure-count"> {report.failed}</span>
<span className="failure-label">{report.label} failing</span>
{report.truncated && (
<span className="failure-truncated">showing first {report.failures.length}</span>
)}
</div>
{flat ? (
files.map(([path, fs]) => (
<div key={path} className="failure-file-flat">
<div className="failure-path">{path}</div>
{fs.map((f, i) => (
<FailureRow key={i} f={f} />
))}
</div>
))
) : (
<>
{visibleFiles.map(([path, fs]) => (
<details key={path} className="failure-file">
<summary>
<span className="failure-path">{path}</span>
<span className="failure-file-count">{fs.length}</span>
</summary>
{fs.map((f, i) => (
<FailureRow key={i} f={f} />
))}
</details>
))}
{!showAllFiles && files.length > FILE_GROUPS_SHOWN && (
<button className="load-more" onClick={() => setShowAllFiles(true)}>
Show all {files.length} files
</button>
)}
</>
)}
</div>
);
}
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 (
<div className="failures-panel">
{reports.map((r) => (
<ReportSection key={r.key} report={r} />
))}
</div>
);
}

View File

@ -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 (
<div
className={`pr-card${selected ? ' selected' : ''}`}
@ -47,6 +53,11 @@ export default function PrCard({
<ReviewBadge pr={pr} />
<CategoryChips categories={pr.categories} />
</div>
{failureText && (
<div className="fail-callout" title="Click card for the full list">
{failureText} failing
</div>
)}
</div>
);
}

View File

@ -58,6 +58,7 @@ export default function PrList({
<PrCard
pr={pr}
unread={state.unreadCountsByPr[pr.id] ?? 0}
failures={state.failuresByPr[pr.id] ?? []}
selected={pr.id === selectedPrId}
onSelect={() => onSelect(pr.id)}
/>

View File

@ -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({
</button>
</div>
)}
{selectedPr && <FailuresPanel prId={selectedPr.id} />}
<div className="timeline-list">
{query.isLoading && <div className="empty-note">Loading</div>}
{!query.isLoading && groups.length === 0 && (

View File

@ -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;
}

View File

@ -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();