feat: one-way Discord to Linear bridge for #help threads - #61
Draft
phorcys420 wants to merge 51 commits into
Draft
feat: one-way Discord to Linear bridge for #help threads#61phorcys420 wants to merge 51 commits into
phorcys420 wants to merge 51 commits into
Conversation
Mirror #help forum threads into Linear: new thread -> issue, new message -> comment, status change -> workflow state + team-scoped grouped labels. Introduce an internal typed domain event bus (src/lib/bus.ts) so the help flow emits enriched events (helpThreadCreated, helpMessagePosted, helpThreadStatusChanged) and the bridge subscribes instead of re-deriving help-post state from raw Discord events. Bridge lives under src/bridge/linear/ (subfolder for future bridges) and uses @linear/sdk. Thread<->issue mapping is stateless via the thread-URL attachment, which also carries thread id + tags in metadata and doubles as the issue link. Labels go in a team-scoped 'Discord (#help)' group with the Discord tag id stored in each label description. Toggle via config.linearBridge.enabled (default off); teamId/apiKey are required only when enabled. Adds a scripts/discord-linear-sync.ts backfill.
De-duplicate the attachment field literals behind an attachmentFields helper, skip the pointless attachmentsForURL lookup when attaching to a freshly created issue, drop a needless cast in label sync, and shorten verbose comments.
Drop src/lib/bus.ts and the enriched-context helpers. The Linear bridge now registers its own Discord listeners (ThreadCreate, MessageCreate, debounced ThreadUpdate) and reads thread state via new HelpThread(thread), a wrapper whose getters derive status/waiting/tags from applied tags. Reverts the help flow (help.ts, channels.ts, messages.ts) to its pre-bus shape, exporting isHumanMessage and resolveMember for the bridge.
Replace the free mirror* functions and the guard helper with a LinearMirror class (constructed from a HelpThread), mirroring the HelpThread structure. Event handlers now build HelpThread + LinearMirror inline and handle errors with local try/catch. Add isClosed/isOpen getters to HelpThread.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 15:28
Inactive
The opening post becomes the issue description; the thread URL stays on the attachment (issue link) only.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 15:49
Inactive
Pass createAsUser (display name) and displayIconUrl (avatar) on comments so they render as the external Discord author once the bridge uses an OAuth app token. Personal API keys ignore these fields, so the author name stays in the comment body for now.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 16:51
Inactive
phorcys420
force-pushed
the
phorcys/linear-bridge
branch
from
August 19, 2026 17:02
6fd36e5 to
5c6a8fa
Compare
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 17:02
Inactive
Personal API keys reject createAsUser/displayIconUrl with a 400 and are sent verbatim; OAuth app-actor tokens must be sent as a Bearer token. Add linearBridge.createAsUser (default false): when off, authenticate with the personal API key and post plain comments; when on, send the token via accessToken (Bearer) and attribute comments to the Discord author. Body-prefix attribution is unchanged.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 18:49
Inactive
Add a ThreadDelete listener and LinearMirror.delete() that trashes the issue mapped to the deleted #help thread. Also drop the author prefix from comment bodies now that app-actor mode attributes the Discord author, removing the now-unused team-detection plumbing.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 19:05
Inactive
Pass createAsUser/displayIconUrl on issueCreate so the mirrored issue is owned by the opening-post author under app-actor auth, matching comment attribution. Fold author resolution into one helper and fetch the starter message once.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 19:08
Inactive
Closing a thread already moves its issue to the completed state (Done); reopening now moves it to the triage state (Triage) instead of started, so the Linear status tracks the Discord thread lifecycle.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 19:17
Inactive
The /close command changes tags via discord.js REST, which updates the local cache before the gateway ThreadUpdate fires, so the old/new isClosed diff was always empty and the issue never moved to Done. Reconcile against the Linear issue state instead: close moves a non-completed issue to Done, reopen moves a completed issue to Triage. Debounce per thread to coalesce tag bursts and avoid duplicate transition comments.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 19:22
Inactive
App-actor tokens cannot create team labels (requires a team owner, which apps cannot be), so label sync 403s. Default labels.enabled to false; re-enable once a label-capable credential is wired.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 20:06
Inactive
Append an invisible marker (a markdown reference-link definition holding the Discord message id) to each mirrored comment, so edits and deletes on Discord can find the matching Linear comment. Add MessageUpdate and MessageDelete listeners: an edited message updates its comment (or the issue description for the opening post), a deleted message removes its comment. Enable Message/Channel partials so uncached messages still emit these events.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 20:16
Inactive
When a mirrored message replies to another message, parent its Linear comment to the referenced message's mirrored comment (found via the message-id marker). Falls back to a top-level comment when the reference isn't mirrored (e.g. a reply to the opening post) or when the parent is itself a reply, since Linear threads are one level deep.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 20:23
Inactive
Linear threads are one level deep, so a reply to a reply resolves to the referenced comment's root and attaches there, keeping the whole reply chain in one Linear thread instead of orphaning deeper replies.
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 20:26
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:25
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:28
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:30
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:42
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:43
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:52
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:55
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 22:58
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:01
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:10
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:15
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:15
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:18
Inactive
railway-app
Bot
temporarily deployed
to
Discord bot (Codercord) / production
August 19, 2026 23:19
Inactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
One-way Discord → Linear bridge for the
#helpforum (MVP for DEVREL-329).config.linearBridge.enabledHow it's wired
discord.js has no middleware, so the help flow now emits enriched domain events on an internal typed bus (
src/lib/bus.ts, no new dep) and the bridge subscribes. The bridge never re-runsisHelpPost.helpThreadCreated,helpMessagePosted,helpThreadStatusChangedemitted fromevents/channels.ts,events/messages.ts, andlib/discord/help.ts.src/bridge/linear/(subfolder so future bridges sit beside it), using@linear/sdk.Mapping & metadata (stateless)
url= thread URL (also the issue's Link),title"Discord thread",metadata{ threadId, tagIds, tagNames, status, waiting }, readable subtitle. Lookup/dedup viaattachmentsForURL.Labels
Discord (#help)(created withteamId, never global).description= the Discord tag ID, and find-or-create matches on that (rename-safe, no dupes). Diffed on status change.config.linearBridge.labels.enabled = false→ attachment metadata only.Config
linearBridge { enabled, apiKey, teamId, labels{ enabled, groupName } }, default off.apiKeyis env-only (Codercord_linearBridge__apiKey);validateLinearBridgeConfig()exits at startup if enabled withoutapiKey/teamId.Backfill
scripts/discord-linear-sync.ts(bun run sync:linear) reuses the same lib to seed existing threads.Notes / follow-ups
MessageContentintent in code; it must be enabled in the Discord dev portal or comment bodies come through empty.closed/reopenedis emitted only from theThreadUpdatehandler (notclose.ts) since/closetriggersThreadUpdatetoo, avoiding duplicate status comments.Verification
tsc --noEmitclean (incl. script),bun lint/formatclean, runtime smoke tests for disabled/enabled/fail-fast paths pass. Bot behavior unchanged when disabled.Implementation plan
Plan: Discord to Linear bridge (codercord)
Repo
coder/codercord· branchphorcys/linear-bridge· one-way MVP for DEVREL-329.What it does
config.linearBridge.enabled.How it's wired: internal event bus
discord.js has no middleware, so the help flow emits enriched domain events and the bridge subscribes. Bridge never re-runs
isHelpPost.src/lib/bus.ts(typed EventEmitter, no new dep) with:helpThreadCreated(ctx)helpMessagePosted(ctx)helpThreadStatusChanged(ctx)Emitted from the existing handlers (
events/channels.ts,events/messages.ts,lib/discord/help.tsapplyWaitingTag,commands/util/close.ts). Emits are best-effort so a consumer error can't break the help flow.Context resolver in
lib/discord/help.ts:Linear side
New dep
@linear/sdk. Code undersrc/bridge/linear/(subfolder so future bridges live beside it):client.ts—LinearClientfromconfig.linearBridge.apiKey.api.ts— thin wrappers: find/create issue, comment, set state, attachment upsert, label group + label ensure.index.ts— orchestration:mirrorThreadCreated,mirrorMessage,mirrorStatus,ensureIssueForThread.src/events/bridge.ts—registerEvents(client); if enabled, subscribe to the bus. Wired intosrc/index.ts.Mapping & metadata (stateless)
url= thread URL (also the issue's Link),title"Discord thread", plusmetadata{ threadId, tagIds, tagNames, status, waiting }and a readablesubtitle.attachmentsForURL; in-runMapis just a cache.Labels
Discord (#help)(created withteamId, never global).description= the Discord tag ID; find-or-create matches on that (rename-safe, no dupes).ctx.tagsand diff.config.linearBridge.labels.enabled = false-> metadata only.Config (
config.ts+config.json.example)configmasher
mandatoryis static, sovalidateLinearBridgeConfig()throws at startup ifenabledbutapiKey/teamIdmissing.Backfill script
scripts/discord-linear-sync.ts(bun scripts/discord-linear-sync.ts, plus async:linearpackage script): logs in, walks active help threads likecatchUpHelpPosts, and runs the samesrc/bridge/linearlib. Keeps the always-on bot lean.Also
GatewayIntentBits.MessageContentinindex.ts(privileged; needs the Discord dev-portal toggle or comment bodies are empty).AGENTS.mdlayout + config example.Generated by Coder Agents on behalf of @phorcys420.