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

86 lines
2.6 KiB
TypeScript

import { 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 repoSchema = z.object({
name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
checkCategories: z
.object({
lint: categorySchema.optional(),
specs: categorySchema.optional(),
e2e: categorySchema.optional(),
})
.default({}),
});
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),
repos: z.array(repoSchema).min(1),
});
export type RawConfig = z.infer<typeof configSchema>;
interface GlobMatcher {
isMatch: (name: string) => boolean;
category: CheckCategory;
}
export interface RepoRules {
exact: Map<string, CheckCategory>;
globs: GlobMatcher[];
}
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);
}
export const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..');
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 });
}
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(' ') };
}