Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
96 lines
3.4 KiB
TypeScript
96 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;
|
|
if (rules?.ignore.has(name)) continue;
|
|
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,
|
|
};
|
|
});
|
|
}
|