feat(slack): mirror GitHub + Discord tickets into one internal Slack channel - #150
feat(slack): mirror GitHub + Discord tickets into one internal Slack channel#150NathanTarbert wants to merge 6 commits into
Conversation
…channel Every ticket opens a Slack thread; follow-ups and the AI's reply post underneath it, so one thread is the whole life of one ticket. Read-only in v1 — replying in Slack does not post back to the source. Thread identity reuses TicketExternalLink (plugin `slack`, externalId `channelId:ts`), the same table the Linear and GitHub links use, so the unique(ticketId, plugin) constraint is what prevents a ticket from ever opening two threads. Ships inert. SLACK_MIRROR_MODE defaults to `off` and an unrecognized value fails closed; with no SLACK_MIRROR_CHANNEL_ID the producers never enqueue. The flag is deliberately independent of SHADOW_MODE: that flag protects community surfaces where real reporters are watching, while this targets an internal team channel, so staging posting here is intended rather than a violation of the standing shadow-mode rule. Two correctness details worth calling out: - An AI reply is labelled with whether it actually reached the reporter. Shadow mode, a failed post, and a suppressed (ungrounded) draft all leave an AI Message row that nobody outside saw; mirroring those as if they were delivered would reproduce the divergence #148 describes. A suppressed run counts as undelivered even though a post succeeded, because what went out was the safe replacement copy, not the draft the mirror renders. - Slack-sourced tickets are never mirrored. If the mirror channel were also monitored, each mirror post would arrive as inbound, open a ticket, mirror again, and loop. Mirror failures never touch the reporter's path: enqueue errors are logged and swallowed in both producers.
CR loop — Round 1 findings (12 reviewers, standard mode)Partition: 20 (a) mandatory · 10 (b) ledger-only · 5 (c) pre-existing · 1 (d) other-PR subject. Ledger: Fixes are not yet applied — see the note at the bottom. (a) Code — mandatory
A2/A3/A5/A6 are one root cause — the link row is not treated as the idempotent source of thread identity — so they are being fixed as a single structural change, not four patches. (a) Docs — claims this diff makes that the code contradicts
(a) Tests — defects in the tests added by this PR
(d) Not this PR — but worth a look now
StatusReview round is complete and verified. The fix cycle has not landed: all six fix agents died mid-work (2 stalls, 4 "connection closed mid-response"), leaving the PR branch untouched at |
…dentity The mirror read the link, posted to Slack, then created the link. Two jobs for one ticket (the ticket job and the reply job, at SLACK_MIRROR concurrency 2) could both read "no link", both post a root message, and the loser of @@unique([ticketId, plugin]) died on P2002 — leaving a duplicate Slack thread and a dead-lettered job. Any retry after a successful post did the same. Four related defects, one cause: the link row was not treated as the identity of the thread. - parseThreadTs -> parseThreadRef, returning {channelId, ts} with both halves required. Replies now post to the channel recorded in the link instead of whatever SLACK_MIRROR_CHANNEL_ID currently says, so changing the configured channel no longer detaches every existing ticket's replies. The channel half was being written and never read. - The create is wrapped in a P2002 catch (duck-typed on err.code, so the Prisma runtime stays out of this handler) that re-reads the row and threads under the winner's ts rather than opening a rival thread. - A row whose externalId cannot be parsed is repaired in place via update. It previously re-entered the open-thread path and hit the unique constraint on every attempt, forever. The already-mirrored short-circuit now requires a PARSEABLE link, so a malformed row reaches repair exactly once. - SLACK_MIRROR concurrency 2 -> 1, plus an explicit 60s jobTimeouts entry (a reply can make two postMessage calls through WebClient's rate-limit sleeps, and the 30s default could cut that off mid-flight and duplicate work). Call-site enumeration: - parseThreadTs: removed; sole caller was handleSlackMirror's thread lookup, now calling parseThreadRef. Zero remaining references (grep). - parseThreadRef: new; called only from handleSlackMirror. - SLACK_MIRROR_PLUGIN: unchanged; readers are this handler and the tests. - handleSlackMirror signature unchanged; worker registration at apps/worker/src/index.ts:90 still holds. - JobType.SLACK_MIRROR concurrency/timeout maps: read only by Worker's scheduler in packages/outpost/queue/src/worker.ts; both keys are optional and additive. Not fixed here, still open: message-level reply dedup. A duplicate reply job can still re-post the same message; it can no longer spawn a rival thread. That needs a per-message marker and is tracked separately. Tests: 4 new cases in the handler suite (P2002 recovery, malformed-link repair, reply routes to the link's channel when config differs, duplicate reply opens no thread). Red-green verified — 3 of the 4 fail before this change. Handler suite 20/20, packages/outpost 958 tests, typecheck clean across 10 packages.
Round 1 of the CR loop returned 20 mandatory findings across 12 reviewers. This lands the rest of them (the link-idempotency lever went in as b58ff89). Code - Both producers now route through one allowlist. `isMirrorableSource` is the single place deciding what gets mirrored, and it is an ALLOWLIST (Discord, GitHub issues, GitHub discussions) rather than "anything but SLACK". The rule previously existed only in the inbound producer, so the AI-reply producer mirrored everything — a Slack-sourced ticket's AI reply opened a thread in the mirror channel, and the denylist silently pulled in TEAMS/EMAIL/WEB/MANUAL/ LINEAR tickets the feature was never specified for. - `live` with no SLACK_BOT_TOKEN now reads as DISABLED and logs why once, per process. It used to read as enabled, so every job reached buildPoster, threw, and burned five attempts into the dead-letter queue — one per ticket, forever. - Permanent Slack errors are classified and not retried: not_in_channel, channel_not_found, channel_is_archived, invalid_auth, account_inactive, missing_scope. Each failure message names the remedy. - Reply jobs are validated BEFORE anything is posted. A reply with no messageId used to reach the thread-opening post first, so every retry posted another root message to Slack; same for a messageId naming a row that no longer exists. - The undelivered label states the reason the producer recorded instead of guessing. `delivered: boolean` became `delivery: 'delivered' | 'shadow' | 'withheld' | 'post-failed' | 'no-adapter'`, set at the branch that knows. "withheld or shadow mode" was being printed for five distinct causes, which asserted a cause nobody established — the misreporting this label exists to prevent. An AI reply with no delivery status renders "unconfirmed", never delivered: unknown is not the same as fine. - `source` is now declared on SlackMirrorPayload and sent by both producers. It was riding CreateJobFn's index signature undeclared, which is how the two producers drifted into different payload shapes. - Mirror-enqueue failures log the error class and stack, so schema drift is not swallowed as "a queue hiccup". Queue (additive) - JobResult gains an optional `retryable`. A handler that sets it false is dead-lettered immediately instead of consuming every attempt. Omitting it — which every pre-existing handler does — preserves the old behavior exactly; both directions are covered by tests. Docs — each of these was a claim the diff made that the code contradicted - The loop rationale was wrong. `.env.example` and docs/deployment.md justified keeping the mirror channel unmonitored by saying the bot would read its own mirror posts and open a ticket per post. It cannot: the Slack bot drops events carrying bot_id (apps/slack-bot/src/events/message.ts). The advice stands as defense-in-depth and against duplicated context; the false mechanism is gone. - Scope is stated by naming isMirrorableSource rather than listing sources that drift. - The absolute "check SHADOW_MODE on any new outbound path" rule now carries the mirror's exception where the RULE is stated, with a gating table. - Documented that shadow needs no token, that any mode is inert without a channel ID, and — the one that would have made this ship dead — that the PRODUCERS gate on the same config, so the vars are needed on outpost-discord-bot and outpost-github-app as well as outpost-worker, not the worker alone. Tests (the reviewers found real defects in the ones this PR added) - The mirror suite was nested inside describe('restoreShadowMode') instead of describe('handleAiResponse'), inheriting an unrelated afterEach and duplicating setup. Relocated; the duplicated setup is gone. - `delivered: true` passed only on leftover mockPostResponse state from an earlier suite. It now re-stubs and asserts the post happened. - The suppressed-undelivered case never asserted a post occurred, so it survived the exact regression it targets. It asserts it now. - `toContain('missing')` matched the ticket id, not the field — it asserted nothing. Now matches the field name. - The enum test listed five members and omitted SLACK_MIRROR; only the count had been bumped. - The inbound suite pinned an env-dependent default; the env-derived fallback is now covered explicitly, including that it is OFF when unset. Call-site enumeration - isSlackMirrorEnabled: callers inbound.ts:139, ai-response.ts:296, slack-mirror.ts:124 — all three still hold; the added token requirement only narrows when it returns true. - isMirrorableSource: new; called from inbound.ts:161 and ai-response.ts:296. - CreateJobFn: `source` stays REQUIRED. Narrowing it broke assignability for every bot wrapper (discord-bot/src/events/{message-create,thread-create}.ts declare it required) — verified by typecheck, reverted, field declared on the payload instead. - JobResult.retryable: read only in worker.ts's failure dispatch; optional, so the ten existing handlers are unaffected. - SlackMirrorPayload.delivered -> delivery: producers ai-response.ts:301 and the handler's formatReplyPost were the only readers; zero remaining references to `delivered` (grep). Not fixed, still open: message-level reply dedup (a duplicate reply job re-posts the same message; it cannot spawn a rival thread). Needs a per-message marker. Verified: typecheck 10/10, build 10/10, 1,765 tests pass. Red-green confirmed on the worker's permanent-failure path. Prettier clean on every file whose baseline was clean; worker.ts was already prettier-dirty on main and was left unformatted to avoid unrelated churn.
Confirmation round (8 reviewers) returned a new bucket (a). Several items were defects in the round-1 fixes themselves, which is what the round is for. Introduced by the previous commit, now fixed - The token requirement in `isSlackMirrorEnabled` made the PRODUCERS treat a tokenless `live` as disabled. Since the producers run in the bots and only enqueue, that left the mirror silently dead while the docs said the token belonged on the worker. Split the predicate: `isSlackMirrorEnabled` (mode + channel) gates the producers, `canSlackMirrorPost` additionally requires a token and gates the consumer, which reports a permanent failure naming the variable. The bot token no longer has to be spread to services that never post. - `retryable: false` dead-lettered by presenting the attempt as `maxAttempts`, writing a fabricated attempt count — an exhausted-retry trail for a job that ran once. `handleFailure` now takes an explicit `permanent` flag and records the true count. - The mirror payload forwarded the AI job's optional `source` hint, so it could record `source: undefined` while the inbound producer sent a resolved value. Sends the resolved source now, and the type's comment no longer overclaims. Also fixed - An unrecognized `delivery` value indexed to `undefined` and rendered the literal string "undefined" into Slack. Unknown values fall back to "unconfirmed". - An unknown `kind` fell through both branches: it posted a root message and returned success. Rejected up front as permanent. - "Accepted but no ts" was retryable even though the post had landed, so every retry posted another root message. Now permanent, and says the message was posted but cannot be tracked. - Ticket bodies and replies were interpolated into Slack mrkdwn unescaped, so a reporter on a public tracker could inject links and mentions into an internal channel. Escapes &, <, > per Slack's formatting rules. - Slack error classification preferred a substring scan that was order-dependent; now prefers the API's own code with a word-boundary fallback, and covers token_expired, token_revoked, not_authed, msg_too_long. - An unrecognized SLACK_MIRROR_MODE failed closed silently, indistinguishable from a deliberate `off`. It logs now. - WebClient's default retry policy can sleep for minutes, outliving the 60s job timeout and leaving a post in flight after the worker gave up. Capped to 2 retries with a 2s ceiling. - Corrected comments that overclaimed: the idempotency guarantee covers THREAD identity only (a duplicate reply still re-posts), the text cap is a readability budget rather than a Slack hard limit, and the shadow log now says shadow does not persist thread identity so repeated opens are expected. Tests - Ambient-environment sensitivity, reproduced by a reviewer: 3 tests in the inbound suite failed with SLACK_MIRROR_MODE exported, and 7 in the AI suite failed with SHADOW_MODE=true inherited. Both suites now neutralize and restore the ambient values. - The permanent-failure test now pins `attempts: 1` — the assertion whose absence hid the fabricated count. - New coverage: no-ts branch, unknown kind, live-without-token, mrkdwn escaping, unrecognized delivery, the split producer/consumer predicates, empty channel id. - Fixture corrections: `source: 'GITHUB'` was not a TicketSource value, and the mirror fixture encoded live-with-null-token, a config production rejects. Call-site enumeration - isSlackMirrorEnabled: inbound.ts:139, ai-response.ts:296, slack-mirror.ts:124 — all three still hold; the predicate only widened (token no longer required). - canSlackMirrorPost: new; sole caller slack-mirror.ts, after the enabled check. - resetSlackMirrorWarnings: removed with the warn latch it served; zero remaining references (grep), test import updated. - handleFailure: sole caller is worker.ts's failure dispatch, both arms updated; the new parameter defaults to false so the throw path is unchanged. Still open, tracked: message-level reply dedup (needs a per-message marker); truncation splits surrogate pairs; a new WebClient per job. Verified: typecheck 10/10, 1,774 tests pass, prettier clean on files whose baseline was clean.
Six conflicts. Four were unions where main and this branch each added a job
type — `PENDING_RESPONSE_SWEEP` and `SLACK_MIRROR` — so the enum, payload map,
queue barrel and worker registration take both. Two needed real work.
`handlers/ai-response.ts`: main restructured the delivery and escalation flow
(`responseDelivered`, `deliveryFailure`, `nonDeliveryEscalationReason`,
`requiredEscalationRecorded`), which moved every place the mirror's `delivery`
label was being set. Took main's structure wholesale and re-threaded the label
into it: `delivered`/`withheld` where `responseDelivered` is set after a
successful post, `post-failed` in the post catch alongside `deliveryFailure`,
`shadow` and `no-adapter` unchanged on their own paths.
Worth flagging because a marker-only resolution would have shipped it: git left
`delivery = 'post-failed'` sitting inside main's *escalation-recording* catch,
since both sides happened to have a `} catch (error) {` at that point. That
catch fires when the escalation write fails, which has nothing to do with a post
failing — the mirror would have reported a delivered answer as post-failed.
Removed.
`__tests__/ai-response.test.ts`: both sides appended a whole test suite at the
same place and git interleaved them, so deleting markers would have spliced one
feature's suite into the other's. Rebuilt from the three merge stages instead —
main's 2630-line version plus this branch's four additions (the vitest import
and its ambient-SHADOW_MODE guard, the mutable mirror config, three predicates
on the platforms mock, and the 114-line mirror suite verbatim).
`JobType` count assertion moved 11 → 12, and now names both recently added types
so a future failure says which one is missing rather than just that the number
moved.
63 files / 1145 tests in packages/outpost; discord-bot, worker, slack-bot and
github-app suites all green.
CPK-7936 Rebase PR #150 — Slack ticket mirror (conflicting; must follow PR #191)
PR #150 — Why it must follow #191It shares 8 files with #191 and 6 with #187 — including There is a real design interaction, not just a textual conflict: #150 mirrors AI replies with a delivery reason ( Scope remindersShips inert behind Follow-ups already split out as outpost#152 — a duplicate reply job still re-posts its message, UNBLOCKED 2026-08-20 — #191 merged, so the dependency this ticket recorded is gone. Moved out of Blocked. Current state: #150 is still a draft and still |
|
Merged Six conflicts. Four were straightforward, two were not, and one of those would have shipped a real bug if I'd resolved it by deleting markers. The four unionsMain added
|
| State | Where it lands now |
|---|---|
delivered / withheld |
alongside responseDelivered = true, after a successful post |
post-failed |
in the postResponse catch, next to deliveryFailure = message |
shadow |
unchanged |
no-adapter |
unchanged (still the declared default, which also covers adapter-construction failure) |
The part worth flagging. Because both sides happened to have a } catch (error) { at the same point, git left delivery = 'post-failed' sitting inside main's escalation-recording catch — the one that fires when the escalationRequiredReason write fails. That has nothing to do with a post failing, so a successfully delivered answer whose escalation write failed would have been mirrored as post-failed. Which is precisely the class of misreporting your SlackMirrorDelivery type exists to prevent — the type comment says the reason travels with the payload "instead of being inferred", and this would have inferred wrong. Removed.
__tests__/ai-response.test.ts — rebuilt from the merge stages
Both sides appended a whole test suite at the same location and git interleaved them, so deleting markers would have spliced main's "answers the opening message" suite into the middle of your mirror suite. Your side's first hunk ended mid-object literal, with its continuation stranded in a common region.
So I rebuilt it: main's 2630-line version, plus your four additions applied on top — the vitest import and its ambient-SHADOW_MODE guard, the mutable mockMirrorConfig, the three mirror predicates on the platforms mock, and your 114-line mirror suite lifted in verbatim (inserted at the close of describe('handleAiResponse'), where it was). Nothing of yours was reworded.
Result: 104 tests in that file, all passing, including all five delivery-label cases (delivered, withheld, post-failed, no-adapter, mirror-off).
One count assertion
JobType "has exactly 11 job types" → 12, since both sides added one. It now also names both recently-added types, so the next failure says which is missing instead of just that the number moved.
Verified
packages/outpost 63 files / 1145 tests. apps/discord-bot 8/8, apps/worker 1/1, apps/slack-bot 7/7, apps/github-app 6/6 — all green.
Ready for review. I haven't approved it: the delivery-label re-threading is a judgment call about your feature's semantics, and you should confirm the four states land where you intended — particularly that adapter-construction failure is meant to read as no-adapter rather than getting its own label.
#250's format check covers the files a PR touches, and these four were already unformatted on main. Cosmetic only — re-export lists collapsed or wrapped, long calls broken across lines. Verified: 1145 tests passing in packages/outpost, unchanged.
Builds the Slack ticket mirror described on the roadmap. Every ticket from GitHub and Discord opens a thread in one internal Slack channel; community follow-ups and the AI's reply post underneath it, so a single Slack thread is the whole life of one ticket.
Read-only in v1 — replying inside Slack does not post back to the source.
Ships inert
SLACK_MIRROR_MODEdefaults tooff, and an unrecognized value fails closed rather than posting. With noSLACK_MIRROR_CHANNEL_IDthe producers never enqueue, so merging this changes nothing until the Slack app is configured.shadowlogs exactly what a live run would post, and needs no token.The flag is deliberately independent of
SHADOW_MODE. That flag protects community surfaces where real reporters are watching; the mirror targets an internal team channel, so staging posting here is intended rather than a violation of the standing shadow-mode rule. Called out indocs/deployment.mdso it does not read as one.How it works
packages/outpost/queue/src/types.ts(SLACK_MIRROR)packages/outpost/shared/src/platforms/slack-mirror-config.tspackages/outpost/queue/src/handlers/slack-mirror.tspackages/outpost/shared/src/platforms/inbound.tspackages/outpost/queue/src/handlers/ai-response.tsapps/worker/src/index.tsThread identity reuses
TicketExternalLink(pluginslack, externalIdchannelId:ts) — the same table the Linear and GitHub links use. Theunique(ticketId, plugin)constraint is what stops a ticket from ever opening two threads. Areplythat finds no thread opens one first, so enabling the mirror partway through a live conversation does not drop messages.Two correctness details
An AI reply is labelled with whether it actually reached the reporter. Shadow mode, a failed post, and a suppressed (ungrounded) draft each leave an AI
Messagerow that nobody outside ever saw. Mirroring those as if delivered would reproduce exactly the divergence #148 describes between what the DB records and what was published. A suppressed run counts as undelivered even though a post succeeded, because what went out was the pipeline's safe replacement copy, not the draft the mirror renders.Slack-sourced tickets are never mirrored. If the mirror channel were also monitored, each mirror post would arrive as an inbound message, open a ticket, mirror that, and loop. The guard is in the producer;
docs/deployment.mdalso says to keep the mirror channel out ofMONITORED_CHANNEL_IDS, which is the durable fix.Mirror failures never touch the reporter's path — enqueue errors are logged and swallowed in both producers.
Verification
pnpm build,pnpm typecheck— clean across all 10 packagespnpm test— 1,729 passing, 0 failingNot verified: no live Slack workspace was exercised.
chat.postMessageis behind the injectableSlackPosterseam and is faked in tests, so the real API call is the one thing still unproven — worth ashadowrun before flipping tolive.Follow-ups not in scope
Replying from Slack back to the source, per-source channel routing, and backfilling threads for tickets that predate the mirror.