Skip to content

feat(notifications): per-repo Slack webhook routing to match Discord/PagerDuty (notify-discord.ts, review-recap.ts) #8371

Description

@JSONbored

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

  1. 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).
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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

  • resolveSlackWebhook(env, repoFullName) exported from src/services/notify-discord.ts, mirroring
    resolveDiscordWebhook's exact precedence and return shape (SLACK_REPO_WEBHOOKS JSON map → global
    SLACK_WEBHOOK_URL → disabled).
  • notifyActionToSlack uses resolveSlackWebhook for per-repo routing.
  • review-recap.ts's deliverRecapToSlack uses resolveSlackWebhook for per-repo routing.
  • .env.example / .env.selfhost.example document the new SLACK_REPO_WEBHOOKS var next to the
    existing SLACK_WEBHOOK_URL and DISCORD_REPO_WEBHOOKS entries (matching whichever of those two
    files already documents DISCORD_REPO_WEBHOOKS).

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.tsresolveDiscordWebhook (the pattern to mirror), notifyActionToSlack
    (to update), notifyActionToDiscord (the Discord sibling already doing this correctly).
  • src/services/review-recap.tssendReviewRecapToDiscord (the Discord sibling already doing this
    correctly), deliverRecapToSlack (to update).
  • src/services/notify-pagerduty.tsresolvePagerDutyRoutingKey (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:featureGittensor-scored feature linked to a feature issue — scores a 0.25x multiplier.help wantedExtra attention is needed

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions