mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
chore: auto-fix lint & formatting [skip ci]
This commit is contained in:
parent
a77ac8ad91
commit
df16dc94f7
17 changed files with 142 additions and 125 deletions
|
|
@ -17,19 +17,19 @@ bun run dev # Start Nuxt dev server (HMR + Nitro)
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | ---------------------------------------- |
|
| ---------------------- | ------------------------------------ |
|
||||||
| `bun run dev` | Start Nuxt dev server |
|
| `bun run dev` | Start Nuxt dev server |
|
||||||
| `bun run build` | Production build (cloudflare preset) |
|
| `bun run build` | Production build (cloudflare preset) |
|
||||||
| `bunx wrangler dev` | Preview a built worker (Miniflare) |
|
| `bunx wrangler dev` | Preview a built worker (Miniflare) |
|
||||||
| `bun run typecheck` | TypeScript type checking |
|
| `bun run typecheck` | TypeScript type checking |
|
||||||
| `bun run lint` | ESLint (TypeScript) |
|
| `bun run lint` | ESLint (TypeScript) |
|
||||||
| `bun run lint:md` | Markdownlint |
|
| `bun run lint:md` | Markdownlint |
|
||||||
| `bun test` | Run the unit-test suite |
|
| `bun test` | Run the unit-test suite |
|
||||||
| `bun run format` | Format all files with Prettier |
|
| `bun run format` | Format all files with Prettier |
|
||||||
| `bun run format:check` | Check Prettier formatting |
|
| `bun run format:check` | Check Prettier formatting |
|
||||||
| `bun run docs:dev` | Start the VitePress docs dev server |
|
| `bun run docs:dev` | Start the VitePress docs dev server |
|
||||||
| `bun run db:migrate` | Apply D1 migrations locally |
|
| `bun run db:migrate` | Apply D1 migrations locally |
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { FILTER_TYPES, FILTER_OPS } from "~/types";
|
||||||
|
|
||||||
defineOptions({ name: "FilterNodeEditor" });
|
defineOptions({ name: "FilterNodeEditor" });
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(), {
|
||||||
defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(),
|
depth: 0,
|
||||||
{ depth: 0, deletable: false },
|
deletable: false,
|
||||||
);
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: "remove"): void }>();
|
const emit = defineEmits<{ (e: "remove"): void }>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import { computed, reactive, ref, watch } from "vue";
|
||||||
import type { Filter, FilterNode, NamedFragment, Route, RouteTarget, RouteTemplate } from "~/types";
|
import type { Filter, FilterNode, NamedFragment, Route, RouteTarget, RouteTemplate } from "~/types";
|
||||||
import { FRAGMENT_PRESETS, ROUTE_TEMPLATES } from "~/types";
|
import { FRAGMENT_PRESETS, ROUTE_TEMPLATES } from "~/types";
|
||||||
import type { NodeForm } from "~/composables/useFilterNode";
|
import type { NodeForm } from "~/composables/useFilterNode";
|
||||||
import { blankLeafForm, blankNode, nodeToForm, nodeFormToRouteFilters, formToNode } from "~/composables/useFilterNode";
|
import {
|
||||||
|
blankLeafForm,
|
||||||
|
blankNode,
|
||||||
|
nodeToForm,
|
||||||
|
nodeFormToRouteFilters,
|
||||||
|
formToNode,
|
||||||
|
} from "~/composables/useFilterNode";
|
||||||
|
|
||||||
interface TargetForm {
|
interface TargetForm {
|
||||||
platform: "discord" | "telegram";
|
platform: "discord" | "telegram";
|
||||||
|
|
@ -392,11 +398,7 @@ watch(
|
||||||
<section class="editor-section">
|
<section class="editor-section">
|
||||||
<h3 class="editor-section-title">{{ t("routeEditor.testMatch") }}</h3>
|
<h3 class="editor-section-title">{{ t("routeEditor.testMatch") }}</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input
|
<input v-model="testEvent" class="input" :placeholder="t('routeEditor.testEvent')" />
|
||||||
v-model="testEvent"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('routeEditor.testEvent')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -413,7 +415,11 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
<div v-if="testResult" class="test-result" :class="{ ok: testResult.matched }">
|
<div v-if="testResult" class="test-result" :class="{ ok: testResult.matched }">
|
||||||
<span class="test-badge">
|
<span class="test-badge">
|
||||||
{{ testResult.matched ? t("routeEditor.testMatched") : t("routeEditor.testNotMatched") }}
|
{{
|
||||||
|
testResult.matched
|
||||||
|
? t("routeEditor.testMatched")
|
||||||
|
: t("routeEditor.testNotMatched")
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="test-explanation">{{ testResult.explanation }}</span>
|
<span class="test-explanation">{{ testResult.explanation }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ function remove(index: number) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center gap-1 rounded-md border border-border bg-surface/60 px-2 py-1.5 focus-within:border-accent">
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-1 rounded-md border border-border bg-surface/60 px-2 py-1.5 focus-within:border-accent"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-for="(v, i) in modelValue"
|
v-for="(v, i) in modelValue"
|
||||||
:key="`${v}-${i}`"
|
:key="`${v}-${i}`"
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,7 @@ export function describeLeaf(f: Filter, t: (key: string) => string): string {
|
||||||
const op = f.op ?? "eq";
|
const op = f.op ?? "eq";
|
||||||
const match = f.match;
|
const match = f.match;
|
||||||
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
|
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
|
||||||
const value =
|
const value = values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
|
||||||
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
|
|
||||||
let base: string;
|
let base: string;
|
||||||
if (f.type === "keyword") {
|
if (f.type === "keyword") {
|
||||||
base = `${label} ${t("filterNode.matches")} ${value}`;
|
base = `${label} ${t("filterNode.matches")} ${value}`;
|
||||||
|
|
@ -169,9 +168,7 @@ export function describeNode(node: FilterNode, t: (key: string) => string): stri
|
||||||
.join(` ${t("filterNode.and")} `);
|
.join(` ${t("filterNode.and")} `);
|
||||||
}
|
}
|
||||||
if ("any" in node) {
|
if ("any" in node) {
|
||||||
const parts = node.any
|
const parts = node.any.map((c) => describeNode(c, t)).filter((s) => s.length);
|
||||||
.map((c) => describeNode(c, t))
|
|
||||||
.filter((s) => s.length);
|
|
||||||
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
|
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
|
||||||
}
|
}
|
||||||
if ("not" in node) {
|
if ("not" in node) {
|
||||||
|
|
|
||||||
19
app/types.ts
19
app/types.ts
|
|
@ -1,11 +1,4 @@
|
||||||
export type FilterType =
|
export type FilterType = "event" | "repo" | "actor" | "action" | "branch" | "keyword" | "field";
|
||||||
| "event"
|
|
||||||
| "repo"
|
|
||||||
| "actor"
|
|
||||||
| "action"
|
|
||||||
| "branch"
|
|
||||||
| "keyword"
|
|
||||||
| "field";
|
|
||||||
|
|
||||||
export type FilterOp =
|
export type FilterOp =
|
||||||
| "eq"
|
| "eq"
|
||||||
|
|
@ -267,7 +260,15 @@ export const FRAGMENT_PRESETS: FragmentPreset[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword", "field"] as const;
|
export const FILTER_TYPES = [
|
||||||
|
"event",
|
||||||
|
"repo",
|
||||||
|
"actor",
|
||||||
|
"action",
|
||||||
|
"branch",
|
||||||
|
"keyword",
|
||||||
|
"field",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const FILTER_OPS: FilterOp[] = [
|
export const FILTER_OPS: FilterOp[] = [
|
||||||
"eq",
|
"eq",
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL
|
||||||
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
||||||
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
||||||
| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
|
| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
|
||||||
| `GET /admin/api/groups/:id/fragments` | List a group's named filter fragments |
|
| `GET /admin/api/groups/:id/fragments` | List a group's named filter fragments |
|
||||||
| `PUT /admin/api/groups/:id/fragments` | Replace a group's named filter fragments (owner/admin) |
|
| `PUT /admin/api/groups/:id/fragments` | Replace a group's named filter fragments (owner/admin) |
|
||||||
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
||||||
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
||||||
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
||||||
|
|
@ -33,7 +33,7 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL
|
||||||
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
||||||
| `GET /admin/api/metrics` | Delivery stats (totals, failure rate, per platform/event/status, recent failures); optional `?groupId=` scope; recent failures scoped to accessible groups for non-super |
|
| `GET /admin/api/metrics` | Delivery stats (totals, failure rate, per platform/event/status, recent failures); optional `?groupId=` scope; recent failures scoped to accessible groups for non-super |
|
||||||
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
||||||
| `POST /admin/api/test-match` | Stateless filter dry-run — evaluate a filter node against a pasted JSON payload (no event is stored) |
|
| `POST /admin/api/test-match` | Stateless filter dry-run — evaluate a filter node against a pasted JSON payload (no event is stored) |
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,15 +66,15 @@ 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]` |
|
||||||
| `action` | Event action | `opened`, `closed`, `published` |
|
| `action` | Event action | `opened`, `closed`, `published` |
|
||||||
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
||||||
| `field` | Any payload field (JSONPath) | `path: "pull_request.user.login"` |
|
| `field` | Any payload field (JSONPath) | `path: "pull_request.user.login"` |
|
||||||
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
||||||
|
|
||||||
### Filter Behavior
|
### Filter Behavior
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,18 +210,18 @@ Matches an arbitrary field of the webhook payload using a dot-separated path, e.
|
||||||
|
|
||||||
Field filters (and every filter type except `keyword`) accept an `op` to change how the value is compared. The default `eq` keeps the classic glob/regex/exact behaviour.
|
Field filters (and every filter type except `keyword`) accept an `op` to change how the value is compared. The default `eq` keeps the classic glob/regex/exact behaviour.
|
||||||
|
|
||||||
| Operator | Meaning |
|
| Operator | Meaning |
|
||||||
| -------------- | -------------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------- |
|
||||||
| `eq` (default) | Equal — globs, regexes and plain text, case-insensitive |
|
| `eq` (default) | Equal — globs, regexes and plain text, case-insensitive |
|
||||||
| `ne` | Not equal (inverse of `eq`) |
|
| `ne` | Not equal (inverse of `eq`) |
|
||||||
| `contains` | Value contains the pattern (substring) |
|
| `contains` | Value contains the pattern (substring) |
|
||||||
| `startsWith` | Value starts with the pattern |
|
| `startsWith` | Value starts with the pattern |
|
||||||
| `endsWith` | Value ends with the pattern |
|
| `endsWith` | Value ends with the pattern |
|
||||||
| `regex` | Explicit regular expression match |
|
| `regex` | Explicit regular expression match |
|
||||||
| `gt` / `gte` | Numeric greater-than / greater-or-equal |
|
| `gt` / `gte` | Numeric greater-than / greater-or-equal |
|
||||||
| `lt` / `lte` | Numeric less-than / less-or-equal |
|
| `lt` / `lte` | Numeric less-than / less-or-equal |
|
||||||
| `in` | Value equals any of the listed patterns |
|
| `in` | Value equals any of the listed patterns |
|
||||||
| `exists` | The field is present (non-null); `match` is ignored |
|
| `exists` | The field is present (non-null); `match` is ignored |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
|
|
@ -242,10 +242,12 @@ A route can use a nested `ast` to combine filters with explicit grouping instead
|
||||||
"ast": {
|
"ast": {
|
||||||
"all": [
|
"all": [
|
||||||
{ "type": "event", "match": "pull_request" },
|
{ "type": "event", "match": "pull_request" },
|
||||||
{ "any": [
|
{
|
||||||
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
"any": [
|
||||||
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
||||||
]}
|
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"targets": [{ "channelId": "..." }]
|
"targets": [{ "channelId": "..." }]
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ There are **no default routes** — each route must define its own target. If no
|
||||||
|
|
||||||
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
|
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
|
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
| `groupId` | string | Yes | Id of the [group](./groups) this route belongs to |
|
| `groupId` | string | Yes | Id of the [group](./groups) this route belongs to |
|
||||||
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
|
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
|
||||||
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
|
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
|
||||||
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
|
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
|
||||||
| `ast` | object | No | Boolean filter tree (`{all:[...]}` / `{any:[...]}` / `{not:{...}}`); takes precedence over `filters` when present |
|
| `ast` | object | No | Boolean filter tree (`{all:[...]}` / `{any:[...]}` / `{not:{...}}`); takes precedence over `filters` when present |
|
||||||
|
|
||||||
## Discord Role Mentions
|
## Discord Role Mentions
|
||||||
|
|
|
||||||
|
|
@ -65,15 +65,15 @@ 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-/` |
|
||||||
| `field` | 任意载荷字段(JSONPath) | `path: "pull_request.user.login"` |
|
| `field` | 任意载荷字段(JSONPath) | `path: "pull_request.user.login"` |
|
||||||
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
||||||
|
|
||||||
### 过滤器行为
|
### 过滤器行为
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,18 +210,18 @@
|
||||||
|
|
||||||
字段过滤器(以及除 `keyword` 之外的所有过滤器类型)可通过 `op` 改变值的比较方式。默认的 `eq` 保持经典的 glob/正则/精确匹配行为。
|
字段过滤器(以及除 `keyword` 之外的所有过滤器类型)可通过 `op` 改变值的比较方式。默认的 `eq` 保持经典的 glob/正则/精确匹配行为。
|
||||||
|
|
||||||
| 操作符 | 含义 |
|
| 操作符 | 含义 |
|
||||||
| -------------- | ------------------------------------------ |
|
| ------------ | ---------------------------------------- |
|
||||||
| `eq`(默认) | 相等——通配符、正则与纯文本,不区分大小写 |
|
| `eq`(默认) | 相等——通配符、正则与纯文本,不区分大小写 |
|
||||||
| `ne` | 不相等(`eq` 的反义) |
|
| `ne` | 不相等(`eq` 的反义) |
|
||||||
| `contains` | 值包含模式(子串) |
|
| `contains` | 值包含模式(子串) |
|
||||||
| `startsWith` | 值以模式开头 |
|
| `startsWith` | 值以模式开头 |
|
||||||
| `endsWith` | 值以模式结尾 |
|
| `endsWith` | 值以模式结尾 |
|
||||||
| `regex` | 显式正则表达式匹配 |
|
| `regex` | 显式正则表达式匹配 |
|
||||||
| `gt` / `gte` | 数值大于 / 大于等于 |
|
| `gt` / `gte` | 数值大于 / 大于等于 |
|
||||||
| `lt` / `lte` | 数值小于 / 小于等于 |
|
| `lt` / `lte` | 数值小于 / 小于等于 |
|
||||||
| `in` | 值等于任一列出的模式 |
|
| `in` | 值等于任一列出的模式 |
|
||||||
| `exists` | 字段存在(非 null);忽略 `match` |
|
| `exists` | 字段存在(非 null);忽略 `match` |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
|
|
@ -242,10 +242,12 @@
|
||||||
"ast": {
|
"ast": {
|
||||||
"all": [
|
"all": [
|
||||||
{ "type": "event", "match": "pull_request" },
|
{ "type": "event", "match": "pull_request" },
|
||||||
{ "any": [
|
{
|
||||||
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
"any": [
|
||||||
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
||||||
]}
|
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"targets": [{ "channelId": "..." }]
|
"targets": [{ "channelId": "..." }]
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@
|
||||||
|
|
||||||
`targets` 的每一项都是一个推送目标,因此一条路由可同时转发到多个频道(例如一个 Discord 频道**和**一个 Telegram 群组)。`target.platform` 选择平台:`discord`(默认)或 `telegram`。**Discord** 目标要求 `target.channelId`(可选 `target.threadId` 指定子区);**Telegram** 目标要求 `target.chatId`(群组/超级群组 id,如 `-1001234567890`),可选 `target.topicId`(话题的 `message_thread_id`,相当于 Discord 子区)。没有默认频道回退。
|
`targets` 的每一项都是一个推送目标,因此一条路由可同时转发到多个频道(例如一个 Discord 频道**和**一个 Telegram 群组)。`target.platform` 选择平台:`discord`(默认)或 `telegram`。**Discord** 目标要求 `target.channelId`(可选 `target.threadId` 指定子区);**Telegram** 目标要求 `target.chatId`(群组/超级群组 id,如 `-1001234567890`),可选 `target.topicId`(话题的 `message_thread_id`,相当于 Discord 子区)。没有默认频道回退。
|
||||||
|
|
||||||
| 字段 | 类型 | 必需 | 说明 |
|
| 字段 | 类型 | 必需 | 说明 |
|
||||||
| ---------------- | -------- | ---- | ------------------------------------------------------------------------ |
|
| ---------------- | -------- | ---- | ------------------------------------------------------------------------------------- |
|
||||||
| `groupId` | string | 是 | 路由所属[分组](./groups)的 id |
|
| `groupId` | string | 是 | 路由所属[分组](./groups)的 id |
|
||||||
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
|
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
|
||||||
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |
|
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |
|
||||||
| `discordRoleIds` | string[] | 否 | 路由触发时要提醒的 Discord 身份组 id;仅对 Discord 目标生效 |
|
| `discordRoleIds` | string[] | 否 | 路由触发时要提醒的 Discord 身份组 id;仅对 Discord 目标生效 |
|
||||||
| `ast` | object | 否 | 布尔过滤器树(`{all:[...]}` / `{any:[...]}` / `{not:{...}}`);存在时优先于 `filters` |
|
| `ast` | object | 否 | 布尔过滤器树(`{all:[...]}` / `{any:[...]}` / `{not:{...}}`);存在时优先于 `filters` |
|
||||||
|
|
||||||
## Discord 身份组提醒
|
## Discord 身份组提醒
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ export function evaluateFilterNode(
|
||||||
export function explainFilter(filter: Filter): string {
|
export function explainFilter(filter: Filter): string {
|
||||||
const patterns = toPatterns(filter);
|
const patterns = toPatterns(filter);
|
||||||
const value = patterns.map((m) => JSON.stringify(m)).join(" or ");
|
const value = patterns.map((m) => JSON.stringify(m)).join(" or ");
|
||||||
const label = filter.type === "field" ? filter.path ?? "field" : filter.type;
|
const label = filter.type === "field" ? (filter.path ?? "field") : filter.type;
|
||||||
const op = filter.op ?? "eq";
|
const op = filter.op ?? "eq";
|
||||||
let base: string;
|
let base: string;
|
||||||
if (filter.type === "keyword") {
|
if (filter.type === "keyword") {
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@ interface D1FragmentRow {
|
||||||
|
|
||||||
export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
|
export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare("SELECT id, group_id, name, node FROM d1_fragments ORDER BY id");
|
||||||
"SELECT id, group_id, name, node FROM d1_fragments ORDER BY id",
|
|
||||||
);
|
|
||||||
if (typeof stmt.all !== "function") return [];
|
if (typeof stmt.all !== "function") return [];
|
||||||
const { results } = await stmt.all<D1FragmentRow>();
|
const { results } = await stmt.all<D1FragmentRow>();
|
||||||
if (!results || results.length === 0) return [];
|
if (!results || results.length === 0) return [];
|
||||||
|
|
@ -35,10 +33,7 @@ export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveFragments(
|
export async function saveFragments(db: D1Database, fragments: NamedFragment[]): Promise<void> {
|
||||||
db: D1Database,
|
|
||||||
fragments: NamedFragment[],
|
|
||||||
): Promise<void> {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const statements: D1PreparedStatement[] = [
|
const statements: D1PreparedStatement[] = [
|
||||||
db.prepare("DELETE FROM d1_fragments"),
|
db.prepare("DELETE FROM d1_fragments"),
|
||||||
|
|
|
||||||
|
|
@ -151,14 +151,7 @@ export interface Group {
|
||||||
logTarget?: RouteTarget;
|
logTarget?: RouteTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterType =
|
export type FilterType = "event" | "repo" | "actor" | "action" | "branch" | "keyword" | "field";
|
||||||
| "event"
|
|
||||||
| "repo"
|
|
||||||
| "actor"
|
|
||||||
| "action"
|
|
||||||
| "branch"
|
|
||||||
| "keyword"
|
|
||||||
| "field";
|
|
||||||
|
|
||||||
export type FilterOp =
|
export type FilterOp =
|
||||||
| "eq"
|
| "eq"
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,29 @@ import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants"
|
||||||
import { cfEnv } from "../cf";
|
import { cfEnv } from "../cf";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
|
|
||||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword", "field"]);
|
const VALID_FILTER_TYPES = new Set([
|
||||||
const VALID_OPS = new Set(["eq", "ne", "contains", "startsWith", "endsWith", "regex", "gt", "gte", "lt", "lte", "in", "exists"]);
|
"event",
|
||||||
|
"repo",
|
||||||
|
"actor",
|
||||||
|
"action",
|
||||||
|
"branch",
|
||||||
|
"keyword",
|
||||||
|
"field",
|
||||||
|
]);
|
||||||
|
const VALID_OPS = new Set([
|
||||||
|
"eq",
|
||||||
|
"ne",
|
||||||
|
"contains",
|
||||||
|
"startsWith",
|
||||||
|
"endsWith",
|
||||||
|
"regex",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"in",
|
||||||
|
"exists",
|
||||||
|
]);
|
||||||
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const HOST_RE =
|
const HOST_RE =
|
||||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||||
|
|
@ -1175,9 +1196,7 @@ export async function adminGroupFragmentsPut(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /admin/api/test-match */
|
/** POST /admin/api/test-match */
|
||||||
export async function adminApiTestMatch(
|
export async function adminApiTestMatch(event: H3Event): Promise<Record<string, unknown>> {
|
||||||
event: H3Event,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
await requireAnyAccess(event);
|
await requireAnyAccess(event);
|
||||||
const body = await readJsonBody(event);
|
const body = await readJsonBody(event);
|
||||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue