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