merge: resolve conflicts with origin/main (auto-fix formatting)

This commit is contained in:
RhenCloud 2026-08-14 06:45:15 +08:00
commit 6f1a334150
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
22 changed files with 921 additions and 910 deletions

366
README.md
View file

@ -1,187 +1,187 @@
# WebHooker # WebHooker
GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `server/lib/providers/` (GitHub + Gitea today; GitLab etc. can be added later). GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `server/lib/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
## Features ## Features
- **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback, + `custom` webhooks) - **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback, + `custom` webhooks)
- **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers - **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers
- **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 `*`/`?` globs and `/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
- **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target) - **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react) - GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs - **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals - **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
- **Telegram `/gh` commands** (login/logout/comment/merge/close) via the Telegram webhook, with avatar link-preview cards - **Telegram `/gh` commands** (login/logout/comment/merge/close) via the Telegram webhook, with avatar link-preview cards
- Cloudflare KV for token/state/config/session storage + D1 for send logs and platform account links - Cloudflare KV for token/state/config/session storage + D1 for send logs and platform account links
- Graceful degradation (webhook-only mode if Discord unavailable) - Graceful degradation (webhook-only mode if Discord unavailable)
## Architecture ## Architecture
```text ```text
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro) GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API) ├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal ├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
├── POST /telegram/webhook → verify (secret token) → handle /gh commands ├── POST /telegram/webhook → verify (secret token) → handle /gh commands
├── GET /auth/github → OAuth flow ├── GET /auth/github → OAuth flow
├── GET /api/richheader → Telegram avatar link-preview card ├── GET /api/richheader → Telegram avatar link-preview card
├── POST /api/* → user actions (Bearer token auth) ├── POST /api/* → user actions (Bearer token auth)
├── /admin → routes, groups & send logs Web UI ├── /admin → routes, groups & send logs Web UI
└── GET /health → status check └── GET /health → status check
``` ```
- **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch - **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch
- **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API - **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API
- **KV** — token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`), group config (`config:groups`), admin sessions (`session:{id}`), delivery dedup (`delivery:{id}`), message-update tracking (`msg:*`) - **KV** — token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`), group config (`config:groups`), admin sessions (`session:{id}`), delivery dedup (`delivery:{id}`), message-update tracking (`msg:*`)
- **D1** — send logs (`send_logs`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`) - **D1** — send logs (`send_logs`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`)
## Quick Start ## Quick Start
```bash ```bash
bun install # package manager is bun (lockfile: bun.lock) bun install # package manager is bun (lockfile: bun.lock)
cp .env.example .dev.vars # Fill in secrets for local dev cp .env.example .dev.vars # Fill in secrets for local dev
bun run build # Production build first (wrangler dev serves the built worker) bun run build # Production build first (wrangler dev serves the built worker)
bunx wrangler dev # Start local dev server bunx wrangler dev # Start local dev server
``` ```
## Configuration ## Configuration
### Secrets (`.dev.vars` for local, Worker Secrets for production) ### Secrets (`.dev.vars` for local, Worker Secrets for production)
| Variable | Description | | Variable | Description |
|-----------------------------|------------------------------------------------------------------------------------------------| | --------------------------- | ---------------------------------------------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from GitHub | | `GITHUB_WEBHOOK_SECRET` | Webhook secret from GitHub |
| `GITEA_WEBHOOK_SECRET` | Webhook secret from Gitea (required only to receive Gitea webhooks) | | `GITEA_WEBHOOK_SECRET` | Webhook secret from Gitea (required only to receive Gitea webhooks) |
| `GITHUB_APP_ID` | GitHub App ID (used by the App install flow to resolve the installing account) | | `GITHUB_APP_ID` | GitHub App ID (used by the App install flow to resolve the installing account) |
| `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; used by the App install flow; optional) | | `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; used by the App install flow; optional) |
| `GITHUB_CLIENT_ID` | OAuth client ID | | `GITHUB_CLIENT_ID` | OAuth client ID |
| `GITHUB_CLIENT_SECRET` | OAuth client secret | | `GITHUB_CLIENT_SECRET` | OAuth client secret |
| `DISCORD_TOKEN` | Bot token | | `DISCORD_TOKEN` | Bot token |
| `DISCORD_PUBLIC_KEY` | Discord application public key (from the Developer Portal) — required for interactions | | `DISCORD_PUBLIC_KEY` | Discord application public key (from the Developer Portal) — required for interactions |
| `DISCORD_APPLICATION_ID` | Discord application id (optional; auto-resolved via `GET /oauth2/applications/@me` if omitted) | | `DISCORD_APPLICATION_ID` | Discord application id (optional; auto-resolved via `GET /oauth2/applications/@me` if omitted) |
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes | | `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
| `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification | | `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification |
| `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards | | `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards |
| `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync | | `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync |
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` |
| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) | | `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) |
| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup (default 90) | | `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup (default 90) |
| `NUXT_PUBLIC_DOCS_URL` | Optional docs site URL used by the landing page | | `NUXT_PUBLIC_DOCS_URL` | Optional docs site URL used by the landing page |
| `NUXT_PUBLIC_REPO_URL` | Optional GitHub repo URL used by the landing page | | `NUXT_PUBLIC_REPO_URL` | Optional GitHub repo URL used by the landing page |
| `NUXT_PUBLIC_LEGAL_CONTACT` | Optional contact shown on `/terms` and `/privacy` | | `NUXT_PUBLIC_LEGAL_CONTACT` | Optional contact shown on `/terms` and `/privacy` |
### Routes ### Routes
Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV. A route may carry multiple `targets`, so one rule can forward to several channels at once: Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV. A route may carry multiple `targets`, so one rule can forward to several channels at once:
```json ```json
[ [
{ {
"id": "all-push", "id": "all-push",
"name": "Push Events", "name": "Push Events",
"enabled": true, "enabled": true,
"groupId": "default", "groupId": "default",
"filters": [{ "type": "event", "match": "push" }], "filters": [{ "type": "event", "match": "push" }],
"stop": true, "stop": true,
"targets": [ "targets": [
{ "platform": "discord", "channelId": "CHANNEL_ID" }, { "platform": "discord", "channelId": "CHANNEL_ID" },
{ "platform": "telegram", "chatId": "-1001234567890" } { "platform": "telegram", "chatId": "-1001234567890" }
] ]
} }
] ]
``` ```
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema. `target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
### Web UI (`/admin`) ### Web UI (`/admin`)
The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), inspect send logs, manage group members and invite links, and read the audit log — no KV access needed: The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), inspect send logs, manage group members and invite links, and read the audit log — no KV access needed:
1. Set `ADMIN_USER_IDS` to the GitHub user IDs (or logins) allowed to manage the console, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. 1. Set `ADMIN_USER_IDS` to the GitHub user IDs (or logins) allowed to manage the console, e.g. `ADMIN_USER_IDS=12345,RhenCloud`.
2. Visit `/admin` and sign in with GitHub. Users with no access get `403` — unless `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or they follow a group invite link. 2. Visit `/admin` and sign in with GitHub. Users with no access get `403` — unless `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or they follow a group invite link.
3. Changes are written to KV immediately and picked up by the webhook pipeline. 3. Changes are written to KV immediately and picked up by the webhook pipeline.
Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `admin` / `viewer`); all admin operations are recorded in the D1 `audit_logs` table. Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `admin` / `viewer`); all admin operations are recorded in the D1 `audit_logs` table.
### 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. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference.
## API ## API
### Health ### Health
- `GET /health` — Returns `{"status": "ok"}` - `GET /health` — Returns `{"status": "ok"}`
### OAuth ### OAuth
- `GET /auth/github` — Start GitHub OAuth flow (redirects to GitHub) - `GET /auth/github` — Start GitHub OAuth flow (redirects to GitHub)
- `GET /auth/github/callback` — OAuth callback (exchanges code for token; admin session / Discord link / Telegram link) - `GET /auth/github/callback` — OAuth callback (exchanges code for token; admin session / Discord link / Telegram link)
- `DELETE /auth/token/:userId` — Revoke user token - `DELETE /auth/token/:userId` — Revoke user token
### Actions (require `Authorization: Bearer <token>` header) ### Actions (require `Authorization: Bearer <token>` header)
- `POST /api/comment` — Create issue comment - `POST /api/comment` — Create issue comment
- `POST /api/merge` — Merge pull request - `POST /api/merge` — Merge pull request
- `POST /api/close` — Close pull request - `POST /api/close` — Close pull request
- `POST /api/react` — Add reaction to issue - `POST /api/react` — Add reaction to issue
### Admin (require admin OAuth session) ### Admin (require admin OAuth session)
- `GET /admin` — Config console UI - `GET /admin` — Config console UI
- `GET /admin/login` — Start admin sign-in (GitHub OAuth) - `GET /admin/login` — Start admin sign-in (GitHub OAuth)
- `GET /admin/logout` — Sign out - `GET /admin/logout` — Sign out
- `GET /admin/invite?token=…` — Accept a group invite (browser page) - `GET /admin/invite?token=…` — Accept a group invite (browser page)
- `GET /admin/api/routes` — List routes - `GET /admin/api/routes` — List routes
- `PUT /admin/api/routes` — Replace routes (owner/admin per group) - `PUT /admin/api/routes` — Replace routes (owner/admin per group)
- `GET /admin/api/groups` — List groups + your role in each - `GET /admin/api/groups` — List groups + your role in each
- `PUT /admin/api/groups` — Replace groups (super: all; owner: own groups only) - `PUT /admin/api/groups` — Replace groups (super: all; owner: own groups only)
- `GET /admin/api/groups/:groupId/routes` — List a group's routes - `GET /admin/api/groups/:groupId/routes` — List a group's routes
- `PUT /admin/api/groups/:groupId/routes` — Replace a group's routes - `PUT /admin/api/groups/:groupId/routes` — Replace a group's routes
- `POST /admin/api/groups/:groupId/invites` — Create invite link (owner) - `POST /admin/api/groups/:groupId/invites` — Create invite link (owner)
- `GET /admin/api/groups/:groupId/invites` — List pending invites (owner) - `GET /admin/api/groups/:groupId/invites` — List pending invites (owner)
- `DELETE /admin/api/invites/:token` — Revoke an invite (owner) - `DELETE /admin/api/invites/:token` — Revoke an invite (owner)
- `GET /admin/api/audit` — Audit log (scoped) - `GET /admin/api/audit` — Audit log (scoped)
- `GET /admin/api/me` — Current session / scope / roles - `GET /admin/api/me` — Current session / scope / roles
- `GET /admin/api/logs` — Send logs (scoped) - `GET /admin/api/logs` — Send logs (scoped)
- `GET /admin/api/logs/:id` — Single send-log entry - `GET /admin/api/logs/:id` — Single send-log entry
## Setup Guides ## Setup Guides
- **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup) - **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup)
- **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST). - **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
- **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup) - **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup)
- **Deployment** — KV namespace, D1 database + migrations, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment) - **Deployment** — KV namespace, D1 database + migrations, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment)
### Bot Commands (comment on GitHub as yourself) ### Bot Commands (comment on GitHub as yourself)
The bot registers native **slash** and **message context-menu** commands, synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, and globally (24h dedup, ~1h propagation). After `/gh login` you can comment on issues/PRs as yourself, edit/delete your comments, and merge/close PRs via buttons — all replies are ephemeral and GitHub enforces permission. The bot registers native **slash** and **message context-menu** commands, synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, and globally (24h dedup, ~1h propagation). After `/gh login` you can comment on issues/PRs as yourself, edit/delete your comments, and merge/close PRs via buttons — all replies are ephemeral and GitHub enforces permission.
``` ```
/gh login /gh logout /gh login /gh logout
/gh comment add|edit|del link:<url> (or right-click a notification → Apps → GitHub: 添加/编辑/删除评论) /gh comment add|edit|del link:<url> (or right-click a notification → Apps → GitHub: 添加/编辑/删除评论)
``` ```
See the full reference in the [Bot Commands guide](https://webhooker.docs.worldexecute.me/guide/commands). See the full reference in the [Bot Commands guide](https://webhooker.docs.worldexecute.me/guide/commands).
## Development ## Development
```bash ```bash
bun run dev # Nuxt dev server (HMR) bun run dev # Nuxt dev server (HMR)
bun run typecheck # Type checking bun run typecheck # Type checking
bun run lint # ESLint bun run lint # ESLint
bun test # Unit tests bun test # Unit tests
``` ```
## Supported Events ## Supported Events
28 event formatters (push, pull_request, issues, workflow_run, release, ...) plus `custom` webhooks; unsupported events fall back to a generic formatter. See the full table with embed highlights in [Supported Events](https://webhooker.docs.worldexecute.me/events/supported). 28 event formatters (push, pull_request, issues, workflow_run, release, ...) plus `custom` webhooks; unsupported events fall back to a generic formatter. See the full table with embed highlights in [Supported Events](https://webhooker.docs.worldexecute.me/events/supported).
## License ## License
MIT MIT

View file

@ -258,12 +258,7 @@ if (view.value === null) {
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false }); throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
} }
const { const { logs, loading: logsLoading, error: logsError, load: loadLogs } = useSendLogs();
logs,
loading: logsLoading,
error: logsError,
load: loadLogs,
} = useSendLogs();
const { const {
entries: auditEntries, entries: auditEntries,
loading: auditLoading, loading: auditLoading,

View file

@ -13,22 +13,22 @@
<div class="row2"> <div class="row2">
<div class="field"> <div class="field">
<label>{{ t("groupEditor.name") }}</label> <label>{{ t("groupEditor.name") }}</label>
<input <input
v-model="form.name" v-model="form.name"
type="text" type="text"
class="input" class="input"
:placeholder="t('groupEditor.namePlaceholder')" :placeholder="t('groupEditor.namePlaceholder')"
required required
/> />
</div> </div>
<div class="field"> <div class="field">
<label>{{ t("groupEditor.id") }}</label> <label>{{ t("groupEditor.id") }}</label>
<input <input
v-model="form.id" v-model="form.id"
type="text" type="text"
class="input" class="input"
:placeholder="t('groupEditor.idPlaceholder')" :placeholder="t('groupEditor.idPlaceholder')"
required required
/> />
<div class="hint"> <div class="hint">
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }} {{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
@ -38,11 +38,11 @@
<div class="row2"> <div class="row2">
<div class="field"> <div class="field">
<label>{{ t("groupEditor.language") }}</label> <label>{{ t("groupEditor.language") }}</label>
<input <input
v-model="form.lang" v-model="form.lang"
type="text" type="text"
class="input" class="input"
:placeholder="t('groupEditor.langPlaceholder')" :placeholder="t('groupEditor.langPlaceholder')"
/> />
<div class="hint">{{ t("groupEditor.langHint") }}</div> <div class="hint">{{ t("groupEditor.langHint") }}</div>
</div> </div>
@ -71,20 +71,20 @@
>{{ t("groupEditor.owners") }} >{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label <span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
> >
<input <input
v-model="form.owners" v-model="form.owners"
type="text" type="text"
class="input" class="input"
:placeholder="t('groupEditor.ownersPlaceholder')" :placeholder="t('groupEditor.ownersPlaceholder')"
/> />
<div class="hint">{{ t("groupEditor.ownersHint") }}</div> <div class="hint">{{ t("groupEditor.ownersHint") }}</div>
</div> </div>
<div v-else class="field"> <div v-else class="field">
<label <label
>{{ t("groupEditor.owners") }} >{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label <span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
> >
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled /> <input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
</div> </div>
<div class="field"> <div class="field">
<label <label
@ -116,12 +116,12 @@
>{{ t("groupEditor.installationId") }} >{{ t("groupEditor.installationId") }}
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label <span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
> >
<input <input
v-model="form.installationId" v-model="form.installationId"
type="text" type="text"
class="input" class="input"
inputmode="numeric" inputmode="numeric"
:placeholder="t('groupEditor.installationIdPlaceholder')" :placeholder="t('groupEditor.installationIdPlaceholder')"
/> />
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div> <div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
</div> </div>
@ -130,38 +130,38 @@
>{{ t("groupEditor.logTarget") }} >{{ t("groupEditor.logTarget") }}
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label <span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
> >
<select v-model="form.logPlatform" class="select"> <select v-model="form.logPlatform" class="select">
<option value="">{{ t("groupEditor.logDisabled") }}</option> <option value="">{{ t("groupEditor.logDisabled") }}</option>
<option value="discord">Discord</option> <option value="discord">Discord</option>
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
</select> </select>
<template v-if="form.logPlatform === 'discord'"> <template v-if="form.logPlatform === 'discord'">
<input <input
v-model="form.logChannelId" v-model="form.logChannelId"
type="text" type="text"
class="input mt-2" class="input mt-2"
:placeholder="t('routeEditor.channelPlaceholder')" :placeholder="t('routeEditor.channelPlaceholder')"
/> />
<input <input
v-model="form.logThreadId" v-model="form.logThreadId"
type="text" type="text"
class="input mt-2" class="input mt-2"
:placeholder="t('routeEditor.threadPlaceholder')" :placeholder="t('routeEditor.threadPlaceholder')"
/> />
</template> </template>
<template v-else-if="form.logPlatform === 'telegram'"> <template v-else-if="form.logPlatform === 'telegram'">
<input <input
v-model="form.logChatId" v-model="form.logChatId"
type="text" type="text"
class="input mt-2" class="input mt-2"
:placeholder="t('routeEditor.chatPlaceholder')" :placeholder="t('routeEditor.chatPlaceholder')"
/> />
<input <input
v-model="form.logTopicId" v-model="form.logTopicId"
type="text" type="text"
class="input mt-2" class="input mt-2"
:placeholder="t('routeEditor.topicPlaceholder')" :placeholder="t('routeEditor.topicPlaceholder')"
/> />
</template> </template>
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div> <div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
</div> </div>
@ -322,4 +322,3 @@ function save(): void {
}); });
} }
</script> </script>

View file

@ -57,7 +57,10 @@ const updated = computed(() => "2026-08-01");
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en); const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
const q = (p: string): string => `${p}?lang=${lang.value}`; const q = (p: string): string => `${p}?lang=${lang.value}`;
const altLink = computed(() => const altLink = computed(() =>
q(props.active === "terms" ? "/terms" : "/privacy").replace(`lang=${lang.value}`, `lang=${altLang.value}`), q(props.active === "terms" ? "/terms" : "/privacy").replace(
`lang=${lang.value}`,
`lang=${altLang.value}`,
),
); );
useHead({ title: `${props.title} · WebHooker` }); useHead({ title: `${props.title} · WebHooker` });

View file

@ -22,7 +22,11 @@
v-for="it in items" v-for="it in items"
:key="it.label" :key="it.label"
class="flex items-center gap-4 rounded-[14px] border border-border bg-surface px-5 py-[18px] text-text no-underline shadow-card transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover" class="flex items-center gap-4 rounded-[14px] border border-border bg-surface px-5 py-[18px] text-text no-underline shadow-card transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover"
:class="it.primary ? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg' : ''" :class="
it.primary
? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg'
: ''
"
:href="it.href" :href="it.href"
:target="it.external ? '_blank' : undefined" :target="it.external ? '_blank' : undefined"
:rel="it.external ? 'noopener noreferrer' : undefined" :rel="it.external ? 'noopener noreferrer' : undefined"
@ -68,7 +72,9 @@ const config = useRuntimeConfig();
const lang = computed(() => (route.query.lang === "en" ? "en" : "zh")); const lang = computed(() => (route.query.lang === "en" ? "en" : "zh"));
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh")); const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
const repo = computed(() => (config.public.repoUrl as string) || DEFAULT_REPO); const repo = computed(() => (config.public.repoUrl as string) || DEFAULT_REPO);
const docsBase = computed(() => ((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, "")); const docsBase = computed(() =>
((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, ""),
);
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en); const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
@ -119,7 +125,8 @@ const ICON_PATHS: Record<string, string> = {
docs: '<path d="M4 4a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z"/><path d="M13 2v5h5"/><path d="M8 12h8M8 16h6"/>', docs: '<path d="M4 4a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z"/><path d="M13 2v5h5"/><path d="M8 12h8M8 16h6"/>',
github: github:
'<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>', '<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>',
terms: '<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>', terms:
'<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>',
privacy: privacy:
'<path d="M12 2l7 3v6c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V5l7-3z"/><path d="M9 12l2 2 4-4"/>', '<path d="M12 2l7 3v6c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V5l7-3z"/><path d="M9 12l2 2 4-4"/>',
login: login:
@ -143,4 +150,3 @@ useHead({
], ],
}); });
</script> </script>

View file

@ -2,13 +2,13 @@
This page is the reference for secrets and the Web UI. Core concepts live in dedicated pages: This page is the reference for secrets and the Web UI. Core concepts live in dedicated pages:
| Topic | Page | | Topic | Page |
|---------------------------------------------------|----------------------------------------------------------------------| | ------------------------------------------------- | ------------------------------------------------------------------ |
| Routes, targets, `fallback` / `stop`, role pings | [Routes & Targets](./routes) | | Routes, targets, `fallback` / `stop`, role pings | [Routes & Targets](./routes) |
| Groups, roles, invites, self sign-up, log channel | [Groups & Access Control](./groups) | | Groups, roles, invites, self sign-up, log channel | [Groups & Access Control](./groups) |
| Webhook providers, per-group ingress, custom | [Webhook Ingress & Tenancy](./ingress) | | Webhook providers, per-group ingress, custom | [Webhook Ingress & Tenancy](./ingress) |
| KV / D1 key layout | [Storage Layout](./storage) | | KV / D1 key layout | [Storage Layout](./storage) |
| Filters (pattern syntax reference) | [Filter Types](#filter-types) below / [Filter Tutorial](./filters) | | Filters (pattern syntax reference) | [Filter Types](#filter-types) below / [Filter Tutorial](./filters) |
## Secrets ## Secrets
@ -17,7 +17,7 @@ WebHooker requires several secrets to function. For local development, store the
### Required Secrets ### Required Secrets
| Variable | Description | | Variable | Description |
|-------------------------|--------------------------------------------------------------------------| | ----------------------- | ------------------------------------------------------------------------ |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from your GitHub App settings | | `GITHUB_WEBHOOK_SECRET` | Webhook secret from your GitHub App settings |
| `GITEA_WEBHOOK_SECRET` | Webhook secret from your Gitea instance (only to receive Gitea webhooks) | | `GITEA_WEBHOOK_SECRET` | Webhook secret from your Gitea instance (only to receive Gitea webhooks) |
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings | | `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
@ -35,7 +35,7 @@ WebHooker requires several secrets to function. For local development, store the
### Optional Secrets ### Optional Secrets
| Variable | Description | Default | | Variable | Description | Default |
|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------| | --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` | | `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` |
| `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved | | `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved |
| `TELEGRAM_WEBHOOK_SECRET` | Secret token for `POST /telegram/webhook` verification (X-Telegram-Bot-Api-Secret-Token) | Disabled (no verification) | | `TELEGRAM_WEBHOOK_SECRET` | Secret token for `POST /telegram/webhook` verification (X-Telegram-Bot-Api-Secret-Token) | Disabled (no verification) |
@ -67,7 +67,7 @@ All management endpoints (`/admin/api/*`) are documented in the [Admin API](../a
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_*`, `pull_request` | | `event` | GitHub event name | `push`, `pull_*`, `pull_request` |
| `repo` | Repository full name | `org/repo`, `org/*` | | `repo` | Repository full name | `org/repo`, `org/*` |
| `actor` | Sender login | `username`, `[bot]`, `*[bot]` | | `actor` | Sender login | `username`, `[bot]`, `*[bot]` |

View file

@ -1,263 +1,263 @@
# Filter Tutorial # Filter Tutorial
Filters decide which webhook events a [route](./routes) forwards. A route fires only when **every** filter in its `filters` array matches (AND logic). This page is a hands-on tutorial: it explains how each filter type behaves and how to combine them into real-world routing rules. Filters decide which webhook events a [route](./routes) forwards. A route fires only when **every** filter in its `filters` array matches (AND logic). This page is a hands-on tutorial: it explains how each filter type behaves and how to combine them into real-world routing rules.
See [Filter Types](./configuration#filter-types) in the configuration guide for the reference table, and [Supported Events](../events/supported) for the full event list. See [Filter Types](./configuration#filter-types) in the configuration guide for the reference table, and [Supported Events](../events/supported) for the full event list.
## How Matching Works ## How Matching Works
- 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** for every filter type. - 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** match. - Setting `"exclude": true` inverts the result (NOT logic): the filter matches when the value does **not** match.
```json ```json
{ {
"type": "event", "type": "event",
"match": ["push", "pull_request"], "match": ["push", "pull_request"],
"exclude": false "exclude": false
} }
``` ```
The route above matches both `push` and `pull_request` events. The route above matches both `push` and `pull_request` events.
## Pattern Syntax ## Pattern Syntax
Every filter type shares the same three pattern forms: Every filter type shares the same three pattern forms:
| Pattern | Meaning | | Pattern | Meaning |
| ---------------------- | -------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------- |
| `plain text` | Field filters: **exact** match. `keyword`: search anywhere. | | `plain text` | Field filters: **exact** match. `keyword`: search anywhere. |
| `*` / `?` | **Glob wildcards**`*` any run, `?` one char. | | `*` / `?` | **Glob wildcards**`*` any run, `?` one char. |
| `/regular expression/` | Compiled as a **regular expression** (case-insensitive flag). | | `/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. - 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. - Regexes always search: `/^feat/` matches values _starting_ with `feat`, `/feat/` matches anywhere.
Examples: Examples:
```json ```json
{ "type": "event", "match": "pull_*" } { "type": "event", "match": "pull_*" }
``` ```
Matches `pull_request`, `pull_request_review`, `pull_request_review_comment`, ... Matches `pull_request`, `pull_request_review`, `pull_request_review_comment`, ...
```json ```json
{ "type": "repo", "match": "myorg/*" } { "type": "repo", "match": "myorg/*" }
``` ```
```json ```json
{ "type": "branch", "match": "feature-?" } { "type": "branch", "match": "feature-?" }
``` ```
Matches `feature-x`, `feature-1`, but not `feature-xy`. Matches `feature-x`, `feature-1`, but not `feature-xy`.
```json ```json
{ "type": "branch", "match": "/^feat/" } { "type": "branch", "match": "/^feat/" }
``` ```
Matches any branch whose name starts with `feat`. Matches any branch whose name starts with `feat`.
> [!TIP] > [!TIP]
> Globs and regular expressions are case-insensitive too, and `*` matches across `/` in repo names (`myorg/*` also matches `myorg/sub/backend`). > 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
Matches the GitHub event name, e.g. `push`, `pull_request`, `issues`, `release`. Use this as the backbone of every route. Matches the GitHub event name, e.g. `push`, `pull_request`, `issues`, `release`. Use this as the backbone of every route.
```json ```json
{ "type": "event", "match": "release" } { "type": "event", "match": "release" }
``` ```
Match several events with an array: Match several events with an array:
```json ```json
{ "type": "event", "match": ["create", "delete"] } { "type": "event", "match": ["create", "delete"] }
``` ```
### `repo` — Repository ### `repo` — Repository
Matches the repository **full name** (`owner/name`). Case-insensitive. Matches the repository **full name** (`owner/name`). Case-insensitive.
```json ```json
{ "type": "repo", "match": "myorg/backend" } { "type": "repo", "match": "myorg/backend" }
``` ```
Route multiple repositories to one channel: Route multiple repositories to one channel:
```json ```json
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] } { "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
``` ```
### `actor` — Sender ### `actor` — Sender
Matches the **sender's GitHub login** that triggered the event (`sender.login` in the payload). Useful for ignoring bots. Matches the **sender's GitHub login** that triggered the event (`sender.login` in the payload). Useful for ignoring bots.
```json ```json
{ "type": "actor", "match": "dependabot[bot]", "exclude": true } { "type": "actor", "match": "dependabot[bot]", "exclude": true }
``` ```
The route above fires for every event **except** those triggered by Dependabot. The route above fires for every event **except** those triggered by Dependabot.
### `action` — Event action ### `action` — Event action
Matches the `action` field of the payload, e.g. `opened`, `closed`, `published`, `completed`. Not all events carry an action — see [Filter Compatibility](../events/supported#filter-compatibility). Combine it with `event` to narrow down a specific lifecycle step: Matches the `action` field of the payload, e.g. `opened`, `closed`, `published`, `completed`. Not all events carry an action — see [Filter Compatibility](../events/supported#filter-compatibility). Combine it with `event` to narrow down a specific lifecycle step:
```json ```json
{ {
"type": "event", "type": "event",
"match": "pull_request", "match": "pull_request",
"exclude": false "exclude": false
}, },
{ {
"type": "action", "type": "action",
"match": ["opened", "reopened"] "match": ["opened", "reopened"]
} }
``` ```
This fires when a pull request is opened or reopened (and not on merge/close/edit). This fires when a pull request is opened or reopened (and not on merge/close/edit).
### `branch` — Branch ### `branch` — Branch
Matches the branch involved in the event. What counts as "the branch" depends on the event type: Matches the branch involved in the event. What counts as "the branch" depends on the event type:
| Event | Branch extracted | | Event | Branch extracted |
| --------------------------- | ------------------------------------------- | | --------------------------- | ------------------------------------------- |
| `push` | The branch that was pushed to | | `push` | The branch that was pushed to |
| `pull_request` (and review) | The pull request's **head** (source) branch | | `pull_request` (and review) | The pull request's **head** (source) branch |
| `create` / `delete` | The created/deleted branch or tag | | `create` / `delete` | The created/deleted branch or tag |
| `workflow_run` | The `head_branch` the workflow ran on | | `workflow_run` | The `head_branch` the workflow ran on |
| `workflow_job` | The `head_branch` the job ran on | | `workflow_job` | The `head_branch` the job ran on |
| `check_suite` | The `head_branch` of the check suite | | `check_suite` | The `head_branch` of the check suite |
| `deployment` | The deployment ref (strips `refs/heads/`) | | `deployment` | The deployment ref (strips `refs/heads/`) |
| `code_scanning_alert` | The branch the alert belongs to | | `code_scanning_alert` | The branch the alert belongs to |
```json ```json
{ {
"type": "event", "type": "event",
"match": "push" "match": "push"
}, },
{ {
"type": "branch", "type": "branch",
"match": "main" "match": "main"
} }
``` ```
Fires for pushes to `main` only. To watch several long-lived branches: Fires for pushes to `main` only. To watch several long-lived branches:
```json ```json
{ "type": "branch", "match": ["main", "develop"] } { "type": "branch", "match": ["main", "develop"] }
``` ```
> [!NOTE] > [!NOTE]
> `branch` matching is case-insensitive. Use globs (`feature/*`) or a `//`-wrapped regex (`/^release-/`) for prefix or wildcard-style matching. > `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 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). 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" } { "type": "keyword", "match": "deploy" }
``` ```
Fires when the payload contains `deploy` anywhere. Because the payload is lowercased, this matches `Deploy`, `DEPLOY`, etc. Fires when the payload contains `deploy` anywhere. Because the payload is lowercased, this matches `Deploy`, `DEPLOY`, etc.
```json ```json
{ "type": "keyword", "match": "*release-*" } { "type": "keyword", "match": "*release-*" }
``` ```
```json ```json
{ "type": "keyword", "match": "/^(fix|hotfix)/" } { "type": "keyword", "match": "/^(fix|hotfix)/" }
``` ```
```json ```json
{ "type": "keyword", "match": "/release-[0-9]+/" } { "type": "keyword", "match": "/release-[0-9]+/" }
``` ```
Behavior details: Behavior details:
- Patterns longer than 200 characters are **not** compiled as glob/regex and fall back to plain matching. - Patterns longer than 200 characters are **not** compiled as glob/regex and fall back to plain matching.
- A `//`-wrapped pattern that is not a valid regex matches **nothing** (the filter stays false) rather than 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 glob or regex special character (e.g. `v1.2.3`), rely on the plain-text form — a pattern without `*`, `?`, or `//` wrapping matches literally. - 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`
Just like the other filters, `exclude` inverts the keyword match: Just like the other filters, `exclude` inverts the keyword match:
```json ```json
{ "type": "keyword", "match": "/wip|draft/", "exclude": true } { "type": "keyword", "match": "/wip|draft/", "exclude": true }
``` ```
Skips events whose payload mentions `wip` or `draft`. Skips events whose payload mentions `wip` or `draft`.
## Worked Example 1: PR alerts that skip bots and drafts ## Worked Example 1: PR alerts that skip bots and drafts
Forward pull request activity, but ignore bot authors and draft PRs, to a `#prs` channel: Forward pull request activity, but ignore bot authors and draft PRs, to a `#prs` channel:
```json ```json
{ {
"id": "pr-notices", "id": "pr-notices",
"name": "PR Notices", "name": "PR Notices",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "pull_request" }, { "type": "event", "match": "pull_request" },
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }, { "type": "actor", "match": "dependabot[bot]", "exclude": true },
{ "type": "keyword", "match": "\"draft\": true", "exclude": true } { "type": "keyword", "match": "\"draft\": true", "exclude": true }
], ],
"target": { "channelId": "111111111111111111" } "target": { "channelId": "111111111111111111" }
} }
``` ```
The `"draft": true` pattern matches the `draft` field that GitHub includes in pull request payloads; combined with `exclude: true` it filters out draft PRs. The `"draft": true` pattern matches the `draft` field that GitHub includes in pull request payloads; combined with `exclude: true` it filters out draft PRs.
## Worked Example 2: Release-only channel ## Worked Example 2: Release-only channel
Forward only published releases from a specific repo: Forward only published releases from a specific repo:
```json ```json
{ {
"id": "release-alerts", "id": "release-alerts",
"name": "Release Alerts", "name": "Release Alerts",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "release" }, { "type": "event", "match": "release" },
{ "type": "action", "match": "published" }, { "type": "action", "match": "published" },
{ "type": "repo", "match": "myorg/backend" } { "type": "repo", "match": "myorg/backend" }
], ],
"target": { "channelId": "222222222222222222" } "target": { "channelId": "222222222222222222" }
} }
``` ```
## Worked Example 3: CI failures ## Worked Example 3: CI failures
Forward workflow runs that ended in failure on any branch, to a `#ci` channel: Forward workflow runs that ended in failure on any branch, to a `#ci` channel:
```json ```json
{ {
"id": "ci-failures", "id": "ci-failures",
"name": "CI Failures", "name": "CI Failures",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "workflow_run" }, { "type": "event", "match": "workflow_run" },
{ "type": "action", "match": "completed" }, { "type": "action", "match": "completed" },
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" } { "type": "keyword", "match": "\"conclusion\":\"failure\"" }
], ],
"target": { "channelId": "333333333333333333" } "target": { "channelId": "333333333333333333" }
} }
``` ```
## Common Pitfalls ## Common Pitfalls
- **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\/.*/"`. - **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. - **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

@ -63,7 +63,7 @@ curl http://localhost:8787/health
## Available Scripts ## Available Scripts
| Script | Description | | Script | Description |
|------------------------|---------------------------------------------| | ---------------------- | ------------------------------------------- |
| `bun run dev` | Start Nuxt dev server (HMR) | | `bun run dev` | Start Nuxt dev server (HMR) |
| `bun run build` | Production build (cloudflare_module preset) | | `bun run build` | Production build (cloudflare_module preset) |
| `bun run deploy` | Deploy to Cloudflare | | `bun run deploy` | Deploy to Cloudflare |

View file

@ -2,13 +2,13 @@
本页是密钥与 Web 控制台的参考。核心概念在独立页面中说明: 本页是密钥与 Web 控制台的参考。核心概念在独立页面中说明:
| 主题 | 页面 | | 主题 | 页面 |
|--------------------------------------------------|----------------------------------------------------------------------| | ------------------------------------------- | ------------------------------------------------------- |
| 路由、目标、`fallback` / `stop`、身份组提醒 | [路由与目标](./routes) | | 路由、目标、`fallback` / `stop`、身份组提醒 | [路由与目标](./routes) |
| 分组、角色、邀请、自助注册、日志频道 | [分组与访问控制](./groups) | | 分组、角色、邀请、自助注册、日志频道 | [分组与访问控制](./groups) |
| Webhook 提供方、分组入口、自定义 webhook | [Webhook 接入与租户隔离](./ingress) | | Webhook 提供方、分组入口、自定义 webhook | [Webhook 接入与租户隔离](./ingress) |
| KV / D1 键布局 | [存储布局](./storage) | | KV / D1 键布局 | [存储布局](./storage) |
| 过滤器(模式语法参考) | 下方[过滤器类型](#过滤器类型) / [过滤器教程](./filters) | | 过滤器(模式语法参考) | 下方[过滤器类型](#过滤器类型) / [过滤器教程](./filters) |
## 密钥 ## 密钥
@ -16,14 +16,14 @@ WebHooker 的运行需要若干密钥。本地开发时放入 `.dev.vars`,生
### 必需密钥 ### 必需密钥
| 变量 | 说明 | | 变量 | 说明 |
|-------------------------|-------------------------------------------------------------------| | ----------------------- | ----------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET` | GitHub App 设置中的 webhook 密钥 | | `GITHUB_WEBHOOK_SECRET` | GitHub App 设置中的 webhook 密钥 |
| `GITEA_WEBHOOK_SECRET` | Gitea 实例的 webhook 密钥(仅接收 Gitea webhook 时需要) | | `GITEA_WEBHOOK_SECRET` | Gitea 实例的 webhook 密钥(仅接收 Gitea webhook 时需要) |
| `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID | | `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID |
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 | | `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
| `DISCORD_TOKEN` | Discord 机器人 Token | | `DISCORD_TOKEN` | Discord 机器人 Token |
| `TELEGRAM_TOKEN` | Telegram 机器人 TokenBotFather 获取)—— Telegram 路由必需 | | `TELEGRAM_TOKEN` | Telegram 机器人 TokenBotFather 获取)—— Telegram 路由必需 |
> [!NOTE] > [!NOTE]
> `GITHUB_APP_ID``GITHUB_PRIVATE_KEY`PKCS#8 PEM用于 GitHub App **安装流程** > `GITHUB_APP_ID``GITHUB_PRIVATE_KEY`PKCS#8 PEM用于 GitHub App **安装流程**
@ -33,19 +33,19 @@ WebHooker 的运行需要若干密钥。本地开发时放入 `.dev.vars`,生
### 可选密钥 ### 可选密钥
| 变量 | 说明 | 默认值 | | 变量 | 说明 | 默认值 |
|-----------------------------|--------------------------------------------------------------------------------------------------|-------------------------| | --------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------- |
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 | | `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
| `DISCORD_APPLICATION_ID` | Discord 应用 ID省略时自动获取 | 自动获取 | | `DISCORD_APPLICATION_ID` | Discord 应用 ID省略时自动获取 | 自动获取 |
| `TELEGRAM_WEBHOOK_SECRET` | `POST /telegram/webhook` 验签密钥X-Telegram-Bot-Api-Secret-Token | 未设置时不校验 | | `TELEGRAM_WEBHOOK_SECRET` | `POST /telegram/webhook` 验签密钥X-Telegram-Bot-Api-Secret-Token | 未设置时不校验 |
| `TELEGRAM_RICH_HEADER_HOST` | 外部 rich-header 服务的基础 URL未设置时使用内置 `GET /api/richheader` 提供 Telegram 头像卡片 | 内置 `/api/richheader` | | `TELEGRAM_RICH_HEADER_HOST` | 外部 rich-header 服务的基础 URL未设置时使用内置 `GET /api/richheader` 提供 Telegram 头像卡片 | 内置 `/api/richheader` |
| `BASE_URL` | OAuth 回调的公共 URL | `http://localhost:8787` | | `BASE_URL` | OAuth 回调的公共 URL | `http://localhost:8787` |
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID或登录名逗号分隔 | 未设置时 WebUI 关闭 | | `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID或登录名逗号分隔 | 未设置时 WebUI 关闭 |
| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 | | `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` | | `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` |
| `NUXT_PUBLIC_DOCS_URL` | 落地页使用的文档站 URL客户端运行时配置 | 落地页默认值 | | `NUXT_PUBLIC_DOCS_URL` | 落地页使用的文档站 URL客户端运行时配置 | 落地页默认值 |
| `NUXT_PUBLIC_REPO_URL` | 落地页使用的 GitHub 仓库 URL | 落地页默认值 | | `NUXT_PUBLIC_REPO_URL` | 落地页使用的 GitHub 仓库 URL | 落地页默认值 |
| `NUXT_PUBLIC_LEGAL_CONTACT` | `/terms``/privacy` 页面展示的联系方式 | 未设置时显示占位文本 | | `NUXT_PUBLIC_LEGAL_CONTACT` | `/terms``/privacy` 页面展示的联系方式 | 未设置时显示占位文本 |
## Web 控制台 ## Web 控制台
@ -65,14 +65,14 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
实操指南见[过滤器教程](./filters),包含完整示例。 实操指南见[过滤器教程](./filters),包含完整示例。
| 类型 | 匹配对象 | 示例 | | 类型 | 匹配对象 | 示例 |
|-----------|------------------|--------------------------------------| | --------- | ---------------- | ---------------------------------- |
| `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` | | `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` |
| `repo` | 仓库全名 | `org/repo`, `org/*` | | `repo` | 仓库全名 | `org/repo`, `org/*` |
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` | | `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
| `action` | 事件操作 | `opened`, `closed`, `published` | | `action` | 事件操作 | `opened`, `closed`, `published` |
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` | | `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` | | `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
### 过滤器行为 ### 过滤器行为

View file

@ -1,263 +1,263 @@
# 过滤器教程 # 过滤器教程
过滤器决定哪些 Webhook 事件会被[路由](./routes)转发。只有当路由 `filters` 数组中的**每一个**过滤器都匹配时路由才会触发AND 逻辑)。本页是一份上手教程:解释每种过滤器类型的行为,以及如何组合它们实现真实场景的路由规则。 过滤器决定哪些 Webhook 事件会被[路由](./routes)转发。只有当路由 `filters` 数组中的**每一个**过滤器都匹配时路由才会触发AND 逻辑)。本页是一份上手教程:解释每种过滤器类型的行为,以及如何组合它们实现真实场景的路由规则。
参考表格见配置指南的[过滤器类型](./configuration#过滤器类型),完整事件列表见[支持的事件](../events/supported)。 参考表格见配置指南的[过滤器类型](./configuration#过滤器类型),完整事件列表见[支持的事件](../events/supported)。
## 匹配机制 ## 匹配机制
- 路由中所有过滤器都必须匹配,否则该路由被跳过。 - 路由中所有过滤器都必须匹配,否则该路由被跳过。
- 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。 - 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。
- 所有过滤器类型都**不区分大小写**。 - 所有过滤器类型都**不区分大小写**。
- `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配该过滤器即匹配。 - `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配该过滤器即匹配。
- 设置 `"exclude": true` 会反转结果NOT 逻辑):当值**不**匹配时,该过滤器才匹配。 - 设置 `"exclude": true` 会反转结果NOT 逻辑):当值**不**匹配时,该过滤器才匹配。
```json ```json
{ {
"type": "event", "type": "event",
"match": ["push", "pull_request"], "match": ["push", "pull_request"],
"exclude": false "exclude": false
} }
``` ```
上面这条路由同时匹配 `push``pull_request` 事件。 上面这条路由同时匹配 `push``pull_request` 事件。
## 模式语法 ## 模式语法
所有过滤器类型共享以下三种模式写法: 所有过滤器类型共享以下三种模式写法:
| 模式 | 含义 | | 模式 | 含义 |
| -------------- | ----------------------------------------------------------------- | | -------------- | --------------------------------------------------------------- |
| `纯文本` | 字段过滤器:**完全相等**匹配;`keyword`:在载荷中任意位置搜索。 | | `纯文本` | 字段过滤器:**完全相等**匹配;`keyword`:在载荷中任意位置搜索。 |
| `*` / `?` | **通配符glob**——`*` 任意长度、`?` 恰好一个字符。 | | `*` / `?` | **通配符glob**——`*` 任意长度、`?` 恰好一个字符。 |
| `/正则表达式/` | 按**正则表达式**编译(忽略大小写标志)。 | | `/正则表达式/` | 按**正则表达式**编译(忽略大小写标志)。 |
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的纯文本与通配符匹配整个值;`keyword` 则在载荷中任意位置搜索。 - 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的纯文本与通配符匹配整个值;`keyword` 则在载荷中任意位置搜索。
- 正则表达式始终是搜索语义:`/^feat/` 匹配以 `feat` **开头**的值,`/feat/` 匹配任意位置出现 `feat` 的值。 - 正则表达式始终是搜索语义:`/^feat/` 匹配以 `feat` **开头**的值,`/feat/` 匹配任意位置出现 `feat` 的值。
示例: 示例:
```json ```json
{ "type": "event", "match": "pull_*" } { "type": "event", "match": "pull_*" }
``` ```
匹配 `pull_request``pull_request_review``pull_request_review_comment` 等。 匹配 `pull_request``pull_request_review``pull_request_review_comment` 等。
```json ```json
{ "type": "repo", "match": "myorg/*" } { "type": "repo", "match": "myorg/*" }
``` ```
```json ```json
{ "type": "branch", "match": "feature-?" } { "type": "branch", "match": "feature-?" }
``` ```
匹配 `feature-x``feature-1`,但不匹配 `feature-xy` 匹配 `feature-x``feature-1`,但不匹配 `feature-xy`
```json ```json
{ "type": "branch", "match": "/^feat/" } { "type": "branch", "match": "/^feat/" }
``` ```
匹配任何以 `feat` 开头的分支名。 匹配任何以 `feat` 开头的分支名。
> [!TIP] > [!TIP]
> 通配符和正则同样不区分大小写,且 `*` 可以跨过仓库名中的 `/``myorg/*` 也能匹配 `myorg/sub/backend`)。 > 通配符和正则同样不区分大小写,且 `*` 可以跨过仓库名中的 `/``myorg/*` 也能匹配 `myorg/sub/backend`)。
## 各过滤器类型详解 ## 各过滤器类型详解
### `event` — 事件类型 ### `event` — 事件类型
匹配 GitHub 事件名称,如 `push``pull_request``issues``release`。它是每条路由的主干。 匹配 GitHub 事件名称,如 `push``pull_request``issues``release`。它是每条路由的主干。
```json ```json
{ "type": "event", "match": "release" } { "type": "event", "match": "release" }
``` ```
用数组匹配多种事件: 用数组匹配多种事件:
```json ```json
{ "type": "event", "match": ["create", "delete"] } { "type": "event", "match": ["create", "delete"] }
``` ```
### `repo` — 仓库 ### `repo` — 仓库
匹配仓库**全名**`owner/name`)。不区分大小写。 匹配仓库**全名**`owner/name`)。不区分大小写。
```json ```json
{ "type": "repo", "match": "myorg/backend" } { "type": "repo", "match": "myorg/backend" }
``` ```
将多个仓库路由到同一频道: 将多个仓库路由到同一频道:
```json ```json
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] } { "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
``` ```
### `actor` — 发送者 ### `actor` — 发送者
匹配触发事件的 **GitHub 发送者登录名**(载荷中的 `sender.login`)。常用于忽略机器人。 匹配触发事件的 **GitHub 发送者登录名**(载荷中的 `sender.login`)。常用于忽略机器人。
```json ```json
{ "type": "actor", "match": "dependabot[bot]", "exclude": true } { "type": "actor", "match": "dependabot[bot]", "exclude": true }
``` ```
上面这条路由对**除** Dependabot 触发之外的所有事件都会触发。 上面这条路由对**除** Dependabot 触发之外的所有事件都会触发。
### `action` — 事件操作 ### `action` — 事件操作
匹配载荷中的 `action` 字段,如 `opened``closed``published``completed`。并非所有事件都带有 action——参见[过滤器兼容性](../events/supported#过滤器兼容性)。与 `event` 组合可精确到某个生命周期步骤: 匹配载荷中的 `action` 字段,如 `opened``closed``published``completed`。并非所有事件都带有 action——参见[过滤器兼容性](../events/supported#过滤器兼容性)。与 `event` 组合可精确到某个生命周期步骤:
```json ```json
{ {
"type": "event", "type": "event",
"match": "pull_request", "match": "pull_request",
"exclude": false "exclude": false
}, },
{ {
"type": "action", "type": "action",
"match": ["opened", "reopened"] "match": ["opened", "reopened"]
} }
``` ```
上面的规则在拉取请求被打开或重新打开时触发(合并/关闭/编辑时不触发)。 上面的规则在拉取请求被打开或重新打开时触发(合并/关闭/编辑时不触发)。
### `branch` — 分支 ### `branch` — 分支
匹配事件涉及的分支。何种字段算作「分支」取决于事件类型: 匹配事件涉及的分支。何种字段算作「分支」取决于事件类型:
| 事件 | 提取的分支 | | 事件 | 提取的分支 |
| --------------------------- | ----------------------------------- | | --------------------------- | ----------------------------------- |
| `push` | 推送到的目标分支 | | `push` | 推送到的目标分支 |
| `pull_request`(及 review | 拉取请求的 **head**(源)分支 | | `pull_request`(及 review | 拉取请求的 **head**(源)分支 |
| `create` / `delete` | 创建/删除的分支或标签 | | `create` / `delete` | 创建/删除的分支或标签 |
| `workflow_run` | 工作流运行所在的 `head_branch` | | `workflow_run` | 工作流运行所在的 `head_branch` |
| `workflow_job` | 作业运行所在的 `head_branch` | | `workflow_job` | 作业运行所在的 `head_branch` |
| `check_suite` | 检查套件的 `head_branch` | | `check_suite` | 检查套件的 `head_branch` |
| `deployment` | 部署引用(去除 `refs/heads/` 前缀) | | `deployment` | 部署引用(去除 `refs/heads/` 前缀) |
| `code_scanning_alert` | 告警所属的分支 | | `code_scanning_alert` | 告警所属的分支 |
```json ```json
{ {
"type": "event", "type": "event",
"match": "push" "match": "push"
}, },
{ {
"type": "branch", "type": "branch",
"match": "main" "match": "main"
} }
``` ```
仅当推送到 `main` 时触发。要关注多个长期分支: 仅当推送到 `main` 时触发。要关注多个长期分支:
```json ```json
{ "type": "branch", "match": ["main", "develop"] } { "type": "branch", "match": ["main", "develop"] }
``` ```
> [!NOTE] > [!NOTE]
> `branch` 匹配不区分大小写。需要前缀或通配符式匹配时,可直接使用通配符(`feature/*`)或用 `/` 包裹正则(`/^release-/`)。 > `branch` 匹配不区分大小写。需要前缀或通配符式匹配时,可直接使用通配符(`feature/*`)或用 `/` 包裹正则(`/^release-/`)。
### `keyword` — 载荷中的文本 ### `keyword` — 载荷中的文本
匹配整个 JSON 载荷(转为小写)。它是最灵活的过滤器:纯文本在任意位置搜索,`*`/`?` 通配符带通配搜索,`//` 包裹的模式按正则表达式编译(带 `i` 标志)。 匹配整个 JSON 载荷(转为小写)。它是最灵活的过滤器:纯文本在任意位置搜索,`*`/`?` 通配符带通配搜索,`//` 包裹的模式按正则表达式编译(带 `i` 标志)。
```json ```json
{ "type": "keyword", "match": "deploy" } { "type": "keyword", "match": "deploy" }
``` ```
当载荷中任意位置包含 `deploy` 时触发。由于载荷已被转为小写,`Deploy``DEPLOY` 等都会匹配。 当载荷中任意位置包含 `deploy` 时触发。由于载荷已被转为小写,`Deploy``DEPLOY` 等都会匹配。
```json ```json
{ "type": "keyword", "match": "*release-*" } { "type": "keyword", "match": "*release-*" }
``` ```
```json ```json
{ "type": "keyword", "match": "/^(fix|hotfix)/" } { "type": "keyword", "match": "/^(fix|hotfix)/" }
``` ```
```json ```json
{ "type": "keyword", "match": "/release-[0-9]+/" } { "type": "keyword", "match": "/release-[0-9]+/" }
``` ```
行为细节: 行为细节:
- 超过 200 个字符的模式**不**编译为通配符/正则,回退为纯文本匹配。 - 超过 200 个字符的模式**不**编译为通配符/正则,回退为纯文本匹配。
- 被 `/` 包裹但**不是合法正则**的模式匹配**任何内容都不命中**(过滤器恒为 false而不会报错。 - 被 `/` 包裹但**不是合法正则**的模式匹配**任何内容都不命中**(过滤器恒为 false而不会报错。
- 要搜索是通配符或正则特殊字符的文本(如 `v1.2.3`),使用纯文本形式即可——不含 `*``?` 且未被 `//` 包裹的模式按字面匹配。 - 要搜索是通配符或正则特殊字符的文本(如 `v1.2.3`),使用纯文本形式即可——不含 `*``?` 且未被 `//` 包裹的模式按字面匹配。
- 搜索覆盖**整个**载荷提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。 - 搜索覆盖**整个**载荷提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。
### `keyword``exclude` 组合 ### `keyword``exclude` 组合
与其他过滤器一样,`exclude` 会反转关键词匹配: 与其他过滤器一样,`exclude` 会反转关键词匹配:
```json ```json
{ "type": "keyword", "match": "/wip|draft/", "exclude": true } { "type": "keyword", "match": "/wip|draft/", "exclude": true }
``` ```
跳过载荷中提及 `wip``draft` 的事件。 跳过载荷中提及 `wip``draft` 的事件。
## 示例 1PR 通知,跳过机器人和草稿 ## 示例 1PR 通知,跳过机器人和草稿
转发拉取请求动态,但忽略机器人作者和草稿 PR发往 `#prs` 频道: 转发拉取请求动态,但忽略机器人作者和草稿 PR发往 `#prs` 频道:
```json ```json
{ {
"id": "pr-notices", "id": "pr-notices",
"name": "PR Notices", "name": "PR Notices",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "pull_request" }, { "type": "event", "match": "pull_request" },
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }, { "type": "actor", "match": "dependabot[bot]", "exclude": true },
{ "type": "keyword", "match": "\"draft\": true", "exclude": true } { "type": "keyword", "match": "\"draft\": true", "exclude": true }
], ],
"target": { "channelId": "111111111111111111" } "target": { "channelId": "111111111111111111" }
} }
``` ```
`"draft": true` 模式匹配 GitHub 在拉取请求载荷中包含的 `draft` 字段;配合 `exclude: true` 即可过滤掉草稿 PR。 `"draft": true` 模式匹配 GitHub 在拉取请求载荷中包含的 `draft` 字段;配合 `exclude: true` 即可过滤掉草稿 PR。
## 示例 2仅发布通知频道 ## 示例 2仅发布通知频道
只转发特定仓库的已发布 release 只转发特定仓库的已发布 release
```json ```json
{ {
"id": "release-alerts", "id": "release-alerts",
"name": "Release Alerts", "name": "Release Alerts",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "release" }, { "type": "event", "match": "release" },
{ "type": "action", "match": "published" }, { "type": "action", "match": "published" },
{ "type": "repo", "match": "myorg/backend" } { "type": "repo", "match": "myorg/backend" }
], ],
"target": { "channelId": "222222222222222222" } "target": { "channelId": "222222222222222222" }
} }
``` ```
## 示例 3CI 失败 ## 示例 3CI 失败
转发任意分支上以失败结束的 workflow run发往 `#ci` 频道: 转发任意分支上以失败结束的 workflow run发往 `#ci` 频道:
```json ```json
{ {
"id": "ci-failures", "id": "ci-failures",
"name": "CI Failures", "name": "CI Failures",
"enabled": true, "enabled": true,
"groupId": "eng", "groupId": "eng",
"filters": [ "filters": [
{ "type": "event", "match": "workflow_run" }, { "type": "event", "match": "workflow_run" },
{ "type": "action", "match": "completed" }, { "type": "action", "match": "completed" },
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" } { "type": "keyword", "match": "\"conclusion\":\"failure\"" }
], ],
"target": { "channelId": "333333333333333333" } "target": { "channelId": "333333333333333333" }
} }
``` ```
## 常见陷阱 ## 常见陷阱
- **通配符是 glob不是正则。** `repo: "myorg/*"` 匹配 `myorg` 下的任意仓库(含 `myorg/sub/backend`),但 `repo: "myorg/.*"` 按字面匹配。需要正则请用 `/` 包裹:`"/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 标题和仓库名。请尽量写得更具体。
- **牢记 `exclude` 语义。** `exclude: true` 反转的是整个过滤器——数组中的一个值不匹配并不会「阻断」路由;只有当**所有**值都不匹配时,取反后的过滤器才匹配。 - **牢记 `exclude` 语义。** `exclude: true` 反转的是整个过滤器——数组中的一个值不匹配并不会「阻断」路由;只有当**所有**值都不匹配时,取反后的过滤器才匹配。

View file

@ -1,6 +1,4 @@
import defaultNitroErrorHandler, { import defaultNitroErrorHandler, { defineNitroErrorHandler } from "nitropack/runtime/error";
defineNitroErrorHandler,
} from "nitropack/runtime/error";
import { setResponseStatus } from "h3"; import { setResponseStatus } from "h3";
/** /**

View file

@ -21,7 +21,10 @@ async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | n
} }
} }
async function userOctokit(event: H3Event, userId: string): Promise<Awaited<ReturnType<typeof getUserOctokit>>> { async function userOctokit(
event: H3Event,
userId: string,
): Promise<Awaited<ReturnType<typeof getUserOctokit>>> {
return getUserOctokit(userId, cfEnv(event).KV); return getUserOctokit(userId, cfEnv(event).KV);
} }
@ -123,16 +126,7 @@ export async function apiClose(event: H3Event): Promise<Record<string, unknown>>
export async function apiReact(event: H3Event): Promise<Record<string, unknown>> { export async function apiReact(event: H3Event): Promise<Record<string, unknown>> {
const userId = await bearerUserId(event); const userId = await bearerUserId(event);
const body = await readJsonBody(event); const body = await readJsonBody(event);
const reactions = [ const reactions = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] as const;
"+1",
"-1",
"laugh",
"confused",
"heart",
"hooray",
"rocket",
"eyes",
] as const;
if ( if (
!body || !body ||
!isNonEmptyString(body.owner) || !isNonEmptyString(body.owner) ||
@ -151,14 +145,7 @@ export async function apiReact(event: H3Event): Promise<Record<string, unknown>>
repo: body.repo, repo: body.repo,
issue_number: body.issueNumber, issue_number: body.issueNumber,
content: body.reaction as content: body.reaction as
| "+1" "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes",
}); });
} catch (err) { } catch (err) {
log.error({ err }, "Failed to create reaction"); log.error({ err }, "Failed to create reaction");

View file

@ -402,7 +402,10 @@ export async function adminInvite(event: H3Event): Promise<void> {
} }
const session = await getAdminSession(env.KV, getHeader(event, "cookie")); const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) { if (!session) {
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`); await sendRedirect(
event,
`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`,
);
return; return;
} }
const result = await acceptInvite(env.KV, token, session.userId, session.login); const result = await acceptInvite(env.KV, token, session.userId, session.login);
@ -480,8 +483,7 @@ export async function adminApiGroupsPut(event: H3Event): Promise<Record<string,
const members = g.members ?? normalizeGroupMembers(g); const members = g.members ?? normalizeGroupMembers(g);
const stillMine = members.some( const stillMine = members.some(
(m) => (m) =>
m.role === "owner" && m.role === "owner" && identityMatches([m.login], auth.session.userId, auth.session.login),
identityMatches([m.login], auth.session.userId, auth.session.login),
); );
const otherOwner = ownerCount(members) > 1; const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) { if (!stillMine && !otherOwner) {
@ -648,7 +650,10 @@ export async function adminApiLogs(event: H3Event): Promise<Record<string, unkno
} }
/** GET /admin/api/logs/:id */ /** GET /admin/api/logs/:id */
export async function adminApiLogsById(event: H3Event, id: number): Promise<Record<string, unknown>> { export async function adminApiLogsById(
event: H3Event,
id: number,
): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event); const auth = await requireAnyAccess(event);
const env = cfEnv(event); const env = cfEnv(event);
if (!Number.isInteger(id) || id <= 0) return respondError(event, 400, "Invalid log id"); if (!Number.isInteger(id) || id <= 0) return respondError(event, 400, "Invalid log id");
@ -783,7 +788,10 @@ export async function adminGroupInvitesGet(
} }
/** DELETE /admin/api/invites/:token */ /** DELETE /admin/api/invites/:token */
export async function adminInviteDelete(event: H3Event, token: string): Promise<Record<string, unknown>> { export async function adminInviteDelete(
event: H3Event,
token: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event); await requireAnyAccess(event);
const env = cfEnv(event); const env = cfEnv(event);
const invite = await getInvite(env.KV, token); const invite = await getInvite(env.KV, token);

View file

@ -64,11 +64,7 @@ export function requireGroup(event: H3Event, groupId: string): GroupAccess {
} }
/** Requires at least `min` role in the group (owner|admin|viewer). */ /** Requires at least `min` role in the group (owner|admin|viewer). */
export function requireGroupRole( export function requireGroupRole(event: H3Event, groupId: string, min: GroupRole): GroupAccess {
event: H3Event,
groupId: string,
min: GroupRole,
): GroupAccess {
const access = requireGroup(event, groupId); const access = requireGroup(event, groupId);
if (!access.ok) return access; if (!access.ok) return access;
const auth = currentAuth(event); const auth = currentAuth(event);
@ -98,7 +94,6 @@ export async function bearerUserId(event: H3Event): Promise<string> {
if (!auth?.startsWith("Bearer ")) if (!auth?.startsWith("Bearer "))
throw createError({ statusCode: 401, statusMessage: "Missing authorization" }); throw createError({ statusCode: 401, statusMessage: "Missing authorization" });
const userId = await findUserIdByToken(env.KV, auth.slice(7)); const userId = await findUserIdByToken(env.KV, auth.slice(7));
if (!userId) if (!userId) throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
return userId; return userId;
} }

View file

@ -157,8 +157,11 @@ export async function handleInstallPage(event: H3Event): Promise<string | void>
return; return;
} }
const accountLogin = const accountLogin =
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ?? (await getInstallationAccount(
""; env.GITHUB_APP_ID ?? "",
env.GITHUB_PRIVATE_KEY ?? "",
installationId,
)) ?? "";
const groups = await loadGroups(env.KV); const groups = await loadGroups(env.KV);
const scope = resolveScope(env, groups, session.userId, session.login); const scope = resolveScope(env, groups, session.userId, session.login);
const owned = groups.filter((g) => roleAt(scope, g.id) === "owner"); const owned = groups.filter((g) => roleAt(scope, g.id) === "owner");
@ -216,8 +219,11 @@ export async function handleInstallBind(event: H3Event): Promise<void> {
// Default: auto-create a dedicated inst-{id} group. // Default: auto-create a dedicated inst-{id} group.
const accountLogin = const accountLogin =
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ?? (await getInstallationAccount(
""; env.GITHUB_APP_ID ?? "",
env.GITHUB_PRIVATE_KEY ?? "",
installationId,
)) ?? "";
const group = await ensureInstallationGroup(env.KV, installationId, accountLogin); const group = await ensureInstallationGroup(env.KV, installationId, accountLogin);
if (!group) { if (!group) {
await sendRedirect(event, "/admin?error=install"); await sendRedirect(event, "/admin?error=install");
@ -248,7 +254,10 @@ export async function handleInstallBind(event: H3Event): Promise<void> {
adminIds: [...new Set([...(group.adminIds ?? []), session.login])], adminIds: [...new Set([...(group.adminIds ?? []), session.login])],
}; };
const all = await loadGroups(env.KV); const all = await loadGroups(env.KV);
await saveGroups(env.KV, all.map((g) => (g.id === group.id ? updated : g))); await saveGroups(
env.KV,
all.map((g) => (g.id === group.id ? updated : g)),
);
await recordAudit(env.DB, { await recordAudit(env.DB, {
ts: Date.now(), ts: Date.now(),
actorId: session.userId, actorId: session.userId,

View file

@ -117,10 +117,7 @@ export async function processWebhook(
} }
/** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */ /** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */
export async function handleWebhookRequest( export async function handleWebhookRequest(event: H3Event, tenantId?: string): Promise<unknown> {
event: H3Event,
tenantId?: string,
): Promise<unknown> {
const contentLength = Number(getHeader(event, "content-length") ?? 0); const contentLength = Number(getHeader(event, "content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) { if (contentLength > MAX_BODY_SIZE) {
setResponseStatus(event, 413); setResponseStatus(event, 413);

View file

@ -47,8 +47,8 @@ export default <Partial<Config>>{
card: "var(--shadow)", card: "var(--shadow)",
"card-hover": "0 8px 24px -12px rgba(15, 23, 42, 0.25)", "card-hover": "0 8px 24px -12px rgba(15, 23, 42, 0.25)",
"accent-lg": "0 12px 28px -10px var(--accent)", "accent-lg": "0 12px 28px -10px var(--accent)",
"drawer": "-16px 0 48px rgba(15, 23, 42, 0.12)", drawer: "-16px 0 48px rgba(15, 23, 42, 0.12)",
"modal": "0 12px 40px rgba(15, 23, 42, 0.25)", modal: "0 12px 40px rgba(15, 23, 42, 0.25)",
}, },
keyframes: { keyframes: {
rise: { rise: {

View file

@ -1,9 +1,5 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { import { adminGroupRename, adminGroupRoutesGet, adminApiMe } from "../server/lib/web/admin";
adminGroupRename,
adminGroupRoutesGet,
adminApiMe,
} from "../server/lib/web/admin";
import { createAdminSession, adminCookie } from "../server/lib/web/session"; import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups"; import { loadGroups } from "../server/lib/web/groups";
import { loadRoutes } from "../server/lib/config"; import { loadRoutes } from "../server/lib/config";

View file

@ -34,7 +34,9 @@ describe("message title spec", () => {
expect(msg.title).toBe( expect(msg.title).toBe(
"acme/widget: Pushed 1 commit to [`main`](https://github.com/acme/widget/tree/main)", "acme/widget: Pushed 1 commit to [`main`](https://github.com/acme/widget/tree/main)",
); );
expect(msg.description).toBe("[View comparison](https://github.com/acme/widget/compare/abc...def)"); expect(msg.description).toBe(
"[View comparison](https://github.com/acme/widget/compare/abc...def)",
);
}); });
it("pull_request title is repo#number: title", () => { it("pull_request title is repo#number: title", () => {
@ -101,7 +103,9 @@ describe("message title spec", () => {
sender, sender,
}), }),
); );
expect(msg.title).toBe("acme/widget: [CI — success](https://github.com/acme/widget/actions/runs/42)"); expect(msg.title).toBe(
"acme/widget: [CI — success](https://github.com/acme/widget/actions/runs/42)",
);
expect(msg.fields![1].value).toBe("✅ build"); expect(msg.fields![1].value).toBe("✅ build");
}); });
@ -122,7 +126,9 @@ describe("message title spec", () => {
sender, sender,
}), }),
); );
expect(queued.title).toBe("acme/widget: [CI — queued](https://github.com/acme/widget/actions/runs/42)"); expect(queued.title).toBe(
"acme/widget: [CI — queued](https://github.com/acme/widget/actions/runs/42)",
);
expect(queued.fields![0].value).toBe("⏳ queued"); expect(queued.fields![0].value).toBe("⏳ queued");
const running = formatEvent( const running = formatEvent(
@ -134,7 +140,9 @@ describe("message title spec", () => {
sender, sender,
}), }),
); );
expect(running.title).toBe("acme/widget: [CI — running](https://github.com/acme/widget/actions/runs/42)"); expect(running.title).toBe(
"acme/widget: [CI — running](https://github.com/acme/widget/actions/runs/42)",
);
expect(running.fields![0].value).toBe("🔄 running"); expect(running.fields![0].value).toBe("🔄 running");
}); });

