Skip to content

feat: one-way Discord to Linear bridge for #help threads - #61

Draft
phorcys420 wants to merge 51 commits into
mainfrom
phorcys/linear-bridge
Draft

feat: one-way Discord to Linear bridge for #help threads#61
phorcys420 wants to merge 51 commits into
mainfrom
phorcys/linear-bridge

Conversation

@phorcys420

Copy link
Copy Markdown
Member

What

One-way Discord → Linear bridge for the #help forum (MVP for DEVREL-329).

  • New help thread → new Linear issue
  • New message → issue comment
  • Status change (closed/reopened, waiting-for-user/team) → workflow state + labels
  • One-way only (no Linear → Discord, no two-way)
  • Off by default, toggled via config.linearBridge.enabled

How 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-runs isHelpPost.

  • helpThreadCreated, helpMessagePosted, helpThreadStatusChanged emitted from events/channels.ts, events/messages.ts, and lib/discord/help.ts.
  • Bridge lib under src/bridge/linear/ (subfolder so future bridges sit beside it), using @linear/sdk.

Mapping & metadata (stateless)

  • Thread↔issue link lives on Linear as an attachment: url = thread URL (also the issue's Link), title "Discord thread", metadata { threadId, tagIds, tagNames, status, waiting }, readable subtitle. Lookup/dedup via attachmentsForURL.

Labels

  • Single team-scoped group Discord (#help) (created with teamId, never global).
  • One label per applied tag (except open/closed); label 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. apiKey is env-only (Codercord_linearBridge__apiKey); validateLinearBridgeConfig() exits at startup if enabled without apiKey/teamId.

Backfill

scripts/discord-linear-sync.ts (bun run sync:linear) reuses the same lib to seed existing threads.

Notes / follow-ups

  • Adds the privileged MessageContent intent in code; it must be enabled in the Discord dev portal or comment bodies come through empty.
  • closed/reopened is emitted only from the ThreadUpdate handler (not close.ts) since /close triggers ThreadUpdate too, avoiding duplicate status comments.
  • Out of scope (future issues): Linear → Discord, two-way sync, mention/issue-link resolution, cross-restart message dedup.

Verification

tsc --noEmit clean (incl. script), bun lint/format clean, 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 · branch phorcys/linear-bridge · one-way MVP for DEVREL-329.

What it does

  • Mirrors #help forum threads into Linear: new thread -> new issue; new message -> issue comment; status change -> workflow state + labels.
  • One-way only (no Linear -> Discord, no two-way).
  • Off by default, toggled via 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.ts applyWaitingTag, 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:

getHelpThreadContext(thread) -> {
  thread, url, title,
  status: "open" | "closed",
  waiting: "user" | "team" | null,
  tags: { id, name }[]   // applied tags minus open/closed
}

Linear side

New dep @linear/sdk. Code under src/bridge/linear/ (subfolder so future bridges live beside it):

  • client.tsLinearClient from config.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.tsregisterEvents(client); if enabled, subscribe to the bus. Wired into src/index.ts.

Mapping & metadata (stateless)

  • Thread<->issue link lives on Linear: an attachment with url = thread URL (also the issue's Link), title "Discord thread", plus metadata { threadId, tagIds, tagNames, status, waiting } and a readable subtitle.
  • Lookup/dedup via attachmentsForURL; in-run Map is just a cache.
  • Status -> Linear workflow state (closed -> Done, etc.), best-effort.

Labels

  • Single team-scoped group Discord (#help) (created with teamId, never global).
  • One label per applied tag (except open/closed), name kept verbatim.
  • Label description = the Discord tag ID; find-or-create matches on that (rename-safe, no dupes).
  • On status change, recompute desired labels from ctx.tags and diff.
  • config.linearBridge.labels.enabled = false -> metadata only.

Config (config.ts + config.json.example)

linearBridge: {
  enabled: boolean;   // default false
  apiKey: string;     // env only
  teamId: string;     // required when enabled
  labels?: { enabled: boolean; groupName: string }; // default on, "Discord (#help)"
}

configmasher mandatory is static, so validateLinearBridgeConfig() throws at startup if enabled but apiKey/teamId missing.

Backfill script

scripts/discord-linear-sync.ts (bun scripts/discord-linear-sync.ts, plus a sync:linear package script): logs in, walks active help threads like catchUpHelpPosts, and runs the same src/bridge/linear lib. Keeps the always-on bot lean.

Also

  • Add GatewayIntentBits.MessageContent in index.ts (privileged; needs the Discord dev-portal toggle or comment bodies are empty).
  • Update AGENTS.md layout + config example.

Generated by Coder Agents on behalf of @phorcys420.

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
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
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
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 16:51 Inactive
@phorcys420
phorcys420 force-pushed the phorcys/linear-bridge branch from 6fd36e5 to 5c6a8fa Compare August 19, 2026 17:02
@railway-app
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
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
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
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
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
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
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
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
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
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 20:26 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:25 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:28 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:30 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:42 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:43 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:52 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:55 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 22:58 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:01 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:10 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:15 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:15 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:18 Inactive
@railway-app
railway-app Bot temporarily deployed to Discord bot (Codercord) / production August 19, 2026 23:19 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant