Context
src/services/notify-discord.ts and src/services/notify-pagerduty.ts both implement per-repo
channel/routing resolution with the same precedence: a per-repo JSON-map override (DISCORD_REPO_WEBHOOKS
/ PAGERDUTY_REPO_ROUTING_KEYS) wins, else a single global fallback (DISCORD_WEBHOOK_URL /
PAGERDUTY_ROUTING_KEY), else disabled. Discord additionally supports a legacy per-repo secret map
(WEBHOOK_SECRET_BY_REPO) for the three first-party repos. This lets each managed repo (jsonbored/loopover,
jsonbored/metagraphed, jsonbored/awesome-claude, or any self-hosted repo added via the JSON map) post its
own terminal-action notifications and recap digests into its own Discord channel / its own PagerDuty
routing key, never mixed with another repo's.
Slack has no equivalent. Every Slack sender reads a single global SLACK_WEBHOOK_URL directly, with no
per-repo resolution at all:
notifyActionToSlack in src/services/notify-discord.ts (around line 267) reads
(env as unknown as Record<string, unknown>).SLACK_WEBHOOK_URL directly — no per-repo lookup.
deliverRecapToSlack in src/services/review-recap.ts (around line 209) does the same for the
per-repo review recap (ReviewRecap, one call per recap.repoFullName) — its Discord sibling,
sendReviewRecapToDiscord (same file, around line 142), correctly calls resolveDiscordWebhook(env, recap.repoFullName) for proper per-repo routing, but the Slack path ignores recap.repoFullName
entirely and always posts to the one global channel.
Note: deliverRecapToSlack in src/services/notify-discord.ts (the multi-repo maintainer digest,
RecapReport) is correctly global-only by design — a cross-repo digest has no single repo to route by, and
its own doc comment says so explicitly. That function is NOT in scope here; only the two per-repo Slack
senders above (notifyActionToSlack and review-recap.ts's deliverRecapToSlack) are missing the routing
their Discord siblings already have.
Any self-host operator running LoopOver across more than one repo and using Slack (instead of Discord) for
per-action/per-repo notifications currently gets every repo's terminal-action and review-recap messages
mixed into one channel, with no way to separate them — exactly the problem resolveDiscordWebhook and
resolvePagerDutyRoutingKey already solve for their channels.
Requirements
- Add a new
resolveSlackWebhook(env: Env, repoFullName: string): SlackWebhookResolution function in
src/services/notify-discord.ts, mirroring resolveDiscordWebhook's exact shape and precedence:
- A new
SLACK_REPO_WEBHOOKS env var: a JSON map ({repoFullName: webhookUrl}, keys lower-cased on
read, same as repoWebhookMap/repoJsonMap's existing pattern) checked first.
- Falls back to the existing global
SLACK_WEBHOOK_URL when the repo has no map entry.
- Returns a discriminated union exactly like
DiscordWebhookResolution:
{ status: "configured"; url: string; source: "repo_map" | "global" } | { status: "disabled"; reason: "missing_repo_webhook" | "invalid_repo_webhook" | "missing_global_webhook" | "invalid_global_webhook" }.
- Every resolved URL must still pass the existing
isValidSlackWebhook check
(https://hooks.slack.com/services/...) — do not weaken that validation.
- Do NOT add a legacy per-repo secret map like Discord's
WEBHOOK_SECRET_BY_REPO — that mirrors
Discord's own historical migration path and has no Slack equivalent to preserve. Two-source precedence
only (repo JSON map, then global).
- Update
notifyActionToSlack (src/services/notify-discord.ts) to call resolveSlackWebhook(env, params.repoFullName) instead of reading SLACK_WEBHOOK_URL directly, and audit the disabled reason
the same way notifyActionToDiscord already does for Discord (denied audit event with the resolution's
reason).
- Update
deliverRecapToSlack in src/services/review-recap.ts to call resolveSlackWebhook(env, recap.repoFullName) the same way its Discord sibling sendReviewRecapToDiscord already calls
resolveDiscordWebhook(env, recap.repoFullName) in that file — same audit-event shape, same
denied/completed/error outcomes.
- Export
resolveSlackWebhook from src/services/notify-discord.ts (same visibility as
resolveDiscordWebhook) so review-recap.ts can import it, matching how review-recap.ts already
imports resolveDiscordWebhook, isValidSlackWebhook, and escapeSlackMrkdwnText from that file.
- Do NOT touch
deliverRecapToSlack in src/services/notify-discord.ts (the multi-repo maintainer-recap
sender) — that one is correctly global-only by design (see Context) and is out of scope.
- Do NOT touch
src/services/notify-pagerduty.ts — it already has correct per-repo routing and is only
referenced here as the existing pattern to mirror.
Deliverables
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on every changed line in src/** (only src/** is measured).
At minimum:
resolveSlackWebhook: repo-map hit (valid + invalid URL), no repo-map entry falling through to global
(valid + invalid + unset), and an empty/malformed SLACK_REPO_WEBHOOKS JSON value degrading to {}
(mirror repoWebhookMap's existing malformed-JSON test).
notifyActionToSlack: a repo with a configured per-repo entry posts to that repo's URL, not the global
one; a repo with no entry falls back to global; a repo with an invalid per-repo entry is denied (not
silently falling back to global — matching resolveDiscordWebhook's equivalent invalid_repo_webhook
behavior).
review-recap.ts's deliverRecapToSlack: same per-repo-vs-global assertions, using two different
recap.repoFullName values to prove they route independently (the regression this issue exists to add).
- Existing tests in
test/unit/notify-discord.test.ts and test/unit/review-recap.test.ts that assert
global-only Slack behavior must be updated (not deleted) to reflect the new per-repo-aware behavior while
keeping the global-fallback case covered.
Expected Outcome
A self-host operator managing more than one repo can point each repo's Slack notifications (both terminal
per-action messages and per-repo review recaps) at its own Slack channel via SLACK_REPO_WEBHOOKS, exactly
as they already can for Discord and PagerDuty — instead of every repo's Slack messages landing in one shared
channel with no way to tell them apart.
Links & Resources
src/services/notify-discord.ts — resolveDiscordWebhook (the pattern to mirror), notifyActionToSlack
(to update), notifyActionToDiscord (the Discord sibling already doing this correctly).
src/services/review-recap.ts — sendReviewRecapToDiscord (the Discord sibling already doing this
correctly), deliverRecapToSlack (to update).
src/services/notify-pagerduty.ts — resolvePagerDutyRoutingKey (a second existing precedent for the
same repo-map-then-global precedence).
test/unit/notify-discord.test.ts, test/unit/review-recap.test.ts — existing coverage to extend.
Context
src/services/notify-discord.tsandsrc/services/notify-pagerduty.tsboth implement per-repochannel/routing resolution with the same precedence: a per-repo JSON-map override (
DISCORD_REPO_WEBHOOKS/
PAGERDUTY_REPO_ROUTING_KEYS) wins, else a single global fallback (DISCORD_WEBHOOK_URL/PAGERDUTY_ROUTING_KEY), else disabled. Discord additionally supports a legacy per-repo secret map(
WEBHOOK_SECRET_BY_REPO) for the three first-party repos. This lets each managed repo (jsonbored/loopover,jsonbored/metagraphed,jsonbored/awesome-claude, or any self-hosted repo added via the JSON map) post itsown terminal-action notifications and recap digests into its own Discord channel / its own PagerDuty
routing key, never mixed with another repo's.
Slack has no equivalent. Every Slack sender reads a single global
SLACK_WEBHOOK_URLdirectly, with noper-repo resolution at all:
notifyActionToSlackinsrc/services/notify-discord.ts(around line 267) reads(env as unknown as Record<string, unknown>).SLACK_WEBHOOK_URLdirectly — no per-repo lookup.deliverRecapToSlackinsrc/services/review-recap.ts(around line 209) does the same for theper-repo review recap (
ReviewRecap, one call perrecap.repoFullName) — its Discord sibling,sendReviewRecapToDiscord(same file, around line 142), correctly callsresolveDiscordWebhook(env, recap.repoFullName)for proper per-repo routing, but the Slack path ignoresrecap.repoFullNameentirely and always posts to the one global channel.
Note:
deliverRecapToSlackinsrc/services/notify-discord.ts(the multi-repo maintainer digest,RecapReport) is correctly global-only by design — a cross-repo digest has no single repo to route by, andits own doc comment says so explicitly. That function is NOT in scope here; only the two per-repo Slack
senders above (
notifyActionToSlackandreview-recap.ts'sdeliverRecapToSlack) are missing the routingtheir Discord siblings already have.
Any self-host operator running LoopOver across more than one repo and using Slack (instead of Discord) for
per-action/per-repo notifications currently gets every repo's terminal-action and review-recap messages
mixed into one channel, with no way to separate them — exactly the problem
resolveDiscordWebhookandresolvePagerDutyRoutingKeyalready solve for their channels.Requirements
resolveSlackWebhook(env: Env, repoFullName: string): SlackWebhookResolutionfunction insrc/services/notify-discord.ts, mirroringresolveDiscordWebhook's exact shape and precedence:SLACK_REPO_WEBHOOKSenv var: a JSON map ({repoFullName: webhookUrl}, keys lower-cased onread, same as
repoWebhookMap/repoJsonMap's existing pattern) checked first.SLACK_WEBHOOK_URLwhen the repo has no map entry.DiscordWebhookResolution:{ status: "configured"; url: string; source: "repo_map" | "global" } | { status: "disabled"; reason: "missing_repo_webhook" | "invalid_repo_webhook" | "missing_global_webhook" | "invalid_global_webhook" }.isValidSlackWebhookcheck(
https://hooks.slack.com/services/...) — do not weaken that validation.WEBHOOK_SECRET_BY_REPO— that mirrorsDiscord's own historical migration path and has no Slack equivalent to preserve. Two-source precedence
only (repo JSON map, then global).
notifyActionToSlack(src/services/notify-discord.ts) to callresolveSlackWebhook(env, params.repoFullName)instead of readingSLACK_WEBHOOK_URLdirectly, and audit thedisabledreasonthe same way
notifyActionToDiscordalready does for Discord (denied audit event with the resolution'sreason).deliverRecapToSlackinsrc/services/review-recap.tsto callresolveSlackWebhook(env, recap.repoFullName)the same way its Discord siblingsendReviewRecapToDiscordalready callsresolveDiscordWebhook(env, recap.repoFullName)in that file — same audit-event shape, samedenied/completed/erroroutcomes.resolveSlackWebhookfromsrc/services/notify-discord.ts(same visibility asresolveDiscordWebhook) soreview-recap.tscan import it, matching howreview-recap.tsalreadyimports
resolveDiscordWebhook,isValidSlackWebhook, andescapeSlackMrkdwnTextfrom that file.deliverRecapToSlackinsrc/services/notify-discord.ts(the multi-repo maintainer-recapsender) — that one is correctly global-only by design (see Context) and is out of scope.
src/services/notify-pagerduty.ts— it already has correct per-repo routing and is onlyreferenced here as the existing pattern to mirror.
Deliverables
resolveSlackWebhook(env, repoFullName)exported fromsrc/services/notify-discord.ts, mirroringresolveDiscordWebhook's exact precedence and return shape (SLACK_REPO_WEBHOOKSJSON map → globalSLACK_WEBHOOK_URL→ disabled).notifyActionToSlackusesresolveSlackWebhookfor per-repo routing.review-recap.ts'sdeliverRecapToSlackusesresolveSlackWebhookfor per-repo routing..env.example/.env.selfhost.exampledocument the newSLACK_REPO_WEBHOOKSvar next to theexisting
SLACK_WEBHOOK_URLandDISCORD_REPO_WEBHOOKSentries (matching whichever of those twofiles already documents
DISCORD_REPO_WEBHOOKS).Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on every changed line in
src/**(onlysrc/**is measured).At minimum:
resolveSlackWebhook: repo-map hit (valid + invalid URL), no repo-map entry falling through to global(valid + invalid + unset), and an empty/malformed
SLACK_REPO_WEBHOOKSJSON value degrading to{}(mirror
repoWebhookMap's existing malformed-JSON test).notifyActionToSlack: a repo with a configured per-repo entry posts to that repo's URL, not the globalone; a repo with no entry falls back to global; a repo with an invalid per-repo entry is denied (not
silently falling back to global — matching
resolveDiscordWebhook's equivalentinvalid_repo_webhookbehavior).
review-recap.ts'sdeliverRecapToSlack: same per-repo-vs-global assertions, using two differentrecap.repoFullNamevalues to prove they route independently (the regression this issue exists to add).test/unit/notify-discord.test.tsandtest/unit/review-recap.test.tsthat assertglobal-only Slack behavior must be updated (not deleted) to reflect the new per-repo-aware behavior while
keeping the global-fallback case covered.
Expected Outcome
A self-host operator managing more than one repo can point each repo's Slack notifications (both terminal
per-action messages and per-repo review recaps) at its own Slack channel via
SLACK_REPO_WEBHOOKS, exactlyas they already can for Discord and PagerDuty — instead of every repo's Slack messages landing in one shared
channel with no way to tell them apart.
Links & Resources
src/services/notify-discord.ts—resolveDiscordWebhook(the pattern to mirror),notifyActionToSlack(to update),
notifyActionToDiscord(the Discord sibling already doing this correctly).src/services/review-recap.ts—sendReviewRecapToDiscord(the Discord sibling already doing thiscorrectly),
deliverRecapToSlack(to update).src/services/notify-pagerduty.ts—resolvePagerDutyRoutingKey(a second existing precedent for thesame repo-map-then-global precedence).
test/unit/notify-discord.test.ts,test/unit/review-recap.test.ts— existing coverage to extend.