Initial commit: GitHub-GCC WeCom bot for Gitea
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user