Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import { z } from 'zod';
|
|
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
|
|
.object({
|
|
lint: categorySchema.optional(),
|
|
specs: categorySchema.optional(),
|
|
e2e: categorySchema.optional(),
|
|
})
|
|
.default({}),
|
|
ignoreChecks: z.array(z.string()).default([]),
|
|
failureReports: z.array(failureReportSchema).default([]),
|
|
preview: z
|
|
.object({
|
|
label: z.string(),
|
|
/** `{number}` is replaced with the PR number. */
|
|
urlTemplate: z.string(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
const muteRuleSchema = z.object({
|
|
actor: z.string(),
|
|
kind: z.enum(['issue_comment', 'review_comment', 'review']).optional(),
|
|
bodyContains: z.string().optional(),
|
|
});
|
|
|
|
export type MuteRule = z.infer<typeof muteRuleSchema>;
|
|
|
|
const configSchema = z.object({
|
|
user: z.string(),
|
|
onlyInvolved: z.boolean().default(true),
|
|
pollIntervalSeconds: z.number().int().min(15).default(60),
|
|
port: z.number().int().default(4000),
|
|
host: z.string().default('0.0.0.0'),
|
|
repos: z.array(repoSchema).min(1),
|
|
timeline: z
|
|
.object({ mute: z.array(muteRuleSchema).default([]) })
|
|
.default({ mute: [] }),
|
|
});
|
|
|
|
export type RawConfig = z.infer<typeof configSchema>;
|
|
|
|
interface GlobMatcher {
|
|
isMatch: (name: string) => boolean;
|
|
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 {
|
|
rulesByRepo: Map<string, RepoRules>;
|
|
searchQuery: string;
|
|
}
|
|
|
|
const GLOB_CHARS = /[*?]/;
|
|
|
|
/** Check names are plain strings, not paths — `*` must match anything including `/`
|
|
* (e.g. "Preview E2E *" → "Preview E2E e2e/regression/srm"), and `${{ }}` in names
|
|
* must stay literal. So we compile a minimal glob ourselves instead of picomatch. */
|
|
function compileNameGlob(pattern: string): (name: string) => boolean {
|
|
const regex = new RegExp(
|
|
`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`,
|
|
);
|
|
return (name) => regex.test(name);
|
|
}
|
|
|
|
/** Project root: walk up from this module (which may live in server/ or
|
|
* dist-server/server/) until config.json appears. */
|
|
function findRoot(): string {
|
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
for (let i = 0; i < 4; i++) {
|
|
dir = dirname(dir);
|
|
if (existsSync(join(dir, 'config.json'))) return dir;
|
|
}
|
|
return process.cwd();
|
|
}
|
|
|
|
export const rootDir = findRoot();
|
|
|
|
export function loadConfig(path = join(rootDir, 'config.json')): AppConfig {
|
|
const raw = configSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
|
|
const rulesByRepo = new Map<string, RepoRules>();
|
|
for (const repo of raw.repos) {
|
|
const exact = new Map<string, CheckCategory>();
|
|
const globs: GlobMatcher[] = [];
|
|
for (const [category, patterns] of Object.entries(repo.checkCategories) as [
|
|
CheckCategory,
|
|
string[],
|
|
][]) {
|
|
for (const pattern of patterns ?? []) {
|
|
if (GLOB_CHARS.test(pattern)) {
|
|
globs.push({ isMatch: compileNameGlob(pattern), category });
|
|
} else {
|
|
exact.set(pattern, category);
|
|
}
|
|
}
|
|
}
|
|
rulesByRepo.set(repo.name, {
|
|
exact,
|
|
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,
|
|
})),
|
|
});
|
|
}
|
|
|
|
const parts = ['is:pr', 'is:open', ...raw.repos.map((r) => `repo:${r.name}`)];
|
|
if (raw.onlyInvolved) parts.push(`involves:${raw.user}`);
|
|
|
|
return { ...raw, rulesByRepo, searchQuery: parts.join(' ') };
|
|
}
|