feat(filters): unified glob (*/?) and //-regex matching for all filter types

This commit is contained in:
RhenCloud 2026-08-13 18:44:13 +08:00
parent ebae2dc94f
commit cc3ccadc24
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
10 changed files with 1395 additions and 1191 deletions

View file

@ -51,7 +51,7 @@ 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, keyword regex filtering │ └── match.ts # matchRoute, eventOwners, extractBranch; unified pattern syntax (*/? globs + //-wrapped regex)
├── 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
@ -116,7 +116,7 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
- Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url``compare`, `pull_request_comment``pull_request_review_comment`, ...) - Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url``compare`, `pull_request_comment``pull_request_review_comment`, ...)
- 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 (regex supported) - Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive)
- 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

@ -9,7 +9,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`) - **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`)
- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it - **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it
- HMAC-SHA256 signature verification (Web Crypto API) - HMAC-SHA256 signature verification (Web Crypto API)
- Filter by event type, repo, actor, action, branch, keyword (supports regex) - Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`)
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML - Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes) - Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms - `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
@ -136,9 +136,9 @@ See `config.example.yaml` for full syntax examples.
| `actor` | Sender login | | | `actor` | Sender login | |
| `action` | `opened`, `closed`, `published`, etc. | | | `action` | `opened`, `closed`, `published`, etc. | |
| `branch` | Branch name | Works for push, PR/review, create/delete, workflow_run, workflow_job, check_suite, deployment, code_scanning_alert | | `branch` | Branch name | Works for push, PR/review, create/delete, workflow_run, workflow_job, check_suite, deployment, code_scanning_alert |
| `keyword` | Text in payload body | Supports regex patterns; falls back to substring match | | `keyword` | Text in payload body | All filters support `*`/`?` globs and `/regex/` patterns (case-insensitive) |
Set `exclude: true` to invert any filter. Set `exclude: true` to invert any filter. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax.
## API ## API

View file

