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

@ -48,6 +48,8 @@ server/ # Nitro server
└── lib/ └── lib/
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage ├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env ├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
├── config/ # config schema + migration + validation
│ └── schema.ts # CONFIG_SCHEMA_VERSION, valibot route/group/filter schemas, migrateRoutes/Groups, validateRoutes/Groups (non-destructive), explainRoute
├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare ├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare
├── http.ts # shared HTTP helpers ├── http.ts # shared HTTP helpers
├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, enqueue (or inline dispatch) ├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, enqueue (or inline dispatch)
@ -57,7 +59,8 @@ server/ # Nitro server
├── core/ ├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log) │ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log)
├── events/ ├── events/
│ └── match.ts # matchRoute, eventOwners, extractBranch; unified pattern syntax (*/? globs + //-wrapped regex) │ ├── match.ts # matchRoute, eventOwners; unified pattern syntax (*/? globs + //-wrapped regex)
│ └── filter-ast.ts # FilterNode evaluator (all/any/not), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile)
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events) ├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
│ ├── types.ts # Provider interface (matches/verify/parse) │ ├── types.ts # Provider interface (matches/verify/parse)
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers │ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
@ -128,6 +131,8 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
- Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive) - Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive)
- Combine filters into an AST (`Route.ast`: `all`/`any`/`not` nodes) that overrides the flat `filters` AND-list; `explainRoute`/`explainFilterNode` render a human-readable description of the tree
- Validate route/group config against valibot schemas on load (non-destructive: invalid entries log a warning but still load); `CONFIG_SCHEMA_VERSION` marks the schema version and `migrateRoutes`/`migrateGroups` migrate legacy shapes (e.g. `target``targets`)
- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` - Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
- Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups - Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups
- Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members - Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members

View file

@ -112,7 +112,7 @@ Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `a
### Filter Types ### Filter Types
Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference. Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. Filters can also be grouped into an AST via an optional `ast` on a route (`all` / `any` / `not` nodes) to express boolean combinations beyond the default AND-list. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference.
## API ## API

View file

@ -112,7 +112,7 @@ bunx wrangler dev # 启动本地开发服务器
### 过滤器类型 ### 过滤器类型
所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。 所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。过滤器还可通过路由上可选的 `ast``all` / `any` / `not` 节点)组合为 AST以表达默认 AND 列表之外的布尔组合。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。
## API ## API

View file

