feat(config): valibot schemas, schema version, migrations, filter AST

This commit is contained in:
RhenCloud 2026-08-15 16:08:52 +08:00
parent eaec039ad4
commit 2470bd786d
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
13 changed files with 617 additions and 191 deletions

View file

@ -0,0 +1,169 @@
import type { WebhookEvent, Filter, FilterNode } from "../types";
const regexCache = new Map<string, RegExp>();
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
const MAX_PATTERN_LENGTH = 200;
function isWrappedRegex(pattern: string): boolean {
return pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/");
}
function compileRegex(pattern: string): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cached = regexCache.get(pattern);
if (cached) return cached;
try {
const re = new RegExp(pattern, "i");
regexCache.set(pattern, re);
return re;
} catch {
return null;
}
}
function compileGlob(pattern: string, anchored: boolean): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cacheKey = `${anchored ? "a" : "s"}:${pattern}`;
const cached = regexCache.get(cacheKey);
if (cached) return cached;
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const body = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
try {
const re = new RegExp(anchored ? `^${body}$` : body, "i");
regexCache.set(cacheKey, re);
return re;
} catch {
return null;
}
}
function isGlob(pattern: string): boolean {
return pattern.includes("*") || pattern.includes("?");
}
function matchField(pattern: string, value: string): boolean {
if (isWrappedRegex(pattern)) {
const re = compileRegex(pattern.slice(1, -1));
return re ? re.test(value) : false;
}
if (isGlob(pattern)) {
const re = compileGlob(pattern, true);
return re ? re.test(value) : false;
}
return value.toLowerCase() === pattern.toLowerCase();
}
function matchKeyword(pattern: string, body: string): boolean {
if (isWrappedRegex(pattern)) {
const re = compileRegex(pattern.slice(1, -1));
return re ? re.test(body) : false;
}
if (isGlob(pattern)) {
const re = compileGlob(pattern, false);
return re ? re.test(body) : false;
}
return body.includes(pattern.toLowerCase());
}
export function getKeywordBody(event: WebhookEvent): string {
const cached = keywordBodyCache.get(event);
if (cached !== undefined) return cached;
const body = JSON.stringify(event.payload).toLowerCase();
keywordBodyCache.set(event, body);
return body;
}
function extractBranch(event: WebhookEvent): string | undefined {
const p = event.payload;
switch (event.event) {
case "push":
return typeof p.ref === "string" ? p.ref.replace("refs/heads/", "") : undefined;
case "pull_request":
case "pull_request_review":
case "pull_request_review_comment":
return (p.pull_request as { head?: { ref?: string } } | undefined)?.head?.ref;
case "create":
case "delete":
return typeof p.ref === "string" ? p.ref : undefined;
case "workflow_run":
return (p.workflow_run as { head_branch?: string } | undefined)?.head_branch;
case "check_suite":
return (p.check_suite as { head_branch?: string } | undefined)?.head_branch;
case "workflow_job":
return (p.workflow_job as { head_branch?: string } | undefined)?.head_branch;
case "deployment":
return typeof p.ref === "string" ? p.ref.replace("refs/heads/", "") : undefined;
case "commit_comment":
return undefined;
case "code_scanning_alert":
return typeof p.ref === "string" ? p.ref : undefined;
default:
return undefined;
}
}
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
if (filter.type === "keyword") {
const body = keywordBody ?? getKeywordBody(event);
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => matchKeyword(p, body));
return filter.exclude ? !matches : matches;
}
const value = valueFor(filter, event);
if (!value) return false;
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => matchField(p, value));
return filter.exclude ? !matches : matches;
}
function valueFor(filter: Filter, event: WebhookEvent): string | undefined {
const p = event.payload;
switch (filter.type) {
case "event":
return event.event;
case "repo":
return (p.repository as { full_name?: string } | undefined)?.full_name;
case "actor":
return (p.sender as { login?: string } | undefined)?.login;
case "action":
return typeof p.action === "string" ? p.action : undefined;
case "branch":
return extractBranch(event);
default:
return undefined;
}
}
export function containsKeyword(node: FilterNode): boolean {
if ("all" in node) return node.all.some(containsKeyword);
if ("any" in node) return node.any.some(containsKeyword);
if ("not" in node) return containsKeyword(node.not);
return node.type === "keyword";
}
export function evaluateFilterNode(
node: FilterNode,
event: WebhookEvent,
keywordBody?: string,
): boolean {
if ("all" in node) return node.all.every((n) => evaluateFilterNode(n, event, keywordBody));
if ("any" in node) return node.any.some((n) => evaluateFilterNode(n, event, keywordBody));
if ("not" in node) return !evaluateFilterNode(node.not, event, keywordBody);
return matchFilter(node, event, keywordBody);
}
export function explainFilter(filter: Filter): string {
const value = Array.isArray(filter.match)
? filter.match.map((m) => JSON.stringify(m)).join(" or ")
: JSON.stringify(filter.match);
const base = `${filter.type} ${filter.type === "keyword" ? "matches" : "is"} ${value}`;
return filter.exclude ? `not (${base})` : base;
}
export function explainFilterNode(node: FilterNode): string {
if ("all" in node) return `(${node.all.map(explainFilterNode).join(" and ")})`;
if ("any" in node) return `(${node.any.map(explainFilterNode).join(" or ")})`;
if ("not" in node) return `not (${explainFilterNode(node.not)})`;
return explainFilter(node);
}

View file

@ -1,173 +1,5 @@
import type { WebhookEvent, Route, Filter } from "../types";
const regexCache = new Map<string, RegExp>();
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
const MAX_PATTERN_LENGTH = 200;
/** True when a pattern is wrapped in `/.../` and should be parsed as a RegExp. */
function isWrappedRegex(pattern: string): boolean {
return pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/");
}
function compileRegex(pattern: string): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cached = regexCache.get(pattern);
if (cached) return cached;
try {
const re = new RegExp(pattern, "i");
regexCache.set(pattern, re);
return re;
} catch {
return null;
}
}
/**
* Compile a `*` / `?` glob into a RegExp. `*` matches any sequence of
* characters, `?` matches exactly one; everything else matches literally
* (case-insensitive). Anchored globs are full-value matches (`^...$`),
* unanchored ones behave as a search within the value (keyword semantics).
*/
function compileGlob(pattern: string, anchored: boolean): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const key = `${anchored ? "a" : "s"}:${pattern}`;
const cached = regexCache.get(key);
if (cached) return cached;
try {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const body = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
const re = new RegExp(anchored ? `^${body}$` : body, "i");
regexCache.set(key, re);
return re;
} catch {
return null;
}
}
function isGlob(pattern: string): boolean {
return pattern.includes("*") || pattern.includes("?");
}
/**
* Unified pattern syntax shared by every filter type:
* - wrapped in `//` parsed as a regular expression (case-insensitive)
* - contains `*` or `?` glob matching (`*` = any run, `?` = one character)
* - anything else plain text
*/
/** Match a pattern against a whole field value (event/repo/actor/action/branch). */
function matchField(pattern: string, value: string): boolean {
if (isWrappedRegex(pattern)) {
const re = compileRegex(pattern.slice(1, -1));
return !!re && re.test(value);
}
if (isGlob(pattern)) {
const re = compileGlob(pattern, true);
return !!re && re.test(value);
}
return value.toLowerCase() === pattern.toLowerCase();
}
/** Match a pattern against the lowercased JSON payload body (keyword search). */
function matchKeyword(pattern: string, body: string): boolean {
if (isWrappedRegex(pattern)) {
const re = compileRegex(pattern.slice(1, -1));
return !!re && re.test(body);
}
if (isGlob(pattern)) {
const re = compileGlob(pattern, false);
return !!re && re.test(body);
}
return body.includes(pattern.toLowerCase());
}
function getKeywordBody(event: WebhookEvent): string {
const cached = keywordBodyCache.get(event);
if (cached !== undefined) return cached;
const body = JSON.stringify(event.payload).toLowerCase();
keywordBodyCache.set(event, body);
return body;
}
function extractBranch(event: WebhookEvent): string | undefined {
if (event.event === "push") {
return (event.payload.ref as string)?.replace("refs/heads/", "");
}
if (
event.event === "pull_request" ||
event.event === "pull_request_review" ||
event.event === "pull_request_review_comment"
) {
const pr = event.payload.pull_request as { head?: { ref?: string } } | undefined;
return pr?.head?.ref;
}
if (event.event === "create" || event.event === "delete") {
return event.payload.ref as string | undefined;
}
if (event.event === "workflow_run") {
const wf = event.payload.workflow_run as { head_branch?: string } | undefined;
return wf?.head_branch;
}
if (event.event === "check_suite") {
const suite = event.payload.check_suite as { head_branch?: string } | undefined;
return suite?.head_branch;
}
if (event.event === "workflow_job") {
const job = event.payload.workflow_job as { head_branch?: string } | undefined;
return job?.head_branch;
}
if (event.event === "deployment") {
const deployment = event.payload.deployment as { ref?: string } | undefined;
return deployment?.ref?.replace("refs/heads/", "");
}
if (event.event === "commit_comment") {
const comment = event.payload.comment as { position?: number | null } | undefined;
if (comment?.position != null) {
return undefined;
}
}
if (event.event === "code_scanning_alert") {
return event.payload.ref as string | undefined;
}
return undefined;
}
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
let value: string | undefined;
switch (filter.type) {
case "event":
value = event.event;
break;
case "repo":
value = (event.payload.repository as { full_name?: string })?.full_name;
break;
case "actor":
value = (event.payload.sender as { login?: string })?.login;
break;
case "action":
value = event.payload.action as string;
break;
case "branch":
value = extractBranch(event);
break;
case "keyword": {
const body = keywordBody ?? getKeywordBody(event);
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => matchKeyword(p, body));
return filter.exclude ? !matches : matches;
}
default:
return false;
}
if (!value) return false;
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => matchField(p, value));
return filter.exclude ? !matches : matches;
}
import type { WebhookEvent, Route, FilterNode } from "../types";
import { evaluateFilterNode, containsKeyword, getKeywordBody } from "./filter-ast";
export function eventOwners(event: WebhookEvent): string[] {
const owners = new Set<string>();
@ -181,7 +13,7 @@ export function eventOwners(event: WebhookEvent): string[] {
export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false;
const hasKeyword = route.filters.some((f) => f.type === "keyword");
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined;
return route.filters.every((f) => matchFilter(f, event, keywordBody));
const node: FilterNode = route.ast ?? { all: route.filters };
const keywordBody = containsKeyword(node) ? getKeywordBody(event) : undefined;
return evaluateFilterNode(node, event, keywordBody);
}