Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
66bc259
feat: add one-way Discord to Linear bridge for #help threads
phorcys420 Aug 19, 2026
55205f2
refactor(src/bridge/linear): inline Linear client into api.ts
phorcys420 Aug 19, 2026
82bbbc0
refactor(src/bridge/linear): tidy comments and remove redundancy
phorcys420 Aug 19, 2026
0973f08
refactor: replace event bus with HelpThread wrapper and raw listeners
phorcys420 Aug 19, 2026
76680e9
refactor(src/bridge/linear): mirror via LinearMirror class
phorcys420 Aug 19, 2026
32c2884
fix(src/bridge/linear): keep the Discord link out of the issue body
phorcys420 Aug 19, 2026
a094a16
feat(src/bridge/linear): attribute comments to the Discord author
phorcys420 Aug 19, 2026
3fcbcaa
fix(src): support Linear OAuth app-actor auth for the bridge
phorcys420 Aug 19, 2026
5bdbfb4
feat(src/bridge/linear): delete mirrored issue on thread delete
phorcys420 Aug 19, 2026
632ab43
feat(src/bridge/linear): attribute the issue to its Discord author
phorcys420 Aug 19, 2026
364ffbc
feat(src/bridge/linear): move reopened issues back to Triage
phorcys420 Aug 19, 2026
7f68008
fix(src/bridge/linear): detect bot-initiated thread close/reopen
phorcys420 Aug 19, 2026
00576ce
chore(src/lib/config): disable Linear label sync by default
phorcys420 Aug 19, 2026
f50399d
feat(src/bridge/linear): mirror Discord message edits and deletes
phorcys420 Aug 19, 2026
a8e788b
feat(src/bridge/linear): mirror Discord replies as threaded comments
phorcys420 Aug 19, 2026
57efec5
feat(src/bridge/linear): collapse nested replies to their thread root
phorcys420 Aug 19, 2026
35d1e61
feat(src/bridge/linear): mirror attachment-only messages
phorcys420 Aug 19, 2026
0403317
feat(src/bridge/linear): re-host attachments in Linear for permanence
phorcys420 Aug 19, 2026
7f64c79
fix(src/events/bridge): re-mirror on attachment-only edits
phorcys420 Aug 19, 2026
a400178
chore(src): temporary bridge diagnostics for message edit/delete
phorcys420 Aug 19, 2026
acbaa80
feat(src/bridge/linear): render Discord custom emojis in Linear
phorcys420 Aug 19, 2026
e4d43b9
feat(src/bridge/linear): mirror Discord reactions and use emoji short…
phorcys420 Aug 19, 2026
2b81b58
feat(src/bridge/linear): clear issue description when opening post is…
phorcys420 Aug 19, 2026
ede2927
feat(src/bridge/linear): use a user token for emoji and label creation
phorcys420 Aug 19, 2026
d97c860
chore(src/lib/config): enable Linear label sync by default
phorcys420 Aug 19, 2026
d8204df
fix(src/bridge/linear): re-host custom emojis in Linear before regist…
phorcys420 Aug 19, 2026
b20c9ad
feat(src/bridge/linear): file mirrored issues under a configurable pr…
phorcys420 Aug 19, 2026
76c7e6b
feat(src/bridge/linear): move issue to In Progress on a team reply
phorcys420 Aug 19, 2026
b311efe
feat(src/bridge): cross-link mentioned threads and GitHub issues in L…
phorcys420 Aug 19, 2026
8f1b0a5
fix(src/bridge/linear): target the In Progress state by name on team …
phorcys420 Aug 19, 2026
b4d1dbd
fix(src/bridge/linear): keep replies when deleting a mirrored comment
phorcys420 Aug 19, 2026
14991c4
fix(src/bridge/linear): run all label ops on the user token
phorcys420 Aug 19, 2026
bf12216
fix(src/bridge/linear): mirror tags as flat namespaced labels
phorcys420 Aug 19, 2026
b499303
chore(src/lib/config): name mirrored labels "#help > tag"
phorcys420 Aug 19, 2026
ec48a01
feat(src/bridge/linear): drive issue state from the thread waiting tag
phorcys420 Aug 19, 2026
330c5c3
feat(src/bridge/linear): keep new threads in Triage until the team en…
phorcys420 Aug 19, 2026
4c79dcb
feat(src/lib/discord): exclude waiting-for tags from mirrored labels
phorcys420 Aug 19, 2026
b67295d
feat(src/bridge/linear): keep issues in the configured project
phorcys420 Aug 19, 2026
e8c0fc8
feat(src/bridge/linear): sync issue title when a thread is renamed
phorcys420 Aug 19, 2026
911c297
feat(src/bridge/linear): backfill recent help threads on startup
phorcys420 Aug 19, 2026
384d7ec
feat(src/bridge/linear): announce the mirrored issue link in the thread
phorcys420 Aug 19, 2026
584258a
feat(src/bridge/linear): resolve Discord mentions to profile links
phorcys420 Aug 19, 2026
ad2690c
feat(src/bridge/linear): backfill missing messages and set original t…
phorcys420 Aug 19, 2026
50614b6
fix(src/bridge/linear): apply waiting-for-team status during backfill
phorcys420 Aug 19, 2026
0e55a6d
feat(src/bridge/linear): include archived threads in startup backfill
phorcys420 Aug 19, 2026
38c1db2
feat(src/events/bridge): apply a waiting tag to backfilled threads mi…
phorcys420 Aug 19, 2026
dfca537
fix(src/bridge/linear): scope URL issue lookups to the configured team
phorcys420 Aug 19, 2026
ace1c2d
fix(src/lib/discord): treat archived help threads as closed
phorcys420 Aug 19, 2026
ef94c55
chore(src/bridge/linear): log mirror operations and backfill progress
phorcys420 Aug 19, 2026
b74b828
fix(src/bridge/linear): timestamp the thread-closed comment from the …
phorcys420 Aug 19, 2026
81528ee
feat(src/events/bridge): add backfillAll to import every thread throu…
phorcys420 Aug 19, 2026
4531a54
refactor(src/bridge): reorganize into source-agnostic connectors and hub
phorcys420 Aug 20, 2026
e51802c
fix(src/lib/discord): keep waiting on the team when the OP is a team …
phorcys420 Aug 20, 2026
02025b7
refactor(src): use scoped comma-separated console args and console.debug
phorcys420 Aug 20, 2026
546493f
fix(src/lib/discord): only treat tagless archived threads as closed
phorcys420 Aug 20, 2026
b1de104
fix(src/bridge/discord): never write into archived threads
phorcys420 Aug 20, 2026
e1cd963
feat(src/bridge/discord): attribute as display name with handle
phorcys420 Aug 20, 2026
7c81e1d
feat(src/events/channels): post a desktop deep link on new help posts
phorcys420 Aug 20, 2026
ae608b3
fix(src/lib/config): coerce env booleans and numbers so backfillAll=f…
phorcys420 Aug 20, 2026
637a6b6
feat(src/bridge): post the Discord desktop deep link on the Linear issue
phorcys420 Aug 20, 2026
43998a8
fix(src/bridge): bound normal backfill by a recency window
phorcys420 Aug 20, 2026
b410fc8
fix(src/bridge/discord): resolve mentions via cache and fetch fallback
phorcys420 Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,27 @@ src/
index.ts Aggregates every command into a name -> command map.
util/ close, reopen, walkthrough.
product/ notes (ProductBoard context-menu command).
events/ commands, messages, channels, walkthrough handlers.
events/ commands, messages, channels, walkthrough, bridge handlers.
bridge/
linear/ Discord -> Linear mirror (client, api, orchestration).
lib/
config.ts Typed config loader + mandatory field list.
discord/ channels, users, messages helpers.
discord/ channels, users, messages, help, helpThread helpers.
ui/components/ StringSelectMenu builders for the walkthrough.
scripts/
discord-linear-sync.ts One-shot Linear backfill (bun run sync:linear).
assets/tags.json Canned response text.
```

## Linear bridge

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

## Conventions

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

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

Expand Down
Binary file modified bun.lockb
Binary file not shown.
9 changes: 9 additions & 0 deletions config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,14 @@
"macos": "1078432543696748634",
"windows": "1078432538940416030",
"vscode": "1078432889995268248"
},

"linearBridge": {
"enabled": false,
"teamId": "",
"labels": {
"enabled": true,
"groupName": "Discord (#help)"
}
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@linear/sdk": "^90.0.0",
"@uwu/configmasher": "^2.0.2",
"discord.js": "^14.27.0",
"ofetch": "^1.5.1",
Expand Down
87 changes: 87 additions & 0 deletions src/bridge/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Bridge architecture

The bridge mirrors community conversations into Linear. Today it runs one way,
Discord `#help` -> Linear, but the code is organized around a source-agnostic
model so more platforms (e.g. GitHub Discussions) and the reverse direction can
be added without threading platform specifics through the whole system.

## Layout

```
src/bridge/
core/ # platform-agnostic: model, interfaces, orchestration, reconciler
discord/ # Discord connector: listeners + model mapping
linear/ # Linear connector: the hub store, split by concern
```

- `core/model.ts` - the shared vocabulary: `Post`, `Message`, `Author`,
`Attachment`, `Reaction`, `Reference`, `Label`, and `ExternalRef`
(`{ source, id, url }`). A connector maps its native objects onto these.
- `core/connector.ts` - the `Source` and `Target` capability interfaces.
- `core/mirror.ts` - `Mirror`, the orchestrator. Consumes model objects a
connector produces and drives the `Target`. All logic here is source-agnostic.
- `core/reconciler.ts` - maps a post's lifecycle onto the hub workflow state.
- `core/references.ts` - extractors for cross-links (other threads, GitHub
issues) that any connector can reuse.
- `core/backfill.ts` - rate-limit retry used by startup import.
- `discord/` - `DiscordConnector` (a `Source`) plus `map.ts`, which converts
discord.js objects into the model (mentions, emojis, attachments, references).
- `linear/` - the hub, split into `client`, `issues`, `comments`, `reactions`,
`labels`, `emojis`, `attachments`, `state`, `assets`, with `index.ts` exposing
`LinearConnector` (a `Target`).

## Source and Target are capabilities, not layers

Everything syncs both ways eventually, so a platform is one module, not split
across "source" and "target" folders. `Source` (reads events, enumerates for
backfill, writes the hub link back) and `Target` (the hub store) are capability
interfaces. Discord implements `Source` today; Linear implements `Target`. When
a platform's reverse direction is built, its connector grows the other
capability rather than moving between folders.

## Identity and mapping

A conversation maps to one hub issue. The mapping lives in a Linear **attachment**
on the issue whose `url` is the source conversation's canonical URL; lookups are
scoped to the configured team so a shared link (e.g. a GitHub URL attached to an
unrelated issue) never resolves cross-team.

Mirrored comments carry an invisible marker, a markdown reference-link definition
`[<source>-msg]: <id>`, so a later edit/delete/reply finds the right comment. The
marker is namespaced per source; Discord's is `discord-msg`.

**Cardinality (future).** One issue is the hub, linked to N source entities at
once: the same conversation can map to a Discord thread and a GitHub discussion
via one attachment each. The marker's source namespace keeps per-source comments
distinct on the shared issue.

## Reconciliation model

- **Posts always originate at a source.** Nothing is created in Linear; Linear
is a relay hub.
- **The originating source is authoritative for its own content**: title, body,
lifecycle (open/closed and waiting state), and messages. If a source and Linear
disagree on a source-owned field, the source wins.
- **Linear relays A -> Linear -> B.** The hub holds cross-source identity but
does not author content.
- **No historical catch-up for Linear-originated changes.** Linear edits
propagate only when received live. Propagation of Linear-originated *comments*
is an open question, deferred.
- **State transitions are computed against the current hub state**, not a source
old/new diff, so out-of-band changes (e.g. a `/close` command) are detected
reliably. See `core/reconciler.ts`: closed -> Done, waiting-on-user -> Blocked,
waiting-on-team -> In Progress, with a new live thread held in Triage until the
team engages; backfilled threads bypass that gate.

## Future work (not built)

- **Reverse direction (Linear -> source).** The intended inbound channel is
**Linear webhooks** (the SDK ships a webhook client). Each connector would grow
the write side of its platform.
- **Echo suppression.** Every mirrored write is tagged with its origin (comments
already carry the source marker). Inbound events that match a mirror we just
wrote must be ignored so a `Linear -> Discord` write does not bounce back as a
new Discord event and loop. Only the origin tagging exists today; the ignore
step lands with the reverse direction.
- **GitHub Discussions.** A new `github/` connector implementing `Source`,
reusing `core` unchanged. Its marker namespace would be `github-msg`.
22 changes: 22 additions & 0 deletions src/bridge/core/backfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Retries an operation through hub rate limits. Linear's limits reset on a
// rolling window, so back off and keep waiting rather than dropping work.
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
isRateLimited: (err: unknown) => boolean,
): Promise<T> {
let delayMs = 60_000;
for (;;) {
try {
return await fn();
} catch (err) {
if (!isRateLimited(err)) throw err;
console.warn("[bridge]", "rate limited, waiting", `${delayMs / 1000}s`);
await sleep(delayMs);
delayMs = Math.min(delayMs * 2, 15 * 60_000);
}
}
}
27 changes: 27 additions & 0 deletions src/bridge/core/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Client } from "discord.js";

import { config, validateLinearBridgeConfig } from "@lib/config.js";

import { DiscordConnector } from "@bridge/discord/index.js";
import { LinearConnector } from "@bridge/linear/index.js";

// Composition root: wires the Discord source to the Linear hub. Adding a source
// (e.g. GitHub Discussions) means constructing another connector here.
let connector: DiscordConnector | undefined;

export function registerBridge(client: Client): void {
if (!config.linearBridge.enabled) {
console.log("[bridge]", "disabled");
return;
}
validateLinearBridgeConfig();
connector = new DiscordConnector(client, new LinearConnector());
connector.register();
}

export async function backfillBridge(client: Client): Promise<void> {
if (!config.linearBridge.enabled) return;
const source =
connector ?? new DiscordConnector(client, new LinearConnector());
await source.backfill();
}
82 changes: 82 additions & 0 deletions src/bridge/core/connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type {
ExternalRef,
Message,
Post,
Reaction,
} from "@bridge/core/model.js";

export interface IssueState {
type: string;
name: string;
}

export interface LinkedIssue {
id: string;
identifier: string;
url: string;
}

export type ReactionTarget = { issueId: string } | { commentId: string };

// The hub store. Linear implements this today; every method speaks the
// source-agnostic model so another hub could be swapped in. Keyed by the source
// entity's ExternalRef (resolved to a hub issue via its URL attachment).
export interface Target {
findIssueId(ref: ExternalRef): Promise<string | null>;
ensureIssue(post: Post): Promise<string>;
deleteIssue(ref: ExternalRef): Promise<void>;

// Refresh linking attachment, title, project and labels from the post.
reconcile(issueId: string, post: Post): Promise<void>;
syncLabels(issueId: string, post: Post): Promise<void>;

setDescription(issueId: string, text: string): Promise<void>;
updateDescription(issueId: string, message: Message): Promise<void>;

addComment(
issueId: string,
message: Message,
parentId?: string,
): Promise<void>;
editComment(issueId: string, message: Message): Promise<boolean>;
deleteComment(issueId: string, ref: ExternalRef): Promise<boolean>;
mirroredMessageIds(issueId: string): Promise<Set<string>>;
resolveReplyParent(
issueId: string,
messageId: string,
): Promise<string | null>;
findCommentId(issueId: string, messageId: string): Promise<string | null>;

// Plain system note (no marker), e.g. "thread closed".
note(issueId: string, body: string, createdAt?: Date): Promise<void>;

getState(issueId: string): Promise<IssueState | null>;
setState(
issueId: string,
type: "completed" | "triage" | "started",
name?: string,
): Promise<void>;

addReaction(target: ReactionTarget, reaction: Reaction): Promise<void>;
removeReaction(target: ReactionTarget, reaction: Reaction): Promise<void>;

resolveByUrl(url: string): Promise<LinkedIssue | null>;
relate(issueId: string, otherId: string): Promise<void>;

issueRef(issueId: string): Promise<{ identifier: string; url: string }>;
}

// A platform that originates conversations (Discord today, GitHub Discussions
// planned). It registers listeners that drive the mirror, enumerates posts for
// backfill, and writes the hub link back into the source. Everything syncs both
// ways eventually; a connector grows into the hub's role by implementing more of
// the reverse direction, so Source and Target are capabilities one module can
// hold rather than separate layers.
export interface Source {
register(): void;
backfill(): Promise<void>;
announce(
post: Post,
issue: { identifier: string; url: string },
): Promise<void>;
}
Loading
Loading