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:
co-authored by
Claude Fable 5
parent
3b7850fe1a
commit
2015d20aa5
+4
-1
@@ -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' });
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user