Initial commit: GitHub-GCC WeCom bot for Gitea
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
WEBHOOK_SECRET=replace-with-a-long-random-secret
|
||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-key
|
||||||
|
# ALLOWED_ORGS=my-org,another-org
|
||||||
|
# MAX_MESSAGE_LENGTH=1800
|
||||||
|
# DELIVERY_TTL_SECONDS=600
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.dev.vars
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.wrangler/
|
||||||
|
.serena/
|
||||||
|
.commandcode/
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# GitHub-GCC Bot
|
||||||
|
|
||||||
|
Cloudflare Worker that forwards GitHub or Gitea repository/organization events to an Enterprise WeChat group robot.
|
||||||
|
|
||||||
|
The Worker receives webhook payloads (GitHub sends GitHub headers natively; Gitea sends GitHub-compatible headers), verifies `X-Hub-Signature-256`, formats a short text notification, and posts it to the 企业微信群机器人 Webhook.
|
||||||
|
|
||||||
|
## What This Version Does
|
||||||
|
|
||||||
|
- Runs on Cloudflare Workers.
|
||||||
|
- Does not require your own server.
|
||||||
|
- Sends plain text Enterprise WeChat group robot messages.
|
||||||
|
- Supports GitHub webhooks and Gitea webhooks (including intranet Gitea instances, as long as the Gitea server has outbound internet access).
|
||||||
|
- Uses KV when configured for delivery de-duplication.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- This version only sends text messages.
|
||||||
|
- It depends on 企业微信群机器人 Webhook availability and platform rate limits.
|
||||||
|
- The WeCom robot Webhook URL contains a secret key; store it only as a Cloudflare secret.
|
||||||
|
- Links in messages point to the Git host. For an intranet Gitea (e.g. `https://192.168.87.52:18473`), links are only reachable from the intranet/VPN.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an optional KV namespace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler kv namespace create WEBHOOK_CACHE
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the returned namespace id into `wrangler.toml` by uncommenting the `[[kv_namespaces]]` block.
|
||||||
|
|
||||||
|
Set Cloudflare Worker secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secret put WEBHOOK_SECRET
|
||||||
|
wrangler secret put WECOM_WEBHOOK_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
`WEBHOOK_SECRET` is a random string you generate; the same value goes into the Git host's webhook secret field.
|
||||||
|
|
||||||
|
`WECOM_WEBHOOK_URL` should look like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional non-secret variables can be set in `wrangler.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[vars]
|
||||||
|
MAX_MESSAGE_LENGTH = "1800"
|
||||||
|
DELIVERY_TTL_SECONDS = "600"
|
||||||
|
ALLOWED_ORGS = "my-org"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
The webhook endpoint is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /github/webhook
|
||||||
|
POST /gitea/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
Both paths accept the same payload. Gitea can use either URL.
|
||||||
|
|
||||||
|
The health endpoint is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 企业微信群机器人配置
|
||||||
|
|
||||||
|
1. 在企业微信 App 中进入一个**内部群**(含微信联系人的外部群不支持群机器人)。
|
||||||
|
2. 群聊右上角「群设置」→「群机器人」→「添加机器人」,设置名字和头像后创建。
|
||||||
|
3. 复制机器人的 Webhook 地址(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...`)。
|
||||||
|
4. 用 `wrangler secret put WECOM_WEBHOOK_URL` 保存该地址。
|
||||||
|
|
||||||
|
## Gitea Webhook 配置
|
||||||
|
|
||||||
|
前提:Gitea 服务器能访问外网(能连 `workers.dev` 和 `qyapi.weixin.qq.com`)。如需代理,在 Gitea 的 `app.ini` 中配置 `[webhook] PROXY_URL`。
|
||||||
|
|
||||||
|
在仓库、组织或系统管理页面添加 webhook(Gitea 类型):
|
||||||
|
|
||||||
|
- 目标 URL: `https://your-worker.your-subdomain.workers.dev/gitea/webhook`(`/github/webhook` 也可以)
|
||||||
|
- POST Content Type: `application/json`
|
||||||
|
- Secret: 与 `WEBHOOK_SECRET` 相同的值
|
||||||
|
- Trigger On: 按需选择 `Push Events`、`Issues`、`Issue Comment`、`Pull Request`、`Releases`、`Workflow Run` 等
|
||||||
|
- 保存后点「Test Delivery」发送一个模拟 push 事件验证;失败时在「最近推送记录」中查看请求/响应详情
|
||||||
|
|
||||||
|
## GitHub Organization Webhook
|
||||||
|
|
||||||
|
In GitHub organization settings, add a webhook:
|
||||||
|
|
||||||
|
- Payload URL: `https://your-worker.your-subdomain.workers.dev/github/webhook`
|
||||||
|
- Content type: `application/json`
|
||||||
|
- Secret: the same value as `WEBHOOK_SECRET`
|
||||||
|
- SSL verification: enabled
|
||||||
|
- Events: select `push`, `pull_request`, `issues`, `issue_comment`, `release`, and `workflow_run`
|
||||||
|
|
||||||
|
`ping` events are accepted but do not send WeCom messages.
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
- `push`
|
||||||
|
- `pull_request`
|
||||||
|
- `issues`
|
||||||
|
- `issue_comment` (and Gitea `pull_request_comment`)
|
||||||
|
- `release`
|
||||||
|
- `workflow_run` when completed
|
||||||
|
|
||||||
|
Unsupported events return success without sending a message, so the Git host will not retry them.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
Generated
+1617
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "github-gcc",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"test": "node --import tsx --test tests/**/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20260702.1",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"wrangler": "^4.107.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export interface WorkerEnv {
|
||||||
|
WEBHOOK_SECRET: string;
|
||||||
|
WECOM_WEBHOOK_URL: string;
|
||||||
|
WEBHOOK_CACHE?: KVNamespace;
|
||||||
|
ALLOWED_ORGS?: string;
|
||||||
|
MAX_MESSAGE_LENGTH?: string;
|
||||||
|
DELIVERY_TTL_SECONDS?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
webhookSecret: string;
|
||||||
|
wecomWebhookUrl: string;
|
||||||
|
allowedOrgs?: Set<string>;
|
||||||
|
maxMessageLength: number;
|
||||||
|
deliveryTtlSeconds: number;
|
||||||
|
cache?: KVNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: WorkerEnv): AppConfig {
|
||||||
|
const wecomWebhookUrl = required(env.WECOM_WEBHOOK_URL, "WECOM_WEBHOOK_URL");
|
||||||
|
if (!wecomWebhookUrl.startsWith("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=")) {
|
||||||
|
throw new Error("WECOM_WEBHOOK_URL must be an Enterprise WeChat group robot webhook URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webhookSecret: required(env.WEBHOOK_SECRET, "WEBHOOK_SECRET"),
|
||||||
|
wecomWebhookUrl,
|
||||||
|
allowedOrgs: parseAllowedOrgs(env.ALLOWED_ORGS),
|
||||||
|
maxMessageLength: parsePositiveInt(env.MAX_MESSAGE_LENGTH, 1800, "MAX_MESSAGE_LENGTH"),
|
||||||
|
deliveryTtlSeconds: parsePositiveInt(env.DELIVERY_TTL_SECONDS, 10 * 60, "DELIVERY_TTL_SECONDS"),
|
||||||
|
cache: env.WEBHOOK_CACHE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(value: string | undefined, name: string): string {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined, fallback: number, name: string): number {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllowedOrgs(value: string | undefined): Set<string> | undefined {
|
||||||
|
const orgs = value
|
||||||
|
?.split(",")
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return orgs?.length ? new Set(orgs) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
export type GitHubPayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
const CARD_BORDER = "━━━━━━━━━━━━━━";
|
||||||
|
|
||||||
|
export function formatGitHubEvent(eventName: string, payload: GitHubPayload, maxLength: number): string | null {
|
||||||
|
const message = formatEvent(eventName, payload);
|
||||||
|
return message ? truncateMessage(message, maxLength) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationLogin(payload: GitHubPayload): string | undefined {
|
||||||
|
return (
|
||||||
|
stringAt(payload, ["organization", "login"]) ??
|
||||||
|
stringAt(payload, ["organization", "username"]) ??
|
||||||
|
stringAt(payload, ["repository", "owner", "login"]) ??
|
||||||
|
stringAt(payload, ["repository", "owner", "username"]) ??
|
||||||
|
stringAt(payload, ["repository", "owner", "name"])
|
||||||
|
)?.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEvent(eventName: string, payload: GitHubPayload): string | null {
|
||||||
|
switch (eventName) {
|
||||||
|
case "ping":
|
||||||
|
return null;
|
||||||
|
case "push":
|
||||||
|
return formatPush(payload);
|
||||||
|
case "pull_request":
|
||||||
|
return formatPullRequest(payload);
|
||||||
|
case "issues":
|
||||||
|
return formatIssue(payload);
|
||||||
|
case "issue_comment":
|
||||||
|
case "pull_request_comment":
|
||||||
|
return formatIssueComment(payload);
|
||||||
|
case "release":
|
||||||
|
return formatRelease(payload);
|
||||||
|
case "workflow_run":
|
||||||
|
return formatWorkflowRun(payload);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPush(payload: GitHubPayload): string {
|
||||||
|
const repo = repoName(payload);
|
||||||
|
const branch = branchName(stringAt(payload, ["ref"]) ?? "unknown");
|
||||||
|
const pusher =
|
||||||
|
stringAt(payload, ["pusher", "name"]) ??
|
||||||
|
stringAt(payload, ["pusher", "login"]) ??
|
||||||
|
stringAt(payload, ["pusher", "username"]) ??
|
||||||
|
actorLogin(payload);
|
||||||
|
const commits = arrayAt(payload, ["commits"]);
|
||||||
|
const compareUrl = stringAt(payload, ["compare"]) ?? stringAt(payload, ["compare_url"]);
|
||||||
|
const headUrl = stringAt(payload, ["head_commit", "url"]);
|
||||||
|
const changedFiles = changedFileCount(commits);
|
||||||
|
const latest = latestCommitSummary(payload, commits);
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Push", [
|
||||||
|
`📁 ${repo}`,
|
||||||
|
`🌿 ${branch}`,
|
||||||
|
`👤 ${pusher}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${formatCount(commits.length, "commit")}`,
|
||||||
|
changedFiles !== undefined ? `• ${formatCount(changedFiles, "file")} changed` : undefined,
|
||||||
|
latest ? `• latest: ${latest}` : undefined,
|
||||||
|
"",
|
||||||
|
compareUrl || headUrl ? `🔗 ${compareUrl ?? headUrl}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPullRequest(payload: GitHubPayload): string {
|
||||||
|
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||||
|
const merged = booleanAt(payload, ["pull_request", "merged"]);
|
||||||
|
const state = action === "closed" && merged ? "merged" : action;
|
||||||
|
const title = stringAt(payload, ["pull_request", "title"]) ?? "(no title)";
|
||||||
|
const number = numberAt(payload, ["pull_request", "number"]) ?? numberAt(payload, ["number"]);
|
||||||
|
const user = actorLogin(payload, [
|
||||||
|
["pull_request", "user", "login"],
|
||||||
|
["pull_request", "user", "username"]
|
||||||
|
]);
|
||||||
|
const base = stringAt(payload, ["pull_request", "base", "ref"]);
|
||||||
|
const head = stringAt(payload, ["pull_request", "head", "ref"]);
|
||||||
|
const url = stringAt(payload, ["pull_request", "html_url"]);
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Pull Request", [
|
||||||
|
`📁 ${repoName(payload)}`,
|
||||||
|
`🔀 ${number ? `#${number} ` : ""}${firstLine(title)}`,
|
||||||
|
`👤 ${user}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${state}`,
|
||||||
|
base && head ? `• ${head} -> ${base}` : undefined,
|
||||||
|
"",
|
||||||
|
url ? `🔗 ${url}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssue(payload: GitHubPayload): string {
|
||||||
|
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||||
|
const title = stringAt(payload, ["issue", "title"]) ?? "(no title)";
|
||||||
|
const number = numberAt(payload, ["issue", "number"]) ?? numberAt(payload, ["number"]);
|
||||||
|
const user = actorLogin(payload, [
|
||||||
|
["issue", "user", "login"],
|
||||||
|
["issue", "user", "username"]
|
||||||
|
]);
|
||||||
|
const url = stringAt(payload, ["issue", "html_url"]);
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Issue", [
|
||||||
|
`📁 ${repoName(payload)}`,
|
||||||
|
`🎫 ${number ? `#${number} ` : ""}${firstLine(title)}`,
|
||||||
|
`👤 ${user}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${action}`,
|
||||||
|
"",
|
||||||
|
url ? `🔗 ${url}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssueComment(payload: GitHubPayload): string {
|
||||||
|
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||||
|
const issueTitle = stringAt(payload, ["issue", "title"]) ?? "(no title)";
|
||||||
|
const issueNumber = numberAt(payload, ["issue", "number"]);
|
||||||
|
const user = actorLogin(payload, [
|
||||||
|
["comment", "user", "login"],
|
||||||
|
["comment", "user", "username"]
|
||||||
|
]);
|
||||||
|
const body = firstLine(stringAt(payload, ["comment", "body"]) ?? "(empty comment)");
|
||||||
|
const url = stringAt(payload, ["comment", "html_url"]);
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Issue Comment", [
|
||||||
|
`📁 ${repoName(payload)}`,
|
||||||
|
`🎫 ${issueNumber ? `#${issueNumber} ` : ""}${firstLine(issueTitle)}`,
|
||||||
|
`👤 ${user}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${action}`,
|
||||||
|
`• ${body}`,
|
||||||
|
"",
|
||||||
|
url ? `🔗 ${url}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelease(payload: GitHubPayload): string {
|
||||||
|
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||||
|
const name = stringAt(payload, ["release", "name"]) ?? stringAt(payload, ["release", "tag_name"]) ?? "(unnamed release)";
|
||||||
|
const tag = stringAt(payload, ["release", "tag_name"]);
|
||||||
|
const user = actorLogin(payload, [
|
||||||
|
["release", "author", "login"],
|
||||||
|
["release", "author", "username"]
|
||||||
|
]);
|
||||||
|
const url = stringAt(payload, ["release", "html_url"]);
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Release", [
|
||||||
|
`📁 ${repoName(payload)}`,
|
||||||
|
`🏷️ ${tag ?? firstLine(name)}`,
|
||||||
|
`👤 ${user}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${action}`,
|
||||||
|
tag && tag !== name ? `• ${firstLine(name)}` : undefined,
|
||||||
|
"",
|
||||||
|
url ? `🔗 ${url}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWorkflowRun(payload: GitHubPayload): string | null {
|
||||||
|
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||||
|
const status = stringAt(payload, ["workflow_run", "status"]);
|
||||||
|
const conclusion = stringAt(payload, ["workflow_run", "conclusion"]);
|
||||||
|
const workflow = stringAt(payload, ["workflow_run", "name"]) ?? stringAt(payload, ["workflow", "name"]) ?? "(unnamed workflow)";
|
||||||
|
const branch = stringAt(payload, ["workflow_run", "head_branch"]);
|
||||||
|
const actor = actorLogin(payload, [
|
||||||
|
["workflow_run", "actor", "login"],
|
||||||
|
["workflow_run", "actor", "username"]
|
||||||
|
]);
|
||||||
|
const url = stringAt(payload, ["workflow_run", "html_url"]);
|
||||||
|
|
||||||
|
if (action !== "completed") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatCard("📦 GitHub-GCC Workflow Run", [
|
||||||
|
`📁 ${repoName(payload)}`,
|
||||||
|
`⚙️ ${workflow}`,
|
||||||
|
`👤 ${actor}`,
|
||||||
|
"",
|
||||||
|
"📝 Changes",
|
||||||
|
`• ${conclusion ?? status ?? "unknown"}`,
|
||||||
|
branch ? `• ${branch}` : undefined,
|
||||||
|
"",
|
||||||
|
url ? `🔗 ${url}` : undefined
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actorLogin(payload: GitHubPayload, extraPaths: string[][] = []): string {
|
||||||
|
return (
|
||||||
|
stringAt(payload, ["sender", "login"]) ??
|
||||||
|
stringAt(payload, ["sender", "username"]) ??
|
||||||
|
extraPaths.reduce<string | undefined>(
|
||||||
|
(found, path) => found ?? stringAt(payload, path),
|
||||||
|
undefined
|
||||||
|
) ??
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoName(payload: GitHubPayload): string {
|
||||||
|
return stringAt(payload, ["repository", "full_name"]) ?? stringAt(payload, ["repository", "name"]) ?? "unknown/repo";
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchName(ref: string): string {
|
||||||
|
return ref.replace(/^refs\/heads\//, "").replace(/^refs\/tags\//, "tag:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedFileCount(commits: unknown[]): number | undefined {
|
||||||
|
const files = new Set<string>();
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
if (!isObject(commit)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ["added", "modified", "removed"]) {
|
||||||
|
for (const file of arrayAt(commit, [key])) {
|
||||||
|
if (typeof file === "string") {
|
||||||
|
files.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.size > 0 ? files.size : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestCommitSummary(payload: GitHubPayload, commits: unknown[]): string | undefined {
|
||||||
|
const headCommit = at(payload, ["head_commit"]);
|
||||||
|
const latest = isObject(headCommit) ? headCommit : lastObject(commits);
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = (stringAt(latest, ["id"]) ?? "").slice(0, 7);
|
||||||
|
const message = firstLine(stringAt(latest, ["message"]) ?? "(no commit message)");
|
||||||
|
return id ? `${id} ${message}` : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastObject(values: unknown[]): GitHubPayload | undefined {
|
||||||
|
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||||
|
const value = values[index];
|
||||||
|
if (isObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(value: string): string {
|
||||||
|
return value.split(/\r?\n/, 1)[0]?.trim() || "(empty)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessage(message: string, maxLength: number): string {
|
||||||
|
if (message.length <= maxLength) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = "\n... truncated";
|
||||||
|
const keep = Math.max(0, maxLength - suffix.length);
|
||||||
|
return `${message.slice(0, keep).trimEnd()}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCard(title: string, lines: Array<string | undefined>): string {
|
||||||
|
return compactLines([CARD_BORDER, title, "", ...lines, CARD_BORDER]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactLines(lines: Array<string | undefined>): string {
|
||||||
|
return lines.filter((line): line is string => line !== undefined).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(count: number, label: string): string {
|
||||||
|
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringAt(value: unknown, path: string[]): string | undefined {
|
||||||
|
const current = at(value, path);
|
||||||
|
return typeof current === "string" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberAt(value: unknown, path: string[]): number | undefined {
|
||||||
|
const current = at(value, path);
|
||||||
|
return typeof current === "number" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanAt(value: unknown, path: string[]): boolean | undefined {
|
||||||
|
const current = at(value, path);
|
||||||
|
return typeof current === "boolean" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayAt(value: unknown, path: string[]): unknown[] {
|
||||||
|
const current = at(value, path);
|
||||||
|
return Array.isArray(current) ? current : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(value: unknown, path: string[]): unknown {
|
||||||
|
let current = value;
|
||||||
|
|
||||||
|
for (const key of path) {
|
||||||
|
if (!isObject(current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./worker.js";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const signaturePrefix = "sha256=";
|
||||||
|
|
||||||
|
export async function signBody(secret: string, body: ArrayBuffer | Uint8Array): Promise<string> {
|
||||||
|
const secretBytes = new TextEncoder().encode(secret);
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
toArrayBuffer(secretBytes),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"]
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign("HMAC", key, toArrayBuffer(body));
|
||||||
|
return `${signaturePrefix}${toHex(new Uint8Array(signature))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyGitHubSignature(
|
||||||
|
secret: string,
|
||||||
|
body: ArrayBuffer | Uint8Array,
|
||||||
|
signatureHeader: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalized = normalizeSignature(signatureHeader);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = await signBody(secret, body);
|
||||||
|
return constantTimeEqual(expected, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignature(signatureHeader: string | null | undefined): string | undefined {
|
||||||
|
if (!signatureHeader) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureHeader.startsWith(signaturePrefix)) {
|
||||||
|
return signatureHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea/Gogs X-Gitea-Signature is raw HMAC-SHA256 hex without the GitHub prefix.
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(signatureHeader)) {
|
||||||
|
return `${signaturePrefix}${signatureHeader}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer {
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = new Uint8Array(value.byteLength);
|
||||||
|
copy.set(value);
|
||||||
|
return copy.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEqual(a: string, b: string): boolean {
|
||||||
|
let mismatch = a.length ^ b.length;
|
||||||
|
const maxLength = Math.max(a.length, b.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < maxLength; index += 1) {
|
||||||
|
mismatch |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mismatch === 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
|
||||||
|
export interface WeComResponse {
|
||||||
|
errcode?: number;
|
||||||
|
errmsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWeComTextMessage(
|
||||||
|
config: AppConfig,
|
||||||
|
content: string,
|
||||||
|
fetchImpl: typeof fetch = fetch
|
||||||
|
): Promise<WeComResponse> {
|
||||||
|
if (!content.trim()) {
|
||||||
|
throw new Error("WeCom message content must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(config.wecomWebhookUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: "text",
|
||||||
|
text: {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyText = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WeCom webhook failed: HTTP ${response.status} ${bodyText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parseJsonObject<WeComResponse>(bodyText) ?? {};
|
||||||
|
if (typeof body.errcode === "number" && body.errcode !== 0) {
|
||||||
|
throw new Error(`WeCom webhook failed: errcode=${body.errcode} errmsg=${body.errmsg ?? ""}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject<T>(value: string): T | undefined {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as T) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
import { loadConfig, type AppConfig, type WorkerEnv } from "./config.js";
|
||||||
|
import { formatGitHubEvent, getOrganizationLogin, type GitHubPayload } from "./githubEvents.js";
|
||||||
|
import { verifyGitHubSignature } from "./signature.js";
|
||||||
|
import { sendWeComTextMessage } from "./wecomSender.js";
|
||||||
|
|
||||||
|
export interface ExecutionContextLike {
|
||||||
|
waitUntil(promise: Promise<unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageSender = (config: AppConfig, message: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
const memoryDeliveryCache = new Map<string, number>();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
return handleRequest(request, env, ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handleRequest(
|
||||||
|
request: Request,
|
||||||
|
env: WorkerEnv,
|
||||||
|
ctx?: ExecutionContextLike,
|
||||||
|
sender: MessageSender = sendWeComTextMessage
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "POST" || !isWebhookPath(url.pathname)) {
|
||||||
|
return json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: AppConfig;
|
||||||
|
try {
|
||||||
|
config = loadConfig(env);
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName =
|
||||||
|
request.headers.get("x-github-event") ??
|
||||||
|
request.headers.get("x-gitea-event") ??
|
||||||
|
request.headers.get("x-gogs-event");
|
||||||
|
const deliveryId =
|
||||||
|
request.headers.get("x-github-delivery") ??
|
||||||
|
request.headers.get("x-gitea-delivery") ??
|
||||||
|
request.headers.get("x-gogs-delivery");
|
||||||
|
const signature =
|
||||||
|
request.headers.get("x-hub-signature-256") ??
|
||||||
|
request.headers.get("x-gitea-signature") ??
|
||||||
|
request.headers.get("x-gogs-signature");
|
||||||
|
const rawBody = await request.arrayBuffer();
|
||||||
|
|
||||||
|
if (!eventName || !deliveryId) {
|
||||||
|
return json({ error: "missing webhook event headers" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await verifyGitHubSignature(config.webhookSecret, rawBody, signature))) {
|
||||||
|
return json({ error: "invalid webhook signature" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await hasDelivery(config, deliveryId)) {
|
||||||
|
return json({ duplicate: true }, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsePayload(rawBody);
|
||||||
|
if (!payload) {
|
||||||
|
return json({ error: "invalid JSON payload" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.allowedOrgs) {
|
||||||
|
const org = getOrganizationLogin(payload);
|
||||||
|
if (!org || !config.allowedOrgs.has(org)) {
|
||||||
|
await rememberDelivery(config, deliveryId, ctx);
|
||||||
|
return json({ ignored: true, reason: "organization not allowed" }, 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = formatGitHubEvent(eventName, payload, config.maxMessageLength);
|
||||||
|
if (!message) {
|
||||||
|
await rememberDelivery(config, deliveryId, ctx);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sender(config, message);
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rememberDelivery(config, deliveryId, ctx);
|
||||||
|
return json({ sent: true }, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasDelivery(config: AppConfig, deliveryId: string): Promise<boolean> {
|
||||||
|
pruneMemoryDeliveries();
|
||||||
|
const cacheKey = deliveryCacheKey(deliveryId);
|
||||||
|
|
||||||
|
if (config.cache) {
|
||||||
|
return (await config.cache.get(cacheKey)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = memoryDeliveryCache.get(cacheKey);
|
||||||
|
return expiresAt !== undefined && expiresAt > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rememberDelivery(config: AppConfig, deliveryId: string, ctx?: ExecutionContextLike): Promise<void> {
|
||||||
|
const cacheKey = deliveryCacheKey(deliveryId);
|
||||||
|
|
||||||
|
if (config.cache) {
|
||||||
|
const write = config.cache.put(cacheKey, "1", { expirationTtl: config.deliveryTtlSeconds });
|
||||||
|
ctx?.waitUntil(write);
|
||||||
|
if (!ctx) {
|
||||||
|
await write;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryDeliveryCache.set(cacheKey, Date.now() + config.deliveryTtlSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebhookPath(pathname: string): boolean {
|
||||||
|
return pathname === "/github/webhook" || pathname === "/gitea/webhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryCacheKey(deliveryId: string): string {
|
||||||
|
return `webhook:delivery:${deliveryId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneMemoryDeliveries(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, expiresAt] of memoryDeliveryCache) {
|
||||||
|
if (expiresAt <= now) {
|
||||||
|
memoryDeliveryCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload(rawBody: ArrayBuffer): GitHubPayload | undefined {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(new TextDecoder().decode(rawBody));
|
||||||
|
return isGitHubPayload(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitHubPayload(value: unknown): value is GitHubPayload {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(value: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatGitHubEvent, getOrganizationLogin } from "../src/githubEvents.js";
|
||||||
|
|
||||||
|
test("formats push events with commit summaries", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"push",
|
||||||
|
{
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: {
|
||||||
|
full_name: "acme/app",
|
||||||
|
owner: { login: "acme" }
|
||||||
|
},
|
||||||
|
pusher: { name: "alice" },
|
||||||
|
commits: [
|
||||||
|
{
|
||||||
|
id: "1234567890",
|
||||||
|
message: "Add feature\n\nLong body",
|
||||||
|
author: { name: "Alice" },
|
||||||
|
added: ["src/new.ts"],
|
||||||
|
modified: ["src/app.ts", "README.md"],
|
||||||
|
removed: ["old.txt"],
|
||||||
|
url: "https://github.com/acme/app/commit/1234567"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
compare: "https://github.com/acme/app/compare/a...b"
|
||||||
|
},
|
||||||
|
3500
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.match(message, /^━━━━━━━━━━━━━━\n📦 GitHub-GCC Push/);
|
||||||
|
assert.match(message, /📁 acme\/app/);
|
||||||
|
assert.match(message, /🌿 main/);
|
||||||
|
assert.match(message, /👤 alice/);
|
||||||
|
assert.match(message, /• 1 commit/);
|
||||||
|
assert.match(message, /• 4 files changed/);
|
||||||
|
assert.match(message, /• latest: 1234567 Add feature/);
|
||||||
|
assert.doesNotMatch(message, /https:\/\/github\.com\/acme\/app\/commit\/1234567/);
|
||||||
|
assert.match(message, /🔗 https:\/\/github\.com\/acme\/app\/compare\/a\.\.\.b/);
|
||||||
|
assert.match(message, /━━━━━━━━━━━━━━$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats merged pull requests as merged", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"pull_request",
|
||||||
|
{
|
||||||
|
action: "closed",
|
||||||
|
repository: { full_name: "acme/app" },
|
||||||
|
sender: { login: "bob" },
|
||||||
|
pull_request: {
|
||||||
|
merged: true,
|
||||||
|
number: 12,
|
||||||
|
title: "Improve deployment",
|
||||||
|
base: { ref: "main" },
|
||||||
|
head: { ref: "deploy" },
|
||||||
|
html_url: "https://github.com/acme/app/pull/12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
3500
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.match(message, /📦 GitHub-GCC Pull Request/);
|
||||||
|
assert.match(message, /🔀 #12 Improve deployment/);
|
||||||
|
assert.match(message, /• merged/);
|
||||||
|
assert.match(message, /• deploy -> main/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores ping and non-completed workflow_run events", () => {
|
||||||
|
assert.equal(formatGitHubEvent("ping", {}, 3500), null);
|
||||||
|
assert.equal(formatGitHubEvent("workflow_run", { action: "requested" }, 3500), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts organization login from organization or repository owner", () => {
|
||||||
|
assert.equal(getOrganizationLogin({ organization: { login: "Acme" } }), "acme");
|
||||||
|
assert.equal(getOrganizationLogin({ repository: { owner: { login: "Other" } } }), "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats Gitea-style push payloads with compare_url and pusher login", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"push",
|
||||||
|
{
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "gcc/app", owner: { login: "gcc" } },
|
||||||
|
pusher: { login: "alice", username: "alice" },
|
||||||
|
sender: { login: "alice" },
|
||||||
|
commits: [],
|
||||||
|
total_commits: 0,
|
||||||
|
compare_url: "https://192.168.87.52:18473/gcc/app/compare/a...b"
|
||||||
|
},
|
||||||
|
3500
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.match(message, /👤 alice/);
|
||||||
|
assert.match(message, /🔗 https:\/\/192\.168\.87\.52:18473\/gcc\/app\/compare\/a\.\.\.b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats Gitea pull_request_comment events like issue comments", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"pull_request_comment",
|
||||||
|
{
|
||||||
|
action: "created",
|
||||||
|
repository: { full_name: "gcc/app" },
|
||||||
|
issue: { number: 3, title: "Add login" },
|
||||||
|
comment: { body: "LGTM", html_url: "https://192.168.87.52:18473/gcc/app/pulls/3#issuecomment-1" },
|
||||||
|
sender: { login: "bob" },
|
||||||
|
is_pull: true
|
||||||
|
},
|
||||||
|
3500
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.match(message, /📦 GitHub-GCC Issue Comment/);
|
||||||
|
assert.match(message, /🎫 #3 Add login/);
|
||||||
|
assert.match(message, /• LGTM/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts organization login from Gitea username field", () => {
|
||||||
|
assert.equal(getOrganizationLogin({ organization: { username: "GCC" } }), "gcc");
|
||||||
|
assert.equal(getOrganizationLogin({ repository: { owner: { username: "GCC" } } }), "gcc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats Gitea payloads that only have sender.username", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"issues",
|
||||||
|
{
|
||||||
|
action: "opened",
|
||||||
|
repository: { full_name: "gcc/app" },
|
||||||
|
issue: { number: 8, title: "Broken login", html_url: "https://192.168.87.52:18473/gcc/app/issues/8" },
|
||||||
|
sender: { username: "carol" }
|
||||||
|
},
|
||||||
|
3500
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.match(message, /👤 carol/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates long messages", () => {
|
||||||
|
const message = formatGitHubEvent(
|
||||||
|
"issue_comment",
|
||||||
|
{
|
||||||
|
repository: { full_name: "acme/app" },
|
||||||
|
action: "created",
|
||||||
|
issue: { number: 1, title: "Bug" },
|
||||||
|
comment: { body: "x".repeat(200), html_url: "https://example.test" },
|
||||||
|
sender: { login: "alice" }
|
||||||
|
},
|
||||||
|
120
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(message);
|
||||||
|
assert.ok(message.length <= 120);
|
||||||
|
assert.match(message, /\.\.\. truncated$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AppConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
export function testConfig(overrides: Partial<AppConfig> = {}): AppConfig {
|
||||||
|
return {
|
||||||
|
webhookSecret: "test-secret",
|
||||||
|
wecomWebhookUrl: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
|
||||||
|
maxMessageLength: 1800,
|
||||||
|
deliveryTtlSeconds: 600,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testEnv(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
WEBHOOK_SECRET: "test-secret",
|
||||||
|
WECOM_WEBHOOK_URL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
|
||||||
|
MAX_MESSAGE_LENGTH: "1800",
|
||||||
|
DELIVERY_TTL_SECONDS: "600",
|
||||||
|
...overrides
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { signBody, verifyGitHubSignature } from "../src/signature.js";
|
||||||
|
|
||||||
|
test("verifies a valid GitHub sha256 signature", async () => {
|
||||||
|
const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." }));
|
||||||
|
const signature = await signBody("secret", body);
|
||||||
|
|
||||||
|
assert.equal(await verifyGitHubSignature("secret", body, signature), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an invalid GitHub sha256 signature", async () => {
|
||||||
|
const body = Buffer.from(JSON.stringify({ ok: true }));
|
||||||
|
|
||||||
|
assert.equal(await verifyGitHubSignature("secret", body, "sha256=bad"), false);
|
||||||
|
assert.equal(await verifyGitHubSignature("secret", body, undefined), false);
|
||||||
|
assert.equal(await verifyGitHubSignature("secret", body, "sha1=bad"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifies a raw Gitea hex signature", async () => {
|
||||||
|
const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." }));
|
||||||
|
const signature = await signBody("secret", body);
|
||||||
|
|
||||||
|
assert.equal(await verifyGitHubSignature("secret", body, signature.slice("sha256=".length)), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { sendWeComTextMessage } from "../src/wecomSender.js";
|
||||||
|
import { testConfig } from "./helpers.js";
|
||||||
|
|
||||||
|
test("sends a WeCom text message to the configured webhook", async () => {
|
||||||
|
const requests: Array<{ url: string; init: RequestInit }> = [];
|
||||||
|
const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
requests.push({ url: String(url), init: init ?? {} });
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, errmsg: "ok" }), { status: 200 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { errcode: 0, errmsg: "ok" });
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(requests[0]?.url, "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
|
||||||
|
assert.deepEqual(JSON.parse(String(requests[0]?.init.body)), {
|
||||||
|
msgtype: "text",
|
||||||
|
text: {
|
||||||
|
content: "hello"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when WeCom webhook returns an API error", async () => {
|
||||||
|
const fetchImpl = async () =>
|
||||||
|
new Response(JSON.stringify({ errcode: 93000, errmsg: "invalid webhook" }), { status: 200 });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch),
|
||||||
|
/WeCom webhook failed: errcode=93000/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when WeCom webhook returns an HTTP error", async () => {
|
||||||
|
const fetchImpl = async () => new Response("bad request", { status: 400 });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch),
|
||||||
|
/WeCom webhook failed: HTTP 400/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { AppConfig } from "../src/config.js";
|
||||||
|
import { signBody } from "../src/signature.js";
|
||||||
|
import { handleRequest } from "../src/worker.js";
|
||||||
|
import { testEnv } from "./helpers.js";
|
||||||
|
|
||||||
|
test("health check returns ok", async () => {
|
||||||
|
const response = await handleRequest(new Request("https://worker.test/healthz"), testEnv());
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(await response.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a valid push webhook and sends one WeCom message", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const response = await postGitHubEvent("push", "delivery-1", {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "acme/app", owner: { login: "acme" } },
|
||||||
|
pusher: { name: "alice" },
|
||||||
|
commits: []
|
||||||
|
}, async (_config, message) => {
|
||||||
|
sent.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
assert.deepEqual(await response.json(), { sent: true });
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.match(sent[0] ?? "", /📁 acme\/app/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid signatures", async () => {
|
||||||
|
const body = JSON.stringify({ repository: { full_name: "acme/app" } });
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://worker.test/github/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-github-event": "push",
|
||||||
|
"x-github-delivery": "bad-sig",
|
||||||
|
"x-hub-signature-256": "sha256=bad"
|
||||||
|
},
|
||||||
|
body
|
||||||
|
}),
|
||||||
|
testEnv(),
|
||||||
|
undefined,
|
||||||
|
async () => {
|
||||||
|
throw new Error("should not send");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates GitHub delivery ids", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const payload = {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "acme/app", owner: { login: "acme" } },
|
||||||
|
commits: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => {
|
||||||
|
sent.push(message);
|
||||||
|
});
|
||||||
|
const second = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => {
|
||||||
|
sent.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(first.status, 202);
|
||||||
|
assert.equal(second.status, 202);
|
||||||
|
assert.deepEqual(await second.json(), { duplicate: true });
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores events outside the allowed organization list", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const response = await postGitHubEvent(
|
||||||
|
"push",
|
||||||
|
"wrong-org",
|
||||||
|
{
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "other/app", owner: { login: "other" } },
|
||||||
|
commits: []
|
||||||
|
},
|
||||||
|
async (_config, message) => {
|
||||||
|
sent.push(message);
|
||||||
|
},
|
||||||
|
{ ALLOWED_ORGS: "acme" }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
assert.equal((await response.json() as { ignored: boolean }).ignored, true);
|
||||||
|
assert.equal(sent.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts Gitea event headers on the Gitea webhook path", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const payload = {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "gcc/app", owner: { username: "gcc" } },
|
||||||
|
pusher: { username: "alice" },
|
||||||
|
commits: []
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const signature = await signBody("test-secret", new TextEncoder().encode(body));
|
||||||
|
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://worker.test/gitea/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-gitea-event": "push",
|
||||||
|
"x-gitea-delivery": "gitea-delivery-1",
|
||||||
|
"x-gitea-signature": signature.slice("sha256=".length)
|
||||||
|
},
|
||||||
|
body
|
||||||
|
}),
|
||||||
|
testEnv(),
|
||||||
|
undefined,
|
||||||
|
async (_config, message) => {
|
||||||
|
sent.push(message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
assert.deepEqual(await response.json(), { sent: true });
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.match(sent[0] ?? "", /📁 gcc\/app/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 500 when sending to WeCom fails so GitHub can retry", async () => {
|
||||||
|
const response = await postGitHubEvent("push", "send-fails", {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
repository: { full_name: "acme/app", owner: { login: "acme" } },
|
||||||
|
commits: []
|
||||||
|
}, async () => {
|
||||||
|
throw new Error("WeCom failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function postGitHubEvent(
|
||||||
|
eventName: string,
|
||||||
|
deliveryId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
sender: (config: AppConfig, message: string) => Promise<unknown>,
|
||||||
|
envOverrides: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const signature = await signBody("test-secret", new TextEncoder().encode(body));
|
||||||
|
|
||||||
|
return handleRequest(
|
||||||
|
new Request("https://worker.test/github/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-github-event": eventName,
|
||||||
|
"x-github-delivery": deliveryId,
|
||||||
|
"x-hub-signature-256": signature
|
||||||
|
},
|
||||||
|
body
|
||||||
|
}),
|
||||||
|
testEnv(envOverrides),
|
||||||
|
undefined,
|
||||||
|
sender
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node", "@cloudflare/workers-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name = "github-gcc"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-07-02"
|
||||||
|
|
||||||
|
# Non-secret defaults. Put secrets in Cloudflare with `wrangler secret put`.
|
||||||
|
[vars]
|
||||||
|
MAX_MESSAGE_LENGTH = "1800"
|
||||||
|
DELIVERY_TTL_SECONDS = "600"
|
||||||
|
|
||||||
|
# Optional KV cache for GitHub delivery de-duplication.
|
||||||
|
# Create it with:
|
||||||
|
# wrangler kv namespace create WEBHOOK_CACHE
|
||||||
|
# Then uncomment and replace the id below.
|
||||||
|
# [[kv_namespaces]]
|
||||||
|
# binding = "WEBHOOK_CACHE"
|
||||||
|
# id = "replace-with-kv-namespace-id"
|
||||||
Reference in New Issue
Block a user