Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/developer-issue-notify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/developer-pr-notify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion apps/controller/tests/skillhub-custom-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
37 changes: 33 additions & 4 deletions scripts/notify/developer-notify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand All @@ -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");
}

Expand Down Expand Up @@ -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 };
}

Expand All @@ -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 };
}

Expand Down
8 changes: 4 additions & 4 deletions specs/current/developer-notify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 |

Expand Down
49 changes: 46 additions & 3 deletions tests/notify/developer-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildDeveloperPrPayload,
checkOrganizationMembership,
isInternalEquivalentAuthor,
parseWebhookUrls,
runFromEnv,
sanitizeText,
validateGithubUrl,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
),
});
});

Expand All @@ -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(
Expand All @@ -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);
Expand Down
Loading