diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index a6d53f5d0a..827be1ad72 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -11,11 +11,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "AI_EMBED_API_KEY", - firstReference: "src/server.ts:425", + firstReference: "src/server.ts:428", }, { name: "AI_EMBED_BASE_URL", - firstReference: "src/server.ts:422", + firstReference: "src/server.ts:425", }, { name: "AI_EMBED_MODEL", @@ -43,7 +43,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "BACKUP_ACKNOWLEDGED", - firstReference: "src/server.ts:364", + firstReference: "src/server.ts:367", }, { name: "BROWSER_WS_ENDPOINT", @@ -79,11 +79,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "CRON_INTERVAL_MS", - firstReference: "src/server.ts:885", + firstReference: "src/server.ts:904", }, { name: "DATABASE_PATH", - firstReference: "src/server.ts:247", + firstReference: "src/server.ts:250", }, { name: "DATABASE_URL", @@ -91,11 +91,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "DISCORD_REPO_WEBHOOKS", - firstReference: "src/selfhost/discord-notify.ts:31", + firstReference: "src/services/notify-discord.ts:41", }, { name: "DISCORD_WEBHOOK_URL", - firstReference: "src/selfhost/discord-notify.ts:40", + firstReference: "src/services/notify-discord.ts:78", }, { name: "FOREGROUND_LIVENESS_ENABLED", @@ -111,11 +111,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "GITHUB_CACHE_TTL_SECONDS", - firstReference: "src/server.ts:493", + firstReference: "src/server.ts:496", }, { name: "GITTENSORY_REPO_CONFIG_DIR", - firstReference: "src/server.ts:281", + firstReference: "src/server.ts:284", }, { name: "GITTENSORY_VERSION", @@ -127,11 +127,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "MAINTENANCE_ADMISSION_ENABLED", - firstReference: "src/selfhost/maintenance-admission.ts:123", + firstReference: "src/selfhost/maintenance-admission.ts:129", }, { name: "MIGRATIONS_DIR", - firstReference: "src/server.ts:377", + firstReference: "src/server.ts:380", }, { name: "OBSERVABILITY_SMOKE_POLL_MS", @@ -191,7 +191,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ORB_BROKER_URL", - firstReference: "src/server.ts:934", + firstReference: "src/server.ts:953", }, { name: "ORB_COLLECTOR_TOKEN", @@ -207,7 +207,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ORB_RELAY_MODE", - firstReference: "src/server.ts:936", + firstReference: "src/server.ts:955", }, { name: "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -239,11 +239,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "PGVECTOR_ENABLED", - firstReference: "src/server.ts:227", + firstReference: "src/server.ts:230", }, { name: "PORT", - firstReference: "src/server.ts:684", + firstReference: "src/server.ts:703", }, { name: "PUBLIC_API_ORIGIN", @@ -259,7 +259,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "QDRANT_URL", - firstReference: "src/server.ts:512", + firstReference: "src/server.ts:515", }, { name: "QUEUE_BACKGROUND_CONCURRENCY", @@ -271,7 +271,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "REVIEW_AUDIT_DIR", - firstReference: "src/server.ts:557", + firstReference: "src/server.ts:560", }, { name: "SELFHOST_BUNDLE_ALL", @@ -307,7 +307,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "SETUP_OUTPUT_PATH", - firstReference: "src/server.ts:801", + firstReference: "src/server.ts:820", }, ]; @@ -315,15 +315,15 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| Name | First reference |", "| --- | --- |", "| `AI_COMBINE` | `src/selfhost/ai.ts:936` |", - "| `AI_EMBED_API_KEY` | `src/server.ts:425` |", - "| `AI_EMBED_BASE_URL` | `src/server.ts:422` |", + "| `AI_EMBED_API_KEY` | `src/server.ts:428` |", + "| `AI_EMBED_BASE_URL` | `src/server.ts:425` |", "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:832` |", "| `AI_ON_MERGE` | `src/selfhost/ai.ts:938` |", "| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |", "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:836` |", "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:57` |", "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:835` |", - "| `BACKUP_ACKNOWLEDGED` | `src/server.ts:364` |", + "| `BACKUP_ACKNOWLEDGED` | `src/server.ts:367` |", "| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |", "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:108` |", "| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:49` |", @@ -332,20 +332,20 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:53` |", "| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:112` |", "| `CODEX_HOME` | `src/selfhost/ai.ts:274` |", - "| `CRON_INTERVAL_MS` | `src/server.ts:885` |", - "| `DATABASE_PATH` | `src/server.ts:247` |", + "| `CRON_INTERVAL_MS` | `src/server.ts:904` |", + "| `DATABASE_PATH` | `src/server.ts:250` |", "| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |", - "| `DISCORD_REPO_WEBHOOKS` | `src/selfhost/discord-notify.ts:31` |", - "| `DISCORD_WEBHOOK_URL` | `src/selfhost/discord-notify.ts:40` |", + "| `DISCORD_REPO_WEBHOOKS` | `src/services/notify-discord.ts:41` |", + "| `DISCORD_WEBHOOK_URL` | `src/services/notify-discord.ts:78` |", "| `FOREGROUND_LIVENESS_ENABLED` | `src/selfhost/foreground-liveness.ts:41` |", "| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts:59` |", "| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts:166` |", - "| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:493` |", - "| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:281` |", + "| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:496` |", + "| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:284` |", "| `GITTENSORY_VERSION` | `src/selfhost/health.ts:29` |", "| `HOME` | `src/selfhost/ai.ts:274` |", - "| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:123` |", - "| `MIGRATIONS_DIR` | `src/server.ts:377` |", + "| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:129` |", + "| `MIGRATIONS_DIR` | `src/server.ts:380` |", "| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |", "| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |", "| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:829` |", @@ -360,11 +360,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |", "| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |", "| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |", - "| `ORB_BROKER_URL` | `src/server.ts:934` |", + "| `ORB_BROKER_URL` | `src/server.ts:953` |", "| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts:205` |", "| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts:172` |", "| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts:165` |", - "| `ORB_RELAY_MODE` | `src/server.ts:936` |", + "| `ORB_RELAY_MODE` | `src/server.ts:955` |", "| `OTEL_EXPORTER_OTLP_ENDPOINT` | `src/selfhost/otel.ts:47` |", "| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `src/selfhost/otel.ts:45` |", "| `OTEL_SERVICE_ENVIRONMENT` | `src/selfhost/otel.ts:60` |", @@ -372,15 +372,15 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OTEL_TRACES_EXPORTER` | `src/selfhost/otel.ts:40` |", "| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |", "| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |", - "| `PGVECTOR_ENABLED` | `src/server.ts:227` |", - "| `PORT` | `src/server.ts:684` |", + "| `PGVECTOR_ENABLED` | `src/server.ts:230` |", + "| `PORT` | `src/server.ts:703` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |", "| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts:50` |", "| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts:71` |", - "| `QDRANT_URL` | `src/server.ts:512` |", + "| `QDRANT_URL` | `src/server.ts:515` |", "| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:102` |", "| `REDIS_URL` | `src/selfhost/preflight.ts:144` |", - "| `REVIEW_AUDIT_DIR` | `src/server.ts:557` |", + "| `REVIEW_AUDIT_DIR` | `src/server.ts:560` |", "| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |", "| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs:5` |", "| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts:186` |", @@ -389,5 +389,5 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `SENTRY_RELEASE` | `src/selfhost/otel.ts:62` |", "| `SENTRY_SERVER_NAME` | `src/selfhost/sentry.ts:383` |", "| `SENTRY_TRACES_SAMPLE_RATE` | `src/selfhost/sentry.ts:171` |", - "| `SETUP_OUTPUT_PATH` | `src/server.ts:801` |", + "| `SETUP_OUTPUT_PATH` | `src/server.ts:820` |", ].join("\n"); diff --git a/scripts/gen-selfhost-env-reference.mjs b/scripts/gen-selfhost-env-reference.mjs index 9bfdca6c03..32f6d1722e 100644 --- a/scripts/gen-selfhost-env-reference.mjs +++ b/scripts/gen-selfhost-env-reference.mjs @@ -8,6 +8,7 @@ export const DEFAULT_OUTPUT_PATH = "apps/gittensory-ui/src/lib/selfhost-env-refe export const DEFAULT_SOURCE_ROOTS = [ "src/selfhost", "src/server.ts", + "src/services/notify-discord.ts", "scripts/build-selfhost.mjs", "scripts/migrate-selfhost-sqlite-to-postgres.ts", "scripts/smoke-observability-traces.mjs", @@ -69,6 +70,8 @@ function collectEnvReads(source, fileName) { const name = bindingElementName(element); if (name) addRead(name, element.propertyName ?? element.name); } + } else if (ts.isCallExpression(node) && isStaticEnvHelperCall(node)) { + addRead(node.arguments[1].text, node.arguments[1]); } ts.forEachChild(node, visit); }; @@ -76,6 +79,16 @@ function collectEnvReads(source, fileName) { return reads; } +function isStaticEnvHelperCall(node) { + return ( + ts.isIdentifier(node.expression) && + node.expression.text === "envString" && + node.arguments.length >= 2 && + isEnvContainer(node.arguments[0]) && + ts.isStringLiteralLike(node.arguments[1]) + ); +} + function bindingElementName(element) { const candidate = element.propertyName ?? element.name; if (ts.isIdentifier(candidate) || ts.isStringLiteralLike(candidate)) return candidate.text; diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts index 3236e8d3b5..d143f929ec 100644 --- a/src/review/guardrail-config.ts +++ b/src/review/guardrail-config.ts @@ -50,11 +50,26 @@ export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ "src/review/outcomes-wire.ts", // the pr_outcome + reversal telemetry that feeds self-tuning ]; +// Self-host runtime and persistence surface. These paths do not necessarily decide the verdict directly, but they +// decide whether the review stack can boot, store, queue, notify, migrate, and export safely. Keep this scoped to +// operational chokepoints so ordinary feature/review logic can still auto-merge when it is clean. +export const SELFHOST_RUNTIME_GUARDRAIL_GLOBS = [ + "src/selfhost/**", + "src/server.ts", + "src/db/**", + "Dockerfile", + "docker-compose*.yml*", + "docker-compose*.yaml*", + "compose*.yml*", + "compose*.yaml*", + "systemd/**", +]; + /** * Resolve hard-guardrail path globs. Kept async to avoid touching the processor call graph, but this no longer * reads external policy storage; self-host review policy belongs in container-private `.gittensory.yml`, and these * engine-level guardrails are always-on invariants. */ export async function loadHardGuardrailGlobs(_env: Env, _repoFullName: string): Promise { - return [...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS]; + return [...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS, ...SELFHOST_RUNTIME_GUARDRAIL_GLOBS]; } diff --git a/src/review/outcomes-wire.ts b/src/review/outcomes-wire.ts index ce6647cfd0..31b47477c3 100644 --- a/src/review/outcomes-wire.ts +++ b/src/review/outcomes-wire.ts @@ -24,7 +24,6 @@ // once a repo's merge precision actually drops below the floor over a real sample. import { recordAuditEvent } from "../db/repositories"; -import { notifyDiscordReview } from "../selfhost/discord-notify"; import { incr } from "../selfhost/metrics"; import type { GitHubWebhookPayload } from "../types"; import { errorMessage, nowIso } from "../utils/json"; @@ -338,21 +337,10 @@ export async function recordPrOutcome( ), ); - // Per-repo Discord notification on the FINAL disposition (self-host; no-op unless a webhook is configured). - // Reason = the AI's recorded gate verdict for this PR, falling back to the plain disposition. - const fallbackReason = merged - ? "Pull request merged into the base branch." - : botWasActor - ? "Closed by Gittensory after review." - : "Pull request closed without merging."; - await notifyDiscordReview({ - repoFullName, - prNumber: pr.number, - author: pr.user?.login ?? "unknown", - outcome: decision, - reason: await resolveDispositionReason(env, targetId, fallbackReason), - url: `https://github.com/${repoFullName}/pull/${pr.number}`, - }); + // Discord/Slack action notifications are emitted by the action executor, which knows the exact bot action that + // was attempted and can audit the delivery. This outcome recorder only stores realized ground truth. Emitting + // another webhook from the GitHub `pull_request.closed` event duplicated bot-action notifications and could + // route through stale/global self-host webhook config. } // ── 2) reversals — a human undid a bot action ──────────────────────────────────────────────────────────────── diff --git a/src/selfhost/discord-notify.ts b/src/selfhost/discord-notify.ts deleted file mode 100644 index ecae1d0a8e..0000000000 --- a/src/selfhost/discord-notify.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Per-repo (with global fallback) Discord notifications when gittensory publishes a review (#notify). Reads -// DISCORD_REPO_WEBHOOKS (a JSON map "owner/repo" → webhook URL) and DISCORD_WEBHOOK_URL (global fallback) from -// the environment — self-host only, and a no-op when neither is set or in a runtime without process.env. -// Best-effort: an absent or failing webhook never affects the review (all errors are swallowed). - -const ALLOWED_DISCORD_HOSTS = new Set([ - "discord.com", - "discordapp.com", - "canary.discord.com", - "ptb.discord.com", -]); - -function isValidDiscordWebhook(url: string): boolean { - try { - const parsed = new URL(url); - return ( - parsed.protocol === "https:" && - ALLOWED_DISCORD_HOSTS.has(parsed.hostname.toLowerCase()) && - parsed.pathname.startsWith("/api/webhooks/") - ); - } catch { - return false; - } -} - -function readConfig(): { map: Record; global: string | null } { - /* v8 ignore next */ // process is always defined in the self-host (node) runtime; the guard is for the Worker bundle - const env: Record = - (typeof process !== "undefined" ? process.env : undefined) ?? {}; - let map: Record = {}; - if (env.DISCORD_REPO_WEBHOOKS) { - try { - const parsed = JSON.parse(env.DISCORD_REPO_WEBHOOKS) as unknown; - if (parsed && typeof parsed === "object") - map = parsed as Record; - } catch { - /* malformed map → ignore, fall back to global */ - } - } - return { map, global: env.DISCORD_WEBHOOK_URL ?? null }; -} - -/** The repo's own webhook if configured, else the global webhook, else null (notifications disabled). */ -export function resolveDiscordWebhook(repoFullName: string): string | null { - const { map, global } = readConfig(); - const repoUrl = map[repoFullName]; - if (typeof repoUrl === "string" && isValidDiscordWebhook(repoUrl)) - return repoUrl; - return global && isValidDiscordWebhook(global) ? global : null; -} - -// Embed accent colour by outcome — green = good, amber = caution, red = blocked/closed (matches the hosted look). -const OUTCOME_COLORS: Record = { - approve: 0x2ecc71, - approved: 0x2ecc71, - merge: 0x2ecc71, - merged: 0x2ecc71, - pass: 0x2ecc71, - clean: 0x2ecc71, - hold: 0xf1c40f, - warn: 0xf1c40f, - flagged: 0xf1c40f, - block: 0xe74c3c, - blocked: 0xe74c3c, - close: 0xe74c3c, - closed: 0xe74c3c, - fail: 0xe74c3c, -}; - -export interface DiscordReviewNotification { - repoFullName: string; - prNumber: number; - author: string; - outcome: string; - reason: string; - url: string; -} - -export async function notifyDiscordReview( - n: DiscordReviewNotification, -): Promise { - const webhook = resolveDiscordWebhook(n.repoFullName); - if (!webhook) return; - const color = OUTCOME_COLORS[n.outcome.toLowerCase()] ?? 0x5865f2; - const body = { - embeds: [ - { - title: `${n.repoFullName}#${n.prNumber} · ${n.outcome}`.slice(0, 256), - url: n.url, - description: n.reason.slice(0, 2048), - color, - fields: [ - { - name: "Outcome", - value: `\`${n.outcome}\``.slice(0, 1024), - inline: true, - }, - { name: "PR", value: `#${n.prNumber}`, inline: true }, - { - name: "Submitter", - value: `@${n.author}`.slice(0, 1024), - inline: true, - }, - ], - footer: { text: `Gittensory · ${n.repoFullName}`.slice(0, 2048) }, - }, - ], - }; - try { - await fetch(webhook, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(8000), - }); - } catch { - /* best-effort: a Discord outage must never break the review */ - } -} diff --git a/src/services/notify-discord.ts b/src/services/notify-discord.ts index 34ccdec6eb..025837ea80 100644 --- a/src/services/notify-discord.ts +++ b/src/services/notify-discord.ts @@ -1,3 +1,4 @@ +import { recordAuditEvent } from "../db/repositories"; import { errorMessage } from "../utils/json"; // Per-repo Discord notifications (reviewbot parity). Each repo notifies its OWN channel on a terminal action — @@ -17,26 +18,70 @@ function isValidDiscordWebhook(url: string): boolean { } } -// Map each repo to its operator-set webhook SECRET name (set via `wrangler secret put`). A repo with no mapping -// (or an unset secret) simply does not notify — byte-identical to today for any other repo. +// Map the first-party repos to legacy operator-set webhook SECRET names. A repo with a specific mapping must never +// fall back to the global webhook: falling back posts repo A's disposition into repo B's channel. Generic +// self-hosters should prefer DISCORD_REPO_WEBHOOKS for per-repo routing, or DISCORD_WEBHOOK_URL for one shared +// channel across unmapped repos. const WEBHOOK_SECRET_BY_REPO: Record = { "jsonbored/gittensory": "GITTENSORY_DISCORD_WEBHOOK", "jsonbored/metagraphed": "METAGRAPHED_DISCORD_WEBHOOK", "jsonbored/awesome-claude": "AWESOME_DISCORD_WEBHOOK", }; -function resolveWebhook(env: Env, repoFullName: string): string | undefined { - // A repo with a specific mapping uses its own channel secret (the built-in map is gittensory's OWN repos). - const name = WEBHOOK_SECRET_BY_REPO[repoFullName.toLowerCase()]; +function envString(env: Env, name: string): string | undefined { + const fromEnv = (env as unknown as Record)[name]; + if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim(); + /* v8 ignore next 2 -- process.env is the self-host Node fallback; Worker/D1 tests pass values on Env. */ + const processEnv = (globalThis as unknown as { process?: { env?: Record } }).process?.env; + const fromProcess = processEnv?.[name]; + return typeof fromProcess === "string" && fromProcess.trim().length > 0 ? fromProcess.trim() : undefined; +} + +function repoWebhookMap(env: Env): Record { + const raw = envString(env, "DISCORD_REPO_WEBHOOKS"); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [repo, value] of Object.entries(parsed)) { + out[repo.toLowerCase()] = value; + } + return out; + } catch { + return {}; + } +} + +export type DiscordWebhookResolution = + | { status: "configured"; url: string; source: "repo_map" | "legacy_repo_secret" | "global" } + | { status: "disabled"; reason: "missing_repo_webhook" | "invalid_repo_webhook" | "missing_global_webhook" | "invalid_global_webhook" }; + +export function resolveDiscordWebhook(env: Env, repoFullName: string): DiscordWebhookResolution { + const repoKey = repoFullName.toLowerCase(); + const map = repoWebhookMap(env); + if (Object.prototype.hasOwnProperty.call(map, repoKey)) { + const mapped = map[repoKey]; + const url = typeof mapped === "string" ? mapped.trim() : ""; + return url && isValidDiscordWebhook(url) ? { status: "configured", url, source: "repo_map" } : { status: "disabled", reason: "invalid_repo_webhook" }; + } + + const name = WEBHOOK_SECRET_BY_REPO[repoKey]; if (name) { - const mapped = (env as unknown as Record)[name]; - if (typeof mapped === "string" && mapped.length > 0) return mapped; + const mapped = envString(env, name); + return mapped && isValidDiscordWebhook(mapped) ? { status: "configured", url: mapped, source: "legacy_repo_secret" } : { status: "disabled", reason: mapped ? "invalid_repo_webhook" : "missing_repo_webhook" }; } + // Modular self-host default: ANY repo not in the built-in map falls back to a single DISCORD_WEBHOOK_URL, so a // self-host operator gets per-action notifications for THEIR repos without editing this source map. Unset → // undefined → no-notify, byte-identical to today. - const fallback = (env as unknown as Record).DISCORD_WEBHOOK_URL; - return typeof fallback === "string" && fallback.length > 0 ? fallback : undefined; + const fallback = envString(env, "DISCORD_WEBHOOK_URL"); + return fallback && isValidDiscordWebhook(fallback) ? { status: "configured", url: fallback, source: "global" } : { status: "disabled", reason: fallback ? "invalid_global_webhook" : "missing_global_webhook" }; +} + +async function postWebhook(url: string, init: RequestInit, provider: "discord" | "slack"): Promise { + const response = await fetch(url, init); + if (!response.ok) throw new Error(`${provider}_webhook_http_${response.status}`); } export type NotifyOutcome = "merged" | "closed" | "manual"; @@ -47,13 +92,36 @@ const OUTCOME_META: Record = { manual: { word: "manual review", color: 0xbf8700 }, }; +async function auditExternalNotification( + env: Env, + params: { repoFullName: string; pullNumber: number; outcome: NotifyOutcome }, + provider: "discord" | "slack", + outcome: "completed" | "denied" | "error", + detail: string, + metadata: Record = {}, +): Promise { + await recordAuditEvent(env, { + eventType: `external_notification.${provider}`, + actor: "gittensory", + targetKey: `${params.repoFullName}#${params.pullNumber}`, + outcome, + detail, + metadata: { repoFullName: params.repoFullName, pullNumber: params.pullNumber, actionOutcome: params.outcome, ...metadata }, + }).catch((error) => { + console.warn(JSON.stringify({ ev: `${provider}_notify_audit_failed`, repo: params.repoFullName, pull: params.pullNumber, message: errorMessage(error).slice(0, 120) })); + }); +} + /** Post a per-action Discord embed (merged/closed/manual) to the repo's channel. Best-effort: never throws. */ export async function notifyActionToDiscord( env: Env, params: { repoFullName: string; pullNumber: number; outcome: NotifyOutcome; summary: string; submitter?: string | null | undefined }, ): Promise { - const webhookUrl = resolveWebhook(env, params.repoFullName); - if (!webhookUrl || !isValidDiscordWebhook(webhookUrl)) return; + const resolved = resolveDiscordWebhook(env, params.repoFullName); + if (resolved.status !== "configured") { + await auditExternalNotification(env, params, "discord", "denied", resolved.reason); + return; + } const meta = OUTCOME_META[params.outcome]; const body = { username: "Gittensory", @@ -73,9 +141,11 @@ export async function notifyActionToDiscord( ], }; try { - await fetch(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }); + await postWebhook(resolved.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "discord"); + await auditExternalNotification(env, params, "discord", "completed", "sent", { source: resolved.source }); } catch (error) { console.warn(JSON.stringify({ ev: "discord_notify_failed", repo: params.repoFullName, pull: params.pullNumber, message: errorMessage(error).slice(0, 120) })); + await auditExternalNotification(env, params, "discord", "error", errorMessage(error).slice(0, 160), { source: resolved.source }); } } @@ -101,7 +171,10 @@ export async function notifyActionToSlack( 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; + if (typeof webhookUrl !== "string" || !isValidSlackWebhook(webhookUrl)) { + await auditExternalNotification(env, params, "slack", "denied", typeof webhookUrl === "string" ? "invalid_webhook" : "missing_webhook"); + return; + } const meta = OUTCOME_META[params.outcome]; const prUrl = `https://github.com/${params.repoFullName}/pull/${params.pullNumber}`; const prLabel = escapeSlackMrkdwnText(`${params.repoFullName}#${params.pullNumber}`); @@ -112,8 +185,10 @@ export async function notifyActionToSlack( 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) }); + await postWebhook(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "slack"); + await auditExternalNotification(env, params, "slack", "completed", "sent"); } catch (error) { console.warn(JSON.stringify({ ev: "slack_notify_failed", repo: params.repoFullName, pull: params.pullNumber, message: errorMessage(error).slice(0, 120) })); + await auditExternalNotification(env, params, "slack", "error", errorMessage(error).slice(0, 160)); } } diff --git a/test/unit/guardrail-config.test.ts b/test/unit/guardrail-config.test.ts index 412066aed1..5634a395a6 100644 --- a/test/unit/guardrail-config.test.ts +++ b/test/unit/guardrail-config.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; import { matchesAny } from "../../src/signals/change-guardrail"; -import { CONFIG_AS_CODE_GUARDRAIL_GLOBS, DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ENGINE_DECISION_GUARDRAIL_GLOBS, loadHardGuardrailGlobs } from "../../src/review/guardrail-config"; +import { CONFIG_AS_CODE_GUARDRAIL_GLOBS, DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ENGINE_DECISION_GUARDRAIL_GLOBS, SELFHOST_RUNTIME_GUARDRAIL_GLOBS, loadHardGuardrailGlobs } from "../../src/review/guardrail-config"; describe("loadHardGuardrailGlobs", () => { it("returns the built-in self-host guardrails without requiring external policy storage", async () => { - expect(await loadHardGuardrailGlobs({} as Env, "JSONbored/gittensory")).toEqual([...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS]); + expect(await loadHardGuardrailGlobs({} as Env, "JSONbored/gittensory")).toEqual([...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS, ...SELFHOST_RUNTIME_GUARDRAIL_GLOBS]); }); it("ignores unrelated env data so old hosted policy cannot affect self-host review policy", async () => { const globs = await loadHardGuardrailGlobs({ LEGACY_POLICY: { hardGuardrailGlobs: ["docs/**"] } } as unknown as Env, "JSONbored/gittensory"); - expect(globs).toEqual([...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS]); + expect(globs).toEqual([...DEFAULT_CRUCIAL_GUARDRAIL_GLOBS, ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...ENGINE_DECISION_GUARDRAIL_GLOBS, ...SELFHOST_RUNTIME_GUARDRAIL_GLOBS]); expect(matchesAny("docs/readme.md", globs)).toBe(false); }); @@ -30,7 +30,24 @@ describe("loadHardGuardrailGlobs", () => { } expect(matchesAny("docs/readme.md", globs)).toBe(false); expect(matchesAny("src/utils/json.ts", globs)).toBe(false); // a non-decision src file remains auto-mergeable - expect(matchesAny("src/db/repositories.ts", globs)).toBe(false); // the data layer stays non-crucial (env kill-switch backstops the freeze there) + }); + + it("guards self-host runtime, persistence, and container chokepoints without guarding ordinary docs", async () => { + const globs = await loadHardGuardrailGlobs({} as Env, "o/r"); + for (const selfhostPath of [ + "src/selfhost/pg-queue.ts", + "src/server.ts", + "src/db/repositories.ts", + "Dockerfile", + "docker-compose.yml", + "docker-compose.override.yml.example", + "systemd/gittensory-docker-prune.timer.example", + ]) { + expect(matchesAny(selfhostPath, globs)).toBe(true); + } + expect(matchesAny("migrations/0107_example.sql", globs)).toBe(false); // covered by the dedicated collision recheck + expect(matchesAny("apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx", globs)).toBe(false); + expect(matchesAny("README.md", globs)).toBe(false); }); it("guards the gate's own policy files for every repo (the config-as-code self-weakening hole)", async () => { diff --git a/test/unit/notify-discord.test.ts b/test/unit/notify-discord.test.ts index 4cb7a037d3..a1a8528691 100644 --- a/test/unit/notify-discord.test.ts +++ b/test/unit/notify-discord.test.ts @@ -1,19 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { notifyActionToDiscord, notifyActionToSlack } from "../../src/services/notify-discord"; +import { notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord"; import { createTestEnv } from "../helpers/d1"; const HOOK = "https://discord.com/api/webhooks/123/abc"; const FALLBACK = "https://discord.com/api/webhooks/999/zzz"; +const ORIG_DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; -function stubFetch(): string[] { +function stubFetch(status = 204): string[] { const calls: string[] = []; vi.stubGlobal("fetch", async (url: RequestInfo | URL) => { calls.push(String(url)); - return new Response(null, { status: 204 }); + return new Response(null, { status }); }); return calls; } -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + if (ORIG_DISCORD_WEBHOOK_URL === undefined) delete process.env.DISCORD_WEBHOOK_URL; + else process.env.DISCORD_WEBHOOK_URL = ORIG_DISCORD_WEBHOOK_URL; + vi.unstubAllGlobals(); +}); // The built-in per-repo secrets (GITTENSORY_DISCORD_WEBHOOK, …) are read via cast and not declared on Env, so // set them with Object.assign; DISCORD_WEBHOOK_URL is declared, so either path works. @@ -21,13 +26,52 @@ const withEnv = (over: Record): Env => Object.assign(createTestE const notify = (env: Env, repo: string): Promise => notifyActionToDiscord(env, { repoFullName: repo, pullNumber: 1, outcome: "merged", summary: "ok" }); +async function externalNotificationAudit(env: Env, provider: "discord" | "slack"): Promise> { + const rows = await env.DB.prepare("select outcome, detail, metadata_json from audit_events where event_type = ? order by created_at").bind(`external_notification.${provider}`).all<{ outcome: string; detail: string; metadata_json: string }>(); + return rows.results ?? []; +} + describe("notify-discord resolveWebhook (modular self-host fallback)", () => { + it("resolves a repo-specific DISCORD_REPO_WEBHOOKS entry case-insensitively", () => { + const env = withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "jsonbored/metagraphed": HOOK }), DISCORD_WEBHOOK_URL: FALLBACK }); + expect(resolveDiscordWebhook(env, "JSONbored/Metagraphed")).toEqual({ status: "configured", url: HOOK, source: "repo_map" }); + }); + + it("ignores malformed or non-object DISCORD_REPO_WEBHOOKS values and still resolves the global fallback", () => { + expect(resolveDiscordWebhook(withEnv({ DISCORD_REPO_WEBHOOKS: "{not json", DISCORD_WEBHOOK_URL: FALLBACK }), "acme/widgets")).toEqual({ status: "configured", url: FALLBACK, source: "global" }); + expect(resolveDiscordWebhook(withEnv({ DISCORD_REPO_WEBHOOKS: "123", DISCORD_WEBHOOK_URL: FALLBACK }), "acme/widgets")).toEqual({ status: "configured", url: FALLBACK, source: "global" }); + expect(resolveDiscordWebhook(withEnv({ DISCORD_REPO_WEBHOOKS: "[]", DISCORD_WEBHOOK_URL: FALLBACK }), "acme/widgets")).toEqual({ status: "configured", url: FALLBACK, source: "global" }); + }); + + it("REGRESSION: explicit non-string and blank DISCORD_REPO_WEBHOOKS entries fail closed", async () => { + const env = withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "acme/widgets": 123, "acme/blank": " " }), DISCORD_WEBHOOK_URL: FALLBACK }); + expect(resolveDiscordWebhook(env, "acme/widgets")).toEqual({ status: "disabled", reason: "invalid_repo_webhook" }); + expect(resolveDiscordWebhook(env, "acme/blank")).toEqual({ status: "disabled", reason: "invalid_repo_webhook" }); + expect(resolveDiscordWebhook(env, "acme/other")).toEqual({ status: "configured", url: FALLBACK, source: "global" }); + + const calls = stubFetch(); + await notify(env, "acme/widgets"); + await notify(env, "acme/blank"); + expect(calls).toEqual([]); + expect(await externalNotificationAudit(env, "discord")).toEqual([ + expect.objectContaining({ outcome: "denied", detail: "invalid_repo_webhook" }), + expect.objectContaining({ outcome: "denied", detail: "invalid_repo_webhook" }), + ]); + }); + it("a mapped repo uses its own per-channel secret", async () => { const calls = stubFetch(); await notify(withEnv({ GITTENSORY_DISCORD_WEBHOOK: HOOK }), "JSONbored/gittensory"); expect(calls).toEqual([HOOK]); }); + it("uses process.env as a self-host fallback when the runtime Env object does not carry the webhook", async () => { + process.env.DISCORD_WEBHOOK_URL = FALLBACK; + expect(resolveDiscordWebhook(createTestEnv(), "acme/widgets")).toEqual({ status: "configured", url: FALLBACK, source: "global" }); + process.env.DISCORD_WEBHOOK_URL = " "; + expect(resolveDiscordWebhook(createTestEnv(), "acme/widgets")).toEqual({ status: "disabled", reason: "missing_global_webhook" }); + }); + it("any UNmapped repo (a self-hoster's) falls back to DISCORD_WEBHOOK_URL", async () => { const calls = stubFetch(); await notify(withEnv({ DISCORD_WEBHOOK_URL: FALLBACK }), "acme/widgets"); @@ -40,33 +84,111 @@ describe("notify-discord resolveWebhook (modular self-host fallback)", () => { expect(calls).toEqual([]); }); - it("a mapped repo whose channel secret is unset falls back to DISCORD_WEBHOOK_URL", async () => { + it("REGRESSION: a mapped repo whose channel secret is unset does not fall back to the wrong global channel", async () => { const calls = stubFetch(); - // JSONbored/metagraphed is in the map, but METAGRAPHED_DISCORD_WEBHOOK is unset → fall through. - await notify(withEnv({ DISCORD_WEBHOOK_URL: FALLBACK }), "JSONbored/metagraphed"); - expect(calls).toEqual([FALLBACK]); + const env = withEnv({ DISCORD_WEBHOOK_URL: FALLBACK }); + // JSONbored/metagraphed is in the legacy map. If its dedicated secret is missing, posting to the global + // fallback sends metagraphed events into the gittensory channel. + await notify(env, "JSONbored/metagraphed"); + expect(calls).toEqual([]); + expect(await externalNotificationAudit(env, "discord")).toEqual([expect.objectContaining({ outcome: "denied", detail: "missing_repo_webhook" })]); + }); + + it("REGRESSION: an invalid repo-specific webhook suppresses instead of falling back to the global channel", async () => { + const calls = stubFetch(); + const env = withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "jsonbored/metagraphed": "https://example.com/not-discord" }), DISCORD_WEBHOOK_URL: FALLBACK }); + await notify(env, "JSONbored/metagraphed"); + expect(calls).toEqual([]); + expect(await externalNotificationAudit(env, "discord")).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_repo_webhook" })]); + }); + + it("an invalid legacy repo secret suppresses instead of falling back to the global channel", async () => { + const calls = stubFetch(); + const env = withEnv({ GITTENSORY_DISCORD_WEBHOOK: "https://example.com/not-discord", DISCORD_WEBHOOK_URL: FALLBACK }); + await notify(env, "JSONbored/gittensory"); + expect(calls).toEqual([]); + expect(await externalNotificationAudit(env, "discord")).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_repo_webhook" })]); + }); + + it("an invalid global webhook suppresses unmapped repos", async () => { + const calls = stubFetch(); + const env = withEnv({ DISCORD_WEBHOOK_URL: "https://example.com/not-discord" }); + await notify(env, "acme/widgets"); + expect(calls).toEqual([]); + expect(resolveDiscordWebhook(env, "acme/widgets")).toEqual({ status: "disabled", reason: "invalid_global_webhook" }); + expect(resolveDiscordWebhook(withEnv({ DISCORD_WEBHOOK_URL: "not-a-url" }), "acme/widgets")).toEqual({ status: "disabled", reason: "invalid_global_webhook" }); + expect(await externalNotificationAudit(env, "discord")).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_global_webhook" })]); + }); + + it("audit failures are best-effort and never break notification suppression", async () => { + const calls = stubFetch(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + await expect(notify({} as Env, "acme/widgets")).resolves.toBeUndefined(); + expect(calls).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("discord_notify_audit_failed")); + warn.mockRestore(); + }); + + it("audits successful Discord sends without storing the webhook URL", async () => { + const calls = stubFetch(); + const env = withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "jsonbored/gittensory": HOOK }) }); + await notify(env, "JSONbored/gittensory"); + expect(calls).toEqual([HOOK]); + const rows = await externalNotificationAudit(env, "discord"); + expect(rows).toEqual([expect.objectContaining({ outcome: "completed", detail: "sent" })]); + const metadata = JSON.parse(rows[0]?.metadata_json ?? "{}"); + expect(metadata).toMatchObject({ repoFullName: "JSONbored/gittensory", pullNumber: 1, actionOutcome: "merged", source: "repo_map" }); + expect(rows[0]?.metadata_json).not.toContain("webhooks/123"); + }); + + it("renders Discord's summary fallback and optional submitter field", async () => { + const calls: Array<{ url: string; body: { embeds: Array<{ description: string; fields: Array<{ name: string; value: string; inline?: boolean }> }> } }> = []; + 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: 204 }); + }); + await notifyActionToDiscord(withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "jsonbored/gittensory": HOOK }) }), { + repoFullName: "JSONbored/gittensory", + pullNumber: 2, + outcome: "closed", + summary: "", + submitter: "octocat", + }); + expect(calls[0]?.url).toBe(HOOK); + expect(calls[0]?.body.embeds[0]?.description).toBe("closed"); + expect(calls[0]?.body.embeds[0]?.fields).toContainEqual({ name: "Submitter", value: "@octocat", inline: true }); + }); + + it("REGRESSION: audits rejected Discord webhook HTTP responses as errors", async () => { + const calls = stubFetch(429); + const env = withEnv({ DISCORD_REPO_WEBHOOKS: JSON.stringify({ "jsonbored/gittensory": HOOK }) }); + await notify(env, "JSONbored/gittensory"); + expect(calls).toEqual([HOOK]); + expect(await externalNotificationAudit(env, "discord")).toEqual([expect.objectContaining({ outcome: "error", detail: "discord_webhook_http_429" })]); }); }); describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () => { const SLACK = "https://hooks.slack.com/services/T0/B0/xyz"; - const slackStub = () => { + const slackStub = (status = 200) => { 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 new Response(null, { status }); }); 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" }); + const env = withEnv({ SLACK_WEBHOOK_URL: SLACK }); + await notifyActionToSlack(env, { 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"); + expect(await externalNotificationAudit(env, "slack")).toEqual([expect.objectContaining({ outcome: "completed", detail: "sent" })]); }); it("escapes untrusted Slack mrkdwn in the summary and submitter", async () => { @@ -107,4 +229,12 @@ describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () => 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(); }); + + it("REGRESSION: audits rejected Slack webhook HTTP responses as errors", async () => { + const calls = slackStub(403); + const env = withEnv({ SLACK_WEBHOOK_URL: SLACK }); + await notifyActionToSlack(env, { repoFullName: "acme/widgets", pullNumber: 7, outcome: "closed", summary: "x" }); + expect(calls).toHaveLength(1); + expect(await externalNotificationAudit(env, "slack")).toEqual([expect.objectContaining({ outcome: "error", detail: "slack_webhook_http_403" })]); + }); }); diff --git a/test/unit/outcomes-wire.test.ts b/test/unit/outcomes-wire.test.ts index 73a33986ba..b6248e6606 100644 --- a/test/unit/outcomes-wire.test.ts +++ b/test/unit/outcomes-wire.test.ts @@ -170,6 +170,31 @@ describe("recordPrOutcome — realized merge/close ground truth", () => { }); }); + it("REGRESSION: does not send a duplicate Discord notification from the pull_request.closed outcome webhook", async () => { + const env = Object.assign(createTestEnv(), { DISCORD_REPO_WEBHOOKS: JSON.stringify({ "owner/repo": "https://discord.com/api/webhooks/repo/token" }) }) as Env; + const fetchSpy = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchSpy); + await recordPrOutcome(env, "pull_request", { + action: "closed", + repository: { + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + }, + pull_request: pullRequestPayload({ + number: 44, + merged_at: null, + user: { login: "contributor", type: "User" }, + }), + sender: { login: "gittensory[bot]", type: "Bot" }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect((await reviewAuditRows(env, "pr_outcome"))[0]).toMatchObject({ + target_id: "owner/repo#44", + decision: "closed", + }); + }); + it("records NOTHING for an unmerged contributor PR self-close", async () => { const env = createTestEnv(); await recordPrOutcome(env, "pull_request", { diff --git a/test/unit/selfhost-discord-notify.test.ts b/test/unit/selfhost-discord-notify.test.ts deleted file mode 100644 index ef9411b784..0000000000 --- a/test/unit/selfhost-discord-notify.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - notifyDiscordReview, - resolveDiscordWebhook, -} from "../../src/selfhost/discord-notify"; - -const ORIG = { - repo: process.env.DISCORD_REPO_WEBHOOKS, - global: process.env.DISCORD_WEBHOOK_URL, -}; -function setEnv(repo: string | undefined, global: string | undefined): void { - if (repo === undefined) delete process.env.DISCORD_REPO_WEBHOOKS; - else process.env.DISCORD_REPO_WEBHOOKS = repo; - if (global === undefined) delete process.env.DISCORD_WEBHOOK_URL; - else process.env.DISCORD_WEBHOOK_URL = global; -} -afterEach(() => { - setEnv(ORIG.repo, ORIG.global); - vi.unstubAllGlobals(); -}); - -describe("resolveDiscordWebhook", () => { - it("returns the repo-specific webhook when configured", () => { - setEnv( - JSON.stringify({ "o/a": "https://discord.com/api/webhooks/a/token" }), - undefined, - ); - expect(resolveDiscordWebhook("o/a")).toBe( - "https://discord.com/api/webhooks/a/token", - ); - }); - it("falls back to the global webhook for an unmapped repo", () => { - setEnv( - JSON.stringify({ "o/a": "https://discord.com/api/webhooks/a/token" }), - "https://discord.com/api/webhooks/global/token", - ); - expect(resolveDiscordWebhook("o/b")).toBe( - "https://discord.com/api/webhooks/global/token", - ); - }); - it("returns null when nothing is configured", () => { - setEnv(undefined, undefined); - expect(resolveDiscordWebhook("o/a")).toBeNull(); - }); - it("ignores malformed JSON and uses the global", () => { - setEnv("{not json", "https://discord.com/api/webhooks/global/token"); - expect(resolveDiscordWebhook("o/a")).toBe( - "https://discord.com/api/webhooks/global/token", - ); - }); - it("ignores a non-object map value and uses the global", () => { - setEnv("123", "https://discord.com/api/webhooks/global/token"); - expect(resolveDiscordWebhook("o/a")).toBe( - "https://discord.com/api/webhooks/global/token", - ); - }); - it("ignores non-Discord repo webhooks and uses a valid global fallback", () => { - setEnv( - JSON.stringify({ "o/a": "http://127.0.0.1:9999/not-discord" }), - "https://discord.com/api/webhooks/global/token", - ); - expect(resolveDiscordWebhook("o/a")).toBe( - "https://discord.com/api/webhooks/global/token", - ); - }); - it("returns null for non-Discord global webhooks", () => { - setEnv(undefined, "http://127.0.0.1:9999/not-discord"); - expect(resolveDiscordWebhook("o/a")).toBeNull(); - }); - it("rejects HTTPS webhooks on non-Discord hosts", () => { - setEnv(undefined, "https://example.com/api/webhooks/a/token"); - expect(resolveDiscordWebhook("o/a")).toBeNull(); - }); - it("rejects malformed and non-webhook Discord URLs", () => { - setEnv( - JSON.stringify({ "o/a": "not a url" }), - "https://discord.com/channels/1", - ); - expect(resolveDiscordWebhook("o/a")).toBeNull(); - }); -}); - -describe("notifyDiscordReview", () => { - it("posts a rich embed (title repo#pr·outcome, reason, Outcome/PR/Submitter fields, footer) — closed → red", async () => { - setEnv( - JSON.stringify({ - "JSONbored/gittensory": "https://discord.com/api/webhooks/gt/token", - }), - undefined, - ); - let posted: { - url: string; - body: { - embeds: { - title: string; - url: string; - description: string; - color: number; - fields: { name: string; value: string; inline: boolean }[]; - footer: { text: string }; - }[]; - }; - } | null = null; - vi.stubGlobal("fetch", async (url: string, init: { body: string }) => { - posted = { url, body: JSON.parse(init.body) }; - return new Response(null, { status: 204 }); - }); - await notifyDiscordReview({ - repoFullName: "JSONbored/gittensory", - prNumber: 1171, - author: "jaso0n0818", - outcome: "closed", - reason: "An AI reviewer flagged a likely blocking defect", - url: "https://gh/JSONbored/gittensory/pull/1171", - }); - const e = posted!.body.embeds[0]!; - expect(posted!.url).toBe("https://discord.com/api/webhooks/gt/token"); - expect(e.title).toBe("JSONbored/gittensory#1171 · closed"); - expect(e.url).toBe("https://gh/JSONbored/gittensory/pull/1171"); - expect(e.description).toBe( - "An AI reviewer flagged a likely blocking defect", - ); - expect(e.color).toBe(0xe74c3c); // closed → red - expect(e.fields.map((f) => f.name)).toEqual(["Outcome", "PR", "Submitter"]); - expect(e.fields[0]!.value).toBe("`closed`"); - expect(e.fields[1]!.value).toBe("#1171"); - expect(e.fields[2]!.value).toBe("@jaso0n0818"); - expect(e.footer.text).toBe("Gittensory · JSONbored/gittensory"); - }); - it("no-ops (no fetch) when a configured webhook is not a Discord webhook", async () => { - setEnv(undefined, "http://127.0.0.1:9999/not-discord"); - const fetchSpy = vi.fn(async () => new Response(null, { status: 204 })); - vi.stubGlobal("fetch", fetchSpy); - await notifyDiscordReview({ - repoFullName: "o/a", - prNumber: 1, - author: "a", - outcome: "reviewed", - reason: "x", - url: "u", - }); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - it("no-ops (no fetch) when no webhook is configured", async () => { - setEnv(undefined, undefined); - const fetchSpy = vi.fn(async () => new Response(null, { status: 204 })); - vi.stubGlobal("fetch", fetchSpy); - await notifyDiscordReview({ - repoFullName: "o/a", - prNumber: 1, - author: "a", - outcome: "reviewed", - reason: "x", - url: "u", - }); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - it("swallows fetch errors (best-effort)", async () => { - setEnv(undefined, "https://discord.com/api/webhooks/global/token"); - vi.stubGlobal("fetch", async () => { - throw new Error("network down"); - }); - await expect( - notifyDiscordReview({ - repoFullName: "o/a", - prNumber: 1, - author: "a", - outcome: "hold", - reason: "x", - url: "u", - }), - ).resolves.toBeUndefined(); - }); - it("uses the default colour for an unknown outcome", async () => { - setEnv(undefined, "https://discord.com/api/webhooks/global/token"); - let color = -1; - vi.stubGlobal("fetch", async (_u: string, init: { body: string }) => { - color = JSON.parse(init.body).embeds[0].color; - return new Response(null, { status: 204 }); - }); - await notifyDiscordReview({ - repoFullName: "o/a", - prNumber: 1, - author: "a", - outcome: "reviewed", - reason: "x", - url: "u", - }); - expect(color).toBe(0x5865f2); - }); -}); diff --git a/test/unit/selfhost-env-reference-script.test.ts b/test/unit/selfhost-env-reference-script.test.ts index f8fc8e37bb..725a906276 100644 --- a/test/unit/selfhost-env-reference-script.test.ts +++ b/test/unit/selfhost-env-reference-script.test.ts @@ -45,6 +45,15 @@ function fixtureRoot(): string { ); mkdirSync(join(root, "scripts"), { recursive: true }); writeFileSync(join(root, "scripts", "selfhost-smoke.mjs"), "const scriptOnly = process.env.SCRIPT_ONLY;\n"); + mkdirSync(join(root, "src", "services"), { recursive: true }); + writeFileSync( + join(root, "src", "services", "notify-discord.ts"), + [ + "const serviceOnly = process.env.SERVICE_ONLY;", + "const helperOnly = envString(env, 'SERVICE_HELPER_ONLY');", + "", + ].join("\n"), + ); return root; } @@ -64,6 +73,8 @@ describe("gen-selfhost-env-reference (#2081)", () => { { name: "OBJECT_DESTRUCTURED", firstReference: "src/selfhost/a.ts:9" }, { name: "SECOND", firstReference: "src/selfhost/a.ts:2" }, { name: "SERVER_ONLY", firstReference: "src/server.ts:1" }, + { name: "SERVICE_HELPER_ONLY", firstReference: "src/services/notify-discord.ts:2" }, + { name: "SERVICE_ONLY", firstReference: "src/services/notify-discord.ts:1" }, ]); });