mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(config): valibot schemas, schema version, migrations, filter AST
This commit is contained in:
parent
eaec039ad4
commit
2470bd786d
13 changed files with 617 additions and 191 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
106
server/lib/config/schema.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
169
server/lib/events/filter-ast.ts
Normal file
169
server/lib/events/filter-ast.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
170
tests/filter-ast.test.ts
Normal 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
116
tests/schema.test.ts
Normal 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")');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue