chore: auto-fix lint & formatting [skip ci]

This commit is contained in:
github-actions[bot] 2026-08-24 14:52:59 +00:00
parent a77ac8ad91
commit df16dc94f7
17 changed files with 142 additions and 125 deletions

View file

@ -18,7 +18,7 @@ bun run dev # Start Nuxt dev server (HMR + Nitro)
## Scripts
| Command | Description |
| ---------------------- | ---------------------------------------- |
| ---------------------- | ------------------------------------ |
| `bun run dev` | Start Nuxt dev server |
| `bun run build` | Production build (cloudflare preset) |
| `bunx wrangler dev` | Preview a built worker (Miniflare) |

View file

@ -5,10 +5,10 @@ import { FILTER_TYPES, FILTER_OPS } from "~/types";
defineOptions({ name: "FilterNodeEditor" });
const props = withDefaults(
defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(),
{ depth: 0, deletable: false },
);
const props = withDefaults(defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(), {
depth: 0,
deletable: false,
});
const emit = defineEmits<{ (e: "remove"): void }>();

View file

@ -3,7 +3,13 @@ import { computed, reactive, ref, watch } from "vue";
import type { Filter, FilterNode, NamedFragment, Route, RouteTarget, RouteTemplate } from "~/types";
import { FRAGMENT_PRESETS, ROUTE_TEMPLATES } from "~/types";
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 {
platform: "discord" | "telegram";
@ -392,11 +398,7 @@ watch(
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.testMatch") }}</h3>
<div class="field">
<input
v-model="testEvent"
class="input"
:placeholder="t('routeEditor.testEvent')"
/>
<input v-model="testEvent" class="input" :placeholder="t('routeEditor.testEvent')" />
</div>
<div class="field">
<textarea
@ -413,7 +415,11 @@ watch(
</div>
<div v-if="testResult" class="test-result" :class="{ ok: testResult.matched }">
<span class="test-badge">
{{ testResult.matched ? t("routeEditor.testMatched") : t("routeEditor.testNotMatched") }}
{{
testResult.matched
? t("routeEditor.testMatched")
: t("routeEditor.testNotMatched")
}}
</span>
<span class="test-explanation">{{ testResult.explanation }}</span>
</div>

View file

@ -38,7 +38,9 @@ function remove(index: number) {
</script>
<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
v-for="(v, i) in modelValue"
:key="`${v}-${i}`"

View file

@ -146,8 +146,7 @@ export function describeLeaf(f: Filter, t: (key: string) => string): string {
const op = f.op ?? "eq";
const match = f.match;
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
const value =
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
const value = values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
let base: string;
if (f.type === "keyword") {
base = `${label} ${t("filterNode.matches")} ${value}`;
@ -169,9 +168,7 @@ export function describeNode(node: FilterNode, t: (key: string) => string): stri
.join(` ${t("filterNode.and")} `);
}
if ("any" in node) {
const parts = node.any
.map((c) => describeNode(c, t))
.filter((s) => s.length);
const parts = node.any.map((c) => describeNode(c, t)).filter((s) => s.length);
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
}
if ("not" in node) {

View file

@ -1,11 +1,4 @@
export type FilterType =
| "event"
| "repo"
| "actor"
| "action"
| "branch"
| "keyword"
| "field";
export type FilterType = "event" | "repo" | "actor" | "action" | "branch" | "keyword" | "field";
export type FilterOp =
| "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[] = [
"eq",

View file

@ -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.
| Type | Matches | Example |
| --------- | -------------------- | ---------------------------------- |
| --------- | ---------------------------- | ---------------------------------- |
| `event` | GitHub event name | `push`, `pull_*`, `pull_request` |
| `repo` | Repository full name | `org/repo`, `org/*` |
| `actor` | Sender login | `username`, `[bot]`, `*[bot]` |

View file

@ -211,7 +211,7 @@ 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.
| Operator | Meaning |
| -------------- | -------------------------------------------------------------------- |
| -------------- | ------------------------------------------------------- |
| `eq` (default) | Equal — globs, regexes and plain text, case-insensitive |
| `ne` | Not equal (inverse of `eq`) |
| `contains` | Value contains the pattern (substring) |
@ -242,10 +242,12 @@ A route can use a nested `ast` to combine filters with explicit grouping instead
"ast": {
"all": [
{ "type": "event", "match": "pull_request" },
{ "any": [
{
"any": [
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
]}
]
}
]
},
"targets": [{ "channelId": "..." }]

View file

@ -32,7 +32,7 @@ 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.
| Field | Type | Required | Description |
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `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 |
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |

View file

@ -66,7 +66,7 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
实操指南见[过滤器教程](./filters),包含完整示例。
| 类型 | 匹配对象 | 示例 |
| --------- | ---------------- | ---------------------------------- |
| --------- | ------------------------ | ---------------------------------- |
| `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` |
| `repo` | 仓库全名 | `org/repo`, `org/*` |
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |

View file

@ -211,7 +211,7 @@
字段过滤器(以及除 `keyword` 之外的所有过滤器类型)可通过 `op` 改变值的比较方式。默认的 `eq` 保持经典的 glob/正则/精确匹配行为。
| 操作符 | 含义 |
| -------------- | ------------------------------------------ |
| ------------ | ---------------------------------------- |
| `eq`(默认) | 相等——通配符、正则与纯文本,不区分大小写 |
| `ne` | 不相等(`eq` 的反义) |
| `contains` | 值包含模式(子串) |
@ -242,10 +242,12 @@
"ast": {
"all": [
{ "type": "event", "match": "pull_request" },
{ "any": [
{
"any": [
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
]}
]
}
]
},
"targets": [{ "channelId": "..." }]

View file

@ -32,7 +32,7 @@
`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 |
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |

View file

@ -241,7 +241,7 @@ export function evaluateFilterNode(
export function explainFilter(filter: Filter): string {
const patterns = toPatterns(filter);
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";
let base: string;
if (filter.type === "keyword") {

View file

@ -17,9 +17,7 @@ interface D1FragmentRow {
export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
try {
const stmt = db.prepare(
"SELECT id, group_id, name, node FROM d1_fragments ORDER BY id",
);
const stmt = db.prepare("SELECT id, group_id, name, node FROM d1_fragments ORDER BY id");
if (typeof stmt.all !== "function") return [];
const { results } = await stmt.all<D1FragmentRow>();
if (!results || results.length === 0) return [];
@ -35,10 +33,7 @@ export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
}
}
export async function saveFragments(
db: D1Database,
fragments: NamedFragment[],
): Promise<void> {
export async function saveFragments(db: D1Database, fragments: NamedFragment[]): Promise<void> {
const now = Date.now();
const statements: D1PreparedStatement[] = [
db.prepare("DELETE FROM d1_fragments"),

View file

@ -151,14 +151,7 @@ export interface Group {
logTarget?: RouteTarget;
}
export type FilterType =
| "event"
| "repo"
| "actor"
| "action"
| "branch"
| "keyword"
| "field";
export type FilterType = "event" | "repo" | "actor" | "action" | "branch" | "keyword" | "field";
export type FilterOp =
| "eq"

View file

@ -37,8 +37,29 @@ import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants"
import { cfEnv } from "../cf";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["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 VALID_FILTER_TYPES = new Set([
"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 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])?)*$/;
@ -1175,9 +1196,7 @@ export async function adminGroupFragmentsPut(
}
/** POST /admin/api/test-match */
export async function adminApiTestMatch(
event: H3Event,
): Promise<Record<string, unknown>> {
export async function adminApiTestMatch(event: H3Event): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");