diff --git a/AGENTS.md b/AGENTS.md index 5c35156..6a4930a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ server/ # Nitro server └── lib/ ├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage ├── 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 ├── http.ts # shared HTTP helpers ├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, enqueue (or inline dispatch) @@ -57,7 +59,8 @@ server/ # Nitro server ├── core/ │ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log) ├── 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) │ ├── types.ts # Provider interface (matches/verify/parse) │ ├── 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 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) +- 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` - 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 diff --git a/README.md b/README.md index abebc0d..bbc1a54 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `a ### 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 diff --git a/README.zh.md b/README.zh.md index 243da9f..f6120db 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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 diff --git a/bun.lock b/bun.lock index 444307b..d450255 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "h3": "^1.15.3", "nuxt": "^4.1.0", "octokit": "^4.1.0", + "valibot": "^1.4.2", "vue": "^3.5.13", }, "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=="], + "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=="], "verkit": ["verkit@0.3.2", "https://registry.npmmirror.com/verkit/-/verkit-0.3.2.tgz", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], diff --git a/package.json b/package.json index 85d1cd9..9e3265b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "h3": "^1.15.3", "nuxt": "^4.1.0", "octokit": "^4.1.0", + "valibot": "^1.4.2", "vue": "^3.5.13" }, "overrides": { diff --git a/server/lib/config.ts b/server/lib/config.ts index 1743e8c..197d52f 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -1,5 +1,6 @@ import type { Env, Config, Route } from "./types"; import { log } from "./lib/log"; +import { migrateRoutes, validateRoutes } from "./config/schema"; const CONFIG_CACHE_TTL = 60_000; const ROUTES_KEY = "config:routes"; @@ -8,26 +9,13 @@ let configCache: { config: Config; expiresAt: number } | null = null; export async function loadRoutes(kv: KVNamespace): Promise { try { const stored = await kv.get(ROUTES_KEY, "json"); - if (stored) return normalizeRoutes(stored); + if (stored) return validateRoutes(migrateRoutes(stored)); } catch (err) { log.warn({ err }, "Failed to load routes from KV"); } 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 { await kv.put(ROUTES_KEY, JSON.stringify(routes)); configCache = null; diff --git a/server/lib/config/schema.ts b/server/lib/config/schema.ts new file mode 100644 index 0000000..92776d7 --- /dev/null +++ b/server/lib/config/schema.ts @@ -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)}`; +} diff --git a/server/lib/events/filter-ast.ts b/server/lib/events/filter-ast.ts new file mode 100644 index 0000000..e46ec70 --- /dev/null +++ b/server/lib/events/filter-ast.ts @@ -0,0 +1,169 @@ +import type { WebhookEvent, Filter, FilterNode } from "../types"; + +const regexCache = new Map(); +const keywordBodyCache = new WeakMap(); +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); +} diff --git a/server/lib/events/match.ts b/server/lib/events/match.ts index 4c5bbdb..6cc422e 100644 --- a/server/lib/events/match.ts +++ b/server/lib/events/match.ts @@ -1,173 +1,5 @@ -import type { WebhookEvent, Route, Filter } from "../types"; - -const regexCache = new Map(); -const keywordBodyCache = new WeakMap(); -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(); @@ -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); } diff --git a/server/lib/types.ts b/server/lib/types.ts index ea1c644..05ea55d 100644 --- a/server/lib/types.ts +++ b/server/lib/types.ts @@ -60,6 +60,12 @@ export interface Route { filters: Filter[]; targets: RouteTarget[]; 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 * event. Multiple fallback routes may exist; they are all skipped whenever at @@ -154,6 +160,35 @@ export interface Filter { 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 interface WebhookEvent { diff --git a/server/lib/web/groups.ts b/server/lib/web/groups.ts index 80c2068..dc185de 100644 --- a/server/lib/web/groups.ts +++ b/server/lib/web/groups.ts @@ -1,6 +1,7 @@ import type { Env, Group, GroupMember, GroupRole } from "../types"; import { isAdminUser } from "./session"; import { log } from "../lib/log"; +import { migrateGroups, validateGroups } from "../config/schema"; const GROUPS_KEY = "config:groups"; @@ -39,7 +40,7 @@ export function normalizeGroupMembers(group: Group): GroupMember[] { export async function loadGroups(kv: KVNamespace): Promise { try { const stored = await kv.get(GROUPS_KEY, "json"); - if (Array.isArray(stored)) return stored; + if (Array.isArray(stored)) return validateGroups(migrateGroups(stored)); } catch (err) { log.warn({ err }, "Failed to load groups from KV"); } diff --git a/tests/filter-ast.test.ts b/tests/filter-ast.test.ts new file mode 100644 index 0000000..487434f --- /dev/null +++ b/tests/filter-ast.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..0027fcc --- /dev/null +++ b/tests/schema.test.ts @@ -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")'); + }); +});