Commit 653e41a
authored
feat: one-way Discord to Linear bridge for #help threads (#61)
* feat: add one-way Discord to Linear bridge for #help threads
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.
* refactor(src/bridge/linear): inline Linear client into api.ts
* refactor(src/bridge/linear): tidy comments and remove redundancy
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.
* refactor: replace event bus with HelpThread wrapper and raw listeners
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.
* refactor(src/bridge/linear): mirror via LinearMirror class
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.
* fix(src/bridge/linear): keep the Discord link out of the issue body
The opening post becomes the issue description; the thread URL stays on the
attachment (issue link) only.
* feat(src/bridge/linear): attribute comments to the Discord author
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.
* fix(src): support Linear OAuth app-actor auth for the bridge
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.
* feat(src/bridge/linear): delete mirrored issue on thread delete
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.
* feat(src/bridge/linear): attribute the issue to its Discord author
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.
* feat(src/bridge/linear): move reopened issues back to Triage
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.
* fix(src/bridge/linear): detect bot-initiated thread close/reopen
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.
* chore(src/lib/config): disable Linear label sync by default
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.
* feat(src/bridge/linear): mirror Discord message edits and deletes
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.
* feat(src/bridge/linear): mirror Discord replies as threaded comments
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.
* feat(src/bridge/linear): collapse nested replies to their thread root
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.
* feat(src/bridge/linear): mirror attachment-only messages
Build the comment body from the message text and its attachments (images
inline, other files as links) so a message with no text still mirrors
instead of being skipped.
* feat(src/bridge/linear): re-host attachments in Linear for permanence
Discord CDN attachment URLs expire, so mirror a message instantly with
the CDN links, then edit the comment to swap in permanent Linear-hosted
URLs (uploaded via fileUpload). Failed uploads keep the CDN link. Edits
re-render durably too.
* fix(src/events/bridge): re-mirror on attachment-only edits
The message edit guard skipped updates when text was unchanged, so
removing (or adding) an attachment without editing text was ignored.
Also compare the attachment set, so an attachment removed on Discord is
dropped from the Linear comment.
* chore(src): temporary bridge diagnostics for message edit/delete
* feat(src/bridge/linear): render Discord custom emojis in Linear
Rewrite custom emoji tokens (<:name:id>, <a:name:id>) to image markdown
against the permanent Discord emoji CDN so they display in mirrored
comments instead of showing as raw text.
* feat(src/bridge/linear): mirror Discord reactions and use emoji shortcodes
* feat(src/bridge/linear): clear issue description when opening post is deleted
* feat(src/bridge/linear): use a user token for emoji and label creation
* chore(src/lib/config): enable Linear label sync by default
* fix(src/bridge/linear): re-host custom emojis in Linear before registering
* feat(src/bridge/linear): file mirrored issues under a configurable project
* feat(src/bridge/linear): move issue to In Progress on a team reply
* feat(src/bridge): cross-link mentioned threads and GitHub issues in Linear
* fix(src/bridge/linear): target the In Progress state by name on team reply
* fix(src/bridge/linear): keep replies when deleting a mirrored comment
* fix(src/bridge/linear): run all label ops on the user token
* fix(src/bridge/linear): mirror tags as flat namespaced labels
* chore(src/lib/config): name mirrored labels "#help > tag"
* feat(src/bridge/linear): drive issue state from the thread waiting tag
* feat(src/bridge/linear): keep new threads in Triage until the team engages
* feat(src/lib/discord): exclude waiting-for tags from mirrored labels
* feat(src/bridge/linear): keep issues in the configured project
* feat(src/bridge/linear): sync issue title when a thread is renamed
* feat(src/bridge/linear): backfill recent help threads on startup
* feat(src/bridge/linear): announce the mirrored issue link in the thread
* feat(src/bridge/linear): resolve Discord mentions to profile links
* feat(src/bridge/linear): backfill missing messages and set original timestamps
* fix(src/bridge/linear): apply waiting-for-team status during backfill
* feat(src/bridge/linear): include archived threads in startup backfill
* feat(src/events/bridge): apply a waiting tag to backfilled threads missing one
* fix(src/bridge/linear): scope URL issue lookups to the configured team
* fix(src/lib/discord): treat archived help threads as closed
* chore(src/bridge/linear): log mirror operations and backfill progress
* fix(src/bridge/linear): timestamp the thread-closed comment from the archive time
* feat(src/events/bridge): add backfillAll to import every thread through rate limits
* refactor(src/bridge): reorganize into source-agnostic connectors and hub
Split the Discord-baked one-way mirror into a source-agnostic core, a Discord
connector, and a Linear hub connector, making way for a future GitHub
Discussions bridge without changing behavior or the on-the-wire format.
- core/: canonical model, Source/Target capability interfaces, orchestrating
Mirror, reconciler, references, rate-limit retry.
- discord/: DiscordConnector (listeners + backfill + announce) and model mapping.
- linear/: hub split into client/issues/comments/reactions/labels/emojis/
attachments/state/assets behind LinearConnector.
- Add src/bridge/ARCHITECTURE.md documenting the source-authoritative,
Linear-as-relay-hub reconciliation model and the planned bidirectional path.
- Delete the redundant one-shot sync script (superseded by startup backfill).
* fix(src/lib/discord): keep waiting on the team when the OP is a team member
* refactor(src): use scoped comma-separated console args and console.debug
* fix(src/lib/discord): only treat tagless archived threads as closed
* fix(src/bridge/discord): never write into archived threads
* feat(src/bridge/discord): attribute as display name with handle
* feat(src/events/channels): post a desktop deep link on new help posts
* fix(src/lib/config): coerce env booleans and numbers so backfillAll=false works
* feat(src/bridge): post the Discord desktop deep link on the Linear issue
* fix(src/bridge): bound normal backfill by a recency window
* fix(src/bridge/discord): resolve mentions via cache and fetch fallback1 parent 521c15c commit 653e41a
32 files changed
Lines changed: 2404 additions & 23 deletions
File tree
- src
- bridge
- core
- discord
- linear
- commands/util
- events
- lib
- discord
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
43 | 45 | | |
44 | 46 | | |
45 | | - | |
| 47 | + | |
46 | 48 | | |
| 49 | + | |
| 50 | + | |
47 | 51 | | |
48 | 52 | | |
49 | 53 | | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
50 | 63 | | |
51 | 64 | | |
52 | 65 | | |
| |||
68 | 81 | | |
69 | 82 | | |
70 | 83 | | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
71 | 87 | | |
72 | 88 | | |
73 | 89 | | |
| |||
Binary file not shown.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
22 | 31 | | |
23 | 32 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
0 commit comments