mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
refactor: modularize into core/events/formatters/drivers
This commit is contained in:
parent
7b341ff9f4
commit
0af6b9a4b8
62 changed files with 2430 additions and 2015 deletions
59
AGENTS.md
59
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 设置中订阅该事件
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, { value: string; expiration?: number }>();
|
||||
|
|
|
|||
|
|
@ -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<string, { value: string; expiration?: number }>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
const result = await sendMessage(token, channelId, message, threadId);
|
||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||
}
|
||||
174
src/drivers/discord/commands.ts
Normal file
174
src/drivers/discord/commands.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
await registerGlobalCommands(env);
|
||||
await syncGuildCommands(env);
|
||||
}
|
||||
17
src/drivers/discord/index.ts
Normal file
17
src/drivers/discord/index.ts
Normal file
|
|
@ -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<SendResult> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
return sendMessage(token, target.channelId, renderNeutralMessage(message), target.threadId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
await registerGlobalCommands(env);
|
||||
await syncGuildCommands(env);
|
||||
}
|
||||
48
src/drivers/discord/render.ts
Normal file
48
src/drivers/discord/render.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { log } from "./log";
|
||||
import { log } from "../../lib/log";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
16
src/drivers/index.ts
Normal file
16
src/drivers/index.ts
Normal file
|
|
@ -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<string, PlatformDriver> = {
|
||||
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;
|
||||
}
|
||||
14
src/drivers/telegram/index.ts
Normal file
14
src/drivers/telegram/index.ts
Normal file
|
|
@ -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<SendResult> {
|
||||
return { ok: false, error: "Telegram driver not implemented yet" };
|
||||
}
|
||||
}
|
||||
11
src/drivers/types.ts
Normal file
11
src/drivers/types.ts
Normal file
|
|
@ -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<SendResult>;
|
||||
}
|
||||
115
src/events/match.ts
Normal file
115
src/events/match.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { WebhookEvent, Route, Filter } from "../types";
|
||||
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
||||
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<string>();
|
||||
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));
|
||||
}
|
||||
15
src/events/parse.ts
Normal file
15
src/events/parse.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { WebhookEvent } from "../types";
|
||||
|
||||
export function parseEvent(headers: Record<string, string>, 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;
|
||||
}
|
||||
}
|
||||
43
src/events/verify.ts
Normal file
43
src/events/verify.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const keyCache = new Map<string, CryptoKey>();
|
||||
|
||||
async function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
1689
src/formatter.ts
1689
src/formatter.ts
File diff suppressed because it is too large
Load diff
66
src/formatters/check.ts
Normal file
66
src/formatters/check.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
64
src/formatters/colors.ts
Normal file
64
src/formatters/colors.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
success: "✅",
|
||||
failure: "❌",
|
||||
cancelled: "🚫",
|
||||
timed_out: "⏱️",
|
||||
action_required: "⚠️",
|
||||
neutral: "➖",
|
||||
stale: "♻️",
|
||||
queued: "⏳",
|
||||
running: "🔄",
|
||||
};
|
||||
43
src/formatters/comments.ts
Normal file
43
src/formatters/comments.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
50
src/formatters/commit-comment.ts
Normal file
50
src/formatters/commit-comment.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
84
src/formatters/create.ts
Normal file
84
src/formatters/create.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
92
src/formatters/deployment.ts
Normal file
92
src/formatters/deployment.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
96
src/formatters/discussion.ts
Normal file
96
src/formatters/discussion.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
26
src/formatters/generic.ts
Normal file
26
src/formatters/generic.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
25
src/formatters/helpers.ts
Normal file
25
src/formatters/helpers.ts
Normal file
|
|
@ -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, string | number>) => 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<Partial<NeutralMessage>, "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(),
|
||||
};
|
||||
}
|
||||
115
src/formatters/index.ts
Normal file
115
src/formatters/index.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
87
src/formatters/issues.ts
Normal file
87
src/formatters/issues.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
64
src/formatters/label.ts
Normal file
64
src/formatters/label.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
34
src/formatters/member.ts
Normal file
34
src/formatters/member.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
83
src/formatters/milestone.ts
Normal file
83
src/formatters/milestone.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
112
src/formatters/pull-request.ts
Normal file
112
src/formatters/pull-request.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
106
src/formatters/push.ts
Normal file
106
src/formatters/push.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
62
src/formatters/release.ts
Normal file
62
src/formatters/release.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
58
src/formatters/repo.ts
Normal file
58
src/formatters/repo.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
92
src/formatters/repository.ts
Normal file
92
src/formatters/repository.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
114
src/formatters/review.ts
Normal file
114
src/formatters/review.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
173
src/formatters/security.ts
Normal file
173
src/formatters/security.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
100
src/formatters/workflow.ts
Normal file
100
src/formatters/workflow.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
32
src/types.ts
32
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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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-]*$/;
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import type { Env } from "../types";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import type { Env } from "../types";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue