Skip to content

Commit 653e41a

Browse files
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 fallback
1 parent 521c15c commit 653e41a

32 files changed

Lines changed: 2404 additions & 23 deletions

‎AGENTS.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,27 @@ src/
3939
index.ts Aggregates every command into a name -> command map.
4040
util/ close, reopen, walkthrough.
4141
product/ notes (ProductBoard context-menu command).
42-
events/ commands, messages, channels, walkthrough handlers.
42+
events/ commands, messages, channels, walkthrough, bridge handlers.
43+
bridge/
44+
linear/ Discord -> Linear mirror (client, api, orchestration).
4345
lib/
4446
config.ts Typed config loader + mandatory field list.
45-
discord/ channels, users, messages helpers.
47+
discord/ channels, users, messages, help, helpThread helpers.
4648
ui/components/ StringSelectMenu builders for the walkthrough.
49+
scripts/
50+
discord-linear-sync.ts One-shot Linear backfill (bun run sync:linear).
4751
assets/tags.json Canned response text.
4852
```
4953

54+
## Linear bridge
55+
56+
The Linear bridge (`src/events/bridge.ts`) registers its own Discord listeners
57+
(`ThreadCreate`, `MessageCreate`, `ThreadUpdate`) and mirrors #help forum posts
58+
into Linear via `src/bridge/linear`. It reads enriched thread state through
59+
`new HelpThread(thread)` (`src/lib/discord/helpThread.ts`), whose getters derive
60+
status/waiting/tags from the thread's applied tags. Disabled by default via
61+
`config.linearBridge.enabled`.
62+
5063
## Conventions
5164

5265
- **Imports**: Use the `.js` extension on relative imports (ESM/NodeNext),
@@ -68,6 +81,9 @@ environment file -> process environment. Keys are **case-sensitive**.
6881

6982
- Copy `config.json.example` to `config.json` (gitignored) for local IDs.
7083
- Secrets come from the environment, e.g. `Codercord_token` (the bot token).
84+
- The Linear bridge API key is a secret too: `Codercord_linearBridge__apiKey`
85+
(nested keys use `__`). `linearBridge.teamId` and `enabled` live in
86+
`config.json`; the bridge exits at startup if enabled without apiKey/teamId.
7187
- Mandatory fields are declared in `src/lib/config.ts`; the process exits if
7288
any are missing.
7389

‎bun.lockb‎

1.17 KB
Binary file not shown.

‎config.json.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,14 @@
1919
"macos": "1078432543696748634",
2020
"windows": "1078432538940416030",
2121
"vscode": "1078432889995268248"
22+
},
23+
24+
"linearBridge": {
25+
"enabled": false,
26+
"teamId": "",
27+
"labels": {
28+
"enabled": true,
29+
"groupName": "Discord (#help)"
30+
}
2231
}
2332
}

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"typescript": "^5.9.3"
2121
},
2222
"dependencies": {
23+
"@linear/sdk": "^90.0.0",
2324
"@uwu/configmasher": "^2.0.2",
2425
"discord.js": "^14.27.0",
2526
"ofetch": "^1.5.1",

‎src/bridge/ARCHITECTURE.md‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Bridge architecture
2+
3+
The bridge mirrors community conversations into Linear. Today it runs one way,
4+
Discord `#help` -> Linear, but the code is organized around a source-agnostic
5+
model so more platforms (e.g. GitHub Discussions) and the reverse direction can
6+
be added without threading platform specifics through the whole system.
7+
8+
## Layout
9+
10+
```
11+
src/bridge/
12+
core/ # platform-agnostic: model, interfaces, orchestration, reconciler
13+
discord/ # Discord connector: listeners + model mapping
14+
linear/ # Linear connector: the hub store, split by concern
15+
```
16+
17+
- `core/model.ts` - the shared vocabulary: `Post`, `Message`, `Author`,
18+
`Attachment`, `Reaction`, `Reference`, `Label`, and `ExternalRef`
19+
(`{ source, id, url }`). A connector maps its native objects onto these.
20+
- `core/connector.ts` - the `Source` and `Target` capability interfaces.
21+
- `core/mirror.ts` - `Mirror`, the orchestrator. Consumes model objects a
22+
connector produces and drives the `Target`. All logic here is source-agnostic.
23+
- `core/reconciler.ts` - maps a post's lifecycle onto the hub workflow state.
24+
- `core/references.ts` - extractors for cross-links (other threads, GitHub
25+
issues) that any connector can reuse.
26+
- `core/backfill.ts` - rate-limit retry used by startup import.
27+
- `discord/` - `DiscordConnector` (a `Source`) plus `map.ts`, which converts
28+
discord.js objects into the model (mentions, emojis, attachments, references).
29+
- `linear/` - the hub, split into `client`, `issues`, `comments`, `reactions`,
30+
`labels`, `emojis`, `attachments`, `state`, `assets`, with `index.ts` exposing
31+
`LinearConnector` (a `Target`).
32+
33+
## Source and Target are capabilities, not layers
34+
35+
Everything syncs both ways eventually, so a platform is one module, not split
36+
across "source" and "target" folders. `Source` (reads events, enumerates for
37+
backfill, writes the hub link back) and `Target` (the hub store) are capability
38+
interfaces. Discord implements `Source` today; Linear implements `Target`. When
39+
a platform's reverse direction is built, its connector grows the other
40+
capability rather than moving between folders.
41+
42+
## Identity and mapping
43+
44+
A conversation maps to one hub issue. The mapping lives in a Linear **attachment**
45+
on the issue whose `url` is the source conversation's canonical URL; lookups are
46+
scoped to the configured team so a shared link (e.g. a GitHub URL attached to an
47+
unrelated issue) never resolves cross-team.
48+
49+
Mirrored comments carry an invisible marker, a markdown reference-link definition
50+
`[<source>-msg]: <id>`, so a later edit/delete/reply finds the right comment. The
51+
marker is namespaced per source; Discord's is `discord-msg`.
52+
53+
**Cardinality (future).** One issue is the hub, linked to N source entities at
54+
once: the same conversation can map to a Discord thread and a GitHub discussion
55+
via one attachment each. The marker's source namespace keeps per-source comments
56+
distinct on the shared issue.
57+
58+
## Reconciliation model
59+
60+
- **Posts always originate at a source.** Nothing is created in Linear; Linear
61+
is a relay hub.
62+
- **The originating source is authoritative for its own content**: title, body,
63+
lifecycle (open/closed and waiting state), and messages. If a source and Linear
64+
disagree on a source-owned field, the source wins.
65+
- **Linear relays A -> Linear -> B.** The hub holds cross-source identity but
66+
does not author content.
67+
- **No historical catch-up for Linear-originated changes.** Linear edits
68+
propagate only when received live. Propagation of Linear-originated *comments*
69+
is an open question, deferred.
70+
- **State transitions are computed against the current hub state**, not a source
71+
old/new diff, so out-of-band changes (e.g. a `/close` command) are detected
72+
reliably. See `core/reconciler.ts`: closed -> Done, waiting-on-user -> Blocked,
73+
waiting-on-team -> In Progress, with a new live thread held in Triage until the
74+
team engages; backfilled threads bypass that gate.
75+
76+
## Future work (not built)
77+
78+
- **Reverse direction (Linear -> source).** The intended inbound channel is
79+
**Linear webhooks** (the SDK ships a webhook client). Each connector would grow
80+
the write side of its platform.
81+
- **Echo suppression.** Every mirrored write is tagged with its origin (comments
82+
already carry the source marker). Inbound events that match a mirror we just
83+
wrote must be ignored so a `Linear -> Discord` write does not bounce back as a
84+
new Discord event and loop. Only the origin tagging exists today; the ignore
85+
step lands with the reverse direction.
86+
- **GitHub Discussions.** A new `github/` connector implementing `Source`,
87+
reusing `core` unchanged. Its marker namespace would be `github-msg`.

