pr-monitor/server/categorize.ts
2026-07-28 14:21:25 +00:00

95 lines
3.4 KiB
TypeScript

import type {
CategoryState,
CategoryStatus,
CheckCategory,
CheckInfo,
} from '../shared/types.js';
import type { RepoRules } from './config.js';
import type { CheckContext } from './github/queries.js';
const FAIL_CONCLUSIONS = new Set([
'FAILURE',
'TIMED_OUT',
'ACTION_REQUIRED',
'CANCELLED',
'STARTUP_FAILURE',
]);
const PENDING_STATUSES = new Set(['QUEUED', 'WAITING', 'PENDING', 'REQUESTED']);
export function checkState(ctx: CheckContext): CategoryState {
if (ctx.__typename === 'StatusContext') {
if (ctx.state === 'SUCCESS') return 'pass';
if (ctx.state === 'ERROR' || ctx.state === 'FAILURE') return 'fail';
return 'pending'; // PENDING, EXPECTED
}
if (ctx.status === 'IN_PROGRESS') return 'running';
if (PENDING_STATUSES.has(ctx.status)) return 'pending';
// COMPLETED
if (ctx.conclusion === 'SKIPPED') return 'skipped';
if (ctx.conclusion === 'SUCCESS' || ctx.conclusion === 'NEUTRAL') return 'pass';
if (ctx.conclusion && FAIL_CONCLUSIONS.has(ctx.conclusion)) return 'fail';
return 'pending'; // STALE or unknown
}
export function categoryFor(rules: RepoRules | undefined, name: string): CheckCategory {
if (!rules) return 'other';
const exact = rules.exact.get(name);
if (exact) return exact;
for (const glob of rules.globs) {
if (glob.isMatch(name)) return glob.category;
}
return 'other';
}
const AGGREGATE_ORDER: CategoryState[] = ['fail', 'running', 'pending', 'pass', 'skipped'];
const CATEGORIES: CheckCategory[] = ['lint', 'specs', 'e2e', 'other'];
/** A commit accrues one CheckRun per workflow run, so re-runs and multi-trigger
* workflows duplicate names — keep only the latest run per name (like GitHub's UI). */
function dedupeByName(contexts: CheckContext[]): CheckContext[] {
const byName = new Map<string, CheckContext>();
for (const ctx of contexts) {
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
const prev = byName.get(name);
if (!prev) {
byName.set(name, ctx);
continue;
}
const prevAt = prev.__typename === 'CheckRun' ? (prev.startedAt ?? '') : '';
const curAt = ctx.__typename === 'CheckRun' ? (ctx.startedAt ?? '') : '';
if (curAt >= prevAt) byName.set(name, ctx);
}
return [...byName.values()];
}
/** Group check contexts into lint/specs/e2e/other with aggregate state per category.
* Every check is kept — skipped placeholders included — so nothing is ever hidden. */
export function categorize(rules: RepoRules | undefined, contexts: CheckContext[]): CategoryStatus[] {
const buckets = new Map<CheckCategory, CheckInfo[]>(CATEGORIES.map((c) => [c, []]));
for (const ctx of dedupeByName(contexts)) {
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
buckets.get(categoryFor(rules, name))!.push({
name,
state: checkState(ctx),
url: ctx.__typename === 'StatusContext' ? ctx.targetUrl : ctx.detailsUrl,
});
}
return CATEGORIES.map((category) => {
const checks = buckets.get(category)!;
checks.sort((a, b) => a.name.localeCompare(b.name));
const active = checks.filter((c) => c.state !== 'skipped');
let state: CategoryState = 'none';
if (checks.length > 0) {
state = AGGREGATE_ORDER.find((s) => checks.some((c) => c.state === s)) ?? 'none';
}
return {
category,
state,
passed: checks.filter((c) => c.state === 'pass').length,
total: active.length,
checks,
};
});
}