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`.
|
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
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
13
config.json
13
config.json
@ -6,7 +6,7 @@
|
|||||||
"timeline": {
|
"timeline": {
|
||||||
"mute": [
|
"mute": [
|
||||||
{ "actor": "qltysh" },
|
{ "actor": "qltysh" },
|
||||||
{ "actor": "github-actions", "kind": "review_comment" }
|
{ "actor": "github-actions" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"repos": [
|
"repos": [
|
||||||
@ -27,6 +27,17 @@
|
|||||||
"e2e": ["Preview E2E *"]
|
"e2e": ["Preview E2E *"]
|
||||||
},
|
},
|
||||||
"ignoreChecks": ["Preview E2E ${{ matrix.folder }}"],
|
"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": {
|
"preview": {
|
||||||
"label": "preview",
|
"label": "preview",
|
||||||
"urlTemplate": "https://pr-{number}.argocd.testmd.co.uk/"
|
"urlTemplate": "https://pr-{number}.argocd.testmd.co.uk/"
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
picomatch:
|
picomatch:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.5
|
version: 4.0.5
|
||||||
@ -650,6 +653,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
@ -1500,6 +1506,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|||||||
@ -6,6 +6,18 @@ import type { CheckCategory } from '../shared/types.js';
|
|||||||
|
|
||||||
const categorySchema = z.array(z.string());
|
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({
|
const repoSchema = z.object({
|
||||||
name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
|
name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
|
||||||
checkCategories: z
|
checkCategories: z
|
||||||
@ -16,6 +28,7 @@ const repoSchema = z.object({
|
|||||||
})
|
})
|
||||||
.default({}),
|
.default({}),
|
||||||
ignoreChecks: z.array(z.string()).default([]),
|
ignoreChecks: z.array(z.string()).default([]),
|
||||||
|
failureReports: z.array(failureReportSchema).default([]),
|
||||||
preview: z
|
preview: z
|
||||||
.object({
|
.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
@ -52,11 +65,21 @@ interface GlobMatcher {
|
|||||||
category: CheckCategory;
|
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 {
|
export interface RepoRules {
|
||||||
exact: Map<string, CheckCategory>;
|
exact: Map<string, CheckCategory>;
|
||||||
globs: GlobMatcher[];
|
globs: GlobMatcher[];
|
||||||
ignore: Set<string>;
|
ignore: Set<string>;
|
||||||
preview?: { label: string; urlTemplate: string };
|
preview?: { label: string; urlTemplate: string };
|
||||||
|
failureRules: CompiledFailureRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig extends RawConfig {
|
export interface AppConfig extends RawConfig {
|
||||||
@ -113,6 +136,13 @@ export function loadConfig(path = join(rootDir, 'config.json')): AppConfig {
|
|||||||
globs,
|
globs,
|
||||||
ignore: new Set(repo.ignoreChecks),
|
ignore: new Set(repo.ignoreChecks),
|
||||||
preview: repo.preview,
|
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
|
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_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_pr ON events (pr_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_unread ON events (read) WHERE read = 0;
|
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 {
|
export interface EventInsert {
|
||||||
id: string;
|
id: string;
|
||||||
prId: 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 {
|
getSnapshot(id: string): PrSnapshot | null {
|
||||||
const row = this.db.prepare('SELECT snapshot FROM prs WHERE id = ?').get(id) as
|
const row = this.db.prepare('SELECT snapshot FROM prs WHERE id = ?').get(id) as
|
||||||
| { snapshot: string }
|
| { 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);
|
db.upsertPr(snapshot, signature(pr), detail ? now : null);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
newEventCount += ingest(db, detail, config.user, backfillAsRead);
|
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.
|
// Thin wrapper so poller stays the single import for index.ts / sync-once.
|
||||||
import { ingestTimeline } from './timeline.js';
|
import { ingestTimeline } from './timeline.js';
|
||||||
|
import { syncFailureReports } from './failures.js';
|
||||||
function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number {
|
function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number {
|
||||||
return ingestTimeline(db, detail, { user, backfillAsRead });
|
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 type { Db } from './db.js';
|
||||||
import { rootDir } from './config.js';
|
import { rootDir } from './config.js';
|
||||||
import { addClient } from './sse.js';
|
import { addClient } from './sse.js';
|
||||||
|
import { buildFailureReport } from './failures.js';
|
||||||
|
|
||||||
export interface RouteDeps {
|
export interface RouteDeps {
|
||||||
db: Db;
|
db: Db;
|
||||||
@ -33,10 +34,15 @@ export function createApp({ db, triggerSync }: RouteDeps): Express {
|
|||||||
lastSyncError: db.getMeta('last_sync_error'),
|
lastSyncError: db.getMeta('last_sync_error'),
|
||||||
unreadCountsByPr: byPr,
|
unreadCountsByPr: byPr,
|
||||||
unreadTotal: total,
|
unreadTotal: total,
|
||||||
|
failuresByPr: db.failureSummaries(),
|
||||||
};
|
};
|
||||||
res.json(payload);
|
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) => {
|
app.get('/api/timeline', (req, res) => {
|
||||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||||
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
||||||
|
|||||||
@ -68,12 +68,32 @@ export interface TimelineEvent {
|
|||||||
inReplyTo: { actor: string; bodyExcerpt: string } | null;
|
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 {
|
export interface StatePayload {
|
||||||
prs: PrSnapshot[];
|
prs: PrSnapshot[];
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
lastSyncError: string | null;
|
lastSyncError: string | null;
|
||||||
unreadCountsByPr: Record<string, number>;
|
unreadCountsByPr: Record<string, number>;
|
||||||
unreadTotal: number;
|
unreadTotal: number;
|
||||||
|
failuresByPr: Record<string, FailureReportSummary[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelinePage {
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, init);
|
const res = await fetch(path, init);
|
||||||
@ -20,6 +20,9 @@ export const fetchTimeline = (opts: {
|
|||||||
return request(`/api/timeline?${params}`);
|
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> =>
|
export const markPrRead = (prId: string): Promise<unknown> =>
|
||||||
request(`/api/prs/${encodeURIComponent(prId)}/read-all`, { method: 'POST' });
|
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 CategoryChips from './CategoryChips';
|
||||||
import ReviewBadge from './ReviewBadge';
|
import ReviewBadge from './ReviewBadge';
|
||||||
import { relativeTime } from '../format';
|
import { relativeTime } from '../format';
|
||||||
@ -6,14 +6,20 @@ import { relativeTime } from '../format';
|
|||||||
export default function PrCard({
|
export default function PrCard({
|
||||||
pr,
|
pr,
|
||||||
unread,
|
unread,
|
||||||
|
failures,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
pr: PrSnapshot;
|
pr: PrSnapshot;
|
||||||
unread: number;
|
unread: number;
|
||||||
|
failures: FailureReportSummary[];
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const failureText = failures
|
||||||
|
.filter((f) => f.failed > 0)
|
||||||
|
.map((f) => `${f.failed}${f.truncated ? '+' : ''} ${f.label}`)
|
||||||
|
.join(' · ');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pr-card${selected ? ' selected' : ''}`}
|
className={`pr-card${selected ? ' selected' : ''}`}
|
||||||
@ -47,6 +53,11 @@ export default function PrCard({
|
|||||||
<ReviewBadge pr={pr} />
|
<ReviewBadge pr={pr} />
|
||||||
<CategoryChips categories={pr.categories} />
|
<CategoryChips categories={pr.categories} />
|
||||||
</div>
|
</div>
|
||||||
|
{failureText && (
|
||||||
|
<div className="fail-callout" title="Click card for the full list">
|
||||||
|
✗ {failureText} failing
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export default function PrList({
|
|||||||
<PrCard
|
<PrCard
|
||||||
pr={pr}
|
pr={pr}
|
||||||
unread={state.unreadCountsByPr[pr.id] ?? 0}
|
unread={state.unreadCountsByPr[pr.id] ?? 0}
|
||||||
|
failures={state.failuresByPr[pr.id] ?? []}
|
||||||
selected={pr.id === selectedPrId}
|
selected={pr.id === selectedPrId}
|
||||||
onSelect={() => onSelect(pr.id)}
|
onSelect={() => onSelect(pr.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import type { TimelineEvent } from '../../../shared/types';
|
import type { TimelineEvent } from '../../../shared/types';
|
||||||
import type { PrSnapshot } from '../../../shared/types';
|
import type { PrSnapshot } from '../../../shared/types';
|
||||||
import { fetchTimeline, markAllRead, markPrRead, markRead } from '../api';
|
import { fetchTimeline, markAllRead, markPrRead, markRead } from '../api';
|
||||||
|
import FailuresPanel from './FailuresPanel';
|
||||||
import { relativeTime, shortRepo } from '../format';
|
import { relativeTime, shortRepo } from '../format';
|
||||||
|
|
||||||
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
||||||
@ -162,6 +163,7 @@ export default function Timeline({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedPr && <FailuresPanel prId={selectedPr.id} />}
|
||||||
<div className="timeline-list">
|
<div className="timeline-list">
|
||||||
{query.isLoading && <div className="empty-note">Loading…</div>}
|
{query.isLoading && <div className="empty-note">Loading…</div>}
|
||||||
{!query.isLoading && groups.length === 0 && (
|
{!query.isLoading && groups.length === 0 && (
|
||||||
|
|||||||
@ -566,6 +566,138 @@ a.check-row:hover {
|
|||||||
white-space: nowrap;
|
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 {
|
.timeline-list {
|
||||||
padding: 0 8px 24px;
|
padding: 0 8px 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function useSSE(): ConnectionState {
|
|||||||
if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) {
|
if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['state'] });
|
void queryClient.invalidateQueries({ queryKey: ['state'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
|
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['failures'] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => source.close();
|
return () => source.close();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user