Skip to content
Merged
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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
Expand Down
34 changes: 34 additions & 0 deletions src/services/notify-discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const webhookUrl = (env as unknown as Record<string, unknown>).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) }));
}
}
47 changes: 46 additions & 1 deletion test/unit/notify-discord.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
});
});
Loading