diff --git a/AGENTS.md b/AGENTS.md index f158eee..04cbfb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,22 +22,49 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord ```text src/ ├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync -├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage +├── types.ts # Env, Config, Route, Filter, WebhookEvent, NeutralMessage ├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env ├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + / -├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute -├── discord.ts # Dispatch to Discord via REST (sendMessage) -├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling -├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration -├── formatter.ts # 24 event formatters + generic fallback (~1570 lines) -├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit -├── oauth-routes.ts # GET /auth/github, callback (sets admin session if redirect=/admin), DELETE /token/:userId -├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup) -├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes (session + ADMIN_USER_IDS auth, validation) -├── admin-session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers -├── admin-ui.ts # ADMIN_HTML: single-file config console (vanilla HTML/CSS/JS) -├── token-store.ts # KV-based token CRUD with findUserIdByToken reverse lookup -└── log.ts # JSON console logger (info/warn/error/fatal) +├── core/ +│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send (recordSend + group filter) +├── events/ # GitHub webhook pipeline (was webhook.ts) +│ ├── verify.ts # HMAC signature verify (Web Crypto, timing-safe) +│ ├── parse.ts # parseEvent (headers + body → WebhookEvent) +│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering +├── formatters/ # Platform-neutral message formatters (was formatter.ts) +│ ├── index.ts # formatEvent: 24-event switch → NeutralMessage + re-exports +│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI +│ ├── helpers.ts # emojiPrefix, T, buildMessage +│ └── *.ts # push, pull-request, issues, comments, workflow, release, create, +│ # repo, check, review, commit-comment, deployment, member, label, +│ # milestone, discussion, repository, security, generic +├── drivers/ # Platform drivers (pluggable push targets) +│ ├── types.ts # PlatformDriver interface + SendResult +│ ├── index.ts # getDriver() registry (discord default + telegram stub) +│ ├── discord/ +│ │ ├── index.ts # DiscordDriver: send → renderNeutralMessage + rest.sendMessage +│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage +│ │ ├── rest.ts # Discord REST sendMessage with retry + rate-limit handling +│ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) +│ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands +│ └── telegram/ +│ └── index.ts # TelegramDriver stub (not implemented yet) +├── github/ +│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions +│ └── store.ts # KV-based token CRUD + discord-link mapping (was token-store.ts) +├── web/ # HTTP UI/API routes +│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId +│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup) +│ ├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes (session + ADMIN_USER_IDS auth, validation) +│ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers +│ ├── groups.ts # Group CRUD (config:groups), resolveScope, hasAnyAccess +│ ├── home-routes.ts # home page +│ └── legal-routes.ts # legal / privacy / terms pages +└── lib/ # shared infra + ├── i18n.ts # loadTranslations, t() with param interpolation + ├── send-log.ts # SendRecord, recordSend, getSendLog + ├── log.ts # JSON console logger (info/warn/error/fatal) + └── locales/ # en.ts, zh.ts translation dictionaries ``` ## Responsibilities @@ -57,8 +84,8 @@ src/ `payload.repository.full_name`; fall back to `t("common.repository")` when missing. - Do NOT use `"Comment on org/repo"` / `"Review on org/repo"` prefixes. Comments, reviews and inline comments use the same `{repo}{#number}: {title}` title as their parent object. -- All event-specific emoji live in `src/formatter.ts` (via the `em()` helper), never in the - locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true); +- All event-specific emoji live in `src/formatters/` (via the `emojiPrefix` helper), never in + the locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true); `showEmoji=false` must strip every emoji from titles, descriptions, fields and links. - Milestone progress bars (🟢🟡🟠⬜) are data visualization and are exempt from the emoji toggle. - Locale templates use a `{emoji}` placeholder immediately followed by the text (no space); diff --git a/docs/contributing.md b/docs/contributing.md index 0568c25..059b303 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -15,26 +15,42 @@ npm run dev # Start local dev server ```text src/ ├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync -├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage +├── types.ts # Env, Config, Route, Filter, WebhookEvent, NeutralMessage ├── config.ts # Loads routes from KV (returns [] if unset), builds Config from env ├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + / -├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute -├── discord.ts # Dispatch to Discord via REST (sendMessage) -├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling -├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration -├── formatter.ts # 23 event formatters + generic fallback -├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit -├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state) -├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup) -├── admin-routes.ts # /admin API: routes, groups, me, logs (session + scope auth) -├── admin-session.ts # Admin session CRUD (KV session:{id}), cookie helpers -├── groups.ts # Group loading, group-admin access scoping -├── i18n.ts # Message language overrides (en/zh) -├── send-log.ts # Send logging (logs:send KV keys) -├── token-store.ts # KV-based token CRUD with findUserIdByToken reverse lookup -├── home-routes.ts # Landing page routes -├── legal-routes.ts # Legal page routes -└── log.ts # JSON console logger (info/warn/error/fatal) +├── core/ +│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send +├── events/ # GitHub webhook pipeline +│ ├── verify.ts # HMAC signature verification (Web Crypto, timing-safe) +│ ├── parse.ts # parseEvent (headers + body → WebhookEvent) +│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering +├── formatters/ # Platform-neutral formatters (produce NeutralMessage) +│ ├── index.ts # formatEvent: 24-event switch → NeutralMessage + re-exports +│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI +│ ├── helpers.ts # emojiPrefix, T, buildMessage +│ └── *.ts # push, pull-request, issues, comments, workflow, release, repo, ... +├── drivers/ # Platform drivers (pluggable push targets) +│ ├── types.ts # PlatformDriver interface + SendResult +│ ├── index.ts # getDriver() registry (discord + telegram stub) +│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed), +│ │ # rest.ts, interactions.ts, commands.ts +│ └── telegram/ # TelegramDriver stub (not implemented yet) +├── github/ # GitHub OAuth + as-user actions +│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions +│ └── store.ts # KV-based token CRUD + discord-link mapping +├── web/ # HTTP UI/API routes +│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state) +│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup) +│ ├── admin-routes.ts # /admin API: routes, groups, me, logs (session + scope auth) +│ ├── session.ts # Admin session CRUD (KV session:{id}), cookie helpers +│ ├── groups.ts # Group loading, group-admin access scoping +│ ├── home-routes.ts # Landing page routes +│ └── legal-routes.ts # Legal page routes +└── lib/ # Shared infrastructure + ├── i18n.ts # Message language overrides (en/zh) + ├── send-log.ts # Send logging (logs:send KV keys) + ├── log.ts # JSON console logger (info/warn/error/fatal) + └── locales/ # en.ts, zh.ts translation dictionaries ``` ## Scripts @@ -74,11 +90,11 @@ curl http://localhost:8787/health ## Adding a New Event Formatter -1. Add the event type to `GITHUB_COLORS` in `formatter.ts` (if new color needed) +1. Add the event type to `GITHUB_COLORS` in `src/formatters/colors.ts` (if new color needed) 2. Add action labels to `ACTION_LABELS` (if new actions) -3. Create a `formatEventType` function in `formatter.ts` -4. Add the case to the `formatEvent` switch statement -5. Update `extractBranch` in `webhook.ts` if the event has branch info +3. Create a `formatEventType` function in `src/formatters/` +4. Add the case to the `formatEvent` switch statement in `src/formatters/index.ts` +5. Update `extractBranch` in `src/events/match.ts` if the event has branch info 6. Add the event to the documentation in `docs/events/supported.md` 7. Subscribe to the event in your GitHub App settings diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 25f6b3e..f30a2da 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -15,26 +15,42 @@ npm run dev # 启动本地开发服务器 ```text src/ ├── index.ts # CF Workers 入口 (fetch + scheduled),scheduled = 命令同步 -├── types.ts # Env、Config、Route、Filter、WebhookEvent、FormattedMessage +├── types.ts # Env、Config、Route、Filter、WebhookEvent、NeutralMessage ├── config.ts # 从 KV 加载路由(未设置时返回 []),从 env 构建 Config ├── server.ts # Hono 应用: /health、/webhook、/discord/interactions,挂载 /auth、/admin + / -├── webhook.ts # HMAC 验证 (Web Crypto)、parseEvent、extractBranch、matchRoute -├── discord.ts # 通过 Discord REST 分发 (sendMessage) -├── discord-rest.ts # Discord REST sendMessage,带重试和限流处理 -├── discord-interactions.ts # Ed25519 验签 + 交互处理 (/gh、按钮、modal) + 命令注册 -├── formatter.ts # 23 种事件格式化器 + 通用回退 -├── github-oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit -├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态) -├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权) -├── admin-routes.ts # /admin API:路由、分组、me、日志(会话 + 权限范围鉴权) -├── admin-session.ts # 管理员会话 CRUD (KV session:{id})、Cookie 辅助函数 -├── groups.ts # 分组加载、分组管理员权限范围 -├── i18n.ts # 消息语言覆盖 (en/zh) -├── send-log.ts # 发送日志 (logs:send KV 键) -├── token-store.ts # 基于 KV 的 Token CRUD,带 findUserIdByToken 反向查找 -├── home-routes.ts # 落地页路由 -├── legal-routes.ts # 法律页面路由 -└── log.ts # JSON 控制台日志 (info/warn/error/fatal) +├── core/ +│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send +├── events/ # GitHub webhook 事件流水线 +│ ├── verify.ts # HMAC 签名验证 (Web Crypto,时间安全) +│ ├── parse.ts # parseEvent (headers + body → WebhookEvent) +│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤 +├── formatters/ # 平台中立格式化器(产出 NeutralMessage) +│ ├── index.ts # formatEvent:24 事件 switch → NeutralMessage + re-export +│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI +│ ├── helpers.ts # emojiPrefix、T、buildMessage +│ └── *.ts # push、pull-request、issues、comments、workflow、release、repo 等 +├── drivers/ # 平台驱动(可插拔推送目标) +│ ├── types.ts # PlatformDriver 接口 + SendResult +│ ├── index.ts # getDriver() 注册表(discord + telegram 占位) +│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、 +│ │ # rest.ts、interactions.ts、commands.ts +│ └── telegram/ # TelegramDriver 占位(未实现) +├── github/ # GitHub OAuth + 以用户身份操作 +│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作 +│ └── store.ts # 基于 KV 的 Token CRUD + discord-link 映射 +├── web/ # HTTP UI/API 路由 +│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态) +│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权) +│ ├── admin-routes.ts # /admin API:路由、分组、me、日志(会话 + 权限范围鉴权) +│ ├── session.ts # 管理员会话 CRUD (KV session:{id})、Cookie 辅助函数 +│ ├── groups.ts # 分组加载、分组管理员权限范围 +│ ├── home-routes.ts # 落地页路由 +│ └── legal-routes.ts # 法律页面路由 +└── lib/ # 共享基础设施 + ├── i18n.ts # 消息语言覆盖 (en/zh) + ├── send-log.ts # 发送日志 (logs:send KV 键) + ├── log.ts # JSON 控制台日志 (info/warn/error/fatal) + └── locales/ # en.ts、zh.ts 翻译字典 ``` ## 脚本 @@ -74,11 +90,11 @@ curl http://localhost:8787/health ## 添加新事件格式化器 -1. 将事件类型添加到 `formatter.ts` 中的 `GITHUB_COLORS`(如果需要新颜色) +1. 将事件类型添加到 `src/formatters/colors.ts` 中的 `GITHUB_COLORS`(如果需要新颜色) 2. 将操作标签添加到 `ACTION_LABELS`(如果有新操作) -3. 在 `formatter.ts` 中创建 `formatEventType` 函数 -4. 将 case 添加到 `formatEvent` switch 语句 -5. 如果事件包含分支信息,更新 `webhook.ts` 中的 `extractBranch` +3. 在 `src/formatters/` 中创建 `formatEventType` 函数 +4. 将 case 添加到 `src/formatters/index.ts` 中的 `formatEvent` switch 语句 +5. 如果事件包含分支信息,更新 `src/events/match.ts` 中的 `extractBranch` 6. 将事件添加到 `docs/events/supported.md` 文档中 7. 在 GitHub App 设置中订阅该事件 diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index df0a58a..65abd30 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -6,7 +6,7 @@ import { destroyAdminSession, adminCookie, clearAdminCookie, -} from "../admin-session"; +} from "../web/session"; import { loadRoutes, saveRoutes, loadConfig } from "../config"; import type { Env, Route } from "../types"; diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index d4f368e..4f7037d 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { sendMessage } from "../discord-rest"; -import { dispatchEvent } from "../discord"; +import { sendMessage } from "../drivers/discord/rest"; +import { dispatchEvent } from "../core/dispatch"; import type { Env, Route } from "../types"; function mockFetch(handler: (url: string, init?: RequestInit) => Response): void { diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts index 0725023..fbb7b83 100644 --- a/src/__tests__/formatter.test.ts +++ b/src/__tests__/formatter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { formatEvent } from "../formatter"; +import { formatEvent } from "../formatters"; import type { Route, WebhookEvent } from "../types"; const route: Route = { @@ -31,7 +31,7 @@ describe("message title spec", () => { sender, }), ); - expect(msg.embeds![0].title).toBe("acme/widget: Pushed 1 commit"); + expect(msg.title).toBe("acme/widget: Pushed 1 commit"); }); it("pull_request title is repo#number: title", () => { @@ -56,7 +56,7 @@ describe("message title spec", () => { sender, }), ); - expect(msg.embeds![0].title).toBe("acme/widget#7: Add feature"); + expect(msg.title).toBe("acme/widget#7: Add feature"); }); it("issue_comment title has no 'Comment on' prefix", () => { @@ -77,8 +77,8 @@ describe("message title spec", () => { sender, }), ); - expect(msg.embeds![0].title).toBe("acme/widget#3: Bug report"); - expect(msg.embeds![0].title).not.toContain("Comment on"); + expect(msg.title).toBe("acme/widget#3: Bug report"); + expect(msg.title).not.toContain("Comment on"); }); it("workflow_run title is repo: name — conclusion", () => { @@ -98,8 +98,8 @@ describe("message title spec", () => { sender, }), ); - expect(msg.embeds![0].title).toBe("acme/widget: CI — success"); - expect(msg.embeds![0].fields![1].value).toBe("✅ build"); + expect(msg.title).toBe("acme/widget: CI — success"); + expect(msg.fields![1].value).toBe("✅ build"); }); it("workflow_run distinguishes queued and running from pending", () => { @@ -119,8 +119,8 @@ describe("message title spec", () => { sender, }), ); - expect(queued.embeds![0].title).toBe("acme/widget: CI — queued"); - expect(queued.embeds![0].fields![0].value).toBe("⏳ queued"); + expect(queued.title).toBe("acme/widget: CI — queued"); + expect(queued.fields![0].value).toBe("⏳ queued"); const running = formatEvent( route, @@ -131,8 +131,8 @@ describe("message title spec", () => { sender, }), ); - expect(running.embeds![0].title).toBe("acme/widget: CI — running"); - expect(running.embeds![0].fields![0].value).toBe("🔄 running"); + expect(running.title).toBe("acme/widget: CI — running"); + expect(running.fields![0].value).toBe("🔄 running"); }); it("check_run uses status for queued and running", () => { @@ -146,8 +146,8 @@ describe("message title spec", () => { route, event("check_run", { check_run: checkRun, repository: repo, sender }), ); - expect(msg.embeds![0].title).toBe("acme/widget: Lint — running"); - expect(msg.embeds![0].fields![0].value).toBe("🔄 running"); + expect(msg.title).toBe("acme/widget: Lint — running"); + expect(msg.fields![0].value).toBe("🔄 running"); }); it("unknown events fall back to repo: event: action", () => { @@ -155,7 +155,7 @@ describe("message title spec", () => { route, event("custom_event", { action: "ran", repository: repo, sender }), ); - expect(msg.embeds![0].title).toBe("acme/widget: custom_event: ran"); + expect(msg.title).toBe("acme/widget: custom_event: ran"); }); }); @@ -169,8 +169,8 @@ describe("group emoji toggle", () => { sender, }), ); - expect(msg.embeds![0].title).toBe("acme/widget: 📦 Repository Created"); - expect(msg.embeds![0].description).toContain("🔗"); + expect(msg.title).toBe("acme/widget: 📦 Repository Created"); + expect(msg.description).toContain("🔗"); }); it("strips emoji when showEmoji is false", () => { @@ -184,9 +184,9 @@ describe("group emoji toggle", () => { undefined, false, ); - expect(msg.embeds![0].title).toBe("acme/widget: Repository Created"); - expect(msg.embeds![0].title).not.toContain("📦"); - expect(msg.embeds![0].description).not.toContain("🔗"); + expect(msg.title).toBe("acme/widget: Repository Created"); + expect(msg.title).not.toContain("📦"); + expect(msg.description).not.toContain("🔗"); }); it("strips emoji from push description when disabled", () => { @@ -204,8 +204,8 @@ describe("group emoji toggle", () => { undefined, false, ); - expect(msg.embeds![0].description).not.toContain("⚠️"); - expect(msg.embeds![0].description).not.toContain("🆕"); + expect(msg.description).not.toContain("⚠️"); + expect(msg.description).not.toContain("🆕"); }); it("strips emoji from workflow_run status when disabled", () => { @@ -227,7 +227,7 @@ describe("group emoji toggle", () => { undefined, false, ); - expect(msg.embeds![0].fields![0].value).toBe("failure"); - expect(msg.embeds![0].fields![1].value).toBe("build"); + expect(msg.fields![0].value).toBe("failure"); + expect(msg.fields![1].value).toBe("build"); }); }); diff --git a/src/__tests__/send-log.test.ts b/src/__tests__/send-log.test.ts index 06a0d8c..cddd9aa 100644 --- a/src/__tests__/send-log.test.ts +++ b/src/__tests__/send-log.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { recordSend, getSendLog } from "../send-log"; +import { recordSend, getSendLog } from "../lib/send-log"; function createMockKV(): KVNamespace { const store = new Map(); diff --git a/src/__tests__/token-store.test.ts b/src/__tests__/token-store.test.ts index fd872d3..abb4102 100644 --- a/src/__tests__/token-store.test.ts +++ b/src/__tests__/token-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { saveToken, getToken, removeToken, findUserIdByToken } from "../token-store"; +import { saveToken, getToken, removeToken, findUserIdByToken } from "../github/store"; function createMockKV(): KVNamespace { const store = new Map(); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts index 62448d1..78ec396 100644 --- a/src/__tests__/webhook.test.ts +++ b/src/__tests__/webhook.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "bun:test"; import { createHmac } from "crypto"; -import { verifySignature, parseEvent, matchRoute } from "../webhook"; +import { verifySignature } from "../events/verify"; +import { parseEvent } from "../events/parse"; +import { matchRoute } from "../events/match"; import type { Route, WebhookEvent } from "../types"; function sign(body: string, secret: string): string { diff --git a/src/config.ts b/src/config.ts index 038e773..c3db813 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import type { Env, Config, Route } from "./types"; -import { log } from "./log"; +import { log } from "./lib/log"; const CONFIG_CACHE_TTL = 60_000; const ROUTES_KEY = "config:routes"; diff --git a/src/discord.ts b/src/core/dispatch.ts similarity index 66% rename from src/discord.ts rename to src/core/dispatch.ts index a14bc56..650e30b 100644 --- a/src/discord.ts +++ b/src/core/dispatch.ts @@ -1,11 +1,11 @@ -import type { Config, FormattedMessage, WebhookEvent, Env, Route } from "./types"; -import { formatEvent } from "./formatter"; -import { matchRoute, eventOwners } from "./webhook"; -import { log } from "./log"; -import { loadTranslations, type Translations } from "./i18n"; -import { sendMessage } from "./discord-rest"; -import { recordSend } from "./send-log"; -import { loadGroups, groupAcceptsOwners } from "./groups"; +import type { Config, WebhookEvent, Env, Route } from "../types"; +import { formatEvent } from "../formatters"; +import { matchRoute, eventOwners } from "../events/match"; +import { log } from "../lib/log"; +import { loadTranslations, type Translations } from "../lib/i18n"; +import { recordSend } from "../lib/send-log"; +import { loadGroups, groupAcceptsOwners } from "../web/groups"; +import { getDriver } from "../drivers"; export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise { const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))]; @@ -20,10 +20,6 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En const groupById = new Map(groups.map((g) => [g.id, g])); const owners = eventOwners(event); - // A regular route counts as "matched" only when it passes both its filters - // and its group's owner restriction. Fallback routes ignore their own filters - // and fire whenever no regular route matched, so they still catch events that - // a regular route's group suppressed. const accepted = (route: Route): boolean => { if (!route.groupId) return true; const group = groupById.get(route.groupId); @@ -50,7 +46,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En const group = route.groupId ? groupById.get(route.groupId) : undefined; const showEmoji = group?.emoji !== false; const message = formatEvent(route, event, tr, showEmoji); - await sendToChannel(route.target.channelId, message, env, route.target.threadId); + const result = await getDriver(route.target).send(message, route.target, env); + if (!result.ok) throw new Error(result.error ?? "Send failed"); await recordSend(env.KV, { ts: Date.now(), routeId: route.id, @@ -75,14 +72,3 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En await Promise.allSettled(tasks); } - -async function sendToChannel( - channelId: string, - message: FormattedMessage, - env: Env, - threadId?: string, -): Promise { - const token = env.DISCORD_TOKEN ?? ""; - const result = await sendMessage(token, channelId, message, threadId); - if (!result.ok) throw new Error(result.error ?? "Send failed"); -} diff --git a/src/drivers/discord/commands.ts b/src/drivers/discord/commands.ts new file mode 100644 index 0000000..1b781e1 --- /dev/null +++ b/src/drivers/discord/commands.ts @@ -0,0 +1,174 @@ +import { log } from "../../lib/log"; +import type { Env } from "../../types"; + +const DISCORD_API = "https://discord.com/api/v10"; + +const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const; +const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const; + +export const MSG_CMD_ADD = "GitHub: 添加评论"; +export const MSG_CMD_EDIT = "GitHub: 编辑评论"; +export const MSG_CMD_DEL = "GitHub: 删除评论"; + +export const APP_COMMANDS = [ + { + name: "gh", + type: COMMAND_TYPE.CHAT_INPUT, + description: "GitHub 集成", + options: [ + { + type: OPTION_TYPE.SUB_COMMAND, + name: "login", + description: "绑定你的 GitHub 账号以用本人身份评论", + }, + { type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" }, + { + type: OPTION_TYPE.SUB_COMMAND_GROUP, + name: "comment", + description: "对 issue/PR 评论进行增删改", + options: [ + { + type: OPTION_TYPE.SUB_COMMAND, + name: "add", + description: "在 issue/PR 下新增评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "issue/PR 链接", + required: true, + }, + ], + }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: "edit", + description: "编辑一条评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "评论链接(含 #issuecomment-)", + required: true, + }, + ], + }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: "del", + description: "删除一条评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "评论链接(含 #issuecomment-)", + required: true, + }, + ], + }, + ], + }, + ], + }, + { name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE }, + { name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE }, + { name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE }, +]; + +export async function getApplicationId(env: Env): Promise { + if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID; + try { + const cached = await env.KV.get("config:discord-app-id"); + if (cached) return cached; + } catch { + // fall through to the API + } + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return null; + const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + log.warn({ status: res.status }, "Failed to fetch Discord application id"); + return null; + } + const app = (await res.json()) as { id?: string }; + if (app.id) { + try { + await env.KV.put("config:discord-app-id", app.id); + } catch { + // cache is best-effort + } + return app.id; + } + return null; +} + +export async function registerGlobalCommands(env: Env): Promise { + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return; + try { + if (await env.KV.get("cmd:registered:global")) return; + } catch { + // fall through and register + } + const appId = await getApplicationId(env); + if (!appId) return; + const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, { + method: "PUT", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(APP_COMMANDS), + }); + if (res.ok) { + try { + await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 }); + } catch { + // best-effort + } + log.info("Registered global application commands"); + } else { + const err = await res.text(); + log.warn({ status: res.status, err }, "Global command registration failed"); + } +} + +export async function syncGuildCommands(env: Env): Promise { + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return; + const appId = await getApplicationId(env); + if (!appId) return; + const res = await fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const err = await res.text(); + log.warn({ status: res.status, err }, "Failed to list guilds"); + return; + } + const guilds = (await res.json()) as Array<{ id: string }>; + for (const guild of guilds) { + try { + if (await env.KV.get(`cmd:guild:${guild.id}`)) continue; + const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, { + method: "PUT", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(APP_COMMANDS), + }); + if (r.ok) { + await env.KV.put(`cmd:guild:${guild.id}`, "1"); + log.info({ guildId: guild.id }, "Registered guild application commands"); + } else { + const err = await r.text(); + log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed"); + } + } catch (err) { + log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed"); + } + } +} + +export async function syncCommands(env: Env): Promise { + if (!env.DISCORD_TOKEN) return; + await registerGlobalCommands(env); + await syncGuildCommands(env); +} diff --git a/src/drivers/discord/index.ts b/src/drivers/discord/index.ts new file mode 100644 index 0000000..6cc3ba5 --- /dev/null +++ b/src/drivers/discord/index.ts @@ -0,0 +1,17 @@ +import type { Route, Env, NeutralMessage } from "../../types"; +import type { PlatformDriver, SendResult } from "../types"; +import { sendMessage } from "./rest"; +import { renderNeutralMessage } from "./render"; + +export class DiscordDriver implements PlatformDriver { + readonly id = "discord"; + + async send( + message: NeutralMessage, + target: Route["target"], + env: Env, + ): Promise { + const token = env.DISCORD_TOKEN ?? ""; + return sendMessage(token, target.channelId, renderNeutralMessage(message), target.threadId); + } +} diff --git a/src/discord-interactions.ts b/src/drivers/discord/interactions.ts similarity index 74% rename from src/discord-interactions.ts rename to src/drivers/discord/interactions.ts index 79d740f..61446ad 100644 --- a/src/discord-interactions.ts +++ b/src/drivers/discord/interactions.ts @@ -1,4 +1,4 @@ -import { log } from "./log"; +import { log } from "../../lib/log"; import { getOAuthURL, commentAsUser, @@ -7,9 +7,10 @@ import { deleteCommentAsUser, mergePullRequestAsUser, closePullRequestAsUser, -} from "./github-oauth"; -import { getDiscordLink, removeDiscordLink } from "./token-store"; -import type { Env } from "./types"; +} from "../../github/oauth"; +import { getDiscordLink, removeDiscordLink } from "../../github/store"; +import type { Env } from "../../types"; +import { MSG_CMD_ADD, MSG_CMD_EDIT, MSG_CMD_DEL } from "./commands"; const DISCORD_API = "https://discord.com/api/v10"; @@ -17,14 +18,8 @@ const DISCORD_API = "https://discord.com/api/v10"; const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const; const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const; const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const; -const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const; const EPHEMERAL = 64; -// Right-click (message context-menu) command names → operation. -const MSG_CMD_ADD = "GitHub: 添加评论"; -const MSG_CMD_EDIT = "GitHub: 编辑评论"; -const MSG_CMD_DEL = "GitHub: 删除评论"; - // Modal custom_id encodings (delimiter '|' never appears in owner/repo). const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId @@ -33,71 +28,6 @@ const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber -const APP_COMMANDS = [ - { - name: "gh", - type: COMMAND_TYPE.CHAT_INPUT, - description: "GitHub 集成", - options: [ - { - type: OPTION_TYPE.SUB_COMMAND, - name: "login", - description: "绑定你的 GitHub 账号以用本人身份评论", - }, - { type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" }, - { - type: OPTION_TYPE.SUB_COMMAND_GROUP, - name: "comment", - description: "对 issue/PR 评论进行增删改", - options: [ - { - type: OPTION_TYPE.SUB_COMMAND, - name: "add", - description: "在 issue/PR 下新增评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "issue/PR 链接", - required: true, - }, - ], - }, - { - type: OPTION_TYPE.SUB_COMMAND, - name: "edit", - description: "编辑一条评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "评论链接(含 #issuecomment-)", - required: true, - }, - ], - }, - { - type: OPTION_TYPE.SUB_COMMAND, - name: "del", - description: "删除一条评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "评论链接(含 #issuecomment-)", - required: true, - }, - ], - }, - ], - }, - ], - }, - { name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE }, - { name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE }, - { name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE }, -]; - // Comment link (has the comment id); check this BEFORE the plain issue regex. const GITHUB_COMMENT_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/; @@ -602,108 +532,3 @@ async function modalSubmit( } } } - -/** - * Resolve the Discord application id: env var → KV cache → Discord API - * (then cached in KV for later runs). - */ -export async function getApplicationId(env: Env): Promise { - if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID; - try { - const cached = await env.KV.get("config:discord-app-id"); - if (cached) return cached; - } catch { - // fall through to the API - } - const token = env.DISCORD_TOKEN ?? ""; - if (!token) return null; - const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, { - headers: { Authorization: `Bot ${token}` }, - }); - if (!res.ok) { - log.warn({ status: res.status }, "Failed to fetch Discord application id"); - return null; - } - const app = (await res.json()) as { id?: string }; - if (app.id) { - try { - await env.KV.put("config:discord-app-id", app.id); - } catch { - // cache is best-effort - } - return app.id; - } - return null; -} - -/** Register commands globally (~1h propagation); dedup for a day. */ -export async function registerGlobalCommands(env: Env): Promise { - const token = env.DISCORD_TOKEN ?? ""; - if (!token) return; - try { - if (await env.KV.get("cmd:registered:global")) return; - } catch { - // fall through and register - } - const appId = await getApplicationId(env); - if (!appId) return; - const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, { - method: "PUT", - headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(APP_COMMANDS), - }); - if (res.ok) { - try { - await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 }); - } catch { - // best-effort - } - log.info("Registered global application commands"); - } else { - const err = await res.text(); - log.warn({ status: res.status, err }, "Global command registration failed"); - } -} - -/** Register commands per guild for instant availability (new guilds only). */ -export async function syncGuildCommands(env: Env): Promise { - const token = env.DISCORD_TOKEN ?? ""; - if (!token) return; - const appId = await getApplicationId(env); - if (!appId) return; - const res = await fetch(`${DISCORD_API}/users/@me/guilds`, { - headers: { Authorization: `Bot ${token}` }, - }); - if (!res.ok) { - const err = await res.text(); - log.warn({ status: res.status, err }, "Failed to list guilds"); - return; - } - const guilds = (await res.json()) as Array<{ id: string }>; - for (const guild of guilds) { - try { - if (await env.KV.get(`cmd:guild:${guild.id}`)) continue; - const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, { - method: "PUT", - headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(APP_COMMANDS), - }); - if (r.ok) { - await env.KV.put(`cmd:guild:${guild.id}`, "1"); - log.info({ guildId: guild.id }, "Registered guild application commands"); - } else { - const err = await r.text(); - log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed"); - } - } catch (err) { - log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed"); - } - } -} - -/** Entry point for the scheduled (cron) command sync. */ -export async function syncCommands(env: Env): Promise { - if (!env.DISCORD_TOKEN) return; - await registerGlobalCommands(env); - await syncGuildCommands(env); -} diff --git a/src/drivers/discord/render.ts b/src/drivers/discord/render.ts new file mode 100644 index 0000000..51611e8 --- /dev/null +++ b/src/drivers/discord/render.ts @@ -0,0 +1,48 @@ +import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types"; + +function toStyle(style: NeutralActionStyle): number { + switch (style) { + case "primary": + return 3; + case "danger": + return 4; + default: + return 2; + } +} + +export function renderNeutralMessage(message: NeutralMessage): FormattedMessage { + return { + embeds: [ + { + title: message.title, + url: message.url, + color: message.color, + description: message.description, + author: message.author + ? { + name: message.author.name, + icon_url: message.author.iconUrl, + url: message.author.url, + } + : undefined, + fields: message.fields, + footer: message.footer ? { text: message.footer } : undefined, + timestamp: message.timestamp, + }, + ], + components: message.actions?.length + ? [ + { + type: 1, + components: message.actions.map((action) => ({ + type: 2, + style: toStyle(action.style), + label: action.label, + custom_id: action.id, + })), + }, + ] + : undefined, + }; +} diff --git a/src/discord-rest.ts b/src/drivers/discord/rest.ts similarity index 97% rename from src/discord-rest.ts rename to src/drivers/discord/rest.ts index 17aacf5..62c221c 100644 --- a/src/discord-rest.ts +++ b/src/drivers/discord/rest.ts @@ -1,4 +1,4 @@ -import { log } from "./log"; +import { log } from "../../lib/log"; const DISCORD_API = "https://discord.com/api/v10"; diff --git a/src/drivers/index.ts b/src/drivers/index.ts new file mode 100644 index 0000000..d005c4a --- /dev/null +++ b/src/drivers/index.ts @@ -0,0 +1,16 @@ +import type { Route } from "../types"; +import type { PlatformDriver } from "./types"; +import { DiscordDriver } from "./discord"; +import { TelegramDriver } from "./telegram"; + +const drivers: Record = { + discord: new DiscordDriver(), + telegram: new TelegramDriver(), +}; + +export function getDriver(target: Route["target"]): PlatformDriver { + const platform = (target as { platform?: string }).platform ?? "discord"; + const driver = drivers[platform]; + if (!driver) throw new Error(`No driver for platform "${platform}"`); + return driver; +} diff --git a/src/drivers/telegram/index.ts b/src/drivers/telegram/index.ts new file mode 100644 index 0000000..72fc9ba --- /dev/null +++ b/src/drivers/telegram/index.ts @@ -0,0 +1,14 @@ +import type { Route, Env, NeutralMessage } from "../../types"; +import type { PlatformDriver, SendResult } from "../types"; + +export class TelegramDriver implements PlatformDriver { + readonly id = "telegram"; + + async send( + _message: NeutralMessage, + _target: Route["target"], + _env: Env, + ): Promise { + return { ok: false, error: "Telegram driver not implemented yet" }; + } +} diff --git a/src/drivers/types.ts b/src/drivers/types.ts new file mode 100644 index 0000000..35b3a7c --- /dev/null +++ b/src/drivers/types.ts @@ -0,0 +1,11 @@ +import type { Route, Env, NeutralMessage } from "../types"; + +export interface SendResult { + ok: boolean; + error?: string; +} + +export interface PlatformDriver { + readonly id: string; + send(message: NeutralMessage, target: Route["target"], env: Env): Promise; +} diff --git a/src/events/match.ts b/src/events/match.ts new file mode 100644 index 0000000..bd15e3b --- /dev/null +++ b/src/events/match.ts @@ -0,0 +1,115 @@ +import type { WebhookEvent, Route, Filter } from "../types"; + +const regexCache = new Map(); +const keywordBodyCache = new WeakMap(); +const MAX_PATTERN_LENGTH = 200; + +function compileKeywordRegex(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 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 === "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) => { + const re = compileKeywordRegex(p); + if (!re) return body.includes(p.toLowerCase()); + return re.test(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) => value!.toLowerCase() === p.toLowerCase()); + + return filter.exclude ? !matches : matches; +} + +export function eventOwners(event: WebhookEvent): string[] { + const owners = new Set(); + const repoOwner = (event.payload.repository as { owner?: { login?: string } } | undefined)?.owner + ?.login; + if (repoOwner) owners.add(repoOwner); + const org = (event.payload.organization as { login?: string } | undefined)?.login; + if (org) owners.add(org); + return [...owners]; +} + +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)); +} diff --git a/src/events/parse.ts b/src/events/parse.ts new file mode 100644 index 0000000..36905b2 --- /dev/null +++ b/src/events/parse.ts @@ -0,0 +1,15 @@ +import type { WebhookEvent } from "../types"; + +export function parseEvent(headers: Record, body: string): WebhookEvent | null { + const event = headers["x-github-event"]; + const signature = headers["x-hub-signature-256"]; + + if (!event) return null; + + try { + const payload = JSON.parse(body); + return { event, payload, signature }; + } catch { + return null; + } +} diff --git a/src/events/verify.ts b/src/events/verify.ts new file mode 100644 index 0000000..64c0ef6 --- /dev/null +++ b/src/events/verify.ts @@ -0,0 +1,43 @@ +const keyCache = new Map(); + +async function getHmacKey(secret: string): Promise { + const cached = keyCache.get(secret); + if (cached) return cached; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + keyCache.set(secret, key); + return key; +} + +export async function verifySignature( + payload: string, + signature: string | undefined, + secret: string, +): Promise { + if (!signature) return false; + + const encoder = new TextEncoder(); + const key = await getHmacKey(secret); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + const expected = `sha256=${Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; + + try { + const a = encoder.encode(signature); + const b = encoder.encode(expected); + if (a.byteLength !== b.byteLength) return false; + let diff = 0; + for (let i = 0; i < a.byteLength; i++) { + diff |= a[i]! ^ b[i]!; + } + return diff === 0; + } catch { + return false; + } +} diff --git a/src/formatter.ts b/src/formatter.ts deleted file mode 100644 index 473c079..0000000 --- a/src/formatter.ts +++ /dev/null @@ -1,1689 +0,0 @@ -import type { Route, WebhookEvent, FormattedMessage } from "./types"; -import type { Translations } from "./i18n"; -import { t as translate } from "./i18n"; - -const GITHUB_COLORS = { - push: 0x2ea44f, - pull_request_opened: 0x2da44e, - pull_request_closed: 0xf85149, - pull_request_merged: 0x8957e5, - pull_request_ready_for_review: 0x2da44e, - pull_request_other: 0x1f6feb, - issues_opened: 0x2da44e, - issues_closed: 0xf85149, - issues_reopened: 0x1f6feb, - issues_other: 0x8957e5, - issue_comment: 0x6e7681, - workflow_run_success: 0x2da44e, - workflow_run_failure: 0xf85149, - workflow_run_other: 0xd29922, - release_published: 0x2da44e, - release_prerelease: 0xd29922, - release_deleted: 0xf85149, - create: 0x3fb950, - delete: 0xf85149, - star: 0xd29922, - fork: 0x1f6feb, - discussion: 0x8957e5, - check_run_success: 0x2da44e, - check_run_failure: 0xf85149, - check_run_other: 0xd29922, - pull_request_review_approved: 0x2da44e, - pull_request_review_changes: 0xf85149, - pull_request_review_commented: 0x8b949e, - commit_comment: 0x6e7681, - deployment_success: 0x2da44e, - deployment_failure: 0xf85149, - deployment_pending: 0xd29922, - member_added: 0x2da44e, - member_removed: 0xf85149, - label: 0x8957e5, - milestone_opened: 0x1f6feb, - milestone_closed: 0x2da44e, - discussion_created: 0x8957e5, - discussion_answered: 0x2da44e, - discussion_comment: 0x6e7681, - repository: 0x8b949e, - code_scanning_critical: 0xf85149, - code_scanning_high: 0xf85149, - code_scanning_medium: 0xd29922, - code_scanning_low: 0x8b949e, - dependabot_critical: 0xf85149, - dependabot_high: 0xf85149, - dependabot_medium: 0xd29922, - dependabot_low: 0x8b949e, - default: 0x8b949e, -}; - -const WORKFLOW_CONCLUSION_EMOJI: Record = { - success: "✅", - failure: "❌", - cancelled: "🚫", - timed_out: "⏱️", - action_required: "⚠️", - neutral: "➖", - stale: "♻️", - queued: "⏳", - running: "🔄", -}; - -function emojiPrefix(emoji: string, show: boolean): string { - return show ? `${emoji} ` : ""; -} - -type T = (key: string, params?: Record) => string; - -export function formatEvent( - route: Route, - event: WebhookEvent, - tr?: Translations, - showEmoji = true, -): FormattedMessage { - const { event: eventType, payload } = event; - const repo = (payload.repository as { full_name?: string })?.full_name; - const sender = (payload.sender as { login?: string })?.login; - const senderAvatar = (payload.sender as { avatar_url?: string })?.avatar_url; - const repoUrl = (payload.repository as { html_url?: string })?.html_url; - - const t: T = (key, params) => translate(key, params, undefined, tr); - - const author = { - name: sender ?? t("common.unknown"), - icon_url: senderAvatar, - url: sender ? `https://github.com/${sender}` : undefined, - }; - - switch (eventType) { - case "push": - return formatPush(payload, repo, author, t, showEmoji); - case "pull_request": - return formatPullRequest(payload, repo, author, t, showEmoji); - case "pull_request_review": - return formatPullRequestReview(payload, repo, author, t, showEmoji); - case "pull_request_review_comment": - return formatPullRequestReviewComment(payload, repo, author, t, showEmoji); - case "issues": - return formatIssues(payload, repo, author, t, showEmoji); - case "issue_comment": - return formatIssueComment(payload, repo, author, t, showEmoji); - case "workflow_run": - return formatWorkflowRun(payload, repo, author, t, showEmoji); - case "release": - return formatRelease(payload, repo, author, t, showEmoji); - case "create": - return formatCreate(payload, repo, author, t, showEmoji); - case "delete": - return formatDelete(payload, repo, author, t, showEmoji); - case "star": - return formatStar(payload, repo, repoUrl, author, t, showEmoji); - case "fork": - return formatFork(payload, repo, repoUrl, author, t, showEmoji); - case "check_run": - return formatCheckRun(payload, repo, author, t, showEmoji); - case "commit_comment": - return formatCommitComment(payload, repo, author, t, showEmoji); - case "deployment_status": - return formatDeploymentStatus(payload, repo, author, t, showEmoji); - case "member": - return formatMember(payload, repo, author, t, showEmoji); - case "label": - return formatLabel(payload, repo, author, t, showEmoji); - case "milestone": - return formatMilestone(payload, repo, author, t, showEmoji); - case "discussion": - return formatDiscussion(payload, repo, author, t, showEmoji); - case "discussion_comment": - return formatDiscussionComment(payload, repo, author, t, showEmoji); - case "repository": - return formatRepository(payload, repo, repoUrl, author, t, showEmoji); - case "code_scanning_alert": - return formatCodeScanningAlert(payload, repo, author, t, showEmoji); - case "dependabot_alert": - return formatDependabotAlert(payload, repo, author, t, showEmoji); - default: - return formatGeneric(eventType, payload, repo, author, t, showEmoji); - } -} - -function formatPush( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const ref = (payload.ref as string)?.replace("refs/heads/", "").replace("refs/tags/", "tag: "); - const commits = (payload.commits ?? []) as Array<{ - id?: string; - message?: string; - author?: { name?: string; email?: string }; - added?: string[]; - removed?: string[]; - modified?: string[]; - }>; - const count = commits.length; - const compareUrl = payload.compare as string | undefined; - const forced = payload.forced as boolean | undefined; - const created = payload.created as boolean | undefined; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const descriptionParts: string[] = []; - - if (forced) { - descriptionParts.push(em("⚠️") + t("events.push.force_push")); - } - if (created) { - descriptionParts.push(em("🆕") + t("events.push.branch_created")); - } - - descriptionParts.push( - t("events.push.commits_pushed", { count, s: count !== 1 ? "s" : "", ref: ref ?? "" }), - ); - - if (compareUrl) { - descriptionParts.push(t("events.push.view_comparison", { url: compareUrl })); - } - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (count <= 5) { - for (const c of commits) { - const shortId = c.id?.slice(0, 7) ?? "???????"; - const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message"); - fields.push({ - name: `\`${shortId}\``, - value: msg, - inline: false, - }); - } - } else { - const first3 = commits.slice(0, 3); - for (const c of first3) { - const shortId = c.id?.slice(0, 7) ?? "???????"; - const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message"); - fields.push({ - name: `\`${shortId}\``, - value: msg, - inline: false, - }); - } - fields.push({ - name: `\u200b`, - value: t("common.and_n_more", { count: count - 3 }), - inline: false, - }); - } - - const added = commits.flatMap((c) => c.added ?? []); - const removed = commits.flatMap((c) => c.removed ?? []); - const modified = commits.flatMap((c) => c.modified ?? []); - - if (added.length > 0 || removed.length > 0 || modified.length > 0) { - const changes: string[] = []; - if (added.length > 0) changes.push(t("events.push.added", { count: added.length })); - if (removed.length > 0) changes.push(t("events.push.removed", { count: removed.length })); - if (modified.length > 0) changes.push(t("events.push.modified", { count: modified.length })); - fields.push({ - name: t("fields.changes"), - value: changes.join(" | "), - inline: true, - }); - } - - return { - embeds: [ - { - author, - title: t("events.push.title", { - count, - s: count !== 1 ? "s" : "", - repo: repo ?? t("common.repository"), - }), - url: compareUrl, - color: GITHUB_COLORS.push, - description: descriptionParts.join("\n"), - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatPullRequest( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "opened"; - const pr = payload.pull_request as { - number?: number; - title?: string; - html_url?: string; - state?: string; - draft?: boolean; - merged?: boolean; - head?: { ref?: string; sha?: string }; - base?: { ref?: string }; - body?: string; - labels?: Array<{ name?: string; color?: string }>; - changed_files?: number; - additions?: number; - deletions?: number; - }; - - const colorKey = pr.merged - ? "pull_request_merged" - : action === "closed" - ? "pull_request_closed" - : action === "ready_for_review" - ? "pull_request_ready_for_review" - : action === "opened" - ? "pull_request_opened" - : "pull_request_other"; - - const al = t("actions." + action) ?? action; - const stateEmoji = pr.merged - ? "🟣" - : action === "closed" - ? "🔴" - : action === "opened" - ? "🟢" - : "🔵"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const descriptionParts: string[] = []; - descriptionParts.push(t("events.pr.action_pr", { emoji: em(stateEmoji), action: al })); - - if (pr.body) { - const truncated = pr.body.slice(0, 300); - descriptionParts.push(`\n${truncated}${pr.body.length > 300 ? "..." : ""}`); - } - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (pr.head?.ref && pr.base?.ref) { - fields.push({ - name: t("fields.branch"), - value: `\`${pr.head.ref}\` → \`${pr.base.ref}\``, - inline: true, - }); - } - - if (pr.changed_files != null || pr.additions != null || pr.deletions != null) { - const parts: string[] = []; - if (pr.additions != null) parts.push(`+${pr.additions}`); - if (pr.deletions != null) parts.push(`-${pr.deletions}`); - if (pr.changed_files != null) parts.push(t("common.n_files", { count: pr.changed_files })); - fields.push({ - name: t("fields.changes"), - value: parts.join(" | "), - inline: true, - }); - } - - if (pr.labels && pr.labels.length > 0) { - fields.push({ - name: t("fields.labels"), - value: pr.labels.map((l) => l.name).join(", "), - inline: true, - }); - } - - // Merge / close buttons on the Discord notification. They only make sense - // while the PR is still open. custom_id encodes the action + owner/repo/number - // (repo full_name never contains "|"). - const actionable = pr.state === "open" && !!repo && pr.number != null; - const components = actionable - ? [ - { - type: 1, - components: [ - { type: 2, style: 3, label: "合并", custom_id: `ghpr|merge|${repo}|${pr.number}` }, - { type: 2, style: 4, label: "关闭", custom_id: `ghpr|close|${repo}|${pr.number}` }, - ], - }, - ] - : undefined; - - return { - embeds: [ - { - author, - title: t("events.pr.title", { - repo: repo ?? t("common.repository"), - number: pr.number ?? "?", - title: pr.title ?? t("common.untitled"), - }), - url: pr.html_url, - color: GITHUB_COLORS[colorKey], - description: descriptionParts.join("\n"), - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - ...(components ? { components } : {}), - }; -} - -function formatIssues( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "opened"; - const issue = payload.issue as { - number?: number; - title?: string; - html_url?: string; - state?: string; - body?: string; - labels?: Array<{ name?: string; color?: string }>; - assignees?: Array<{ login?: string }>; - milestone?: { title?: string }; - }; - - const colorKey = - action === "closed" - ? "issues_closed" - : action === "reopened" - ? "issues_reopened" - : action === "opened" - ? "issues_opened" - : "issues_other"; - - const al = t("actions." + action) ?? action; - const stateEmoji = action === "closed" ? "🔴" : action === "opened" ? "🟢" : "🟣"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const descriptionParts: string[] = []; - descriptionParts.push(t("events.issues.action_issue", { emoji: em(stateEmoji), action: al })); - - if (issue.body) { - const truncated = issue.body.slice(0, 300); - descriptionParts.push(`\n${truncated}${issue.body.length > 300 ? "..." : ""}`); - } - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (issue.labels && issue.labels.length > 0) { - fields.push({ - name: t("fields.labels"), - value: issue.labels.map((l) => l.name).join(", "), - inline: true, - }); - } - - if (issue.assignees && issue.assignees.length > 0) { - fields.push({ - name: t("fields.assignees"), - value: issue.assignees.map((a) => a.login).join(", "), - inline: true, - }); - } - - if (issue.milestone) { - fields.push({ - name: t("fields.milestone"), - value: issue.milestone.title ?? t("common.unknown"), - inline: true, - }); - } - - return { - embeds: [ - { - author, - title: t("events.issues.title", { - repo: repo ?? t("common.repository"), - number: issue.number ?? "?", - title: issue.title ?? t("common.untitled"), - }), - url: issue.html_url, - color: GITHUB_COLORS[colorKey], - description: descriptionParts.join("\n"), - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatIssueComment( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const issue = payload.issue as { - number?: number; - title?: string; - html_url?: string; - }; - const comment = payload.comment as { - body?: string; - html_url?: string; - }; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const commentBody = comment.body?.slice(0, 500) ?? ""; - const truncated = comment.body && comment.body.length > 500; - - return { - embeds: [ - { - author, - title: t("events.issue_comment.title", { - repo: repo ?? t("common.repository"), - number: issue.number ?? "?", - title: issue.title ?? t("common.untitled"), - }), - url: comment.html_url ?? issue.html_url, - color: GITHUB_COLORS.issue_comment, - description: `${t("events.issue_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatWorkflowRun( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const workflow = payload.workflow_run as { - name?: string; - conclusion?: string; - html_url?: string; - head_branch?: string; - run_number?: number; - created_at?: string; - updated_at?: string; - elapsed_seconds?: number; - jobs?: Array<{ name?: string; conclusion?: string }>; - }; - - const action = payload.action as string | undefined; - const status = - action === "in_progress" - ? "running" - : action === "requested" - ? "queued" - : (workflow.conclusion ?? "pending"); - const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const colorKey = - status === "success" - ? "workflow_run_success" - : status === "failure" - ? "workflow_run_failure" - : "workflow_run_other"; - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.status"), - value: `${em(emoji)}${status}`, - inline: true, - }); - - if (workflow.jobs?.length) { - const jobLines = workflow.jobs.map( - (j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`, - ); - fields.push({ - name: t("fields.job"), - value: jobLines.join("\n"), - inline: false, - }); - } - - if (workflow.head_branch) { - fields.push({ - name: t("fields.branch"), - value: `\`${workflow.head_branch}\``, - inline: true, - }); - } - - if (workflow.run_number) { - fields.push({ - name: t("fields.run"), - value: `#${workflow.run_number}`, - inline: true, - }); - } - - if (workflow.elapsed_seconds != null) { - const mins = Math.floor(workflow.elapsed_seconds / 60); - const secs = workflow.elapsed_seconds % 60; - fields.push({ - name: t("fields.duration"), - value: `${mins}m ${secs}s`, - inline: true, - }); - } - - return { - embeds: [ - { - author, - title: t("events.workflow_run.title", { - repo: repo ?? t("common.repository"), - name: workflow.name ?? "Workflow", - conclusion: status, - }), - url: workflow.html_url, - color: GITHUB_COLORS[colorKey], - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatRelease( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "published"; - const release = payload.release as { - tag_name?: string; - name?: string; - html_url?: string; - body?: string; - prerelease?: boolean; - draft?: boolean; - author?: { login?: string }; - }; - - const isPrerelease = release.prerelease; - const colorKey = - action === "deleted" - ? "release_deleted" - : isPrerelease - ? "release_prerelease" - : "release_published"; - const al = t("actions." + action) ?? action; - const emoji = action === "deleted" ? "🗑️" : isPrerelease ? "⚠️" : "🚀"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const descriptionParts: string[] = []; - descriptionParts.push( - t("events.release.action_release", { - emoji: em(emoji), - action: al, - tag: release.tag_name ?? t("common.unknown"), - }), - ); - - if (release.body) { - const truncated = release.body.slice(0, 300); - descriptionParts.push(`\n${truncated}${release.body.length > 300 ? "..." : ""}`); - } - - return { - embeds: [ - { - author, - title: t("events.release.title", { - repo: repo ?? t("common.repository"), - name: release.name ?? release.tag_name ?? "Release", - }), - url: release.html_url, - color: GITHUB_COLORS[colorKey], - description: descriptionParts.join("\n"), - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatCreate( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const refType = (payload.ref_type as string) ?? "branch"; - const ref = (payload.ref as string) ?? t("common.unknown"); - - const emoji = refType === "tag" ? "🏷️" : "🌿"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.type"), - value: refType, - inline: true, - }); - - fields.push({ - name: t("fields.name"), - value: `\`${ref}\``, - inline: true, - }); - - if (payload.description) { - fields.push({ - name: t("fields.description"), - value: payload.description as string, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.create.title", { - repo: repo ?? t("common.repository"), - emoji: em(emoji), - type: refType, - ref, - }), - color: GITHUB_COLORS.create, - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatDelete( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const refType = (payload.ref_type as string) ?? "branch"; - const ref = (payload.ref as string) ?? t("common.unknown"); - - const emoji = refType === "tag" ? "🏷️" : "🌿"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - return { - embeds: [ - { - author, - title: t("events.delete.title", { - repo: repo ?? t("common.repository"), - emoji: em(emoji), - type: refType, - ref, - }), - color: GITHUB_COLORS.delete, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatStar( - payload: Record, - repo: string | undefined, - repoUrl: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const actionLabel = action === "created" ? t("events.star.starred") : t("events.star.unstarred"); - const em = (e: string): string => emojiPrefix(e, showEmoji); - - return { - embeds: [ - { - author, - title: t("events.star.title", { - repo: repo ?? t("common.repository"), - emoji: em(action === "created" ? "⭐" : "💫"), - label: actionLabel, - }), - url: repoUrl, - color: GITHUB_COLORS.star, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatFork( - payload: Record, - repo: string | undefined, - repoUrl: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const forkee = payload.forkee as { full_name?: string; html_url?: string } | undefined; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - return { - embeds: [ - { - author, - title: t("events.fork.title", { - repo: repo ?? t("common.repository"), - emoji: em("🍴"), - forkee: forkee?.full_name ?? t("common.unknown"), - }), - url: forkee?.html_url ?? repoUrl, - color: GITHUB_COLORS.fork, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatCheckRun( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const checkRun = payload.check_run as { - name?: string; - conclusion?: string; - html_url?: string; - status?: string; - output?: { title?: string; summary?: string }; - }; - - const status = - checkRun.status === "queued" - ? "queued" - : checkRun.status === "in_progress" - ? "running" - : (checkRun.conclusion ?? "pending"); - const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const colorKey = - status === "success" - ? "check_run_success" - : status === "failure" - ? "check_run_failure" - : "check_run_other"; - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.status"), - value: `${em(emoji)}${status}`, - inline: true, - }); - - if (checkRun.output?.title) { - fields.push({ - name: t("fields.details"), - value: checkRun.output.title, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.check_run.title", { - repo: repo ?? t("common.repository"), - name: checkRun.name ?? "Check Run", - conclusion: status, - }), - url: checkRun.html_url, - color: GITHUB_COLORS[colorKey], - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatPullRequestReview( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "submitted"; - const review = payload.review as { - state?: string; - body?: string; - html_url?: string; - }; - const pr = payload.pull_request as { - number?: number; - title?: string; - html_url?: string; - }; - - const state = review.state ?? "commented"; - const colorKey = - state === "approved" - ? "pull_request_review_approved" - : state === "changes_requested" - ? "pull_request_review_changes" - : "pull_request_review_commented"; - - const stateEmoji = state === "approved" ? "✅" : state === "changes_requested" ? "🔴" : "💬"; - const al = t("actions." + action) ?? state; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const descriptionParts: string[] = []; - descriptionParts.push(t("events.pr_review.action_review", { emoji: em(stateEmoji), action: al })); - - if (review.body) { - const truncated = review.body.slice(0, 500); - descriptionParts.push(`\n> ${truncated}${review.body.length > 500 ? "..." : ""}`); - } - - return { - embeds: [ - { - author, - title: t("events.pr_review.title", { - repo: repo ?? t("common.repository"), - number: pr.number ?? "?", - title: pr.title ?? t("common.untitled"), - }), - url: review.html_url, - color: GITHUB_COLORS[colorKey], - description: descriptionParts.join("\n"), - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatPullRequestReviewComment( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const comment = payload.comment as { - body?: string; - path?: string; - position?: number | null; - html_url?: string; - }; - const pr = payload.pull_request as { - number?: number; - title?: string; - }; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const commentBody = comment.body?.slice(0, 400) ?? ""; - const truncated = comment.body && comment.body.length > 400; - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (comment.path) { - const loc = - comment.position != null - ? t("events.pr_review_comment.line", { position: comment.position }) - : ""; - fields.push({ - name: t("fields.file"), - value: `\`${comment.path}\`${loc}`, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.pr_review_comment.title", { - repo: repo ?? t("common.repository"), - number: pr.number ?? "?", - title: pr.title ?? t("common.untitled"), - }), - url: comment.html_url, - color: GITHUB_COLORS.pull_request_review_commented, - description: `${t("events.pr_review_comment.action_inline", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatCommitComment( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const comment = payload.comment as { - body?: string; - commit_id?: string; - html_url?: string; - }; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const commentBody = comment.body?.slice(0, 500) ?? ""; - const truncated = comment.body && comment.body.length > 500; - const shortSha = comment.commit_id?.slice(0, 7) ?? "???????"; - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (comment.commit_id) { - fields.push({ - name: t("fields.commit"), - value: `\`${shortSha}\``, - inline: true, - }); - } - - return { - embeds: [ - { - author, - title: t("events.commit_comment.title", { - repo: repo ?? t("common.repository"), - sha: shortSha, - }), - url: comment.html_url, - color: GITHUB_COLORS.commit_comment, - description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatDeploymentStatus( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const status = payload.deployment_status as { - state?: string; - environment?: string; - environment_url?: string; - description?: string; - }; - const deployment = payload.deployment as { - sha?: string; - ref?: string; - environment?: string; - }; - - const state = status.state ?? "pending"; - const colorKey = - state === "success" - ? "deployment_success" - : state === "failure" - ? "deployment_failure" - : "deployment_pending"; - const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const env = status.environment ?? deployment.environment ?? t("common.unknown"); - const shortSha = deployment.sha?.slice(0, 7) ?? "???????"; - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.status"), - value: `${em(emoji)}${status}`, - inline: true, - }); - - fields.push({ - name: t("fields.environment"), - value: env, - inline: true, - }); - - if (deployment.ref) { - fields.push({ - name: t("fields.branch_tag"), - value: `\`${deployment.ref}\``, - inline: true, - }); - } - - if (deployment.sha) { - fields.push({ - name: t("fields.commit"), - value: `\`${shortSha}\``, - inline: true, - }); - } - - if (status.environment_url) { - fields.push({ - name: t("fields.url"), - value: status.environment_url, - inline: false, - }); - } - - if (status.description) { - fields.push({ - name: t("fields.description"), - value: status.description, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.deployment.title", { repo: repo ?? t("common.repository"), env, state }), - color: GITHUB_COLORS[colorKey], - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatMember( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "added"; - const member = payload.member as { login?: string } | undefined; - - const al = t("actions." + action) ?? action; - const emoji = action === "added" ? "➕" : action === "removed" ? "➖" : "👤"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const memberName = member?.login ?? t("common.unknown"); - - return { - embeds: [ - { - author, - title: t("events.member.title", { - repo: repo ?? t("common.repository"), - emoji: em(emoji), - action: al, - name: memberName, - }), - color: action === "added" ? GITHUB_COLORS.member_added : GITHUB_COLORS.member_removed, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatLabel( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const label = payload.label as { - name?: string; - color?: string; - description?: string; - }; - - const al = t("actions." + action) ?? action; - const emoji = action === "deleted" ? "🗑️" : action === "edited" ? "✏️" : "🏷️"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (label.name) { - fields.push({ - name: t("fields.label"), - value: label.name, - inline: true, - }); - } - - if (label.color) { - fields.push({ - name: t("fields.color"), - value: `#${label.color}`, - inline: true, - }); - } - - if (label.description) { - fields.push({ - name: t("fields.description"), - value: label.description, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.label.title", { - repo: repo ?? t("common.repository"), - emoji: em(emoji), - action: al, - name: label.name ?? t("common.unknown"), - }), - color: GITHUB_COLORS.label, - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatMilestone( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const milestone = payload.milestone as { - title?: string; - number?: number; - state?: string; - open_issues?: number; - closed_issues?: number; - due_on?: string; - html_url?: string; - }; - - const al = t("actions." + action) ?? action; - const stateEmoji = milestone.state === "closed" ? "✅" : "🔵"; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (milestone.title) { - fields.push({ - name: t("fields.milestone"), - value: milestone.title, - inline: true, - }); - } - - if (milestone.number) { - fields.push({ - name: t("fields.number"), - value: `#${milestone.number}`, - inline: true, - }); - } - - if (milestone.open_issues != null && milestone.closed_issues != null) { - const total = milestone.open_issues + milestone.closed_issues; - const pct = total > 0 ? Math.round((milestone.closed_issues / total) * 100) : 0; - const bar = pct >= 75 ? "🟢🟢🟢" : pct >= 50 ? "🟡🟡" : pct > 0 ? "🟠" : "⬜"; - fields.push({ - name: t("fields.progress"), - value: `${bar} ${milestone.closed_issues}/${total} (${pct}%)`, - inline: false, - }); - } - - if (milestone.due_on) { - fields.push({ - name: t("fields.due"), - value: milestone.due_on.split("T")[0], - inline: true, - }); - } - - return { - embeds: [ - { - author, - title: t("events.milestone.title", { - repo: repo ?? t("common.repository"), - emoji: em(stateEmoji), - action: al, - title: milestone.title ?? t("common.unknown"), - }), - url: milestone.html_url, - color: - milestone.state === "closed" - ? GITHUB_COLORS.milestone_closed - : GITHUB_COLORS.milestone_opened, - fields: fields.length > 0 ? fields : undefined, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatDiscussion( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const discussion = payload.discussion as { - number?: number; - title?: string; - html_url?: string; - category?: { name?: string }; - }; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const stateEmoji = - action === "answered" - ? "✅" - : action === "closed" - ? "🔴" - : action === "created" - ? "🟢" - : action === "deleted" - ? "🗑️" - : "💬"; - - const category = discussion.category?.name; - - return { - embeds: [ - { - author, - title: t("events.discussion.title", { - repo: repo ?? t("common.repository"), - number: discussion.number ?? "?", - title: discussion.title ?? t("common.untitled"), - }), - url: discussion.html_url, - color: - action === "answered" - ? GITHUB_COLORS.discussion_answered - : GITHUB_COLORS.discussion_created, - description: t("events.discussion.action_discussion", { - emoji: em(stateEmoji), - action: al, - category: category ? ` in **${category}**` : "", - }), - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatDiscussionComment( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const comment = payload.comment as { - body?: string; - html_url?: string; - }; - const discussion = payload.discussion as { - number?: number; - title?: string; - }; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - const commentBody = comment.body?.slice(0, 500) ?? ""; - const truncated = comment.body && comment.body.length > 500; - - return { - embeds: [ - { - author, - title: t("events.discussion_comment.title", { - repo: repo ?? t("common.repository"), - number: discussion.number ?? "?", - title: discussion.title ?? t("common.untitled"), - }), - url: comment.html_url, - color: GITHUB_COLORS.discussion_comment, - description: `${t("events.discussion_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatRepository( - payload: Record, - repo: string | undefined, - repoUrl: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - if (action === "renamed") { - const changes = payload.changes as { name?: { from?: string } } | undefined; - const newName = - (payload.repository as { full_name?: string })?.full_name ?? t("common.unknown"); - fields.push({ - name: t("fields.renamed"), - value: changes?.name?.from ? `${changes.name.from} → ${newName}` : newName, - inline: false, - }); - } - - if (action === "transferred") { - const changes = payload.changes as { owner?: { from?: { login?: string } } } | undefined; - const newOwner = - (payload.repository as { owner?: { login?: string } })?.owner?.login ?? t("common.unknown"); - fields.push({ - name: t("fields.transferred"), - value: changes?.owner?.from?.login ? `${changes.owner.from.login} → ${newOwner}` : newOwner, - inline: false, - }); - } - - // 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(`[${em("🔗")}${t("events.repository.open")}](${repoUrl})`); - } - if (isCreateOrVisibility && repoData.description) { - descriptionParts.push(`> ${repoData.description}`); - } - - return { - embeds: [ - { - author, - title: t("events.repository.title", { - repo: repo ?? t("common.repository"), - emoji: em("📦"), - action: al, - }), - 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(), - }, - ], - }; -} - -function formatCodeScanningAlert( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const alert = payload.alert as { - rule?: { id?: string; severity?: string; description?: string }; - most_recent_instance?: { location?: { path?: string } }; - state?: string; - }; - - const severity = alert.rule?.severity ?? "warning"; - const colorKey = - severity === "critical" - ? "code_scanning_critical" - : severity === "high" - ? "code_scanning_high" - : severity === "medium" - ? "code_scanning_medium" - : "code_scanning_low"; - - const severityEmoji = - severity === "critical" - ? "🔴" - : severity === "high" - ? "🟠" - : severity === "medium" - ? "🟡" - : "⚪"; - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.severity"), - value: `${em(severityEmoji)}${severity}`, - inline: true, - }); - - if (alert.rule?.id) { - fields.push({ - name: t("fields.rule"), - value: alert.rule.id, - inline: true, - }); - } - - if (alert.most_recent_instance?.location?.path) { - fields.push({ - name: t("fields.file"), - value: `\`${alert.most_recent_instance.location.path}\``, - inline: false, - }); - } - - if (alert.rule?.description) { - fields.push({ - name: t("fields.description"), - value: alert.rule.description, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.code_scanning.title", { - repo: repo ?? t("common.repository"), - action: al, - }), - color: GITHUB_COLORS[colorKey], - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatDependabotAlert( - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - showEmoji: boolean, -): FormattedMessage { - const action = (payload.action as string) ?? "created"; - const alert = payload.alert as { - security_advisory?: { severity?: string; summary?: string; description?: string }; - security_vulnerability?: { - package?: { name?: string }; - vulnerable_version_range?: string; - first_patched_version?: { identifier?: string }; - }; - state?: string; - dependency?: { package?: { name?: string } }; - html_url?: string; - }; - - const severity = alert.security_advisory?.severity ?? "medium"; - const colorKey = - severity === "critical" - ? "dependabot_critical" - : severity === "high" - ? "dependabot_high" - : severity === "medium" - ? "dependabot_medium" - : "dependabot_low"; - - const severityEmoji = - severity === "critical" - ? "🔴" - : severity === "high" - ? "🟠" - : severity === "medium" - ? "🟡" - : "⚪"; - const al = t("actions." + action) ?? action; - const em = (e: string): string => emojiPrefix(e, showEmoji); - - const fields: Array<{ name: string; value: string; inline?: boolean }> = []; - - fields.push({ - name: t("fields.severity"), - value: `${em(severityEmoji)}${severity}`, - inline: true, - }); - - const pkgName = alert.security_vulnerability?.package?.name ?? alert.dependency?.package?.name; - if (pkgName) { - fields.push({ - name: t("fields.package"), - value: pkgName, - inline: true, - }); - } - - if (alert.security_vulnerability?.vulnerable_version_range) { - const patched = alert.security_vulnerability.first_patched_version?.identifier; - fields.push({ - name: t("fields.vulnerable_range"), - value: `${alert.security_vulnerability.vulnerable_version_range}${patched ? ` → fix: \`${patched}\`` : ""}`, - inline: false, - }); - } - - if (alert.security_advisory?.summary) { - fields.push({ - name: t("fields.summary"), - value: alert.security_advisory.summary, - inline: false, - }); - } - - return { - embeds: [ - { - author, - title: t("events.dependabot.title", { repo: repo ?? t("common.repository"), action: al }), - url: alert.html_url, - color: GITHUB_COLORS[colorKey], - fields, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} - -function formatGeneric( - eventType: string, - payload: Record, - repo: string | undefined, - author: { name: string; icon_url?: string; url?: string }, - t: T, - _showEmoji: boolean, -): FormattedMessage { - return { - embeds: [ - { - author, - title: t("events.generic.title", { - repo: repo ?? t("common.repository"), - event: eventType, - action: payload.action ? `: ${payload.action}` : "", - }), - color: GITHUB_COLORS.default, - footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, - timestamp: new Date().toISOString(), - }, - ], - }; -} diff --git a/src/formatters/check.ts b/src/formatters/check.ts new file mode 100644 index 0000000..3ad13ed --- /dev/null +++ b/src/formatters/check.ts @@ -0,0 +1,66 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatCheckRun( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const checkRun = payload.check_run as { + name?: string; + conclusion?: string; + html_url?: string; + status?: string; + output?: { title?: string; summary?: string }; + }; + + const status = + checkRun.status === "queued" + ? "queued" + : checkRun.status === "in_progress" + ? "running" + : (checkRun.conclusion ?? "pending"); + const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const colorKey = + status === "success" + ? "check_run_success" + : status === "failure" + ? "check_run_failure" + : "check_run_other"; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.status"), + value: `${em(emoji)}${status}`, + inline: true, + }); + + if (checkRun.output?.title) { + fields.push({ + name: t("fields.details"), + value: checkRun.output.title, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.check_run.title", { + repo: repo ?? t("common.repository"), + name: checkRun.name ?? "Check Run", + conclusion: status, + }), + url: checkRun.html_url, + color: GITHUB_COLORS[colorKey], + fields, + }, + t, + repo, + ); +} diff --git a/src/formatters/colors.ts b/src/formatters/colors.ts new file mode 100644 index 0000000..4b6bab9 --- /dev/null +++ b/src/formatters/colors.ts @@ -0,0 +1,64 @@ +export const GITHUB_COLORS = { + push: 0x2ea44f, + pull_request_opened: 0x2da44e, + pull_request_closed: 0xf85149, + pull_request_merged: 0x8957e5, + pull_request_ready_for_review: 0x2da44e, + pull_request_other: 0x1f6feb, + issues_opened: 0x2da44e, + issues_closed: 0xf85149, + issues_reopened: 0x1f6feb, + issues_other: 0x8957e5, + issue_comment: 0x6e7681, + workflow_run_success: 0x2da44e, + workflow_run_failure: 0xf85149, + workflow_run_other: 0xd29922, + release_published: 0x2da44e, + release_prerelease: 0xd29922, + release_deleted: 0xf85149, + create: 0x3fb950, + delete: 0xf85149, + star: 0xd29922, + fork: 0x1f6feb, + discussion: 0x8957e5, + check_run_success: 0x2da44e, + check_run_failure: 0xf85149, + check_run_other: 0xd29922, + pull_request_review_approved: 0x2da44e, + pull_request_review_changes: 0xf85149, + pull_request_review_commented: 0x8b949e, + commit_comment: 0x6e7681, + deployment_success: 0x2da44e, + deployment_failure: 0xf85149, + deployment_pending: 0xd29922, + member_added: 0x2da44e, + member_removed: 0xf85149, + label: 0x8957e5, + milestone_opened: 0x1f6feb, + milestone_closed: 0x2da44e, + discussion_created: 0x8957e5, + discussion_answered: 0x2da44e, + discussion_comment: 0x6e7681, + repository: 0x8b949e, + code_scanning_critical: 0xf85149, + code_scanning_high: 0xf85149, + code_scanning_medium: 0xd29922, + code_scanning_low: 0x8b949e, + dependabot_critical: 0xf85149, + dependabot_high: 0xf85149, + dependabot_medium: 0xd29922, + dependabot_low: 0x8b949e, + default: 0x8b949e, +} as const; + +export const WORKFLOW_CONCLUSION_EMOJI: Record = { + success: "✅", + failure: "❌", + cancelled: "🚫", + timed_out: "⏱️", + action_required: "⚠️", + neutral: "➖", + stale: "♻️", + queued: "⏳", + running: "🔄", +}; diff --git a/src/formatters/comments.ts b/src/formatters/comments.ts new file mode 100644 index 0000000..46ae233 --- /dev/null +++ b/src/formatters/comments.ts @@ -0,0 +1,43 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatIssueComment( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const issue = payload.issue as { + number?: number; + title?: string; + html_url?: string; + }; + const comment = payload.comment as { + body?: string; + html_url?: string; + }; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const commentBody = comment.body?.slice(0, 500) ?? ""; + const truncated = comment.body && comment.body.length > 500; + + return buildMessage( + { + author, + title: t("events.issue_comment.title", { + repo: repo ?? t("common.repository"), + number: issue.number ?? "?", + title: issue.title ?? t("common.untitled"), + }), + url: comment.html_url ?? issue.html_url, + color: GITHUB_COLORS.issue_comment, + description: `${t("events.issue_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + }, + t, + repo, + ); +} diff --git a/src/formatters/commit-comment.ts b/src/formatters/commit-comment.ts new file mode 100644 index 0000000..fdd6913 --- /dev/null +++ b/src/formatters/commit-comment.ts @@ -0,0 +1,50 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatCommitComment( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const comment = payload.comment as { + body?: string; + commit_id?: string; + html_url?: string; + }; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const commentBody = comment.body?.slice(0, 500) ?? ""; + const truncated = comment.body && comment.body.length > 500; + const shortSha = comment.commit_id?.slice(0, 7) ?? "???????"; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (comment.commit_id) { + fields.push({ + name: t("fields.commit"), + value: `\`${shortSha}\``, + inline: true, + }); + } + + return buildMessage( + { + author, + title: t("events.commit_comment.title", { + repo: repo ?? t("common.repository"), + sha: shortSha, + }), + url: comment.html_url, + color: GITHUB_COLORS.commit_comment, + description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/create.ts b/src/formatters/create.ts new file mode 100644 index 0000000..e80f1f5 --- /dev/null +++ b/src/formatters/create.ts @@ -0,0 +1,84 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatCreate( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const refType = (payload.ref_type as string) ?? "branch"; + const ref = (payload.ref as string) ?? t("common.unknown"); + + const emoji = refType === "tag" ? "🏷️" : "🌿"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.type"), + value: refType, + inline: true, + }); + + fields.push({ + name: t("fields.name"), + value: `\`${ref}\``, + inline: true, + }); + + if (payload.description) { + fields.push({ + name: t("fields.description"), + value: payload.description as string, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.create.title", { + repo: repo ?? t("common.repository"), + emoji: em(emoji), + type: refType, + ref, + }), + color: GITHUB_COLORS.create, + fields, + }, + t, + repo, + ); +} + +export function formatDelete( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const refType = (payload.ref_type as string) ?? "branch"; + const ref = (payload.ref as string) ?? t("common.unknown"); + + const emoji = refType === "tag" ? "🏷️" : "🌿"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + return buildMessage( + { + author, + title: t("events.delete.title", { + repo: repo ?? t("common.repository"), + emoji: em(emoji), + type: refType, + ref, + }), + color: GITHUB_COLORS.delete, + }, + t, + repo, + ); +} diff --git a/src/formatters/deployment.ts b/src/formatters/deployment.ts new file mode 100644 index 0000000..3e1a7a0 --- /dev/null +++ b/src/formatters/deployment.ts @@ -0,0 +1,92 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatDeploymentStatus( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const status = payload.deployment_status as { + state?: string; + environment?: string; + environment_url?: string; + description?: string; + }; + const deployment = payload.deployment as { + sha?: string; + ref?: string; + environment?: string; + }; + + const state = status.state ?? "pending"; + const colorKey = + state === "success" + ? "deployment_success" + : state === "failure" + ? "deployment_failure" + : "deployment_pending"; + const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const env = status.environment ?? deployment.environment ?? t("common.unknown"); + const shortSha = deployment.sha?.slice(0, 7) ?? "???????"; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.status"), + value: `${em(emoji)}${status}`, + inline: true, + }); + + fields.push({ + name: t("fields.environment"), + value: env, + inline: true, + }); + + if (deployment.ref) { + fields.push({ + name: t("fields.branch_tag"), + value: `\`${deployment.ref}\``, + inline: true, + }); + } + + if (deployment.sha) { + fields.push({ + name: t("fields.commit"), + value: `\`${shortSha}\``, + inline: true, + }); + } + + if (status.environment_url) { + fields.push({ + name: t("fields.url"), + value: status.environment_url, + inline: false, + }); + } + + if (status.description) { + fields.push({ + name: t("fields.description"), + value: status.description, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.deployment.title", { repo: repo ?? t("common.repository"), env, state }), + color: GITHUB_COLORS[colorKey], + fields, + }, + t, + repo, + ); +} diff --git a/src/formatters/discussion.ts b/src/formatters/discussion.ts new file mode 100644 index 0000000..e4389e2 --- /dev/null +++ b/src/formatters/discussion.ts @@ -0,0 +1,96 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatDiscussion( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const discussion = payload.discussion as { + number?: number; + title?: string; + html_url?: string; + category?: { name?: string }; + }; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const stateEmoji = + action === "answered" + ? "✅" + : action === "closed" + ? "🔴" + : action === "created" + ? "🟢" + : action === "deleted" + ? "🗑️" + : "💬"; + + const category = discussion.category?.name; + + return buildMessage( + { + author, + title: t("events.discussion.title", { + repo: repo ?? t("common.repository"), + number: discussion.number ?? "?", + title: discussion.title ?? t("common.untitled"), + }), + url: discussion.html_url, + color: + action === "answered" + ? GITHUB_COLORS.discussion_answered + : GITHUB_COLORS.discussion_created, + description: t("events.discussion.action_discussion", { + emoji: em(stateEmoji), + action: al, + category: category ? ` in **${category}**` : "", + }), + }, + t, + repo, + ); +} + +export function formatDiscussionComment( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const comment = payload.comment as { + body?: string; + html_url?: string; + }; + const discussion = payload.discussion as { + number?: number; + title?: string; + }; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const commentBody = comment.body?.slice(0, 500) ?? ""; + const truncated = comment.body && comment.body.length > 500; + + return buildMessage( + { + author, + title: t("events.discussion_comment.title", { + repo: repo ?? t("common.repository"), + number: discussion.number ?? "?", + title: discussion.title ?? t("common.untitled"), + }), + url: comment.html_url, + color: GITHUB_COLORS.discussion_comment, + description: `${t("events.discussion_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + }, + t, + repo, + ); +} diff --git a/src/formatters/generic.ts b/src/formatters/generic.ts new file mode 100644 index 0000000..055a172 --- /dev/null +++ b/src/formatters/generic.ts @@ -0,0 +1,26 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { type T, buildMessage } from "./helpers"; + +export function formatGeneric( + eventType: string, + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + _showEmoji: boolean, +): NeutralMessage { + return buildMessage( + { + author, + title: t("events.generic.title", { + repo: repo ?? t("common.repository"), + event: eventType, + action: payload.action ? `: ${payload.action}` : "", + }), + color: GITHUB_COLORS.default, + }, + t, + repo, + ); +} diff --git a/src/formatters/helpers.ts b/src/formatters/helpers.ts new file mode 100644 index 0000000..ece4e5d --- /dev/null +++ b/src/formatters/helpers.ts @@ -0,0 +1,25 @@ +import type { NeutralMessage } from "../types"; +import { t as translate } from "../lib/i18n"; +import type { Translations } from "../lib/i18n"; + +export type T = (key: string, params?: Record) => string; + +export function emojiPrefix(emoji: string, show: boolean): string { + return show ? `${emoji} ` : ""; +} + +export function makeT(tr?: Translations): T { + return (key, params) => translate(key, params, undefined, tr); +} + +export function buildMessage( + partial: Omit, "title"> & { title: string }, + t: T, + repo?: string +): NeutralMessage { + return { + ...partial, + footer: partial.footer ?? t("common.footer", { repo: repo ?? t("common.github") }), + timestamp: partial.timestamp ?? new Date().toISOString(), + }; +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..c819523 --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1,115 @@ +import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types"; +import type { Translations } from "../lib/i18n"; +import { makeT, type T } from "./helpers"; +import { formatPush } from "./push"; +import { formatPullRequest } from "./pull-request"; +import { formatPullRequestReview, formatPullRequestReviewComment } from "./review"; +import { formatIssues } from "./issues"; +import { formatIssueComment } from "./comments"; +import { formatWorkflowRun } from "./workflow"; +import { formatRelease } from "./release"; +import { formatCreate, formatDelete } from "./create"; +import { formatStar, formatFork } from "./repo"; +import { formatCheckRun } from "./check"; +import { formatCommitComment } from "./commit-comment"; +import { formatDeploymentStatus } from "./deployment"; +import { formatMember } from "./member"; +import { formatLabel } from "./label"; +import { formatMilestone } from "./milestone"; +import { formatDiscussion, formatDiscussionComment } from "./discussion"; +import { formatRepository } from "./repository"; +import { formatCodeScanningAlert, formatDependabotAlert } from "./security"; +import { formatGeneric } from "./generic"; + +export type { T } from "./helpers"; +export { formatPush } from "./push"; +export { formatPullRequest } from "./pull-request"; +export { formatPullRequestReview, formatPullRequestReviewComment } from "./review"; +export { formatIssues } from "./issues"; +export { formatIssueComment } from "./comments"; +export { formatWorkflowRun } from "./workflow"; +export { formatRelease } from "./release"; +export { formatCreate, formatDelete } from "./create"; +export { formatStar, formatFork } from "./repo"; +export { formatCheckRun } from "./check"; +export { formatCommitComment } from "./commit-comment"; +export { formatDeploymentStatus } from "./deployment"; +export { formatMember } from "./member"; +export { formatLabel } from "./label"; +export { formatMilestone } from "./milestone"; +export { formatDiscussion, formatDiscussionComment } from "./discussion"; +export { formatRepository } from "./repository"; +export { formatCodeScanningAlert, formatDependabotAlert } from "./security"; +export { formatGeneric } from "./generic"; + +export function formatEvent( + route: Route, + event: WebhookEvent, + tr?: Translations, + showEmoji = true, +): NeutralMessage { + const { event: eventType, payload } = event; + const repo = (payload.repository as { full_name?: string })?.full_name; + const sender = (payload.sender as { login?: string })?.login; + const senderAvatar = (payload.sender as { avatar_url?: string })?.avatar_url; + const repoUrl = (payload.repository as { html_url?: string })?.html_url; + + const t: T = makeT(tr); + + const author: NeutralAuthor = { + name: sender ?? t("common.unknown"), + iconUrl: senderAvatar, + url: sender ? `https://github.com/${sender}` : undefined, + }; + + switch (eventType) { + case "push": + return formatPush(payload, repo, author, t, showEmoji); + case "pull_request": + return formatPullRequest(payload, repo, author, t, showEmoji); + case "pull_request_review": + return formatPullRequestReview(payload, repo, author, t, showEmoji); + case "pull_request_review_comment": + return formatPullRequestReviewComment(payload, repo, author, t, showEmoji); + case "issues": + return formatIssues(payload, repo, author, t, showEmoji); + case "issue_comment": + return formatIssueComment(payload, repo, author, t, showEmoji); + case "workflow_run": + return formatWorkflowRun(payload, repo, author, t, showEmoji); + case "release": + return formatRelease(payload, repo, author, t, showEmoji); + case "create": + return formatCreate(payload, repo, author, t, showEmoji); + case "delete": + return formatDelete(payload, repo, author, t, showEmoji); + case "star": + return formatStar(payload, repo, repoUrl, author, t, showEmoji); + case "fork": + return formatFork(payload, repo, repoUrl, author, t, showEmoji); + case "check_run": + return formatCheckRun(payload, repo, author, t, showEmoji); + case "commit_comment": + return formatCommitComment(payload, repo, author, t, showEmoji); + case "deployment_status": + return formatDeploymentStatus(payload, repo, author, t, showEmoji); + case "member": + return formatMember(payload, repo, author, t, showEmoji); + case "label": + return formatLabel(payload, repo, author, t, showEmoji); + case "milestone": + return formatMilestone(payload, repo, author, t, showEmoji); + case "discussion": + return formatDiscussion(payload, repo, author, t, showEmoji); + case "discussion_comment": + return formatDiscussionComment(payload, repo, author, t, showEmoji); + case "repository": + return formatRepository(payload, repo, repoUrl, author, t, showEmoji); + case "code_scanning_alert": + return formatCodeScanningAlert(payload, repo, author, t, showEmoji); + case "dependabot_alert": + return formatDependabotAlert(payload, repo, author, t, showEmoji); + default: + return formatGeneric(eventType, payload, repo, author, t, showEmoji); + } +} diff --git a/src/formatters/issues.ts b/src/formatters/issues.ts new file mode 100644 index 0000000..abc8cc6 --- /dev/null +++ b/src/formatters/issues.ts @@ -0,0 +1,87 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatIssues( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "opened"; + const issue = payload.issue as { + number?: number; + title?: string; + html_url?: string; + state?: string; + body?: string; + labels?: Array<{ name?: string; color?: string }>; + assignees?: Array<{ login?: string }>; + milestone?: { title?: string }; + }; + + const colorKey = + action === "closed" + ? "issues_closed" + : action === "reopened" + ? "issues_reopened" + : action === "opened" + ? "issues_opened" + : "issues_other"; + + const al = t("actions." + action) ?? action; + const stateEmoji = action === "closed" ? "🔴" : action === "opened" ? "🟢" : "🟣"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const descriptionParts: string[] = []; + descriptionParts.push(t("events.issues.action_issue", { emoji: em(stateEmoji), action: al })); + + if (issue.body) { + const truncated = issue.body.slice(0, 300); + descriptionParts.push(`\n${truncated}${issue.body.length > 300 ? "..." : ""}`); + } + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (issue.labels && issue.labels.length > 0) { + fields.push({ + name: t("fields.labels"), + value: issue.labels.map((l) => l.name).join(", "), + inline: true, + }); + } + + if (issue.assignees && issue.assignees.length > 0) { + fields.push({ + name: t("fields.assignees"), + value: issue.assignees.map((a) => a.login).join(", "), + inline: true, + }); + } + + if (issue.milestone) { + fields.push({ + name: t("fields.milestone"), + value: issue.milestone.title ?? t("common.unknown"), + inline: true, + }); + } + + return buildMessage( + { + author, + title: t("events.issues.title", { + repo: repo ?? t("common.repository"), + number: issue.number ?? "?", + title: issue.title ?? t("common.untitled"), + }), + url: issue.html_url, + color: GITHUB_COLORS[colorKey], + description: descriptionParts.join("\n"), + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/label.ts b/src/formatters/label.ts new file mode 100644 index 0000000..822687d --- /dev/null +++ b/src/formatters/label.ts @@ -0,0 +1,64 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatLabel( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const label = payload.label as { + name?: string; + color?: string; + description?: string; + }; + + const al = t("actions." + action) ?? action; + const emoji = action === "deleted" ? "🗑️" : action === "edited" ? "✏️" : "🏷️"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (label.name) { + fields.push({ + name: t("fields.label"), + value: label.name, + inline: true, + }); + } + + if (label.color) { + fields.push({ + name: t("fields.color"), + value: `#${label.color}`, + inline: true, + }); + } + + if (label.description) { + fields.push({ + name: t("fields.description"), + value: label.description, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.label.title", { + repo: repo ?? t("common.repository"), + emoji: em(emoji), + action: al, + name: label.name ?? t("common.unknown"), + }), + color: GITHUB_COLORS.label, + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/member.ts b/src/formatters/member.ts new file mode 100644 index 0000000..b2aa680 --- /dev/null +++ b/src/formatters/member.ts @@ -0,0 +1,34 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatMember( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "added"; + const member = payload.member as { login?: string } | undefined; + + const al = t("actions." + action) ?? action; + const emoji = action === "added" ? "➕" : action === "removed" ? "➖" : "👤"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const memberName = member?.login ?? t("common.unknown"); + + return buildMessage( + { + author, + title: t("events.member.title", { + repo: repo ?? t("common.repository"), + emoji: em(emoji), + action: al, + name: memberName, + }), + color: action === "added" ? GITHUB_COLORS.member_added : GITHUB_COLORS.member_removed, + }, + t, + repo, + ); +} diff --git a/src/formatters/milestone.ts b/src/formatters/milestone.ts new file mode 100644 index 0000000..6fbcd42 --- /dev/null +++ b/src/formatters/milestone.ts @@ -0,0 +1,83 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatMilestone( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const milestone = payload.milestone as { + title?: string; + number?: number; + state?: string; + open_issues?: number; + closed_issues?: number; + due_on?: string; + html_url?: string; + }; + + const al = t("actions." + action) ?? action; + const stateEmoji = milestone.state === "closed" ? "✅" : "🔵"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (milestone.title) { + fields.push({ + name: t("fields.milestone"), + value: milestone.title, + inline: true, + }); + } + + if (milestone.number) { + fields.push({ + name: t("fields.number"), + value: `#${milestone.number}`, + inline: true, + }); + } + + if (milestone.open_issues != null && milestone.closed_issues != null) { + const total = milestone.open_issues + milestone.closed_issues; + const pct = total > 0 ? Math.round((milestone.closed_issues / total) * 100) : 0; + const bar = pct >= 75 ? "🟢🟢🟢" : pct >= 50 ? "🟡🟡" : pct > 0 ? "🟠" : "⬜"; + fields.push({ + name: t("fields.progress"), + value: `${bar} ${milestone.closed_issues}/${total} (${pct}%)`, + inline: false, + }); + } + + if (milestone.due_on) { + fields.push({ + name: t("fields.due"), + value: milestone.due_on.split("T")[0], + inline: true, + }); + } + + return buildMessage( + { + author, + title: t("events.milestone.title", { + repo: repo ?? t("common.repository"), + emoji: em(stateEmoji), + action: al, + title: milestone.title ?? t("common.unknown"), + }), + url: milestone.html_url, + color: + milestone.state === "closed" + ? GITHUB_COLORS.milestone_closed + : GITHUB_COLORS.milestone_opened, + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/pull-request.ts b/src/formatters/pull-request.ts new file mode 100644 index 0000000..e2c1fd4 --- /dev/null +++ b/src/formatters/pull-request.ts @@ -0,0 +1,112 @@ +import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatPullRequest( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "opened"; + const pr = payload.pull_request as { + number?: number; + title?: string; + html_url?: string; + state?: string; + draft?: boolean; + merged?: boolean; + head?: { ref?: string; sha?: string }; + base?: { ref?: string }; + body?: string; + labels?: Array<{ name?: string; color?: string }>; + changed_files?: number; + additions?: number; + deletions?: number; + }; + + const colorKey = pr.merged + ? "pull_request_merged" + : action === "closed" + ? "pull_request_closed" + : action === "ready_for_review" + ? "pull_request_ready_for_review" + : action === "opened" + ? "pull_request_opened" + : "pull_request_other"; + + const al = t("actions." + action) ?? action; + const stateEmoji = pr.merged + ? "🟣" + : action === "closed" + ? "🔴" + : action === "opened" + ? "🟢" + : "🔵"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const descriptionParts: string[] = []; + descriptionParts.push(t("events.pr.action_pr", { emoji: em(stateEmoji), action: al })); + + if (pr.body) { + const truncated = pr.body.slice(0, 300); + descriptionParts.push(`\n${truncated}${pr.body.length > 300 ? "..." : ""}`); + } + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (pr.head?.ref && pr.base?.ref) { + fields.push({ + name: t("fields.branch"), + value: `\`${pr.head.ref}\` → \`${pr.base.ref}\``, + inline: true, + }); + } + + if (pr.changed_files != null || pr.additions != null || pr.deletions != null) { + const parts: string[] = []; + if (pr.additions != null) parts.push(`+${pr.additions}`); + if (pr.deletions != null) parts.push(`-${pr.deletions}`); + if (pr.changed_files != null) parts.push(t("common.n_files", { count: pr.changed_files })); + fields.push({ + name: t("fields.changes"), + value: parts.join(" | "), + inline: true, + }); + } + + if (pr.labels && pr.labels.length > 0) { + fields.push({ + name: t("fields.labels"), + value: pr.labels.map((l) => l.name).join(", "), + inline: true, + }); + } + + const actionable = pr.state === "open" && !!repo && pr.number != null; + const actions: NeutralAction[] | undefined = actionable + ? [ + { id: `ghpr|merge|${repo}|${pr.number}`, label: "合并", style: "primary" }, + { id: `ghpr|close|${repo}|${pr.number}`, label: "关闭", style: "danger" }, + ] + : undefined; + + return buildMessage( + { + author, + title: t("events.pr.title", { + repo: repo ?? t("common.repository"), + number: pr.number ?? "?", + title: pr.title ?? t("common.untitled"), + }), + url: pr.html_url, + color: GITHUB_COLORS[colorKey], + description: descriptionParts.join("\n"), + fields: fields.length > 0 ? fields : undefined, + actions, + }, + t, + repo, + ); +} diff --git a/src/formatters/push.ts b/src/formatters/push.ts new file mode 100644 index 0000000..a725258 --- /dev/null +++ b/src/formatters/push.ts @@ -0,0 +1,106 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatPush( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const ref = (payload.ref as string)?.replace("refs/heads/", "").replace("refs/tags/", "tag: "); + const commits = (payload.commits ?? []) as Array<{ + id?: string; + message?: string; + author?: { name?: string; email?: string }; + added?: string[]; + removed?: string[]; + modified?: string[]; + }>; + const count = commits.length; + const compareUrl = payload.compare as string | undefined; + const forced = payload.forced as boolean | undefined; + const created = payload.created as boolean | undefined; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const descriptionParts: string[] = []; + + if (forced) { + descriptionParts.push(em("⚠️") + t("events.push.force_push")); + } + if (created) { + descriptionParts.push(em("🆕") + t("events.push.branch_created")); + } + + descriptionParts.push( + t("events.push.commits_pushed", { count, s: count !== 1 ? "s" : "", ref: ref ?? "" }), + ); + + if (compareUrl) { + descriptionParts.push(t("events.push.view_comparison", { url: compareUrl })); + } + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (count <= 5) { + for (const c of commits) { + const shortId = c.id?.slice(0, 7) ?? "???????"; + const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message"); + fields.push({ + name: `\`${shortId}\``, + value: msg, + inline: false, + }); + } + } else { + const first3 = commits.slice(0, 3); + for (const c of first3) { + const shortId = c.id?.slice(0, 7) ?? "???????"; + const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message"); + fields.push({ + name: `\`${shortId}\``, + value: msg, + inline: false, + }); + } + fields.push({ + name: `\u200b`, + value: t("common.and_n_more", { count: count - 3 }), + inline: false, + }); + } + + const added = commits.flatMap((c) => c.added ?? []); + const removed = commits.flatMap((c) => c.removed ?? []); + const modified = commits.flatMap((c) => c.modified ?? []); + + if (added.length > 0 || removed.length > 0 || modified.length > 0) { + const changes: string[] = []; + if (added.length > 0) changes.push(t("events.push.added", { count: added.length })); + if (removed.length > 0) changes.push(t("events.push.removed", { count: removed.length })); + if (modified.length > 0) changes.push(t("events.push.modified", { count: modified.length })); + fields.push({ + name: t("fields.changes"), + value: changes.join(" | "), + inline: true, + }); + } + + return buildMessage( + { + author, + title: t("events.push.title", { + count, + s: count !== 1 ? "s" : "", + repo: repo ?? t("common.repository"), + }), + url: compareUrl, + color: GITHUB_COLORS.push, + description: descriptionParts.join("\n"), + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/release.ts b/src/formatters/release.ts new file mode 100644 index 0000000..bfa68a8 --- /dev/null +++ b/src/formatters/release.ts @@ -0,0 +1,62 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatRelease( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "published"; + const release = payload.release as { + tag_name?: string; + name?: string; + html_url?: string; + body?: string; + prerelease?: boolean; + draft?: boolean; + author?: { login?: string }; + }; + + const isPrerelease = release.prerelease; + const colorKey = + action === "deleted" + ? "release_deleted" + : isPrerelease + ? "release_prerelease" + : "release_published"; + const al = t("actions." + action) ?? action; + const emoji = action === "deleted" ? "🗑️" : isPrerelease ? "⚠️" : "🚀"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const descriptionParts: string[] = []; + descriptionParts.push( + t("events.release.action_release", { + emoji: em(emoji), + action: al, + tag: release.tag_name ?? t("common.unknown"), + }), + ); + + if (release.body) { + const truncated = release.body.slice(0, 300); + descriptionParts.push(`\n${truncated}${release.body.length > 300 ? "..." : ""}`); + } + + return buildMessage( + { + author, + title: t("events.release.title", { + repo: repo ?? t("common.repository"), + name: release.name ?? release.tag_name ?? "Release", + }), + url: release.html_url, + color: GITHUB_COLORS[colorKey], + description: descriptionParts.join("\n"), + }, + t, + repo, + ); +} diff --git a/src/formatters/repo.ts b/src/formatters/repo.ts new file mode 100644 index 0000000..698e199 --- /dev/null +++ b/src/formatters/repo.ts @@ -0,0 +1,58 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatStar( + payload: Record, + repo: string | undefined, + repoUrl: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const actionLabel = action === "created" ? t("events.star.starred") : t("events.star.unstarred"); + const em = (e: string): string => emojiPrefix(e, showEmoji); + + return buildMessage( + { + author, + title: t("events.star.title", { + repo: repo ?? t("common.repository"), + emoji: em(action === "created" ? "⭐" : "💫"), + label: actionLabel, + }), + url: repoUrl, + color: GITHUB_COLORS.star, + }, + t, + repo, + ); +} + +export function formatFork( + payload: Record, + repo: string | undefined, + repoUrl: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const forkee = payload.forkee as { full_name?: string; html_url?: string } | undefined; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + return buildMessage( + { + author, + title: t("events.fork.title", { + repo: repo ?? t("common.repository"), + emoji: em("🍴"), + forkee: forkee?.full_name ?? t("common.unknown"), + }), + url: forkee?.html_url ?? repoUrl, + color: GITHUB_COLORS.fork, + }, + t, + repo, + ); +} diff --git a/src/formatters/repository.ts b/src/formatters/repository.ts new file mode 100644 index 0000000..a7dc806 --- /dev/null +++ b/src/formatters/repository.ts @@ -0,0 +1,92 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatRepository( + payload: Record, + repo: string | undefined, + repoUrl: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (action === "renamed") { + const changes = payload.changes as { name?: { from?: string } } | undefined; + const newName = + (payload.repository as { full_name?: string })?.full_name ?? t("common.unknown"); + fields.push({ + name: t("fields.renamed"), + value: changes?.name?.from ? `${changes.name.from} → ${newName}` : newName, + inline: false, + }); + } + + if (action === "transferred") { + const changes = payload.changes as { owner?: { from?: { login?: string } } } | undefined; + const newOwner = + (payload.repository as { owner?: { login?: string } })?.owner?.login ?? t("common.unknown"); + fields.push({ + name: t("fields.transferred"), + value: changes?.owner?.from?.login ? `${changes.owner.from.login} → ${newOwner}` : newOwner, + inline: false, + }); + } + + 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(`[${em("🔗")}${t("events.repository.open")}](${repoUrl})`); + } + if (isCreateOrVisibility && repoData.description) { + descriptionParts.push(`> ${repoData.description}`); + } + + return buildMessage( + { + author, + title: t("events.repository.title", { + repo: repo ?? t("common.repository"), + emoji: em("📦"), + action: al, + }), + url: repoUrl, + color: GITHUB_COLORS.repository, + description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined, + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/review.ts b/src/formatters/review.ts new file mode 100644 index 0000000..fa59b26 --- /dev/null +++ b/src/formatters/review.ts @@ -0,0 +1,114 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatPullRequestReview( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "submitted"; + const review = payload.review as { + state?: string; + body?: string; + html_url?: string; + }; + const pr = payload.pull_request as { + number?: number; + title?: string; + html_url?: string; + }; + + const state = review.state ?? "commented"; + const colorKey = + state === "approved" + ? "pull_request_review_approved" + : state === "changes_requested" + ? "pull_request_review_changes" + : "pull_request_review_commented"; + + const stateEmoji = state === "approved" ? "✅" : state === "changes_requested" ? "🔴" : "💬"; + const al = t("actions." + action) ?? state; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const descriptionParts: string[] = []; + descriptionParts.push(t("events.pr_review.action_review", { emoji: em(stateEmoji), action: al })); + + if (review.body) { + const truncated = review.body.slice(0, 500); + descriptionParts.push(`\n> ${truncated}${review.body.length > 500 ? "..." : ""}`); + } + + return buildMessage( + { + author, + title: t("events.pr_review.title", { + repo: repo ?? t("common.repository"), + number: pr.number ?? "?", + title: pr.title ?? t("common.untitled"), + }), + url: review.html_url, + color: GITHUB_COLORS[colorKey], + description: descriptionParts.join("\n"), + }, + t, + repo, + ); +} + +export function formatPullRequestReviewComment( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const comment = payload.comment as { + body?: string; + path?: string; + position?: number | null; + html_url?: string; + }; + const pr = payload.pull_request as { + number?: number; + title?: string; + }; + + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const commentBody = comment.body?.slice(0, 400) ?? ""; + const truncated = comment.body && comment.body.length > 400; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (comment.path) { + const loc = + comment.position != null + ? t("events.pr_review_comment.line", { position: comment.position }) + : ""; + fields.push({ + name: t("fields.file"), + value: `\`${comment.path}\`${loc}`, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.pr_review_comment.title", { + repo: repo ?? t("common.repository"), + number: pr.number ?? "?", + title: pr.title ?? t("common.untitled"), + }), + url: comment.html_url, + color: GITHUB_COLORS.pull_request_review_commented, + description: `${t("events.pr_review_comment.action_inline", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + fields: fields.length > 0 ? fields : undefined, + }, + t, + repo, + ); +} diff --git a/src/formatters/security.ts b/src/formatters/security.ts new file mode 100644 index 0000000..68f04ab --- /dev/null +++ b/src/formatters/security.ts @@ -0,0 +1,173 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatCodeScanningAlert( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const alert = payload.alert as { + rule?: { id?: string; severity?: string; description?: string }; + most_recent_instance?: { location?: { path?: string } }; + state?: string; + }; + + const severity = alert.rule?.severity ?? "warning"; + const colorKey = + severity === "critical" + ? "code_scanning_critical" + : severity === "high" + ? "code_scanning_high" + : severity === "medium" + ? "code_scanning_medium" + : "code_scanning_low"; + + const severityEmoji = + severity === "critical" + ? "🔴" + : severity === "high" + ? "🟠" + : severity === "medium" + ? "🟡" + : "⚪"; + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.severity"), + value: `${em(severityEmoji)}${severity}`, + inline: true, + }); + + if (alert.rule?.id) { + fields.push({ + name: t("fields.rule"), + value: alert.rule.id, + inline: true, + }); + } + + if (alert.most_recent_instance?.location?.path) { + fields.push({ + name: t("fields.file"), + value: `\`${alert.most_recent_instance.location.path}\``, + inline: false, + }); + } + + if (alert.rule?.description) { + fields.push({ + name: t("fields.description"), + value: alert.rule.description, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.code_scanning.title", { + repo: repo ?? t("common.repository"), + action: al, + }), + color: GITHUB_COLORS[colorKey], + fields, + }, + t, + repo, + ); +} + +export function formatDependabotAlert( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const action = (payload.action as string) ?? "created"; + const alert = payload.alert as { + security_advisory?: { severity?: string; summary?: string; description?: string }; + security_vulnerability?: { + package?: { name?: string }; + vulnerable_version_range?: string; + first_patched_version?: { identifier?: string }; + }; + state?: string; + dependency?: { package?: { name?: string } }; + html_url?: string; + }; + + const severity = alert.security_advisory?.severity ?? "medium"; + const colorKey = + severity === "critical" + ? "dependabot_critical" + : severity === "high" + ? "dependabot_high" + : severity === "medium" + ? "dependabot_medium" + : "dependabot_low"; + + const severityEmoji = + severity === "critical" + ? "🔴" + : severity === "high" + ? "🟠" + : severity === "medium" + ? "🟡" + : "⚪"; + const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.severity"), + value: `${em(severityEmoji)}${severity}`, + inline: true, + }); + + const pkgName = alert.security_vulnerability?.package?.name ?? alert.dependency?.package?.name; + if (pkgName) { + fields.push({ + name: t("fields.package"), + value: pkgName, + inline: true, + }); + } + + if (alert.security_vulnerability?.vulnerable_version_range) { + const patched = alert.security_vulnerability.first_patched_version?.identifier; + fields.push({ + name: t("fields.vulnerable_range"), + value: `${alert.security_vulnerability.vulnerable_version_range}${patched ? ` → fix: \`${patched}\`` : ""}`, + inline: false, + }); + } + + if (alert.security_advisory?.summary) { + fields.push({ + name: t("fields.summary"), + value: alert.security_advisory.summary, + inline: false, + }); + } + + return buildMessage( + { + author, + title: t("events.dependabot.title", { repo: repo ?? t("common.repository"), action: al }), + url: alert.html_url, + color: GITHUB_COLORS[colorKey], + fields, + }, + t, + repo, + ); +} diff --git a/src/formatters/workflow.ts b/src/formatters/workflow.ts new file mode 100644 index 0000000..bcf8ca9 --- /dev/null +++ b/src/formatters/workflow.ts @@ -0,0 +1,100 @@ +import type { NeutralMessage, NeutralAuthor } from "../types"; +import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors"; +import { emojiPrefix, type T, buildMessage } from "./helpers"; + +export function formatWorkflowRun( + payload: Record, + repo: string | undefined, + author: NeutralAuthor, + t: T, + showEmoji: boolean, +): NeutralMessage { + const workflow = payload.workflow_run as { + name?: string; + conclusion?: string; + html_url?: string; + head_branch?: string; + run_number?: number; + created_at?: string; + updated_at?: string; + elapsed_seconds?: number; + jobs?: Array<{ name?: string; conclusion?: string }>; + }; + + const action = payload.action as string | undefined; + const status = + action === "in_progress" + ? "running" + : action === "requested" + ? "queued" + : (workflow.conclusion ?? "pending"); + const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; + const em = (e: string): string => emojiPrefix(e, showEmoji); + const colorKey = + status === "success" + ? "workflow_run_success" + : status === "failure" + ? "workflow_run_failure" + : "workflow_run_other"; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + fields.push({ + name: t("fields.status"), + value: `${em(emoji)}${status}`, + inline: true, + }); + + if (workflow.jobs?.length) { + const jobLines = workflow.jobs.map( + (j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`, + ); + fields.push({ + name: t("fields.job"), + value: jobLines.join("\n"), + inline: false, + }); + } + + if (workflow.head_branch) { + fields.push({ + name: t("fields.branch"), + value: `\`${workflow.head_branch}\``, + inline: true, + }); + } + + if (workflow.run_number) { + fields.push({ + name: t("fields.run"), + value: `#${workflow.run_number}`, + inline: true, + }); + } + + if (workflow.elapsed_seconds != null) { + const mins = Math.floor(workflow.elapsed_seconds / 60); + const secs = workflow.elapsed_seconds % 60; + fields.push({ + name: t("fields.duration"), + value: `${mins}m ${secs}s`, + inline: true, + }); + } + + return buildMessage( + { + author, + title: t("events.workflow_run.title", { + repo: repo ?? t("common.repository"), + name: workflow.name ?? "Workflow", + conclusion: status, + }), + url: workflow.html_url, + color: GITHUB_COLORS[colorKey], + fields, + }, + t, + repo, + ); +} diff --git a/src/github-oauth.ts b/src/github/oauth.ts similarity index 99% rename from src/github-oauth.ts rename to src/github/oauth.ts index c614275..10806b6 100644 --- a/src/github-oauth.ts +++ b/src/github/oauth.ts @@ -1,5 +1,5 @@ import { Octokit } from "octokit"; -import { saveToken, getToken } from "./token-store"; +import { saveToken, getToken } from "./store"; export function getOAuthURL(clientId: string, state: string): string { return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`; diff --git a/src/token-store.ts b/src/github/store.ts similarity index 100% rename from src/token-store.ts rename to src/github/store.ts diff --git a/src/index.ts b/src/index.ts index 65ddd0c..3da277c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { createServer } from "./server"; -import { syncCommands } from "./discord-interactions"; +import { syncCommands } from "./drivers/discord/commands"; import type { Env } from "./types"; -import { log } from "./log"; +import { log } from "./lib/log"; const app = createServer(); diff --git a/src/i18n.ts b/src/lib/i18n.ts similarity index 100% rename from src/i18n.ts rename to src/lib/i18n.ts diff --git a/src/locales/en.ts b/src/lib/locales/en.ts similarity index 100% rename from src/locales/en.ts rename to src/lib/locales/en.ts diff --git a/src/locales/zh.ts b/src/lib/locales/zh.ts similarity index 100% rename from src/locales/zh.ts rename to src/lib/locales/zh.ts diff --git a/src/log.ts b/src/lib/log.ts similarity index 100% rename from src/log.ts rename to src/lib/log.ts diff --git a/src/send-log.ts b/src/lib/send-log.ts similarity index 100% rename from src/send-log.ts rename to src/lib/send-log.ts diff --git a/src/server.ts b/src/server.ts index 6dd51cd..c463c8c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,15 +1,16 @@ import { Hono } from "hono"; import type { Env } from "./types"; -import { verifySignature, parseEvent } from "./webhook"; -import { dispatchEvent } from "./discord"; -import { handleInteractionRequest } from "./discord-interactions"; -import { createOAuthRoutes } from "./oauth-routes"; -import { createActionRoutes } from "./action-routes"; -import { createAdminRoutes } from "./admin-routes"; -import { createLegalRoutes } from "./legal-routes"; -import { createHomeRoutes } from "./home-routes"; +import { verifySignature } from "./events/verify"; +import { parseEvent } from "./events/parse"; +import { dispatchEvent } from "./core/dispatch"; +import { handleInteractionRequest } from "./drivers/discord/interactions"; +import { createOAuthRoutes } from "./web/oauth-routes"; +import { createActionRoutes } from "./web/action-routes"; +import { createAdminRoutes } from "./web/admin-routes"; +import { createLegalRoutes } from "./web/legal-routes"; +import { createHomeRoutes } from "./web/home-routes"; import { loadConfig } from "./config"; -import { log } from "./log"; +import { log } from "./lib/log"; const MAX_BODY_SIZE = 1024 * 1024; diff --git a/src/types.ts b/src/types.ts index e67568c..d133b6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,38 @@ export interface WebhookEvent { signature?: string; } +export interface NeutralAuthor { + name: string; + iconUrl?: string; + url?: string; +} + +export interface NeutralField { + name: string; + value: string; + inline?: boolean; +} + +export type NeutralActionStyle = "primary" | "danger" | "secondary"; + +export interface NeutralAction { + id: string; + label: string; + style: NeutralActionStyle; +} + +export interface NeutralMessage { + author?: NeutralAuthor; + title: string; + url?: string; + color?: number; + description?: string; + fields?: NeutralField[]; + footer?: string; + timestamp?: string; + actions?: NeutralAction[]; +} + export interface FormattedMessage { embeds?: Array<{ title?: string; diff --git a/src/action-routes.ts b/src/web/action-routes.ts similarity index 96% rename from src/action-routes.ts rename to src/web/action-routes.ts index 566cc25..feea0e2 100644 --- a/src/action-routes.ts +++ b/src/web/action-routes.ts @@ -1,8 +1,8 @@ import { Hono } from "hono"; -import { getUserOctokit } from "./github-oauth"; -import { findUserIdByToken } from "./token-store"; -import type { Env } from "./types"; -import { log } from "./log"; +import { getUserOctokit } from "../github/oauth"; +import { findUserIdByToken } from "../github/store"; +import type { Env } from "../types"; +import { log } from "../lib/log"; function extractBearerToken(c: { req: { header: (name: string) => string | undefined }; diff --git a/src/admin-routes.ts b/src/web/admin-routes.ts similarity index 98% rename from src/admin-routes.ts rename to src/web/admin-routes.ts index 2f0e1d6..c02c815 100644 --- a/src/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -1,15 +1,15 @@ import { Hono } from "hono"; -import type { Env, Route, Group } from "./types"; -import { loadRoutes, saveRoutes } from "./config"; +import type { Env, Route, Group } from "../types"; +import { loadRoutes, saveRoutes } from "../config"; import { getAdminSession, destroyAdminSession, clearAdminCookie, type AdminSession, -} from "./admin-session"; +} from "./session"; import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups"; -import { getSendLog } from "./send-log"; -import { log } from "./log"; +import { getSendLog } from "../lib/send-log"; +import { log } from "../lib/log"; const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]); const ID_RE = /^[a-z0-9][a-z0-9-]*$/; diff --git a/src/groups.ts b/src/web/groups.ts similarity index 94% rename from src/groups.ts rename to src/web/groups.ts index ca7b934..2b1cca2 100644 --- a/src/groups.ts +++ b/src/web/groups.ts @@ -1,6 +1,6 @@ -import type { Env, Group } from "./types"; -import { isAdminUser } from "./admin-session"; -import { log } from "./log"; +import type { Env, Group } from "../types"; +import { isAdminUser } from "./session"; +import { log } from "../lib/log"; const GROUPS_KEY = "config:groups"; diff --git a/src/home-routes.ts b/src/web/home-routes.ts similarity index 99% rename from src/home-routes.ts rename to src/web/home-routes.ts index c5f0e16..4971f7d 100644 --- a/src/home-routes.ts +++ b/src/web/home-routes.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import type { Env } from "./types"; +import type { Env } from "../types"; type Lang = "zh" | "en"; diff --git a/src/legal-routes.ts b/src/web/legal-routes.ts similarity index 99% rename from src/legal-routes.ts rename to src/web/legal-routes.ts index 7af7ef3..c491abf 100644 --- a/src/legal-routes.ts +++ b/src/web/legal-routes.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import type { Env } from "./types"; +import type { Env } from "../types"; type Lang = "zh" | "en"; diff --git a/src/oauth-routes.ts b/src/web/oauth-routes.ts similarity index 94% rename from src/oauth-routes.ts rename to src/web/oauth-routes.ts index 167f60e..ce941ca 100644 --- a/src/oauth-routes.ts +++ b/src/web/oauth-routes.ts @@ -1,9 +1,9 @@ import { Hono } from "hono"; -import { getOAuthURL, handleOAuthCallback } from "./github-oauth"; -import { removeToken, saveDiscordLink } from "./token-store"; -import { createAdminSession, adminCookie } from "./admin-session"; +import { getOAuthURL, handleOAuthCallback } from "../github/oauth"; +import { removeToken, saveDiscordLink } from "../github/store"; +import { createAdminSession, adminCookie } from "./session"; import { loadGroups, resolveScope, hasAnyAccess } from "./groups"; -import type { Env } from "./types"; +import type { Env } from "../types"; interface PendingState { redirectTo: string; diff --git a/src/admin-session.ts b/src/web/session.ts similarity index 98% rename from src/admin-session.ts rename to src/web/session.ts index 2ed1aff..d5f7602 100644 --- a/src/admin-session.ts +++ b/src/web/session.ts @@ -1,4 +1,4 @@ -import type { Env } from "./types"; +import type { Env } from "../types"; const SESSION_COOKIE = "wh_admin_session"; const SESSION_TTL = 7 * 24 * 3600;