View file

@ -269,7 +269,10 @@ describe("POST /auth/github/install/bind", () => {
const env = createEnv({ KV: kv }); const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice"); const sessionId = await createAdminSession(kv, "1001", "alice");
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "theirs" }); const event = bindEvent(env, adminCookie(sessionId), {
installation_id: "555",
group: "theirs",
});
await handleInstallBind(event); await handleInstallBind(event);
expect(responseStatus(event)).toBe(302); expect(responseStatus(event)).toBe(302);
expect(responseHeader(event, "location")).toBe("/admin?error=forbidden"); expect(responseHeader(event, "location")).toBe("/admin?error=forbidden");
@ -329,7 +332,10 @@ describe("oauth misc", () => {
it("starts the OAuth flow with a state token", async () => { it("starts the OAuth flow with a state token", async () => {
const kv = createMockKV(); const kv = createMockKV();
const env = createEnv({ KV: kv, GITHUB_CLIENT_ID: "client-1" }); const env = createEnv({ KV: kv, GITHUB_CLIENT_ID: "client-1" });
const event = makeEvent("/auth/github?redirect=/admin", { headers: { accept: "text/html" }, env }); const event = makeEvent("/auth/github?redirect=/admin", {
headers: { accept: "text/html" },
env,
});
await handleOAuthStart(event); await handleOAuthStart(event);
expect(responseStatus(event)).toBe(302); expect(responseStatus(event)).toBe(302);
const location = responseHeader(event, "location") ?? ""; const location = responseHeader(event, "location") ?? "";

View file

@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { describe, it, expect, beforeEach } from "bun:test";
import { createInvite, getInvite, listInvites, revokeInvite, acceptInvite } from "../server/lib/web/invites"; import {
createInvite,
getInvite,
listInvites,
revokeInvite,
acceptInvite,
} from "../server/lib/web/invites";
import { saveGroups, loadGroups } from "../server/lib/web/groups"; import { saveGroups, loadGroups } from "../server/lib/web/groups";
import type { Group } from "../server/lib/types"; import type { Group } from "../server/lib/types";

View file

@ -184,22 +184,20 @@ describe("matchRoute", () => {
it("matches branch filter with single-char ? wildcard", () => { it("matches branch filter with single-char ? wildcard", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-?" }] }; const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-?" }] };
expect( expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } })).toBe(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }), true,
).toBe(true); );
expect( expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } })).toBe(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } }), false,
).toBe(false); );
}); });
it("matches branch filter with //-wrapped regex", () => { it("matches branch filter with //-wrapped regex", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "/^feat/" }] }; const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "/^feat/" }] };
expect( expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } })).toBe(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }), true,
).toBe(true); );
expect( expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } })).toBe(false);
matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } }),
).toBe(false);
}); });
it("treats glob special chars literally when not wrapped", () => { it("treats glob special chars literally when not wrapped", () => {