Initial commit: GitHub-GCC WeCom bot for Gitea

This commit is contained in:
2026-09-17 17:43:45 +08:00
commit 74d9523652
18 changed files with 2908 additions and 0 deletions
+157
View File
@@ -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$/);
});
+21
View File
@@ -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;
}
+25
View File
@@ -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);
});
+43
View File
@@ -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/
);
});
+169
View File
@@ -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
);
}