‎src/bridge/core/backfill.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
function sleep(ms: number): Promise<void> {
2+
return new Promise((resolve) => setTimeout(resolve, ms));
3+
}
4+
5+
// Retries an operation through hub rate limits. Linear's limits reset on a
6+
// rolling window, so back off and keep waiting rather than dropping work.
7+
export async function withRateLimitRetry<T>(
8+
fn: () => Promise<T>,
9+
isRateLimited: (err: unknown) => boolean,
10+
): Promise<T> {
11+
let delayMs = 60_000;
12+
for (;;) {
13+
try {
14+
return await fn();
15+
} catch (err) {
16+
if (!isRateLimited(err)) throw err;
17+
console.warn("[bridge]", "rate limited, waiting", `${delayMs / 1000}s`);
18+
await sleep(delayMs);
19+
delayMs = Math.min(delayMs * 2, 15 * 60_000);
20+
}
21+
}
22+
}

‎src/bridge/core/bridge.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Client } from "discord.js";
2+
3+
import { config, validateLinearBridgeConfig } from "@lib/config.js";
4+
5+
import { DiscordConnector } from "@bridge/discord/index.js";
6+
import { LinearConnector } from "@bridge/linear/index.js";
7+
8+
// Composition root: wires the Discord source to the Linear hub. Adding a source
9+
// (e.g. GitHub Discussions) means constructing another connector here.
10+
let connector: DiscordConnector | undefined;
11+
12+
export function registerBridge(client: Client): void {
13+
if (!config.linearBridge.enabled) {
14+
console.log("[bridge]", "disabled");
15+
return;
16+
}
17+
validateLinearBridgeConfig();
18+
connector = new DiscordConnector(client, new LinearConnector());
19+
connector.register();
20+
}
21+
22+
export async function backfillBridge(client: Client): Promise<void> {
23+
if (!config.linearBridge.enabled) return;
24+
const source =
25+
connector ?? new DiscordConnector(client, new LinearConnector());
26+
await source.backfill();
27+
}