@ -8,6 +8,7 @@
"h3": "^1.15.3", "h3": "^1.15.3",
"nuxt": "^4.1.0", "nuxt": "^4.1.0",
"octokit": "^4.1.0", "octokit": "^4.1.0",
"valibot": "^1.4.2",
"vue": "^3.5.13", "vue": "^3.5.13",
}, },
"devDependencies": { "devDependencies": {
@ -1865,6 +1866,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"verkit": ["verkit@0.3.2", "https://registry.npmmirror.com/verkit/-/verkit-0.3.2.tgz", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], "verkit": ["verkit@0.3.2", "https://registry.npmmirror.com/verkit/-/verkit-0.3.2.tgz", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="],

View file

@ -27,6 +27,7 @@
"h3": "^1.15.3", "h3": "^1.15.3",
"nuxt": "^4.1.0", "nuxt": "^4.1.0",
"octokit": "^4.1.0", "octokit": "^4.1.0",
"valibot": "^1.4.2",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"overrides": { "overrides": {

View file

@ -1,5 +1,6 @@
import type { Env, Config, Route } from "./types"; import type { Env, Config, Route } from "./types";
import { log } from "./lib/log"; import { log } from "./lib/log";
import { migrateRoutes, validateRoutes } from "./config/schema";
const CONFIG_CACHE_TTL = 60_000; const CONFIG_CACHE_TTL = 60_000;
const ROUTES_KEY = "config:routes"; const ROUTES_KEY = "config:routes";
@ -8,26 +9,13 @@ let configCache: { config: Config; expiresAt: number } | null = null;
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> { export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
try { try {
const stored = await kv.get<Route[]>(ROUTES_KEY, "json"); const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
if (stored) return normalizeRoutes(stored); if (stored) return validateRoutes(migrateRoutes(stored));
} catch (err) { } catch (err) {
log.warn({ err }, "Failed to load routes from KV"); log.warn({ err }, "Failed to load routes from KV");
} }
return []; return [];
} }
/**
* Migrate legacy single-target routes (`target`) to the array form (`targets`).
*/
function normalizeRoutes(routes: Route[]): Route[] {
return routes.map((r) => {
if (r.targets && r.targets.length > 0) return r;
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
if (!legacy) return r;
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
return { ...rest, targets: [legacy] };
});
}
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> { export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
await kv.put(ROUTES_KEY, JSON.stringify(routes)); await kv.put(ROUTES_KEY, JSON.stringify(routes));
configCache = null; configCache = null;

106
server/lib/config/schema.ts Normal file
View file

@ -0,0 +1,106 @@
import * as v from "valibot";
import type { Group, Route } from "../types";
import { log } from "../lib/log";
import { explainFilterNode } from "../events/filter-ast";
export const CONFIG_SCHEMA_VERSION = 1;
export const filterSchema = v.object({
type: v.picklist(["event", "repo", "actor", "action", "branch", "keyword"]),
match: v.union([v.string(), v.array(v.string())]),
exclude: v.optional(v.boolean()),
});
export const routeTargetSchema = v.object({
platform: v.optional(v.picklist(["discord", "telegram"])),
channelId: v.optional(v.string()),
threadId: v.optional(v.string()),
chatId: v.optional(v.string()),
topicId: v.optional(v.string()),
});
export const filterNodeSchema = v.lazy(() =>
v.union([
filterSchema,
v.object({ all: v.array(filterNodeSchema) }),
v.object({ any: v.array(filterNodeSchema) }),
v.object({ not: filterNodeSchema }),
]),
);
export const routeSchema = v.object({
id: v.string(),
name: v.string(),
enabled: v.boolean(),
filters: v.array(filterSchema),
targets: v.array(routeTargetSchema),
groupId: v.optional(v.string()),
fallback: v.optional(v.boolean()),
stop: v.optional(v.boolean()),
discordRoleIds: v.optional(v.array(v.string())),
ast: v.optional(filterNodeSchema),
});
export const groupMemberSchema = v.object({
login: v.string(),
role: v.picklist(["owner", "admin", "viewer"]),
});
export const forgeSourceSchema = v.object({
host: v.string(),
type: v.picklist(["github", "gitea"]),
name: v.optional(v.string()),
});
export const groupSchema = v.object({
id: v.string(),
name: v.string(),
adminIds: v.array(v.string()),
members: v.optional(v.array(groupMemberSchema)),
owners: v.optional(v.array(v.string())),
providers: v.optional(v.array(v.picklist(["github", "gitea", "gitlab", "custom"]))),
installationId: v.optional(v.number()),
emoji: v.optional(v.boolean()),
forgeSources: v.optional(v.array(forgeSourceSchema)),
lang: v.optional(v.string()),
logTarget: v.optional(routeTargetSchema),
});
export function migrateRoutes(routes: Route[]): Route[] {
return routes.map((r) => {
if (r.targets && r.targets.length > 0) return r;
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
if (!legacy) return r;
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
return { ...rest, targets: [legacy] };
});
}
export function migrateGroups(groups: Group[]): Group[] {
return groups;
}
export function validateRoutes(routes: Route[]): Route[] {
for (const r of routes) {
const result = v.safeParse(routeSchema, r);
if (!result.success) {
log.warn({ id: r?.id, issues: result.issues }, "Invalid route config");
}
}
return routes;
}
export function validateGroups(groups: Group[]): Group[] {
for (const g of groups) {
const result = v.safeParse(groupSchema, g);
if (!result.success) {
log.warn({ id: g?.id, issues: result.issues }, "Invalid group config");
}
}
return groups;
}
export function explainRoute(route: Route): string {
const node = route.ast ?? { all: route.filters };
return `${route.name}: ${explainFilterNode(node)}`;
}

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"; import type { WebhookEvent, Route, FilterNode } from "../types";
import { evaluateFilterNode, containsKeyword, getKeywordBody } from "./filter-ast";
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;
}
export function eventOwners(event: WebhookEvent): string[] { export function eventOwners(event: WebhookEvent): string[] {
const owners = new Set<string>(); const owners = new Set<string>();
@ -181,7 +13,7 @@ export function eventOwners(event: WebhookEvent): string[] {
export function matchRoute(route: Route, event: WebhookEvent): boolean { export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false; if (!route.enabled) return false;
const hasKeyword = route.filters.some((f) => f.type === "keyword"); const node: FilterNode = route.ast ?? { all: route.filters };
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined; const keywordBody = containsKeyword(node) ? getKeywordBody(event) : undefined;
return route.filters.every((f) => matchFilter(f, event, keywordBody)); return evaluateFilterNode(node, event, keywordBody);
} }

View file

@ -60,6 +60,12 @@ export interface Route {
filters: Filter[]; filters: Filter[];
targets: RouteTarget[]; targets: RouteTarget[];
groupId?: string; groupId?: string;
/**
* Optional explicit filter expression tree. When present it replaces the
* flat `filters` array (which is equivalent to `{ all: filters }`). Omitted
* on routes stored without an AST matching falls back to `filters`.
*/
ast?: FilterNode;
/** /**
* Fallback route: only fires when no other (non-fallback) route matched the * Fallback route: only fires when no other (non-fallback) route matched the
* event. Multiple fallback routes may exist; they are all skipped whenever at * event. Multiple fallback routes may exist; they are all skipped whenever at
@ -154,6 +160,35 @@ export interface Filter {
exclude?: boolean; exclude?: boolean;
} }
/**
* A filter node matches an event when every child matches.
*/
export interface FilterAll {
all: FilterNode[];
}
/**
* A filter node matches an event when any child matches.
*/
export interface FilterAny {
any: FilterNode[];
}
/**
* A filter node matches an event when its child does not match.
*/
export interface FilterNot {
not: FilterNode;
}
/**
* A composable filter expression tree. Leaf nodes are `Filter`; the `all`,
* `any` and `not` nodes compose them. A route's `filters` array stays the
* flat AND-of-filters form (equivalent to `{ all: filters }`); `ast` optionally
* replaces it with an explicit tree.
*/
export type FilterNode = Filter | FilterAll | FilterAny | FilterNot;
export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom"; export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom";
export interface WebhookEvent { export interface WebhookEvent {

View file

@ -1,6 +1,7 @@
import type { Env, Group, GroupMember, GroupRole } from "../types"; import type { Env, Group, GroupMember, GroupRole } from "../types";
import { isAdminUser } from "./session"; import { isAdminUser } from "./session";
import { log } from "../lib/log"; import { log } from "../lib/log";
import { migrateGroups, validateGroups } from "../config/schema";
const GROUPS_KEY = "config:groups"; const GROUPS_KEY = "config:groups";
@ -39,7 +40,7 @@ export function normalizeGroupMembers(group: Group): GroupMember[] {
export async function loadGroups(kv: KVNamespace): Promise<Group[]> { export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
try { try {
const stored = await kv.get<Group[]>(GROUPS_KEY, "json"); const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
if (Array.isArray(stored)) return stored; if (Array.isArray(stored)) return validateGroups(migrateGroups(stored));
} catch (err) { } catch (err) {
log.warn({ err }, "Failed to load groups from KV"); log.warn({ err }, "Failed to load groups from KV");
} }

170
tests/filter-ast.test.ts Normal file
View file

@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test";
import type { FilterNode, WebhookEvent } from "../server/lib/types";
import {
evaluateFilterNode,
containsKeyword,
explainFilter,
explainFilterNode,
} from "../server/lib/events/filter-ast";
import { matchRoute } from "../server/lib/events/match";
function event(overrides: Partial<WebhookEvent> = {}): WebhookEvent {
return {
event: "pull_request",
payload: {
repository: { full_name: "acme/widget" },
sender: { login: "alice" },
action: "opened",
},
...overrides,
};
}
describe("filter-ast evaluateFilterNode", () => {
test("leaf event matches", () => {
expect(evaluateFilterNode({ type: "event", match: "pull_request" }, event())).toBe(true);
});
test("leaf repo mismatch", () => {
expect(evaluateFilterNode({ type: "repo", match: "other/repo" }, event())).toBe(false);
});
test("all requires every child", () => {
const node: FilterNode = {
all: [
{ type: "event", match: "pull_request" },
{ type: "repo", match: "acme/widget" },
],
};
expect(evaluateFilterNode(node, event())).toBe(true);
});
test("all fails when one child fails", () => {
const node: FilterNode = {
all: [
{ type: "event", match: "pull_request" },
{ type: "repo", match: "nope/repo" },
],
};
expect(evaluateFilterNode(node, event())).toBe(false);
});
test("any passes when one child matches", () => {
const node: FilterNode = {
any: [
{ type: "repo", match: "nope/repo" },
{ type: "event", match: "pull_request" },
],
};
expect(evaluateFilterNode(node, event())).toBe(true);
});
test("not negates", () => {
expect(evaluateFilterNode({ not: { type: "repo", match: "nope/repo" } }, event())).toBe(true);
});
test("nested structure", () => {
const node: FilterNode = {
all: [
{ type: "event", match: "pull_request" },
{
any: [
{ type: "repo", match: "acme/*" },
{ type: "repo", match: "x/*" },
],
},
{ not: { type: "actor", match: "bob" } },
],
};
expect(evaluateFilterNode(node, event())).toBe(true);
});
});
describe("filter-ast containsKeyword", () => {
test("detects keyword leaf", () => {
expect(containsKeyword({ type: "keyword", match: "TODO" })).toBe(true);
});
test("detects nested keyword", () => {
expect(
containsKeyword({
all: [{ type: "event", match: "push" }, { not: { type: "keyword", match: "TODO" } }],
}),
).toBe(true);
});
test("false without keyword", () => {
expect(containsKeyword({ all: [{ type: "event", match: "push" }] })).toBe(false);
});
});
describe("filter-ast explain", () => {
test("explainFilter leaf", () => {
expect(explainFilter({ type: "event", match: "push" })).toBe('event is "push"');
});
test("explainFilter array match", () => {
expect(explainFilter({ type: "repo", match: ["a/*", "b/*"] })).toBe('repo is "a/*" or "b/*"');
});
test("explainFilter exclude", () => {
expect(explainFilter({ type: "actor", match: "bob", exclude: true })).toBe(
'not (actor is "bob")',
);
});
test("explainFilterNode all", () => {
expect(
explainFilterNode({
all: [
{ type: "event", match: "push" },
{ type: "repo", match: "acme/*" },
],
}),
).toBe('(event is "push" and repo is "acme/*")');
});
test("explainFilterNode not", () => {
expect(explainFilterNode({ not: { type: "event", match: "push" } })).toBe(
'not (event is "push")',
);
});
});
describe("matchRoute AST integration", () => {
const baseRoute = {
id: "r1",
name: "route",
enabled: true,
filters: [{ type: "event" as const, match: "pull_request" }],
targets: [{ platform: "discord" as const, channelId: "c1" }],
};
test("flat filters act as AND", () => {
const route = {
...baseRoute,
filters: [
{ type: "event" as const, match: "pull_request" },
{ type: "repo" as const, match: "acme/widget" },
],
};
expect(matchRoute(route, event())).toBe(true);
});
test("ast overrides flat filters", () => {
const route = {
...baseRoute,
ast: {
any: [
{ type: "repo" as const, match: "nope/*" },
{ type: "event" as const, match: "pull_request" },
],
} as FilterNode,
};
expect(matchRoute(route, event())).toBe(true);
});
test("disabled route never matches", () => {
expect(matchRoute({ ...baseRoute, enabled: false }, event())).toBe(false);
});
});

116
tests/schema.test.ts Normal file
View file

@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import * as v from "valibot";
import type { Group, Route } from "../server/lib/types";
import {
CONFIG_SCHEMA_VERSION,
filterSchema,
routeSchema,
groupSchema,
migrateRoutes,
migrateGroups,
validateRoutes,
validateGroups,
explainRoute,
} from "../server/lib/config/schema";
const validRoute: Route = {
id: "r1",
name: "build",
enabled: true,
filters: [{ type: "event", match: "push" }],
targets: [{ platform: "discord", channelId: "c1" }],
};
describe("config schema", () => {
test("version is defined", () => {
expect(CONFIG_SCHEMA_VERSION).toBe(1);
});
test("filterSchema accepts valid filter", () => {
expect(v.safeParse(filterSchema, { type: "repo", match: "a/*" }).success).toBe(true);
});
test("filterSchema rejects bad type", () => {
expect(v.safeParse(filterSchema, { type: "wat", match: "a" }).success).toBe(false);
});
test("routeSchema accepts valid route", () => {
expect(v.safeParse(routeSchema, validRoute).success).toBe(true);
});
test("routeSchema accepts ast filter node", () => {
const route = {
...validRoute,
ast: { any: [{ type: "event" as const, match: "push" }] },
};
expect(v.safeParse(routeSchema, route).success).toBe(true);
});
test("routeSchema rejects missing enabled", () => {
const { enabled: _e, ...rest } = validRoute;
expect(v.safeParse(routeSchema, rest).success).toBe(false);
});
test("groupSchema accepts valid group", () => {
const group: Group = { id: "g1", name: "team", adminIds: ["a"] };
expect(v.safeParse(groupSchema, group).success).toBe(true);
});
test("groupSchema rejects bad role", () => {
const group: Group = {
id: "g1",
name: "team",
adminIds: [],
members: [{ login: "a", role: "super" as never }],
};
expect(v.safeParse(groupSchema, group).success).toBe(false);
});
});
describe("migrations", () => {
test("migrateRoutes converts legacy target to targets", () => {
const legacy = { id: "r1", name: "x", enabled: true, filters: [], target: { channelId: "c1" } };
const out = migrateRoutes([legacy as unknown as Route]);
expect(out[0].targets).toEqual([{ channelId: "c1" }]);
});
test("migrateRoutes leaves targets array untouched", () => {
const out = migrateRoutes([validRoute]);
expect(out[0]).toEqual(validRoute);
});
test("migrateGroups is a no-op", () => {
const groups: Group[] = [{ id: "g1", name: "team", adminIds: ["a"] }];
expect(migrateGroups(groups)).toEqual(groups);
});
});
describe("validation is non-destructive", () => {
test("validateRoutes returns all entries even when invalid", () => {
const routes = [validRoute, { id: "bad", name: "x", filters: [] } as unknown as Route];
const out = validateRoutes(routes);
expect(out).toHaveLength(2);
});
test("validateGroups returns all entries even when invalid", () => {
const groups = [
{ id: "g1", name: "team", adminIds: ["a"] },
{ id: "bad", name: "x" } as unknown as Group,
];
expect(validateGroups(groups)).toHaveLength(2);
});
});
describe("explainRoute", () => {
test("explains filters", () => {
expect(explainRoute(validRoute)).toBe('build: (event is "push")');
});
test("explains ast", () => {
const route = {
...validRoute,
ast: { any: [{ type: "event" as const, match: "push" }] },
};
expect(explainRoute(route)).toBe('build: (event is "push")');
});
});