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:
parent
3b7850fe1a
commit
2015d20aa5
10
README.md
10
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
|
||||
|
||||
13
config.json
13
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/"
|
||||
|
||||
@ -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
8
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
92
server/db.ts
92
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<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
192
server/failures.ts
Normal 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
29
server/github/rest.ts
Normal 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;
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
web/src/components/FailuresPanel.tsx
Normal file
91
web/src/components/FailuresPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user