‎src/bridge/core/connector.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
ExternalRef,
3+
Message,
4+
Post,
5+
Reaction,
6+
} from "@bridge/core/model.js";
7+
8+
export interface IssueState {
9+
type: string;
10+
name: string;
11+
}
12+
13+
export interface LinkedIssue {
14+
id: string;
15+
identifier: string;
16+
url: string;
17+
}
18+
19+
export type ReactionTarget = { issueId: string } | { commentId: string };
20+
21+
// The hub store. Linear implements this today; every method speaks the
22+
// source-agnostic model so another hub could be swapped in. Keyed by the source
23+
// entity's ExternalRef (resolved to a hub issue via its URL attachment).
24+
export interface Target {
25+
findIssueId(ref: ExternalRef): Promise<string | null>;
26+
ensureIssue(post: Post): Promise<string>;
27+
deleteIssue(ref: ExternalRef): Promise<void>;
28+
29+
// Refresh linking attachment, title, project and labels from the post.
30+
reconcile(issueId: string, post: Post): Promise<void>;
31+
syncLabels(issueId: string, post: Post): Promise<void>;
32+
33+
setDescription(issueId: string, text: string): Promise<void>;
34+
updateDescription(issueId: string, message: Message): Promise<void>;
35+
36+
addComment(
37+
issueId: string,
38+
message: Message,
39+
parentId?: string,
40+
): Promise<void>;
41+
editComment(issueId: string, message: Message): Promise<boolean>;
42+
deleteComment(issueId: string, ref: ExternalRef): Promise<boolean>;
43+
mirroredMessageIds(issueId: string): Promise<Set<string>>;
44+
resolveReplyParent(
45+
issueId: string,
46+
messageId: string,
47+
): Promise<string | null>;
48+
findCommentId(issueId: string, messageId: string): Promise<string | null>;
49+
50+
// Plain system note (no marker), e.g. "thread closed".
51+
note(issueId: string, body: string, createdAt?: Date): Promise<void>;
52+
53+
getState(issueId: string): Promise<IssueState | null>;
54+
setState(
55+
issueId: string,
56+
type: "completed" | "triage" | "started",
57+
name?: string,
58+
): Promise<void>;
59+
60+
addReaction(target: ReactionTarget, reaction: Reaction): Promise<void>;
61+
removeReaction(target: ReactionTarget, reaction: Reaction): Promise<void>;
62+
63+
resolveByUrl(url: string): Promise<LinkedIssue | null>;
64+
relate(issueId: string, otherId: string): Promise<void>;
65+
66+
issueRef(issueId: string): Promise<{ identifier: string; url: string }>;
67+
}
68+
69+
// A platform that originates conversations (Discord today, GitHub Discussions
70+
// planned). It registers listeners that drive the mirror, enumerates posts for
71+
// backfill, and writes the hub link back into the source. Everything syncs both
72+
// ways eventually; a connector grows into the hub's role by implementing more of
73+
// the reverse direction, so Source and Target are capabilities one module can
74+
// hold rather than separate layers.
75+
export interface Source {
76+
register(): void;
77+
backfill(): Promise<void>;
78+
announce(
79+
post: Post,
80+
issue: { identifier: string; url: string },
81+
): Promise<void>;
82+
}

0 commit comments

Comments
 (0)