@ -9,7 +9,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secretGitea、classic GitHub webhook以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON - **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secretGitea、classic GitHub webhook以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON
- **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID只有该组织/用户的事件才能进入该分组 - **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID只有该组织/用户的事件才能进入该分组
- HMAC-SHA256 签名验证Web Crypto API - HMAC-SHA256 签名验证Web Crypto API
- 按事件类型、仓库、操作人、操作、分支、关键词(支持正则)过滤 - 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML - 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标) - 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持 - `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
@ -136,9 +136,9 @@ npx wrangler dev # 启动本地开发服务器
| `actor` | 发送者登录名 | | | `actor` | 发送者登录名 | |
| `action` | `opened``closed``published` 等 | | | `action` | `opened``closed``published` 等 | |
| `branch` | 分支名 | 支持 push、PR/review、create/delete、workflow_run、workflow_job、check_suite、deployment、code_scanning_alert | | `branch` | 分支名 | 支持 push、PR/review、create/delete、workflow_run、workflow_job、check_suite、deployment、code_scanning_alert |
| `keyword` | payload 中的文本 | 支持正则表达式;无效正则回退为子串匹配 | | `keyword` | payload 中的文本 | 所有过滤器均支持 `*`/`?` 通配符与 `/正则/`(不区分大小写) |
设置 `exclude: true` 可取反过滤器。 设置 `exclude: true` 可取反过滤器。模式语法见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。
## API ## API

View file

@ -128,7 +128,7 @@ const en: Dict = {
"routeEditor.stopHint": "When matched, no further routes are evaluated for this event", "routeEditor.stopHint": "When matched, no further routes are evaluated for this event",
"routeEditor.filters": "Filters", "routeEditor.filters": "Filters",
"routeEditor.filtersNote": "(all must match · AND)", "routeEditor.filtersNote": "(all must match · AND)",
"routeEditor.matchPlaceholder": "match value", "routeEditor.matchPlaceholder": "match value (* ? wildcards, /regex/)",
"routeEditor.not": "NOT", "routeEditor.not": "NOT",
"routeEditor.addFilter": "+ Add filter", "routeEditor.addFilter": "+ Add filter",
"routeEditor.discordRoles": "Discord role mentions", "routeEditor.discordRoles": "Discord role mentions",
@ -380,7 +380,7 @@ const zh: Dict = {
"routeEditor.stopHint": "该路由匹配后,不再评估后续路由", "routeEditor.stopHint": "该路由匹配后,不再评估后续路由",
"routeEditor.filters": "过滤器", "routeEditor.filters": "过滤器",
"routeEditor.filtersNote": "(全部匹配 · AND", "routeEditor.filtersNote": "(全部匹配 · AND",
"routeEditor.matchPlaceholder": "匹配值", "routeEditor.matchPlaceholder": "匹配值(支持 * ? 通配符、/正则/",
"routeEditor.not": "取反", "routeEditor.not": "取反",
"routeEditor.addFilter": "+ 添加过滤器", "routeEditor.addFilter": "+ 添加过滤器",
"routeEditor.discordRoles": "Discord 身份组提醒", "routeEditor.discordRoles": "Discord 身份组提醒",

View file

@ -297,20 +297,21 @@ With `ALLOW_SELF_SIGNUP=1`, a GitHub user who has no group access gets a persona
See the [Filter Tutorial](./filters) for a hands-on guide with worked examples. See the [Filter Tutorial](./filters) for a hands-on guide with worked examples.
| Type | Matches | Example | | Type | Matches | Example |
| --------- | -------------------- | -------------------------------- | | --------- | -------------------- | ---------------------------------- |
| `event` | GitHub event name | `push`, `pull_request`, `issues` | | `event` | GitHub event name | `push`, `pull_*`, `pull_request` |
| `repo` | Repository full name | `org/repo` | | `repo` | Repository full name | `org/repo`, `org/*` |
| `actor` | Sender login | `username`, `[bot]` | | `actor` | Sender login | `username`, `[bot]`, `*[bot]` |
| `action` | Event action | `opened`, `closed`, `published` | | `action` | Event action | `opened`, `closed`, `published` |
| `branch` | Branch name | `main`, `develop` | | `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` (regex) | | `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
### Filter Behavior ### Filter Behavior
- All filters in a route must match for the route to trigger (AND logic) - All filters in a route must match for the route to trigger (AND logic)
- Set `"exclude": true` on any filter to invert it (NOT logic) - Set `"exclude": true` on any filter to invert it (NOT logic)
- Non-keyword filters are **exact, case-insensitive matches** — no wildcards (`repo: "org/*"` does not match anything) - Every filter type supports the same pattern forms: plain text, `*`/`?` **globs** (`*` = any run, `?` = one character), and `/regular expression/` — all case-insensitive
- `keyword` filter supports regex patterns — falls back to substring match if regex is invalid or longer than 200 characters - Field filters (`event`/`repo`/`actor`/`action`/`branch`) glob-match the whole value; `keyword` globs and regexes search anywhere in the payload; plain `keyword` text is a substring search
- Patterns longer than 200 characters are not compiled as glob/regex; an invalid `//`-wrapped regex matches nothing
- `branch` filter works for push, pull_request, pull_request_review, pull_request_review_comment, create/delete, workflow_run, workflow_job, check_suite, deployment, and code_scanning_alert events - `branch` filter works for push, pull_request, pull_request_review, pull_request_review_comment, create/delete, workflow_run, workflow_job, check_suite, deployment, and code_scanning_alert events
### Match Values ### Match Values

View file

@ -8,9 +8,9 @@ See [Filter Types](./configuration#filter-types) in the configuration guide for
- All filters in a route must match, otherwise the route is skipped. - All filters in a route must match, otherwise the route is skipped.
- Each filter matches the event against one field of the webhook payload. - Each filter matches the event against one field of the webhook payload.
- Matching is **case-insensitive** and **exact**: `main` matches `main`, `Main`, and `MAIN`, but not `main-v2`. - Matching is **case-insensitive** for every filter type.
- The `match` value accepts either a single string or an array of strings. An array behaves as OR — the filter matches if any of its values match. - The `match` value accepts either a single string or an array of strings. An array behaves as OR — the filter matches if any of its values match.
- Setting `"exclude": true` inverts the result (NOT logic): the filter matches when the value does **not** equal the match value. - Setting `"exclude": true` inverts the result (NOT logic): the filter matches when the value does **not** match.
```json ```json
{ {
@ -22,6 +22,46 @@ See [Filter Types](./configuration#filter-types) in the configuration guide for
The route above matches both `push` and `pull_request` events. The route above matches both `push` and `pull_request` events.
## Pattern Syntax
Every filter type shares the same three pattern forms:
| Pattern | Meaning |
| ---------------------- | -------------------------------------------------------------- |
| `plain text` | Field filters: **exact** match. `keyword`: search anywhere. |
| `*` / `?` | **Glob wildcards**`*` any run, `?` one char. |
| `/regular expression/` | Compiled as a **regular expression** (case-insensitive flag). |
- On field filters (`event`/`repo`/`actor`/`action`/`branch`), plain text and globs match the whole value; on `keyword` they search anywhere in the payload.
- Regexes always search: `/^feat/` matches values *starting* with `feat`, `/feat/` matches anywhere.
Examples:
```json
{ "type": "event", "match": "pull_*" }
```
Matches `pull_request`, `pull_request_review`, `pull_request_review_comment`, ...
```json
{ "type": "repo", "match": "myorg/*" }
```
```json
{ "type": "branch", "match": "feature-?" }
```
Matches `feature-x`, `feature-1`, but not `feature-xy`.
```json
{ "type": "branch", "match": "/^feat/" }
```
Matches any branch whose name starts with `feat`.
> [!TIP]
> Globs and regular expressions are case-insensitive too, and `*` matches across `/` in repo names (`myorg/*` also matches `myorg/sub/backend`).
## Filter Types in Depth ## Filter Types in Depth
### `event` — Event type ### `event` — Event type
@ -113,22 +153,20 @@ Fires for pushes to `main` only. To watch several long-lived branches:
``` ```
> [!NOTE] > [!NOTE]
> `branch` matching is **exact and case-insensitive**, not a glob or prefix match. A value like `feature/*` will **not** work. For prefix or wildcard-style matching, use the `keyword` filter on the payload (see below). > `branch` matching is case-insensitive. Use globs (`feature/*`) or a `//`-wrapped regex (`/^release-/`) for prefix or wildcard-style matching.
### `keyword` — Text in the payload ### `keyword` — Text in the payload
Matches against the **full JSON payload**, lowercased. It supports regular expressions, so it is the most flexible filter. The pattern is compiled with the `i` (case-insensitive) flag. Matches against the **full JSON payload**, lowercased. It is the most flexible filter: plain text searches anywhere, `*`/`?` globs search with wildcards, and `//`-wrapped patterns are compiled as regular expressions (with the `i` flag).
```json ```json
{ "type": "keyword", "match": "/deploy/started/i" } { "type": "keyword", "match": "deploy" }
``` ```
Fires when the payload contains `deploy/started` anywhere. Because the payload is lowercased, the `i` flag is optional but harmless. Fires when the payload contains `deploy` anywhere. Because the payload is lowercased, this matches `Deploy`, `DEPLOY`, etc.
A few practical examples:
```json ```json
{ "type": "keyword", "match": "/dependabot/" } { "type": "keyword", "match": "*release-*" }
``` ```
```json ```json
@ -141,9 +179,9 @@ A few practical examples:
Behavior details: Behavior details:
- Patterns longer than 200 characters are **not** compiled as regex and fall back to plain substring matching. - Patterns longer than 200 characters are **not** compiled as glob/regex and fall back to plain matching.
- If a pattern is not a valid regex, it also falls back to substring matching instead of erroring. - A `//`-wrapped pattern that is not a valid regex matches **nothing** (the filter stays false) rather than erroring.
- To search for text that is a regex special character (e.g. `v1.2.3`), you can rely on the substring fallback and omit the regex syntax — a pattern without regex metacharacters behaves the same either way. - To search for text that is a glob or regex special character (e.g. `v1.2.3`), rely on the plain-text form — a pattern without `*`, `?`, or `//` wrapping matches literally.
- The search covers the **entire** payload: commit messages, PR titles and bodies, labels, refs, even repository and sender names. - The search covers the **entire** payload: commit messages, PR titles and bodies, labels, refs, even repository and sender names.
### Combining `exclude` with `keyword` ### Combining `exclude` with `keyword`
@ -217,8 +255,9 @@ Forward workflow runs that ended in failure on any branch, to a `#ci` channel:
## Common Pitfalls ## Common Pitfalls
- **No wildcards on non-keyword filters.** `event`, `repo`, `actor`, `action`, and `branch` are exact matches. `repo: "myorg/*"` will not match anything. - **Wildcards are globs, not regex.** `repo: "myorg/*"` matches any repo under `myorg` (and `myorg/sub/backend`), but `repo: "myorg/.*"` matches literally. Use `//` wrapping for regex: `"/myorg\/.*/"`.
- **A `//`-wrapped invalid regex never matches.** Unlike plain text, an unwrapped invalid pattern is matched literally — wrap patterns only when they are real regular expressions.
- **An `action` filter on an action-less event never matches.** Check the event has an `action` field first (see [Filter Compatibility](../events/supported#filter-compatibility)). - **An `action` filter on an action-less event never matches.** Check the event has an `action` field first (see [Filter Compatibility](../events/supported#filter-compatibility)).
- **`branch` on an event without a branch never matches.** A `branch` filter on an `issues` event will always be false. Use `keyword` if you need branch-like matching there. - **`branch` on an event without a branch never matches.** A `branch` filter on an `issues` event will always be false. Use `keyword` if you need branch-like matching there.
- **`keyword` searches everything.** Because it scans the whole payload, a pattern like `"fix"` can match commit messages, issue titles, _and_ repository names. Be as specific as possible. - **`keyword` searches everything.** Because it scans the whole payload, a pattern like `"fix"` can match commit messages, issue titles, *and* repository names. Be as specific as possible.
- **Forgetting `exclude` semantics.** `exclude: true` negates the whole filter — one non-matching value in an array does not "block" the route; the negated filter matches only when _none_ of the values match. - **Forgetting `exclude` semantics.** `exclude: true` negates the whole filter — one non-matching value in an array does not "block" the route; the negated filter matches only when *none* of the values match.

View file

@ -297,20 +297,21 @@ owner及超级管理员可在分组的「成员」面板创建一次性邀
实操指南见[过滤器教程](./filters),包含完整示例。 实操指南见[过滤器教程](./filters),包含完整示例。
| 类型 | 匹配对象 | 示例 | | 类型 | 匹配对象 | 示例 |
| --------- | ---------------- | -------------------------------- | | --------- | ---------------- | ----------------------------------------- |
| `event` | GitHub 事件名称 | `push`, `pull_request`, `issues` | | `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` |
| `repo` | 仓库全名 | `org/repo` | | `repo` | 仓库全名 | `org/repo`, `org/*` |
| `actor` | 发送者登录名 | `username`, `[bot]` | | `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
| `action` | 事件操作 | `opened`, `closed`, `published` | | `action` | 事件操作 | `opened`, `closed`, `published` |
| `branch` | 分支名称 | `main`, `develop` | | `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` (正则) | | `keyword` | 载荷正文中的文本 | `deploy`, `*release-*`, `/fix\s+\d+/` |
### 过滤器行为 ### 过滤器行为
- 路由中的所有过滤器必须都匹配才触发路由AND 逻辑) - 路由中的所有过滤器必须都匹配才触发路由AND 逻辑)
- 在任何过滤器上设置 `"exclude": true` 可反转匹配逻辑NOT 逻辑) - 在任何过滤器上设置 `"exclude": true` 可反转匹配逻辑NOT 逻辑)
- 非 keyword 过滤器为**精确、不区分大小写**的匹配——不支持通配符(`repo: "org/*"` 不会匹配任何内容) - 所有过滤器类型支持相同的模式形式:纯文本、`*`/`?` **通配符**`*` 任意长度、`?` 单字符)以及 `/正则表达式/`——均不区分大小写
- `keyword` 过滤器支持正则表达式——正则有误或超过 200 个字符时回退到子串匹配 - 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的通配符匹配整个值;`keyword` 的通配符和正则搜索载荷任意位置;`keyword` 的纯文本为子串搜索
- 超过 200 个字符的模式不编译为通配符/正则;`//` 包裹的非法正则匹配不到任何内容
- `branch` 过滤器适用于 push、pull_request、pull_request_review、pull_request_review_comment、create/delete、workflow_run、workflow_job、check_suite、deployment 和 code_scanning_alert 事件 - `branch` 过滤器适用于 push、pull_request、pull_request_review、pull_request_review_comment、create/delete、workflow_run、workflow_job、check_suite、deployment 和 code_scanning_alert 事件
### 匹配值 ### 匹配值

View file

@ -8,9 +8,9 @@
- 路由中所有过滤器都必须匹配,否则该路由被跳过。 - 路由中所有过滤器都必须匹配,否则该路由被跳过。
- 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。 - 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。
- 匹配**不区分大小写且为精确匹配**`main` 能匹配 `main``Main``MAIN`,但不能匹配 `main-v2` - 所有过滤器类型都**不区分大小写**
- `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配该过滤器即匹配。 - `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配该过滤器即匹配。
- 设置 `"exclude": true` 会反转结果NOT 逻辑):当值**不**等于 match 值时,该过滤器才匹配。 - 设置 `"exclude": true` 会反转结果NOT 逻辑):当值**不**匹配时,该过滤器才匹配。
```json ```json
{ {
@ -22,6 +22,46 @@
上面这条路由同时匹配 `push``pull_request` 事件。 上面这条路由同时匹配 `push``pull_request` 事件。
## 模式语法
所有过滤器类型共享以下三种模式写法:
| 模式 | 含义 |
| -------------- | ----------------------------------------------------------------- |
| `纯文本` | 字段过滤器:**完全相等**匹配;`keyword`:在载荷中任意位置搜索。 |
| `*` / `?` | **通配符glob**——`*` 任意长度、`?` 恰好一个字符。 |
| `/正则表达式/` | 按**正则表达式**编译(忽略大小写标志)。 |
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的纯文本与通配符匹配整个值;`keyword` 则在载荷中任意位置搜索。
- 正则表达式始终是搜索语义:`/^feat/` 匹配以 `feat` **开头**的值,`/feat/` 匹配任意位置出现 `feat` 的值。
示例:
```json
{ "type": "event", "match": "pull_*" }
```
匹配 `pull_request``pull_request_review``pull_request_review_comment` 等。
```json
{ "type": "repo", "match": "myorg/*" }
```
```json
{ "type": "branch", "match": "feature-?" }
```
匹配 `feature-x``feature-1`,但不匹配 `feature-xy`
```json
{ "type": "branch", "match": "/^feat/" }
```
匹配任何以 `feat` 开头的分支名。
> [!TIP]
> 通配符和正则同样不区分大小写,且 `*` 可以跨过仓库名中的 `/``myorg/*` 也能匹配 `myorg/sub/backend`)。
## 各过滤器类型详解 ## 各过滤器类型详解
### `event` — 事件类型 ### `event` — 事件类型
@ -113,22 +153,20 @@
``` ```
> [!NOTE] > [!NOTE]
> `branch` 匹配是**精确且不区分大小写**的,不是通配符或前缀匹配。`feature/*` 这样的值**不会**生效。需要前缀或通配符式匹配时,请改用 `keyword` 过滤器匹配载荷(见下)。 > `branch` 匹配不区分大小写。需要前缀或通配符式匹配时,可直接使用通配符(`feature/*`)或用 `/` 包裹正则(`/^release-/`)。
### `keyword` — 载荷中的文本 ### `keyword` — 载荷中的文本
匹配整个 JSON 载荷(转为小写)。它支持正则表达式,因此是最灵活的过滤器。模式以 `i`(忽略大小写)标志编译 匹配整个 JSON 载荷(转为小写)。它是最灵活的过滤器:纯文本在任意位置搜索,`*`/`?` 通配符带通配搜索,`/` 包裹的模式按正则表达式编译(带 `i` 标志)
```json ```json
{ "type": "keyword", "match": "/deploy/started/i" } { "type": "keyword", "match": "deploy" }
``` ```
当载荷中任意位置包含 `deploy/started` 时触发。由于载荷已被转为小写,`i` 标志可有可无但无副作用。 当载荷中任意位置包含 `deploy` 时触发。由于载荷已被转为小写,`Deploy``DEPLOY` 等都会匹配。
一些实用示例:
```json ```json
{ "type": "keyword", "match": "/dependabot/" } { "type": "keyword", "match": "*release-*" }
``` ```
```json ```json
@ -141,9 +179,9 @@
行为细节: 行为细节:
- 超过 200 个字符的模式**不**编译为正则,回退为纯子串匹配。 - 超过 200 个字符的模式**不**编译为通配符/正则,回退为纯文本匹配。
- 如果某模式不是合法正则,也会回退为子串匹配,而不是报错。 - `/` 包裹但**不是合法正则**的模式匹配**任何内容都不命中**(过滤器恒为 false而不会报错。
- 要搜索是正则特殊字符的文本(如 `v1.2.3`可以省略正则语法直接依赖子串回退——不含正则元字符的模式两种方式行为相同 - 要搜索是通配符或正则特殊字符的文本(如 `v1.2.3`使用纯文本形式即可——不含 `*``?` 且未被 `//` 包裹的模式按字面匹配
- 搜索覆盖**整个**载荷提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。 - 搜索覆盖**整个**载荷提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。
### `keyword``exclude` 组合 ### `keyword``exclude` 组合
@ -217,7 +255,8 @@
## 常见陷阱 ## 常见陷阱
- **非 keyword 过滤器不支持通配符。** `event``repo``actor``action``branch` 都是精确匹配。`repo: "myorg/*"` 不会匹配任何内容。 - **通配符是 glob不是正则。** `repo: "myorg/*"` 匹配 `myorg` 下的任意仓库(含 `myorg/sub/backend`),但 `repo: "myorg/.*"` 按字面匹配。需要正则请用 `/` 包裹:`"/myorg\/.*/"`
- **被 `/` 包裹的非法正则永远不匹配。** 与纯文本不同——未包裹的非法模式按字面匹配。只有确定是真正的正则时才使用 `//` 包裹。
- **`action` 过滤器遇到无 action 的事件永远不匹配。** 先确认该事件带有 `action` 字段(见[过滤器兼容性](../events/supported#过滤器兼容性))。 - **`action` 过滤器遇到无 action 的事件永远不匹配。** 先确认该事件带有 `action` 字段(见[过滤器兼容性](../events/supported#过滤器兼容性))。
- **`branch` 过滤器遇到无分支的事件永远不匹配。** 在 `issues` 事件上使用 `branch` 过滤器恒为假。此时需要类似分支的匹配可用 `keyword` - **`branch` 过滤器遇到无分支的事件永远不匹配。** 在 `issues` 事件上使用 `branch` 过滤器恒为假。此时需要类似分支的匹配可用 `keyword`
- **`keyword` 会搜索一切。** 因为它扫描整个载荷,`"fix"` 这样的模式可能同时匹配提交信息、issue 标题和仓库名。请尽量写得更具体。 - **`keyword` 会搜索一切。** 因为它扫描整个载荷,`"fix"` 这样的模式可能同时匹配提交信息、issue 标题和仓库名。请尽量写得更具体。

View file

@ -4,7 +4,12 @@ const regexCache = new Map<string, RegExp>();
const keywordBodyCache = new WeakMap<WebhookEvent, string>(); const keywordBodyCache = new WeakMap<WebhookEvent, string>();
const MAX_PATTERN_LENGTH = 200; const MAX_PATTERN_LENGTH = 200;
function compileKeywordRegex(pattern: string): RegExp | null { /** 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; if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cached = regexCache.get(pattern); const cached = regexCache.get(pattern);
if (cached) return cached; if (cached) return cached;
@ -17,6 +22,65 @@ function compileKeywordRegex(pattern: string): RegExp | 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 { function getKeywordBody(event: WebhookEvent): string {
const cached = keywordBodyCache.get(event); const cached = keywordBodyCache.get(event);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
@ -90,11 +154,7 @@ function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string):
case "keyword": { case "keyword": {
const body = keywordBody ?? getKeywordBody(event); const body = keywordBody ?? getKeywordBody(event);
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match]; const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => { const matches = patterns.some((p) => matchKeyword(p, body));
const re = compileKeywordRegex(p);
if (!re) return body.includes(p.toLowerCase());
return re.test(body);
});
return filter.exclude ? !matches : matches; return filter.exclude ? !matches : matches;
} }
default: default:
@ -104,7 +164,7 @@ function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string):
if (!value) return false; if (!value) return false;
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match]; const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => value!.toLowerCase() === p.toLowerCase()); const matches = patterns.some((p) => matchField(p, value));
return filter.exclude ? !matches : matches; return filter.exclude ? !matches : matches;
} }

View file

@ -123,10 +123,10 @@ describe("matchRoute", () => {
).toBe(false); ).toBe(false);
}); });
it("matches keyword filter with regex", () => { it("matches keyword filter with regex (//-wrapped)", () => {
const route = { const route = {
...baseRoute, ...baseRoute,
filters: [{ type: "keyword" as const, match: "fix(es|ed)\\s+bug" }], filters: [{ type: "keyword" as const, match: "/fix(es|ed)\\s+bug/" }],
}; };
const event: WebhookEvent = { const event: WebhookEvent = {
event: "push", event: "push",
@ -147,6 +147,70 @@ describe("matchRoute", () => {
expect(matchRoute(route, event)).toBe(true); expect(matchRoute(route, event)).toBe(true);
}); });
it("matches keyword filter with glob search", () => {
const route = { ...baseRoute, filters: [{ type: "keyword" as const, match: "*deploy*" }] };
const event: WebhookEvent = {
event: "push",
payload: { commits: [{ message: "auto deploy pipeline" }] },
};
expect(matchRoute(route, event)).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { commits: [{ message: "build only" }] } }),
).toBe(false);
});
it("matches event filter with glob", () => {
const route = { ...baseRoute, filters: [{ type: "event" as const, match: "pull_*" }] };
expect(matchRoute(route, { event: "pull_request", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "pull_request_review", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "push", payload: {} })).toBe(false);
});
it("matches repo filter with glob across slashes", () => {
const route = { ...baseRoute, filters: [{ type: "repo" as const, match: "myorg/*" }] };
expect(
matchRoute(route, {
event: "push",
payload: { repository: { full_name: "myorg/backend" } },
}),
).toBe(true);
expect(
matchRoute(route, {
event: "push",
payload: { repository: { full_name: "other/backend" } },
}),
).toBe(false);
});
it("matches branch filter with single-char ? wildcard", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-?" }] };
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } }),
).toBe(false);
});
it("matches branch filter with //-wrapped regex", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "/^feat/" }] };
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } }),
).toBe(false);
});
it("treats glob special chars literally when not wrapped", () => {
const route = {
...baseRoute,
filters: [{ type: "event" as const, match: "release-" }],
};
expect(matchRoute(route, { event: "release-x", payload: {} })).toBe(false);
expect(matchRoute(route, { event: "release-", payload: {} })).toBe(true);
});
it("matches multiple filters (AND logic)", () => { it("matches multiple filters (AND logic)", () => {
const route = { const route = {
...baseRoute, ...baseRoute,