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; 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; 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; 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; globs: GlobMatcher[]; ignore: Set; preview?: { label: string; urlTemplate: string }; failureRules: CompiledFailureRule[]; } export interface AppConfig extends RawConfig { rulesByRepo: Map; 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(); for (const repo of raw.repos) { const exact = new Map(); 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(' ') }; }