mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(config): valibot schemas, schema version, migrations, filter AST
This commit is contained in:
parent
eaec039ad4
commit
2470bd786d
13 changed files with 617 additions and 191 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { Env, Config, Route } from "./types";
|
||||
import { log } from "./lib/log";
|
||||
import { migrateRoutes, validateRoutes } from "./config/schema";
|
||||
|
||||
const CONFIG_CACHE_TTL = 60_000;
|
||||
const ROUTES_KEY = "config:routes";
|
||||
|
|
@ -8,26 +9,13 @@ let configCache: { config: Config; expiresAt: number } | null = null;
|
|||
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
|
||||
try {
|
||||
const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
|
||||
if (stored) return normalizeRoutes(stored);
|
||||
if (stored) return validateRoutes(migrateRoutes(stored));
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load routes from KV");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy single-target routes (`target`) to the array form (`targets`).
|
||||
*/
|
||||
function normalizeRoutes(routes: Route[]): Route[] {
|
||||
return routes.map((r) => {
|
||||
if (r.targets && r.targets.length > 0) return r;
|
||||
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
|
||||
if (!legacy) return r;
|
||||
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
|
||||
return { ...rest, targets: [legacy] };
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
|
||||
await kv.put(ROUTES_KEY, JSON.stringify(routes));
|
||||
configCache = null;
|
||||
|
|
|
|||
106
server/lib/config/schema.ts
Normal file
106
server/lib/config/schema.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import * as v from "valibot";
|
||||
import type { Group, Route } from "../types";
|
||||
import { log } from "../lib/log";
|
||||
import { explainFilterNode } from "../events/filter-ast";
|
||||
|
||||
export const CONFIG_SCHEMA_VERSION = 1;
|
||||
|
||||
export const filterSchema = v.object({
|
||||
type: v.picklist(["event", "repo", "actor", "action", "branch", "keyword"]),
|
||||
match: v.union([v.string(), v.array(v.string())]),
|
||||
exclude: v.optional(v.boolean()),
|
||||
});
|
||||
|
||||
export const routeTargetSchema = v.object({
|
||||
platform: v.optional(v.picklist(["discord", "telegram"])),
|
||||
channelId: v.optional(v.string()),
|
||||
threadId: v.optional(v.string()),
|
||||
chatId: v.optional(v.string()),
|
||||
topicId: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const filterNodeSchema = v.lazy(() =>
|
||||
v.union([
|
||||
filterSchema,
|
||||
v.object({ all: v.array(filterNodeSchema) }),
|
||||
v.object({ any: v.array(filterNodeSchema) }),
|
||||
v.object({ not: filterNodeSchema }),
|
||||
]),
|
||||
);
|
||||
|
||||
export const routeSchema = v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
enabled: v.boolean(),
|
||||
filters: v.array(filterSchema),
|
||||
targets: v.array(routeTargetSchema),
|
||||
groupId: v.optional(v.string()),
|
||||
fallback: v.optional(v.boolean()),
|
||||
stop: v.optional(v.boolean()),
|
||||
discordRoleIds: v.optional(v.array(v.string())),
|
||||
ast: v.optional(filterNodeSchema),
|
||||
});
|
||||
|
||||
export const groupMemberSchema = v.object({
|
||||
login: v.string(),
|
||||
role: v.picklist(["owner", "admin", "viewer"]),
|
||||
});
|
||||
|
||||
export const forgeSourceSchema = v.object({
|
||||
host: v.string(),
|
||||
type: v.picklist(["github", "gitea"]),
|
||||
name: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const groupSchema = v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
adminIds: v.array(v.string()),
|
||||
members: v.optional(v.array(groupMemberSchema)),
|
||||
owners: v.optional(v.array(v.string())),
|
||||
providers: v.optional(v.array(v.picklist(["github", "gitea", "gitlab", "custom"]))),
|
||||
installationId: v.optional(v.number()),
|
||||
emoji: v.optional(v.boolean()),
|
||||
forgeSources: v.optional(v.array(forgeSourceSchema)),
|
||||
lang: v.optional(v.string()),
|
||||
logTarget: v.optional(routeTargetSchema),
|
||||
});
|
||||
|
||||
export function migrateRoutes(routes: Route[]): Route[] {
|
||||
return routes.map((r) => {
|
||||
if (r.targets && r.targets.length > 0) return r;
|
||||
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
|
||||
if (!legacy) return r;
|
||||
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
|
||||
return { ...rest, targets: [legacy] };
|
||||
});
|
||||
}
|
||||
|
||||
export function migrateGroups(groups: Group[]): Group[] {
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function validateRoutes(routes: Route[]): Route[] {
|
||||
for (const r of routes) {
|
||||
const result = v.safeParse(routeSchema, r);
|
||||
if (!result.success) {
|
||||
log.warn({ id: r?.id, issues: result.issues }, "Invalid route config");
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
export function validateGroups(groups: Group[]): Group[] {
|
||||
for (const g of groups) {
|
||||
const result = v.safeParse(groupSchema, g);
|
||||
if (!result.success) {
|
||||
log.warn({ id: g?.id, issues: result.issues }, "Invalid group config");
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function explainRoute(route: Route): string {
|
||||
const node = route.ast ?? { all: route.filters };
|
||||
return `${route.name}: ${explainFilterNode(node)}`;
|
||||
}
|
||||
169
server/lib/events/filter-ast.ts
Normal file
169
server/lib/events/filter-ast.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { WebhookEvent, Filter, FilterNode } from "../types";
|
||||
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
||||
const MAX_PATTERN_LENGTH = 200;
|
||||
|
||||
function isWrappedRegex(pattern: string): boolean {
|
||||
return pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/");
|
||||
}
|
||||
|
||||
function compileRegex(pattern: string): RegExp | null {
|
||||
if (pattern.length > MAX_PATTERN_LENGTH) return null;
|
||||
const cached = regexCache.get(pattern);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const re = new RegExp(pattern, "i");
|
||||
regexCache.set(pattern, re);
|
||||
return re;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compileGlob(pattern: string, anchored: boolean): RegExp | null {
|
||||
if (pattern.length > MAX_PATTERN_LENGTH) return null;
|
||||
const cacheKey = `${anchored ? "a" : "s"}:${pattern}`;
|
||||
const cached = regexCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const body = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
try {
|
||||
const re = new RegExp(anchored ? `^${body}$` : body, "i");
|
||||
regexCache.set(cacheKey, re);
|
||||
return re;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGlob(pattern: string): boolean {
|
||||
return pattern.includes("*") || pattern.includes("?");
|
||||
}
|
||||
|
||||
function matchField(pattern: string, value: string): boolean {
|
||||
if (isWrappedRegex(pattern)) {
|
||||
const re = compileRegex(pattern.slice(1, -1));
|
||||
return re ? re.test(value) : false;
|
||||
}
|
||||
if (isGlob(pattern)) {
|
||||
const re = compileGlob(pattern, true);
|
||||
return re ? re.test(value) : false;
|
||||
}
|
||||
return value.toLowerCase() === pattern.toLowerCase();
|
||||
}
|
||||
|
||||
function matchKeyword(pattern: string, body: string): boolean {
|
||||
if (isWrappedRegex(pattern)) {
|
||||
const re = compileRegex(pattern.slice(1, -1));
|
||||
return re ? re.test(body) : false;
|
||||
}
|
||||
if (isGlob(pattern)) {
|
||||
const re = compileGlob(pattern, false);
|
||||
return re ? re.test(body) : false;
|
||||
}
|
||||
return body.includes(pattern.toLowerCase());
|
||||
}
|
||||
|
||||
export function getKeywordBody(event: WebhookEvent): string {
|
||||
const cached = keywordBodyCache.get(event);
|
||||
if (cached !== undefined) return cached;
|
||||
const body = JSON.stringify(event.payload).toLowerCase();
|
||||
keywordBodyCache.set(event, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
function extractBranch(event: WebhookEvent): string | undefined {
|
||||
const p = event.payload;
|
||||
switch (event.event) {
|
||||
case "push":
|
||||
return typeof p.ref === "string" ? p.ref.replace("refs/heads/", "") : undefined;
|
||||
case "pull_request":
|
||||
case "pull_request_review":
|
||||
case "pull_request_review_comment":
|
||||
return (p.pull_request as { head?: { ref?: string } } | undefined)?.head?.ref;
|
||||
case "create":
|
||||
case "delete":
|
||||
return typeof p.ref === "string" ? p.ref : undefined;
|
||||
case "workflow_run":
|
||||
return (p.workflow_run as { head_branch?: string } | undefined)?.head_branch;
|
||||
case "check_suite":
|
||||
return (p.check_suite as { head_branch?: string } | undefined)?.head_branch;
|
||||
case "workflow_job":
|
||||
return (p.workflow_job as { head_branch?: string } | undefined)?.head_branch;
|
||||
case "deployment":
|
||||
return typeof p.ref === "string" ? p.ref.replace("refs/heads/", "") : undefined;
|
||||
case "commit_comment":
|
||||
return undefined;
|
||||
case "code_scanning_alert":
|
||||
return typeof p.ref === "string" ? p.ref : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
|
||||
if (filter.type === "keyword") {
|
||||
const body = keywordBody ?? getKeywordBody(event);
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
const matches = patterns.some((p) => matchKeyword(p, body));
|
||||
return filter.exclude ? !matches : matches;
|
||||
}
|
||||
|
||||
const value = valueFor(filter, event);
|
||||
if (!value) return false;
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
const matches = patterns.some((p) => matchField(p, value));
|
||||
return filter.exclude ? !matches : matches;
|
||||
}
|
||||
|
||||
function valueFor(filter: Filter, event: WebhookEvent): string | undefined {
|
||||
const p = event.payload;
|
||||
switch (filter.type) {
|
||||
case "event":
|
||||
return event.event;
|
||||
case "repo":
|
||||
return (p.repository as { full_name?: string } | undefined)?.full_name;
|
||||
case "actor":
|
||||
return (p.sender as { login?: string } | undefined)?.login;
|
||||
case "action":
|
||||
return typeof p.action === "string" ? p.action : undefined;
|
||||
case "branch":
|
||||
return extractBranch(event);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function containsKeyword(node: FilterNode): boolean {
|
||||
if ("all" in node) return node.all.some(containsKeyword);
|
||||
if ("any" in node) return node.any.some(containsKeyword);
|
||||
if ("not" in node) return containsKeyword(node.not);
|
||||
return node.type === "keyword";
|
||||
}
|
||||
|
||||
export function evaluateFilterNode(
|
||||
node: FilterNode,
|
||||
event: WebhookEvent,
|
||||
keywordBody?: string,
|
||||
): boolean {
|
||||
if ("all" in node) return node.all.every((n) => evaluateFilterNode(n, event, keywordBody));
|
||||
if ("any" in node) return node.any.some((n) => evaluateFilterNode(n, event, keywordBody));
|
||||
if ("not" in node) return !evaluateFilterNode(node.not, event, keywordBody);
|
||||
return matchFilter(node, event, keywordBody);
|
||||
}
|
||||
|
||||
export function explainFilter(filter: Filter): string {
|
||||
const value = Array.isArray(filter.match)
|
||||
? filter.match.map((m) => JSON.stringify(m)).join(" or ")
|
||||
: JSON.stringify(filter.match);
|
||||
const base = `${filter.type} ${filter.type === "keyword" ? "matches" : "is"} ${value}`;
|
||||
return filter.exclude ? `not (${base})` : base;
|
||||
}
|
||||
|
||||
export function explainFilterNode(node: FilterNode): string {
|
||||
if ("all" in node) return `(${node.all.map(explainFilterNode).join(" and ")})`;
|
||||
if ("any" in node) return `(${node.any.map(explainFilterNode).join(" or ")})`;
|
||||
if ("not" in node) return `not (${explainFilterNode(node.not)})`;
|
||||
return explainFilter(node);
|
||||
}
|
||||
|
|
@ -1,173 +1,5 @@
|
|||
import type { WebhookEvent, Route, Filter } from "../types";
|
||||
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
||||
const MAX_PATTERN_LENGTH = 200;
|
||||
|
||||
/** True when a pattern is wrapped in `/.../` and should be parsed as a RegExp. */
|
||||
function isWrappedRegex(pattern: string): boolean {
|
||||
return pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/");
|
||||
}
|
||||
|
||||
function compileRegex(pattern: string): RegExp | null {
|
||||
if (pattern.length > MAX_PATTERN_LENGTH) return null;
|
||||
const cached = regexCache.get(pattern);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const re = new RegExp(pattern, "i");
|
||||
regexCache.set(pattern, re);
|
||||
return re;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `*` / `?` glob into a RegExp. `*` matches any sequence of
|
||||
* characters, `?` matches exactly one; everything else matches literally
|
||||
* (case-insensitive). Anchored globs are full-value matches (`^...$`),
|
||||
* unanchored ones behave as a search within the value (keyword semantics).
|
||||
*/
|
||||
function compileGlob(pattern: string, anchored: boolean): RegExp | null {
|
||||
if (pattern.length > MAX_PATTERN_LENGTH) return null;
|
||||
const key = `${anchored ? "a" : "s"}:${pattern}`;
|
||||
const cached = regexCache.get(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const body = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
const re = new RegExp(anchored ? `^${body}$` : body, "i");
|
||||
regexCache.set(key, re);
|
||||
return re;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGlob(pattern: string): boolean {
|
||||
return pattern.includes("*") || pattern.includes("?");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified pattern syntax shared by every filter type:
|
||||
* - wrapped in `//` → parsed as a regular expression (case-insensitive)
|
||||
* - contains `*` or `?` → glob matching (`*` = any run, `?` = one character)
|
||||
* - anything else → plain text
|
||||
*/
|
||||
|
||||
/** Match a pattern against a whole field value (event/repo/actor/action/branch). */
|
||||
function matchField(pattern: string, value: string): boolean {
|
||||
if (isWrappedRegex(pattern)) {
|
||||
const re = compileRegex(pattern.slice(1, -1));
|
||||
return !!re && re.test(value);
|
||||
}
|
||||
if (isGlob(pattern)) {
|
||||
const re = compileGlob(pattern, true);
|
||||
return !!re && re.test(value);
|
||||
}
|
||||
return value.toLowerCase() === pattern.toLowerCase();
|
||||
}
|
||||
|
||||
/** Match a pattern against the lowercased JSON payload body (keyword search). */
|
||||
function matchKeyword(pattern: string, body: string): boolean {
|
||||
if (isWrappedRegex(pattern)) {
|
||||
const re = compileRegex(pattern.slice(1, -1));
|
||||
return !!re && re.test(body);
|
||||
}
|
||||
if (isGlob(pattern)) {
|
||||
const re = compileGlob(pattern, false);
|
||||
return !!re && re.test(body);
|
||||
}
|
||||
return body.includes(pattern.toLowerCase());
|
||||
}
|
||||
|
||||
function getKeywordBody(event: WebhookEvent): string {
|
||||
const cached = keywordBodyCache.get(event);
|
||||
if (cached !== undefined) return cached;
|
||||
const body = JSON.stringify(event.payload).toLowerCase();
|
||||
keywordBodyCache.set(event, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
function extractBranch(event: WebhookEvent): string | undefined {
|
||||
if (event.event === "push") {
|
||||
return (event.payload.ref as string)?.replace("refs/heads/", "");
|
||||
}
|
||||
if (
|
||||
event.event === "pull_request" ||
|
||||
event.event === "pull_request_review" ||
|
||||
event.event === "pull_request_review_comment"
|
||||
) {
|
||||
const pr = event.payload.pull_request as { head?: { ref?: string } } | undefined;
|
||||
return pr?.head?.ref;
|
||||
}
|
||||
if (event.event === "create" || event.event === "delete") {
|
||||
return event.payload.ref as string | undefined;
|
||||
}
|
||||
if (event.event === "workflow_run") {
|
||||
const wf = event.payload.workflow_run as { head_branch?: string } | undefined;
|
||||
return wf?.head_branch;
|
||||
}
|
||||
if (event.event === "check_suite") {
|
||||
const suite = event.payload.check_suite as { head_branch?: string } | undefined;
|
||||
return suite?.head_branch;
|
||||
}
|
||||
if (event.event === "workflow_job") {
|
||||
const job = event.payload.workflow_job as { head_branch?: string } | undefined;
|
||||
return job?.head_branch;
|
||||
}
|
||||
if (event.event === "deployment") {
|
||||
const deployment = event.payload.deployment as { ref?: string } | undefined;
|
||||
return deployment?.ref?.replace("refs/heads/", "");
|
||||
}
|
||||
if (event.event === "commit_comment") {
|
||||
const comment = event.payload.comment as { position?: number | null } | undefined;
|
||||
if (comment?.position != null) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (event.event === "code_scanning_alert") {
|
||||
return event.payload.ref as string | undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
|
||||
let value: string | undefined;
|
||||
|
||||
switch (filter.type) {
|
||||
case "event":
|
||||
value = event.event;
|
||||
break;
|
||||
case "repo":
|
||||
value = (event.payload.repository as { full_name?: string })?.full_name;
|
||||
break;
|
||||
case "actor":
|
||||
value = (event.payload.sender as { login?: string })?.login;
|
||||
break;
|
||||
case "action":
|
||||
value = event.payload.action as string;
|
||||
break;
|
||||
case "branch":
|
||||
value = extractBranch(event);
|
||||
break;
|
||||
case "keyword": {
|
||||
const body = keywordBody ?? getKeywordBody(event);
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
const matches = patterns.some((p) => matchKeyword(p, body));
|
||||
return filter.exclude ? !matches : matches;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value) return false;
|
||||
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
const matches = patterns.some((p) => matchField(p, value));
|
||||
|
||||
return filter.exclude ? !matches : matches;
|
||||
}
|
||||
import type { WebhookEvent, Route, FilterNode } from "../types";
|
||||
import { evaluateFilterNode, containsKeyword, getKeywordBody } from "./filter-ast";
|
||||
|
||||
export function eventOwners(event: WebhookEvent): string[] {
|
||||
const owners = new Set<string>();
|
||||
|
|
@ -181,7 +13,7 @@ export function eventOwners(event: WebhookEvent): string[] {
|
|||
|
||||
export function matchRoute(route: Route, event: WebhookEvent): boolean {
|
||||
if (!route.enabled) return false;
|
||||
const hasKeyword = route.filters.some((f) => f.type === "keyword");
|
||||
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined;
|
||||
return route.filters.every((f) => matchFilter(f, event, keywordBody));
|
||||
const node: FilterNode = route.ast ?? { all: route.filters };
|
||||
const keywordBody = containsKeyword(node) ? getKeywordBody(event) : undefined;
|
||||
return evaluateFilterNode(node, event, keywordBody);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ export interface Route {
|
|||
filters: Filter[];
|
||||
targets: RouteTarget[];
|
||||
groupId?: string;
|
||||
/**
|
||||
* Optional explicit filter expression tree. When present it replaces the
|
||||
* flat `filters` array (which is equivalent to `{ all: filters }`). Omitted
|
||||
* on routes stored without an AST — matching falls back to `filters`.
|
||||
*/
|
||||
ast?: FilterNode;
|
||||
/**
|
||||
* Fallback route: only fires when no other (non-fallback) route matched the
|
||||
* event. Multiple fallback routes may exist; they are all skipped whenever at
|
||||
|
|
@ -154,6 +160,35 @@ export interface Filter {
|
|||
exclude?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter node matches an event when every child matches.
|
||||
*/
|
||||
export interface FilterAll {
|
||||
all: FilterNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter node matches an event when any child matches.
|
||||
*/
|
||||
export interface FilterAny {
|
||||
any: FilterNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter node matches an event when its child does not match.
|
||||
*/
|
||||
export interface FilterNot {
|
||||
not: FilterNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable filter expression tree. Leaf nodes are `Filter`; the `all`,
|
||||
* `any` and `not` nodes compose them. A route's `filters` array stays the
|
||||
* flat AND-of-filters form (equivalent to `{ all: filters }`); `ast` optionally
|
||||
* replaces it with an explicit tree.
|
||||
*/
|
||||
export type FilterNode = Filter | FilterAll | FilterAny | FilterNot;
|
||||
|
||||
export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom";
|
||||
|
||||
export interface WebhookEvent {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Env, Group, GroupMember, GroupRole } from "../types";
|
||||
import { isAdminUser } from "./session";
|
||||
import { log } from "../lib/log";
|
||||
import { migrateGroups, validateGroups } from "../config/schema";
|
||||
|
||||
const GROUPS_KEY = "config:groups";
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ export function normalizeGroupMembers(group: Group): GroupMember[] {
|
|||
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||
try {
|
||||
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
|
||||
if (Array.isArray(stored)) return stored;
|
||||
if (Array.isArray(stored)) return validateGroups(migrateGroups(stored));
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load groups from KV");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue