mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat(filters): unified glob (*/?) and //-regex matching for all filter types
This commit is contained in:
parent
ebae2dc94f
commit
cc3ccadc24
10 changed files with 1395 additions and 1191 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
||||||
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON)
|
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 身份组提醒",
|
||||||
|
|
|
||||||
|
|
@ -296,21 +296,22 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -296,21 +296,22 @@ 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 事件
|
||||||
|
|
||||||
### 匹配值
|
### 匹配值
|
||||||
|
|
|
||||||
|
|
@ -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 标题和仓库名。请尽量写得更具体。
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue