From 530cfee4802e76ddf40b402f0e3b6150fe509013 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:56:00 -0700 Subject: [PATCH] feat(notify): add Slack per-action notifications alongside Discord Operator notifications were Discord-only. Add notifyActionToSlack as a sibling of notifyActionToDiscord: when SLACK_WEBHOOK_URL (a hooks.slack.com/services webhook) is set, a merged/closed/manual outcome posts a Block Kit message to that channel for ANY repo. The executor now fires both; set either, both, or neither. Best-effort (never throws), URL-validated to hooks.slack.com/services only. --- .env.example | 2 ++ src/env.d.ts | 3 ++ src/services/agent-action-executor.ts | 6 ++-- src/services/notify-discord.ts | 34 +++++++++++++++++++ test/unit/notify-discord.test.ts | 47 ++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 34c478edfa..6ea8933b27 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,8 @@ GITTENSORY_REVIEW_DRAFT=false # DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/ # # manual) on ANY repo you review. Unset = no Discord notifications. # # Collection and schema are auto-created at startup. Off when unset. +# SLACK_WEBHOOK_URL= # a Slack incoming webhook (https://hooks.slack.com/services/…) for the +# # same per-action notifications. Set either, both, or neither. # QDRANT_API_KEY= # Bearer token for an authenticated Qdrant (cloud / on-prem). Omit for # # the local --profile qdrant container (unauthenticated). # QDRANT_DIM=1024 # vector dimension of the collection (default 1024 = bge-m3); set to diff --git a/src/env.d.ts b/src/env.d.ts index fa26b75680..1d3d265733 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -98,6 +98,9 @@ declare global { /** Self-host default Discord webhook URL — per-action notifications (merged/closed/manual) for any repo * not in the built-in per-repo map. Lets a self-host operator wire one channel without a source edit. */ DISCORD_WEBHOOK_URL?: string; + /** Self-host Slack incoming-webhook URL (`https://hooks.slack.com/services/…`) — per-action notifications + * (merged/closed/manual) for ANY repo. Sibling of DISCORD_WEBHOOK_URL; set either, both, or neither. */ + SLACK_WEBHOOK_URL?: string; GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN?: string; PRODUCT_USAGE_HASH_SALT?: string; GITTENSORY_API_TOKEN: string; diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 5f60353ef5..9d83dfb6bf 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -1,6 +1,6 @@ import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories"; import { classifyMergeFailure, MERGE_RETRY_CAP } from "./merge-failure"; -import { notifyActionToDiscord, type NotifyOutcome } from "./notify-discord"; +import { notifyActionToDiscord, notifyActionToSlack, type NotifyOutcome } from "./notify-discord"; import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels"; import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions"; import { isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; @@ -117,7 +117,9 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE const notifyOutcome: NotifyOutcome | null = action.actionClass === "merge" ? "merged" : action.actionClass === "close" ? "closed" : action.actionClass === "request_changes" ? "manual" : null; if (notifyOutcome) { - await notifyActionToDiscord(env, { repoFullName: ctx.repoFullName, pullNumber: ctx.pullNumber, outcome: notifyOutcome, summary: action.reason, submitter: ctx.authorLogin }).catch(() => undefined); + const notifyParams = { repoFullName: ctx.repoFullName, pullNumber: ctx.pullNumber, outcome: notifyOutcome, summary: action.reason, submitter: ctx.authorLogin }; + await notifyActionToDiscord(env, notifyParams).catch(() => undefined); + await notifyActionToSlack(env, notifyParams).catch(() => undefined); } } catch (error) { await audit("error", errorMessage(error)); diff --git a/src/services/notify-discord.ts b/src/services/notify-discord.ts index 8340eb79b4..23dc3da95e 100644 --- a/src/services/notify-discord.ts +++ b/src/services/notify-discord.ts @@ -78,3 +78,37 @@ export async function notifyActionToDiscord( console.warn(JSON.stringify({ ev: "discord_notify_failed", repo: params.repoFullName, pull: params.pullNumber, message: errorMessage(error).slice(0, 120) })); } } + +/** Slack incoming-webhook URL validation — only `https://hooks.slack.com/services/…`. */ +function isValidSlackWebhook(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && parsed.hostname.toLowerCase() === "hooks.slack.com" && parsed.pathname.startsWith("/services/"); + } catch { + return false; + } +} + +/** Post a per-action Slack message (merged/closed/manual) to `SLACK_WEBHOOK_URL` as a Block Kit section. Best-effort: + * never throws. The modular self-host default — ANY repo notifies the operator's single Slack channel when + * `SLACK_WEBHOOK_URL` is set; unset → no-op, byte-identical to today. Sibling of {@link notifyActionToDiscord}. */ +export async function notifyActionToSlack( + env: Env, + params: { repoFullName: string; pullNumber: number; outcome: NotifyOutcome; summary: string; submitter?: string | null | undefined }, +): Promise { + const webhookUrl = (env as unknown as Record).SLACK_WEBHOOK_URL; + if (typeof webhookUrl !== "string" || !isValidSlackWebhook(webhookUrl)) return; + const meta = OUTCOME_META[params.outcome]; + const prUrl = `https://github.com/${params.repoFullName}/pull/${params.pullNumber}`; + const lines = [`*<${prUrl}|${params.repoFullName}#${params.pullNumber}>* · ${meta.word}`, (params.summary || meta.word).slice(0, 1800)]; + if (params.submitter) lines.push(`Submitter: @${params.submitter}`); + const body = { + text: `${params.repoFullName}#${params.pullNumber} ${meta.word}`, + blocks: [{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }], + }; + try { + await fetch(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }); + } catch (error) { + console.warn(JSON.stringify({ ev: "slack_notify_failed", repo: params.repoFullName, pull: params.pullNumber, message: errorMessage(error).slice(0, 120) })); + } +} diff --git a/test/unit/notify-discord.test.ts b/test/unit/notify-discord.test.ts index b634a75ab4..21e9a3cf93 100644 --- a/test/unit/notify-discord.test.ts +++ b/test/unit/notify-discord.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { notifyActionToDiscord } from "../../src/services/notify-discord"; +import { notifyActionToDiscord, notifyActionToSlack } from "../../src/services/notify-discord"; import { createTestEnv } from "../helpers/d1"; const HOOK = "https://discord.com/api/webhooks/123/abc"; @@ -47,3 +47,48 @@ describe("notify-discord resolveWebhook (modular self-host fallback)", () => { expect(calls).toEqual([FALLBACK]); }); }); + +describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () => { + const SLACK = "https://hooks.slack.com/services/T0/B0/xyz"; + const slackStub = () => { + const calls: { url: string; body: { text: string; blocks: Array<{ text: { text: string } }> } }[] = []; + vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(null, { status: 200 }); + }); + return calls; + }; + + it("posts a Block Kit message to SLACK_WEBHOOK_URL for any repo, including the submitter", async () => { + const calls = slackStub(); + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: SLACK }), { repoFullName: "acme/widgets", pullNumber: 7, outcome: "merged", summary: "looks good", submitter: "octocat" }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(SLACK); + expect(calls[0]?.body.text).toContain("acme/widgets#7"); + expect(calls[0]?.body.blocks[0]?.text.text).toContain("looks good"); + expect(calls[0]?.body.blocks[0]?.text.text).toContain("Submitter: @octocat"); + }); + + it("omits the submitter line when absent (and falls back to the outcome word for an empty summary)", async () => { + const calls = slackStub(); + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: SLACK }), { repoFullName: "acme/widgets", pullNumber: 7, outcome: "closed", summary: "" }); + expect(calls[0]?.body.blocks[0]?.text.text).not.toContain("Submitter"); + expect(calls[0]?.body.blocks[0]?.text.text).toContain("closed"); + }); + + it("does NOT notify when SLACK_WEBHOOK_URL is unset or not a valid hooks.slack.com/services URL", async () => { + const calls = slackStub(); + const p = { repoFullName: "acme/widgets", pullNumber: 7, outcome: "merged" as const, summary: "x" }; + await notifyActionToSlack(createTestEnv(), p); // unset + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: "https://evil.example/services/x" }), p); // wrong host + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: "http://hooks.slack.com/services/x" }), p); // not https + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: "https://hooks.slack.com/foo" }), p); // wrong path + await notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: "not-a-url" }), p); // unparseable + expect(calls).toEqual([]); + }); + + it("swallows a fetch failure (best-effort, never throws)", async () => { + vi.stubGlobal("fetch", async () => { throw new Error("network down"); }); + await expect(notifyActionToSlack(withEnv({ SLACK_WEBHOOK_URL: SLACK }), { repoFullName: "acme/widgets", pullNumber: 7, outcome: "manual", summary: "x" })).resolves.toBeUndefined(); + }); +});