diff --git a/admin/assets/css/main.css b/admin/assets/css/main.css
index 51a816e..1fefcb3 100644
--- a/admin/assets/css/main.css
+++ b/admin/assets/css/main.css
@@ -14,6 +14,8 @@
--accent-text: #3730a3;
--ok: #16a34a;
--ok-dim: #ecfdf3;
+ --warn: #b45309;
+ --warn-dim: #fef3c7;
--bad: #dc2626;
--bad-dim: #fef2f2;
--dot: rgba(15, 23, 42, 0.05);
@@ -44,6 +46,8 @@
--accent-text: #c7d2fe;
--ok: #22c55e;
--ok-dim: #10241a;
+ --warn: #fbbf24;
+ --warn-dim: #2a2410;
--bad: #f05252;
--bad-dim: #2a1618;
--dot: rgba(148, 163, 184, 0.08);
@@ -345,6 +349,12 @@ main {
border: 1px solid transparent;
}
+.badge.fallback {
+ background: var(--warn-dim);
+ color: var(--warn);
+ border: 1px solid transparent;
+}
+
.card-actions {
display: flex;
gap: 6px;
diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue
index 3c74c32..1f0b75c 100644
--- a/admin/components/RouteCard.vue
+++ b/admin/components/RouteCard.vue
@@ -9,6 +9,7 @@
{{ route.name || t("route.untitled") }}
{{ route.id }}
{{ route.lang }}
+ {{ t("route.fallback") }}
@@ -91,6 +95,7 @@ const form = reactive({
name: "",
lang: "",
enabled: true,
+ fallback: false,
channelId: "",
threadId: "",
filters: [] as FilterForm[],
@@ -123,6 +128,7 @@ watch(
form.name = r?.name ?? "";
form.lang = r?.lang ?? "";
form.enabled = r?.enabled ?? true;
+ form.fallback = r?.fallback ?? false;
form.channelId = r?.target.channelId ?? "";
form.threadId = r?.target.threadId ?? "";
form.filters = (r && r.filters.length
@@ -158,6 +164,7 @@ function collect(): Route | null {
id: form.id.trim(),
name: form.name.trim(),
enabled: form.enabled,
+ fallback: form.fallback || undefined,
lang: form.lang.trim() || undefined,
filters,
target: {
diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts
index 7c83320..882accb 100644
--- a/admin/composables/useI18n.ts
+++ b/admin/composables/useI18n.ts
@@ -34,6 +34,7 @@ const en: Dict = {
"route.noFilters": "no filters",
"route.channel": "CHANNEL",
"route.thread": "THREAD",
+ "route.fallback": "fallback",
"filter.event": "Event",
"filter.repo": "Repo",
"filter.actor": "Actor",
@@ -51,6 +52,8 @@ const en: Dict = {
"routeEditor.langPlaceholder": "en",
"routeEditor.langHint": "en or zh; custom via KV i18n:",
"routeEditor.enabled": "Route enabled",
+ "routeEditor.fallback": "Fallback route",
+ "routeEditor.fallbackHint": "Only fires when no other route matched",
"routeEditor.filters": "Filters",
"routeEditor.filtersNote": "(all must match · AND)",
"routeEditor.matchPlaceholder": "match value",
@@ -137,6 +140,7 @@ const zh: Dict = {
"route.noFilters": "无过滤器",
"route.channel": "频道",
"route.thread": "子区",
+ "route.fallback": "兜底",
"filter.event": "事件",
"filter.repo": "仓库",
"filter.actor": "操作者",
@@ -154,6 +158,8 @@ const zh: Dict = {
"routeEditor.langPlaceholder": "zh",
"routeEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义",
"routeEditor.enabled": "启用路由",
+ "routeEditor.fallback": "兜底路由",
+ "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送",
"routeEditor.filters": "过滤器",
"routeEditor.filtersNote": "(全部匹配 · AND)",
"routeEditor.matchPlaceholder": "匹配值",
diff --git a/admin/types.ts b/admin/types.ts
index 9daa259..bd1c90c 100644
--- a/admin/types.ts
+++ b/admin/types.ts
@@ -15,6 +15,7 @@ export interface Route {
};
lang?: string;
groupId?: string;
+ fallback?: boolean;
}
export interface Group {
diff --git a/src/admin-routes.ts b/src/admin-routes.ts
index 2baa6c6..b0fd220 100644
--- a/src/admin-routes.ts
+++ b/src/admin-routes.ts
@@ -83,6 +83,9 @@ function validateRoutes(
if (r.lang !== undefined && typeof r.lang !== "string") {
return { ok: false, error: `route "${r.id}".lang must be a string` };
}
+ if (r.fallback !== undefined && typeof r.fallback !== "boolean") {
+ return { ok: false, error: `route "${r.id}".fallback must be a boolean` };
+ }
if (!Array.isArray(r.filters) || r.filters.length === 0) {
return { ok: false, error: `route "${r.id}" needs at least one filter` };
}
diff --git a/src/discord.ts b/src/discord.ts
index 09f3ea8..d11432f 100644
--- a/src/discord.ts
+++ b/src/discord.ts
@@ -1,4 +1,4 @@
-import type { Config, FormattedMessage, WebhookEvent, Env } from "./types";
+import type { Config, FormattedMessage, WebhookEvent, Env, Route } from "./types";
import { formatEvent } from "./formatter";
import { matchRoute, eventOwners } from "./webhook";
import { log } from "./log";
@@ -42,13 +42,20 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const groupById = new Map(groups.map((g) => [g.id, g]));
const owners = eventOwners(event);
- for (const route of config.routes) {
- if (!matchRoute(route, event)) continue;
+ // Only routes that pass both their filters and their group's owner
+ // restriction count as "matched", so a fallback route still fires when a
+ // regular route was suppressed by its group.
+ const accepted = (route: Route): boolean => {
+ if (!route.groupId) return true;
+ const group = groupById.get(route.groupId);
+ return !group || groupAcceptsOwners(group, owners);
+ };
+ const matched = config.routes.filter((route) => matchRoute(route, event) && accepted(route));
+ const anyRegularMatched = matched.some((route) => !route.fallback);
- if (route.groupId) {
- const group = groupById.get(route.groupId);
- if (group && !groupAcceptsOwners(group, owners)) continue;
- }
+ for (const route of matched) {
+ // A fallback route only fires when no regular route matched the event.
+ if (route.fallback && anyRegularMatched) continue;
const target = route.target.threadId
? `${route.target.channelId}/${route.target.threadId}`
diff --git a/src/formatter.ts b/src/formatter.ts
index 5973b5f..ee3f47f 100644
--- a/src/formatter.ts
+++ b/src/formatter.ts
@@ -124,7 +124,7 @@ export function formatEvent(route: Route, event: WebhookEvent, tr?: Translations
case "discussion_comment":
return formatDiscussionComment(payload, repo, author, t);
case "repository":
- return formatRepository(payload, repo, author, t);
+ return formatRepository(payload, repo, repoUrl, author, t);
case "code_scanning_alert":
return formatCodeScanningAlert(payload, repo, author, t);
case "dependabot_alert":
@@ -1231,6 +1231,7 @@ function formatDiscussionComment(
function formatRepository(
payload: Record,
repo: string | undefined,
+ repoUrl: string | undefined,
author: { name: string; icon_url?: string; url?: string },
t: T,
): FormattedMessage {
@@ -1261,12 +1262,50 @@ function formatRepository(
});
}
+ // Enrich create / visibility-change notifications with a clickable link and
+ // basic metadata. repoUrl also makes the embed title a hyperlink for all actions.
+ const repoData = payload.repository as {
+ visibility?: string;
+ fork?: boolean;
+ description?: string | null;
+ };
+
+ const isCreateOrVisibility =
+ action === "created" || action === "publicized" || action === "privatized";
+
+ if (isCreateOrVisibility) {
+ if (repoData.visibility) {
+ fields.push({
+ name: t("events.repository.visibility"),
+ value: t("events.repository." + repoData.visibility) ?? repoData.visibility,
+ inline: true,
+ });
+ }
+ if (repoData.fork) {
+ fields.push({
+ name: t("common.repository"),
+ value: t("events.repository.is_fork"),
+ inline: true,
+ });
+ }
+ }
+
+ const descriptionParts: string[] = [];
+ if (isCreateOrVisibility && repoUrl) {
+ descriptionParts.push(`[${t("events.repository.open")}](${repoUrl})`);
+ }
+ if (isCreateOrVisibility && repoData.description) {
+ descriptionParts.push(`> ${repoData.description}`);
+ }
+
return {
embeds: [
{
author,
title: t("events.repository.title", { action: al, repo: repo ?? t("common.repository") }),
+ url: repoUrl,
color: GITHUB_COLORS.repository,
+ description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined,
fields: fields.length > 0 ? fields : undefined,
footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) },
timestamp: new Date().toISOString(),
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 696262c..69f0a0d 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -27,6 +27,8 @@ export const en = {
pinned: "Pinned",
unpinned: "Unpinned",
transferred: "Transferred",
+ publicized: "made public",
+ privatized: "made private",
locked: "Locked",
unlocked: "Unlocked",
renamed: "Renamed",
@@ -159,6 +161,12 @@ export const en = {
},
repository: {
title: "📦 Repository {action}: {repo}",
+ open: "🔗 Open repository",
+ public: "public",
+ private: "private",
+ internal: "internal",
+ is_fork: "This is a fork",
+ visibility: "Visibility",
},
code_scanning: {
title: "🔍 Code Scanning: {action}",
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 42e54f0..95af9dd 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -27,6 +27,8 @@ export const zh = {
pinned: "已置顶",
unpinned: "已取消置顶",
transferred: "已转移",
+ publicized: "已公开",
+ privatized: "已设为私有",
locked: "已锁定",
unlocked: "已解锁",
renamed: "已重命名",
@@ -159,6 +161,12 @@ export const zh = {
},
repository: {
title: "📦 仓库 {action}: {repo}",
+ open: "🔗 打开仓库",
+ public: "公开",
+ private: "私有",
+ internal: "内部",
+ is_fork: "这是 Fork 仓库",
+ visibility: "可见性",
},
code_scanning: {
title: "🔍 代码扫描: {action}",
diff --git a/src/types.ts b/src/types.ts
index 49608c4..fc001b1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -43,6 +43,12 @@ export interface Route {
};
lang?: string;
groupId?: string;
+ /**
+ * Fallback route: only fires when no other (non-fallback) route matched the
+ * event. Multiple fallback routes may exist; they are all skipped whenever at
+ * least one regular route matches.
+ */
+ fallback?: boolean;
}
export interface Group {