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
co-authored by Claude Fable 5
parent 3b7850fe1a
commit 2015d20aa5
18 changed files with 650 additions and 3 deletions
+4 -1
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' });
+91
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>
);
}
+12 -1
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>
);
}
+1
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)}
/>
+2
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 && (
+132
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;
}
+1
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();