diff --git a/.github/workflows/developer-issue-notify.yml b/.github/workflows/developer-issue-notify.yml index 57a86c3a..d3cd3b88 100644 --- a/.github/workflows/developer-issue-notify.yml +++ b/.github/workflows/developer-issue-notify.yml @@ -25,7 +25,7 @@ jobs: - name: Send developer issue notification env: - WEBHOOK_URL: ${{ secrets.NOTIFY_DEVELOPER_FEISHU_WEBHOOK }} + WEBHOOK_URL: ${{ secrets.NOTIFY_DEVELOPER_FEISHU_WEBHOOK }} # comma-separated for multiple groups EVENT_KIND: issue GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} diff --git a/.github/workflows/developer-pr-notify.yml b/.github/workflows/developer-pr-notify.yml index 7eae3d8d..73f6c3df 100644 --- a/.github/workflows/developer-pr-notify.yml +++ b/.github/workflows/developer-pr-notify.yml @@ -19,7 +19,7 @@ jobs: - name: Send developer PR notification env: - WEBHOOK_URL: ${{ secrets.NOTIFY_DEVELOPER_FEISHU_WEBHOOK }} + WEBHOOK_URL: ${{ secrets.NOTIFY_DEVELOPER_FEISHU_WEBHOOK }} # comma-separated for multiple groups EVENT_KIND: pr TITLE: ${{ github.event.pull_request.title }} URL: ${{ github.event.pull_request.html_url }} diff --git a/apps/controller/tests/skillhub-custom-import.test.ts b/apps/controller/tests/skillhub-custom-import.test.ts index a6bad857..5048f9f3 100644 --- a/apps/controller/tests/skillhub-custom-import.test.ts +++ b/apps/controller/tests/skillhub-custom-import.test.ts @@ -27,7 +27,7 @@ const { SkillDb } = await import("../src/services/skillhub/skill-db.js"); function stubExtractTo( slug: string, - skillsDir: string, + _skillsDir: string, opts: { withPackageJson?: boolean } = {}, ) { vi.mocked(extractZipMock).mockImplementationOnce( diff --git a/scripts/notify/developer-notify.mjs b/scripts/notify/developer-notify.mjs index dc9c17ae..32aaa7d6 100644 --- a/scripts/notify/developer-notify.mjs +++ b/scripts/notify/developer-notify.mjs @@ -270,8 +270,32 @@ export async function sendWebhook(webhookUrl, payload) { } } +export function parseWebhookUrls(raw) { + return raw + .split(",") + .map((u) => u.trim()) + .filter(Boolean); +} + +async function broadcastWebhook(webhookUrls, payload) { + const results = await Promise.allSettled( + webhookUrls.map((url) => sendWebhook(url, payload)), + ); + const failures = results.filter((r) => r.status === "rejected"); + if (failures.length === results.length) { + throw failures[0].reason; + } + if (failures.length > 0) { + const msgs = failures.map((f) => f.reason?.message ?? String(f.reason)); + console.warn( + `Warning: ${failures.length}/${results.length} webhook(s) failed: ${msgs.join("; ")}`, + ); + } + return { sent: results.length - failures.length, failed: failures.length }; +} + export async function runFromEnv(env = process.env) { - const webhookUrl = env.WEBHOOK_URL; + const rawWebhookUrl = env.WEBHOOK_URL; const eventKind = env.EVENT_KIND ?? "issue"; const title = env.TITLE ?? ""; const author = env.AUTHOR ?? ""; @@ -281,7 +305,12 @@ export async function runFromEnv(env = process.env) { const githubToken = env.GITHUB_TOKEN; const repositoryOwner = env.GITHUB_REPOSITORY_OWNER; - if (!webhookUrl) { + if (!rawWebhookUrl) { + throw new Error("WEBHOOK_URL is required"); + } + + const webhookUrls = parseWebhookUrls(rawWebhookUrl); + if (webhookUrls.length === 0) { throw new Error("WEBHOOK_URL is required"); } @@ -321,7 +350,7 @@ export async function runFromEnv(env = process.env) { body, issueUrl: safeUrl, }); - await sendWebhook(webhookUrl, payload); + await broadcastWebhook(webhookUrls, payload); return { skipped: false, eventKind }; } @@ -332,7 +361,7 @@ export async function runFromEnv(env = process.env) { labels, prUrl: safeUrl, }); - await sendWebhook(webhookUrl, payload); + await broadcastWebhook(webhookUrls, payload); return { skipped: false, eventKind }; } diff --git a/specs/current/developer-notify.md b/specs/current/developer-notify.md index 8131ed74..239da769 100644 --- a/specs/current/developer-notify.md +++ b/specs/current/developer-notify.md @@ -19,7 +19,7 @@ Runs on `issues: [opened]` via `.github/workflows/developer-issue-notify.yml`. 2. Runs `scripts/notify/developer-notify.mjs` with `EVENT_KIND=issue`. 3. Skips notifications for `sentry[bot]`. 4. Checks whether the issue author is a member of the repository-owner organization; internal authors are skipped. -5. Sends the developer-community issue card to the shared developer webhook. +5. Broadcasts the developer-community issue card to all webhook URLs (comma-separated in `NOTIFY_DEVELOPER_FEISHU_WEBHOOK`). ### Pull request notification @@ -28,12 +28,12 @@ Runs on `pull_request_target: [opened]` via `.github/workflows/developer-pr-noti 1. Job runs only when `github.event.pull_request.head.repo.fork` is true. 2. Runs `scripts/notify/developer-notify.mjs` with `EVENT_KIND=pr`. 3. Skips notifications for `sentry[bot]`. -4. Sends the external-contributor PR card to the shared developer webhook. +4. Broadcasts the external-contributor PR card to all webhook URLs (comma-separated in `NOTIFY_DEVELOPER_FEISHU_WEBHOOK`). ## Notification payloads - `scripts/notify/developer-notify.mjs` is the single payload builder and delivery entrypoint for both developer issue and developer PR notifications. -- The script selects the payload by `EVENT_KIND` (`issue` or `pr`) and sends a Feishu interactive card to the shared developer webhook. +- The script selects the payload by `EVENT_KIND` (`issue` or `pr`) and broadcasts a Feishu interactive card to all webhook URLs listed in `WEBHOOK_URL` (comma-separated). Delivery uses `Promise.allSettled` so a single group failure does not block others. - Payload layout details are intentionally not documented here; treat the script as the source of truth for message structure. ## Safety and isolation @@ -48,7 +48,7 @@ Runs on `pull_request_target: [opened]` via `.github/workflows/developer-pr-noti | Secret | Purpose | |--------|---------| -| `NOTIFY_DEVELOPER_FEISHU_WEBHOOK` | Shared Feishu incoming webhook URL for both developer issue and PR notifications | +| `NOTIFY_DEVELOPER_FEISHU_WEBHOOK` | Comma-separated Feishu incoming webhook URLs for developer notifications (one per group) | | `NEXU_PAL_APP_ID` | GitHub App ID used for issue-author org-membership filtering | | `NEXU_PAL_PRIVATE_KEY_PEM` | GitHub App private key used for issue-author org-membership filtering | diff --git a/tests/notify/developer-notify.test.ts b/tests/notify/developer-notify.test.ts index cb3b630c..b8cc6e03 100644 --- a/tests/notify/developer-notify.test.ts +++ b/tests/notify/developer-notify.test.ts @@ -4,6 +4,7 @@ import { buildDeveloperPrPayload, checkOrganizationMembership, isInternalEquivalentAuthor, + parseWebhookUrls, runFromEnv, sanitizeText, validateGithubUrl, @@ -36,7 +37,9 @@ describe("developer-notify", () => { prUrl: "https://github.com/nexu-io/nexu/pull/10", }); - expect(payload.card.header.title.content).toContain("又有新贡献者给 Nexu 提 PR"); + expect(payload.card.header.title.content).toContain( + "又有新贡献者给 Nexu 提 PR", + ); expect(payload.card.body.elements[0]).toMatchObject({ tag: "markdown", content: expect.stringContaining("**Title:** fix: resolve login crash"), @@ -72,7 +75,9 @@ describe("developer-notify", () => { ).toEqual(["Good First Issue", "贡献者指南", "查看全部 Issue"]); expect(payload.card.body.elements[2]).toMatchObject({ tag: "markdown", - content: expect.stringContaining("只需 3 步💥:❶ 选任务 ❷ 认领 ❸ 提交 PR"), + content: expect.stringContaining( + "只需 3 步💥:❶ 选任务 ❷ 认领 ❸ 提交 PR", + ), }); }); @@ -81,7 +86,9 @@ describe("developer-notify", () => { issueUrl: "https://github.com/nexu-io/nexu/issues/99", }); - expect(payload.card.header.title.content).toContain("刚新增 1 条 issue 等你来领取"); + expect(payload.card.header.title.content).toContain( + "刚新增 1 条 issue 等你来领取", + ); expect(payload.card.body.elements[1]).toMatchObject({ tag: "column_set" }); expect( payload.card.body.elements[1].columns.map( @@ -102,6 +109,42 @@ describe("developer-notify", () => { }); }); + it("parses comma-separated webhook URLs", () => { + expect(parseWebhookUrls("https://a.com/hook")).toEqual([ + "https://a.com/hook", + ]); + expect(parseWebhookUrls("https://a.com/hook, https://b.com/hook")).toEqual([ + "https://a.com/hook", + "https://b.com/hook", + ]); + expect(parseWebhookUrls(" https://a.com/hook ,, ")).toEqual([ + "https://a.com/hook", + ]); + }); + + it("broadcasts PR notification to multiple webhooks", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ code: 0, msg: "success" }), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await runFromEnv({ + WEBHOOK_URL: + "https://a.feishu.cn/webhook/1, https://b.feishu.cn/webhook/2", + EVENT_KIND: "pr", + AUTHOR: "alice", + LABELS_OR_CATEGORY: "none", + URL: "https://github.com/nexu-io/nexu/pull/1", + }); + + expect(result).toEqual({ skipped: false, eventKind: "pr" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://a.feishu.cn/webhook/1"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("https://b.feishu.cn/webhook/2"); + }); + it("treats sentry bot as internal-equivalent", () => { expect(isInternalEquivalentAuthor("sentry[bot]")).toBe(true); expect(isInternalEquivalentAuthor("alice")).toBe(false);