From 66bc25997d4ca6621cfbefae80e06a806babd931 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:50:40 +0000 Subject: [PATCH 01/62] 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. --- AGENTS.md | 18 +++- bun.lockb | Bin 17180 -> 18380 bytes config.json.example | 9 ++ package.json | 2 + scripts/discord-linear-sync.ts | 86 ++++++++++++++++ src/bridge/linear/api.ts | 179 +++++++++++++++++++++++++++++++++ src/bridge/linear/client.ts | 23 +++++ src/bridge/linear/index.ts | 160 +++++++++++++++++++++++++++++ src/events/bridge.ts | 41 ++++++++ src/events/channels.ts | 21 +++- src/events/messages.ts | 7 +- src/index.ts | 8 +- src/lib/bus.ts | 40 ++++++++ src/lib/config.ts | 37 +++++++ src/lib/discord/help.ts | 107 +++++++++++++++++++- tsconfig.json | 1 + 16 files changed, 730 insertions(+), 9 deletions(-) create mode 100644 scripts/discord-linear-sync.ts create mode 100644 src/bridge/linear/api.ts create mode 100644 src/bridge/linear/client.ts create mode 100644 src/bridge/linear/index.ts create mode 100644 src/events/bridge.ts create mode 100644 src/lib/bus.ts diff --git a/AGENTS.md b/AGENTS.md index 88ef0e2..a6e4536 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + bus.ts Typed domain event bus (help thread/message/status). discord/ channels, users, messages helpers. ui/components/ StringSelectMenu builders for the walkthrough. +scripts/ + discord-linear-sync.ts One-shot Linear backfill (bun scripts/...). assets/tags.json Canned response text. ``` +## Domain event bus + +The help/issue-management flow emits enriched domain events on `src/lib/bus.ts` +(`helpThreadCreated`, `helpMessagePosted`, `helpThreadStatusChanged`). +Consumers such as the Linear bridge (`src/events/bridge.ts`) subscribe to these +instead of re-deriving help-post state from raw Discord events. Emit from the +help flow; never make consumers re-run `isHelpPost`. + ## Conventions - **Imports**: Use the `.js` extension on relative imports (ESM/NodeNext), @@ -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. diff --git a/bun.lockb b/bun.lockb index b986e48edb67158677fb127acc7a31d13823528f..db7040a3b9c4705e1cbca2534e8fcaec74dcb92f 100755 GIT binary patch delta 3169 zcmeHJe^AuN72o{=az7NofZTB>KSd>Q-@D@u?=B#Rkp?S6+GHjLN#+s)7a^jD+>v-r z#yeZaI;~OZn^cX_SmPw-hen18ipEBCl90xhNi(6-nvRJ-l9aY%ZLIAP#eVjC_m#}l zPCK3cnf=atpZ9j(zJ0sj-F^GqMt){1-zJwFeE8Bt`k_Bu-^~ux%$zkc``zyLPmlaH zbbQTJ7CE2{)gYPH}`yn+b(iKScLXxV|=-7xlowY7_PUD~j&%<$W;j>6`Uc z)RfLj{q?)6cxUzHF|)Ir{7LSx;)d==;t5AH4Z~ z=iWccS%`oZRE)^}!J-)!Dj{my5vF8-S1xx~$lyqK1Q&!c|u`J|oG z$QMuxcnJ-G-$@y{jl7gPz{_YD+%jOyyC6|CuZpf0|?qj8~HfttwtWB7Q0c>NK|7rnAGRM3tgG-KJmjJmrL9!+hs9< z`4lP7PI7wO^t9DzQr{gT&7WOt{*`Z1;tsh&78CZ-Fq{VIEV$B#((PtiV9S{r=o6bE z-jw9DjZ>dZn7?Xsh!}1)q1Qql4^s&+-eFFK?O#XsC5HCV>vp3_eYmtXAN}Co?A~q4 zq$|XvZ_ovM10N)NxtMxAxbU1g&5k8hrf1~B#q3 zSDfK%(tb)6ln{VmD+bk U1*)pMbr-OG&vhQ4HF?|q38X}qp#T5? delta 2546 zcmcImdu)?c6u-A!VeKBQIN8d|9&8M{NA0^E!+oPZm?M+OBSTCk3LC3{=vr_lXd5$8 z17wU7K?6pd#!#jNQ&G?;k`OV0pi3l?ggF$56a&g5Q3-;-^Yv@?2M~zSlb-Xt=RVJO z?z!jmaIf6DMsBd%x&}^_<#ZJXcdp*wwzs-TwEt$9|9W_nOMYz+udQshuL}y5s{2|? zqSvcZN=h1qh)qM&3|!IC9$C~W#LA8p>fj-*e{p*_(uKAQY{I-woe*~etw3HkN7EaD zcR{b!a9A%yKJ>q(5MzMLRxExZ9K`|WwT?)*c6q3~y(}v$Efm))ax*0%kCQD! zktPa3&ZRiy8cIUGKsJLS4^Rj)kK&N6l!V+uwo!__L?OsJibF;z3AvkWqZRo(g&?O; z9C86AA^XXesYo-0G8N;}Q9?Aq7D*Qjeq#dK3}`yKFxs!XGK#8<4N{?yQIQdfLw-z2 z$V+51DKbDIlVW%tUaF;Y#(LwIXg#1V{z&hada`AybFvgIwNH|X{nX-9U0VGln4 zQTi~u-e88C=c%m@v>s7gpQ1I3mgh{9*|Z}^$-oV?s000I&E%Hu97b}dMq#U9W@1FV z@cpTDGsGRU+Xb$huR*1oA+BE(gAq=m6wFkr$`tbYG9jXB7aBJpA6}*P-wE=3H<&l} zXy^qpF|r|k4eNkRb=vu8q=*q0Af{{jzeQetN1jc6f6C?mv1`>&xSeOC|DS79F1(Fr ztM1&@iM{4X7I}+jQul-$T318K%w6LNya~A>yzZmtEeV3R&DJ#>|Se-I;b@fJjeel}#6mg#o%<;rG!<5!Jmhi$< zVh8e^?b;CsPS!5oeO@K`XCH0M|KDCAXjdn9sB z?wq(eCvzTE-=+!3(Zz3!qtyyViWF18Y+?-F&2m}1>B?mJrq4L^@~#1$ zblaVFwmMyk-hC$j;Hp>Fx^&Wm5hq5h^p3mGT9+bg3LTw&d*cKi8IEmcyu98X9*Z|!Nq3L+tlxk7z&O=0>i4B~O*C*VCSyK}GQX+@$b lNX>z!#9M&}jfu(kozR { + console.error(err); + process.exit(1); +}); diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts new file mode 100644 index 0000000..0cd0422 --- /dev/null +++ b/src/bridge/linear/api.ts @@ -0,0 +1,179 @@ +import { bridgeConfig, linear } from "./client.js"; + +// Metadata stored on the Discord attachment of a mirrored issue. +export interface ThreadAttachmentFields { + url: string; + title: string; + subtitle: string; + metadata: Record; +} + +export interface GroupLabel { + id: string; + name: string; + description?: string; +} + +// Locates the issue mapped to a Discord thread via the attachment whose URL is +// the thread URL. Returns both the issue and attachment ids so callers can +// update the attachment in place. +export async function findThreadMapping( + url: string, +): Promise<{ issueId: string; attachmentId: string } | null> { + const attachments = await linear().attachmentsForURL(url); + const node = attachments.nodes[0]; + if (!node) return null; + + const issue = await node.issue; + if (!issue) return null; + + return { issueId: issue.id, attachmentId: node.id }; +} + +// Creates an issue in the configured team and returns its id. +export async function createIssue(input: { + title: string; + description: string; +}): Promise { + const payload = await linear().createIssue({ + teamId: bridgeConfig().teamId, + title: input.title, + description: input.description, + }); + + const issue = await payload.issue; + if (!issue) throw new Error("Linear did not return the created issue"); + return issue.id; +} + +// Creates or updates the single Discord attachment on an issue, keyed by URL. +export async function upsertThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + const mapping = await findThreadMapping(fields.url); + + if (mapping) { + await linear().updateAttachment(mapping.attachmentId, { + title: fields.title, + subtitle: fields.subtitle, + metadata: fields.metadata, + }); + return; + } + + await linear().createAttachment({ + issueId, + url: fields.url, + title: fields.title, + subtitle: fields.subtitle, + metadata: fields.metadata, + }); +} + +export async function addComment(issueId: string, body: string): Promise { + await linear().createComment({ issueId, body }); +} + +// Moves an issue to the first workflow state of the given type in the team. +export async function setIssueState( + issueId: string, + type: "completed" | "started", +): Promise { + const stateId = await findStateId(type); + if (!stateId) return; + await linear().updateIssue(issueId, { stateId }); +} + +const stateIdByType = new Map(); + +async function findStateId(type: string): Promise { + const cached = stateIdByType.get(type); + if (cached) return cached; + + const { teamId } = bridgeConfig(); + const states = await linear().workflowStates({ + filter: { team: { id: { eq: teamId } }, type: { eq: type } }, + }); + + const id = states.nodes[0]?.id ?? null; + if (id) stateIdByType.set(type, id); + return id; +} + +// --- Labels --------------------------------------------------------------- + +let groupIdCache: string | undefined; + +// Finds or creates the team-scoped label group that holds Discord tag labels. +export async function ensureLabelGroup(name: string): Promise { + if (groupIdCache) return groupIdCache; + + const { teamId } = bridgeConfig(); + const existing = await linear().issueLabels({ + filter: { name: { eq: name }, team: { id: { eq: teamId } } }, + }); + + const found = existing.nodes[0]; + if (found) { + groupIdCache = found.id; + return found.id; + } + + const payload = await linear().createIssueLabel({ + name, + teamId, + isGroup: true, + }); + const label = await payload.issueLabel; + if (!label) throw new Error("Linear did not return the created label group"); + + groupIdCache = label.id; + return label.id; +} + +// Lists the child labels of a group in the team. +export async function getGroupLabels(groupId: string): Promise { + const { teamId } = bridgeConfig(); + const labels = await linear().issueLabels({ + filter: { parent: { id: { eq: groupId } }, team: { id: { eq: teamId } } }, + }); + + return labels.nodes.map((l) => ({ + id: l.id, + name: l.name, + description: l.description ?? undefined, + })); +} + +// Creates a child label whose description is the Discord tag id. +export async function createLabel(input: { + name: string; + tagId: string; + groupId: string; +}): Promise { + const payload = await linear().createIssueLabel({ + name: input.name, + description: input.tagId, + teamId: bridgeConfig().teamId, + parentId: input.groupId, + }); + + const label = await payload.issueLabel; + if (!label) throw new Error("Linear did not return the created label"); + return { id: label.id, name: label.name, description: input.tagId }; +} + +export async function renameLabel(id: string, name: string): Promise { + await linear().updateIssueLabel(id, { name }); +} + +// Reconciles an issue's group labels to exactly match the given tag set, adding +// missing ones and removing stale ones without touching non-group labels. +export async function setIssueGroupLabels( + issueId: string, + addedLabelIds: string[], + removedLabelIds: string[], +): Promise { + await linear().updateIssue(issueId, { addedLabelIds, removedLabelIds }); +} diff --git a/src/bridge/linear/client.ts b/src/bridge/linear/client.ts new file mode 100644 index 0000000..54c6fdd --- /dev/null +++ b/src/bridge/linear/client.ts @@ -0,0 +1,23 @@ +import { LinearClient } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +// Returns the validated bridge config, narrowing the optional fields to strings. +// validateLinearBridgeConfig() runs at startup, so these are present whenever the +// bridge is enabled. +export function bridgeConfig(): { apiKey: string; teamId: string } { + const { apiKey, teamId } = config.linearBridge; + if (!apiKey || !teamId) { + throw new Error("linearBridge is enabled but apiKey/teamId are missing"); + } + return { apiKey, teamId }; +} + +let client: LinearClient | undefined; + +export function linear(): LinearClient { + if (!client) { + client = new LinearClient({ apiKey: bridgeConfig().apiKey }); + } + return client; +} diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts new file mode 100644 index 0000000..a18a1d4 --- /dev/null +++ b/src/bridge/linear/index.ts @@ -0,0 +1,160 @@ +import { config } from "@lib/config.js"; +import { + getHelpThreadContext, + type HelpMessageContext, + type HelpThreadContext, + type HelpThreadStatusContext, +} from "@lib/discord/help.js"; + +import * as linear from "./api.js"; + +// Caches the create/lookup promise per thread so concurrent events (e.g. thread +// creation racing with the initial waiting-tag change) resolve to one issue. +const issueByThread = new Map>(); + +function threadMetadata(ctx: HelpThreadContext): Record { + return { + threadId: ctx.thread.id, + tagIds: ctx.tags.map((t) => t.id), + tagNames: ctx.tags.map((t) => t.name), + status: ctx.status, + waiting: ctx.waiting, + }; +} + +function attachmentSubtitle(ctx: HelpThreadContext): string { + const parts = ["#help"]; + if (ctx.status === "closed") parts.push("closed"); + if (ctx.waiting) parts.push(`waiting: ${ctx.waiting}`); + if (ctx.tags.length > 0) { + parts.push(`tags: ${ctx.tags.map((t) => t.name).join(", ")}`); + } + return parts.join(" - "); +} + +function authorLabel(ctx: HelpMessageContext): string { + const name = ctx.member?.displayName ?? ctx.message.author.username; + return ctx.isTeam ? `${name} (Coder team)` : name; +} + +async function buildDescription(ctx: HelpThreadContext): Promise { + const starter = await ctx.thread.fetchStarterMessage().catch(() => null); + const body = starter?.content?.trim(); + const link = `[Discord thread](${ctx.url})`; + return body ? `${body}\n\n${link}` : link; +} + +// Ensures a Linear issue exists for a help thread, reusing an existing mapping +// (from the thread-URL attachment) or creating a new issue with its attachment. +export async function ensureIssueForThread( + ctx: HelpThreadContext, +): Promise { + const cached = issueByThread.get(ctx.thread.id); + if (cached) return cached; + + const pending = (async () => { + const mapping = await linear.findThreadMapping(ctx.url); + if (mapping) return mapping.issueId; + + const issueId = await linear.createIssue({ + title: ctx.title, + description: await buildDescription(ctx), + }); + await linear.upsertThreadAttachment(issueId, { + url: ctx.url, + title: "Discord thread", + subtitle: attachmentSubtitle(ctx), + metadata: threadMetadata(ctx), + }); + return issueId; + })(); + + issueByThread.set(ctx.thread.id, pending); + try { + return await pending; + } catch (err) { + issueByThread.delete(ctx.thread.id); + throw err; + } +} + +// Reconciles the issue's group labels to match the thread's current tags. +async function syncLabels( + issueId: string, + ctx: HelpThreadContext, +): Promise { + if (!config.linearBridge.labels.enabled) return; + + const groupId = await linear.ensureLabelGroup( + config.linearBridge.labels.groupName, + ); + const groupLabels = await linear.getGroupLabels(groupId); + const byTagId = new Map( + groupLabels + .filter((l) => l.description) + .map((l) => [l.description as string, l]), + ); + + const desiredIds: string[] = []; + for (const tag of ctx.tags) { + let label = byTagId.get(tag.id); + if (!label) { + label = await linear.createLabel({ + name: tag.name, + tagId: tag.id, + groupId, + }); + byTagId.set(tag.id, label); + } else if (label.name !== tag.name) { + await linear.renameLabel(label.id, tag.name); + } + desiredIds.push(label.id); + } + + const removedIds = groupLabels + .map((l) => l.id) + .filter((id) => !desiredIds.includes(id)); + + await linear.setIssueGroupLabels(issueId, desiredIds, removedIds); +} + +export async function mirrorThreadCreated( + ctx: HelpThreadContext, +): Promise { + const issueId = await ensureIssueForThread(ctx); + await syncLabels(issueId, ctx); +} + +export async function mirrorMessage(ctx: HelpMessageContext): Promise { + // The forum starter message becomes the issue description, not a comment. + // Its id equals the thread id for forum posts. + if (ctx.message.id === ctx.thread.id) return; + + const content = ctx.message.content?.trim(); + if (!content) return; + + const issueId = await ensureIssueForThread(getHelpThreadContext(ctx.thread)); + await linear.addComment(issueId, `**${authorLabel(ctx)}**\n\n${content}`); +} + +export async function mirrorStatus( + ctx: HelpThreadStatusContext, +): Promise { + const issueId = await ensureIssueForThread(ctx); + + await linear.upsertThreadAttachment(issueId, { + url: ctx.url, + title: "Discord thread", + subtitle: attachmentSubtitle(ctx), + metadata: threadMetadata(ctx), + }); + await syncLabels(issueId, ctx); + + if (ctx.reason === "closed") { + await linear.setIssueState(issueId, "completed"); + await linear.addComment(issueId, "_Thread closed on Discord._"); + } else if (ctx.reason === "reopened") { + await linear.setIssueState(issueId, "started"); + await linear.addComment(issueId, "_Thread reopened on Discord._"); + } +} diff --git a/src/events/bridge.ts b/src/events/bridge.ts new file mode 100644 index 0000000..9d54c2d --- /dev/null +++ b/src/events/bridge.ts @@ -0,0 +1,41 @@ +import type { Client } from "discord.js"; + +import { bus } from "@lib/bus.js"; +import { config, validateLinearBridgeConfig } from "@lib/config.js"; + +import { + mirrorMessage, + mirrorStatus, + mirrorThreadCreated, +} from "@bridge/linear/index.js"; + +// Wraps an async bus handler so a Linear failure is logged, never thrown into +// the emitter (which would surface as an unhandled rejection). +function guard( + name: string, + handler: (ctx: T) => Promise, +): (ctx: T) => void { + return (ctx) => { + handler(ctx).catch((err) => + console.error(`Linear bridge "${name}" failed:`, err), + ); + }; +} + +export default function registerEvents(_client: Client) { + if (!config.linearBridge.enabled) { + console.log("Linear bridge is disabled."); + return; + } + + validateLinearBridgeConfig(); + + bus.on("helpThreadCreated", guard("helpThreadCreated", mirrorThreadCreated)); + bus.on("helpMessagePosted", guard("helpMessagePosted", mirrorMessage)); + bus.on( + "helpThreadStatusChanged", + guard("helpThreadStatusChanged", mirrorStatus), + ); + + console.log("Linear bridge is enabled."); +} diff --git a/src/events/channels.ts b/src/events/channels.ts index 005864a..c1629e7 100644 --- a/src/events/channels.ts +++ b/src/events/channels.ts @@ -1,7 +1,12 @@ import { config } from "../lib/config.js"; import { getTagsForCloseState } from "../commands/util/close.js"; +import { bus } from "../lib/bus.js"; import { isHelpPost } from "../lib/discord/channels.js"; -import { applyWaitingTag } from "../lib/discord/help.js"; +import { + applyWaitingTag, + emitStatusChange, + getHelpThreadContext, +} from "../lib/discord/help.js"; import { debounce } from "throttle-debounce"; @@ -20,6 +25,16 @@ const handleEvent = debounce( // Remove from map threadUpdateMap.delete(threadId); + // Propagate open/closed transitions to the domain bus. This fires for both + // manual tag edits and command-driven closes (both call setAppliedTags), + // so status is emitted from a single place. + const { closedTag } = config.helpChannel; + const wasClosed = initialThread.appliedTags.includes(closedTag); + const isClosed = newThread.appliedTags.includes(closedTag); + if (wasClosed !== isClosed) { + emitStatusChange(newThread, isClosed ? "closed" : "reopened"); + } + // Handle tag additions const addedTags = newThread.appliedTags.filter( (t) => !initialThread.appliedTags.includes(t), @@ -70,6 +85,10 @@ export default function registerEvents(client: Client) { return; } + // Announce the new help post to the domain bus before tagging, so consumers + // (bridges) can create their mirror first. + bus.emit("helpThreadCreated", getHelpThreadContext(thread)); + // A new help post is waiting for the Coder team to respond. await applyWaitingTag(thread, false); }); diff --git a/src/events/messages.ts b/src/events/messages.ts index 36e4a3c..b93db0b 100644 --- a/src/events/messages.ts +++ b/src/events/messages.ts @@ -1,5 +1,6 @@ import { type Client, Events, MessageType } from "discord.js"; +import { bus } from "@lib/bus.js"; import { isHelpPost } from "@lib/discord/channels.js"; import { reconcileFromMessage } from "@lib/discord/help.js"; @@ -14,9 +15,11 @@ export default function registerEvents(client: Client) { return; } - // Keep the help posts' waiting tag in sync with the latest interaction. + // Keep the help posts' waiting tag in sync with the latest interaction, and + // forward the enriched message to the domain bus for consumers (bridges). if (message.inGuild() && (await isHelpPost(message.channel))) { - await reconcileFromMessage(message); + const ctx = await reconcileFromMessage(message); + if (ctx) bus.emit("helpMessagePosted", ctx); } }); } diff --git a/src/index.ts b/src/index.ts index b2eb80e..63acec2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,16 @@ import registerCommandEvents from "./events/commands.js"; import registerWalkthroughEvents from "./events/walkthrough.js"; import registerMessageEvents from "./events/messages.js"; import registerChannelEvents from "./events/channels.js"; +import registerBridgeEvents from "./events/bridge.js"; import { Client, Events, GatewayIntentBits, ActivityType } from "discord.js"; const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], }); const presenceList = [ @@ -40,6 +45,7 @@ client.once(Events.ClientReady, () => { registerWalkthroughEvents(client); registerMessageEvents(client); registerChannelEvents(client); + registerBridgeEvents(client); shufflePresence(); setInterval(shufflePresence, config.presenceDelay); diff --git a/src/lib/bus.ts b/src/lib/bus.ts new file mode 100644 index 0000000..670ff6d --- /dev/null +++ b/src/lib/bus.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from "node:events"; + +import type { + HelpMessageContext, + HelpThreadContext, + HelpThreadStatusContext, +} from "@lib/discord/help.js"; + +// Enriched, domain-level events emitted by the help/issue-management flow. +// Consumers (e.g. the Linear bridge) subscribe here instead of re-deriving +// help-post state from raw Discord events. +type HelpEvents = { + helpThreadCreated: [HelpThreadContext]; + helpMessagePosted: [HelpMessageContext]; + helpThreadStatusChanged: [HelpThreadStatusContext]; +}; + +// Thin typed wrapper over Node's EventEmitter. +class TypedEmitter> { + private readonly emitter = new EventEmitter(); + + on( + event: K, + listener: (...args: Events[K]) => void, + ): this { + this.emitter.on(event, listener as (...args: unknown[]) => void); + return this; + } + + // Emits without letting a consumer error propagate into the help flow. + emit(event: K, ...args: Events[K]): void { + try { + this.emitter.emit(event, ...args); + } catch (err) { + console.error(`Error in "${event}" handler:`, err); + } + } +} + +export const bus = new TypedEmitter(); diff --git a/src/lib/config.ts b/src/lib/config.ts index 9b2cedc..842fce9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -35,6 +35,17 @@ interface Config { companyId: string; }; + // One-way Discord -> Linear bridge for #help threads. Disabled by default. + linearBridge: { + enabled: boolean; + apiKey?: string; + teamId?: string; + labels: { + enabled: boolean; + groupName: string; + }; + }; + presenceDelay: number; } @@ -51,6 +62,13 @@ export const { config, layers } = await loadConfig({ defaults: { presenceDelay: 10 * 60 * 1000, startupCatchupLimit: 20, + linearBridge: { + enabled: false, + labels: { + enabled: true, + groupName: "Discord (#help)", + }, + }, }, mandatory: [ "token", @@ -75,3 +93,22 @@ export const { config, layers } = await loadConfig({ ["productBoard", "companyId"], ], }); + +// linearBridge fields are conditionally required: only when the bridge is +// enabled. configmasher's `mandatory` list is static, so validate here and exit +// the same way a missing mandatory field would. +export function validateLinearBridgeConfig(): void { + const { linearBridge } = config; + if (!linearBridge.enabled) return; + + const missing: string[] = []; + if (!linearBridge.apiKey) missing.push("linearBridge.apiKey"); + if (!linearBridge.teamId) missing.push("linearBridge.teamId"); + + if (missing.length > 0) { + console.error( + `linearBridge.enabled is true but required config is missing: ${missing.join(", ")}`, + ); + process.exit(1); + } +} diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts index 573e749..7577290 100644 --- a/src/lib/discord/help.ts +++ b/src/lib/discord/help.ts @@ -1,3 +1,4 @@ +import { bus } from "@lib/bus.js"; import { config } from "@lib/config.js"; import { isTeamMember } from "@lib/discord/users.js"; @@ -10,6 +11,81 @@ import { MessageType, } from "discord.js"; +// A Discord forum tag applied to a help post. +export interface HelpTag { + id: string; + name: string; +} + +// Enriched snapshot of a help post, shared by the domain events. +export interface HelpThreadContext { + thread: ThreadChannel; + url: string; + title: string; + status: "open" | "closed"; + waiting: "user" | "team" | null; + // Applied tags (minus open/closed), resolved to id + name. + tags: HelpTag[]; +} + +// A help post message paired with its resolved author. +export interface HelpMessageContext { + thread: ThreadChannel; + message: Message; + member: GuildMember | null; + isTeam: boolean; +} + +// A help post whose status or waiting state just changed. +export interface HelpThreadStatusContext extends HelpThreadContext { + reason: "closed" | "reopened" | "waiting-changed"; +} + +// Resolves applied tag ids to id + name using the parent forum's tag list, +// dropping the open/closed lifecycle tags. +function resolveTags(thread: ThreadChannel): HelpTag[] { + const forum = thread.parent; + const available = + forum && "availableTags" in forum ? forum.availableTags : []; + const nameById = new Map(available.map((t) => [t.id, t.name])); + + const { closedTag, openedTag } = config.helpChannel; + return thread.appliedTags + .filter((id) => id !== closedTag && id !== openedTag) + .map((id) => ({ id, name: nameById.get(id) ?? id })); +} + +// Builds an enriched snapshot of a help post from its applied tags. +export function getHelpThreadContext(thread: ThreadChannel): HelpThreadContext { + const { closedTag, waitingForTeamTag, waitingForUserTag } = + config.helpChannel; + const tags = thread.appliedTags; + + return { + thread, + url: thread.url, + title: thread.name, + status: tags.includes(closedTag) ? "closed" : "open", + waiting: tags.includes(waitingForTeamTag) + ? "team" + : tags.includes(waitingForUserTag) + ? "user" + : null, + tags: resolveTags(thread), + }; +} + +// Emits a status-change event for a help post. +export function emitStatusChange( + thread: ThreadChannel, + reason: HelpThreadStatusContext["reason"], +): void { + bus.emit("helpThreadStatusChanged", { + ...getHelpThreadContext(thread), + reason, + }); +} + // Message types that represent an actual interaction from a person, as opposed // to system notices (pins, joins, etc). const humanMessageTypes = new Set([MessageType.Default, MessageType.Reply]); @@ -48,6 +124,8 @@ export async function applyWaitingTag( ].slice(0, 5); await thread.setAppliedTags(nextTags, "Help post waiting state"); + + emitStatusChange(thread, "waiting-changed"); } async function resolveMember(message: Message): Promise { @@ -60,6 +138,22 @@ async function resolveMember(message: Message): Promise { } } +// Builds a message context for a human help-post message, resolving the author +// once. Returns null for bot/system messages. +export async function buildHelpMessageContext( + message: Message, +): Promise { + if (!isHumanMessage(message)) return null; + + const member = await resolveMember(message); + return { + thread: message.channel as ThreadChannel, + message, + member, + isTeam: member ? isTeamMember(member) : false, + }; +} + // Applies the waiting tag for a help post based on who sent the given message. async function applyWaitingTagFromMessage( thread: ThreadChannel, @@ -69,10 +163,15 @@ async function applyWaitingTagFromMessage( await applyWaitingTag(thread, member ? isTeamMember(member) : false); } -// Reconciles a single help post from a freshly received message. -export async function reconcileFromMessage(message: Message): Promise { - if (!isHumanMessage(message)) return; - await applyWaitingTagFromMessage(message.channel as ThreadChannel, message); +// Reconciles a single help post from a freshly received message, returning the +// message context so callers can forward it to the domain bus. +export async function reconcileFromMessage( + message: Message, +): Promise { + const ctx = await buildHelpMessageContext(message); + if (!ctx) return null; + await applyWaitingTag(ctx.thread, ctx.isTeam); + return ctx; } // Reconciles a help post by inspecting its most recent human message. diff --git a/tsconfig.json b/tsconfig.json index 3d93445..2154f61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@commands/*": ["./src/commands/*"], "@events/*": ["./src/events/*"], "@lib/*": ["./src/lib/*"], + "@bridge/*": ["./src/bridge/*"], "@components/*": ["./src/ui/components/*"] } From 55205f2bc770f91549bf1d0374cfef27d2fc0f97 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:44:13 +0000 Subject: [PATCH 02/62] refactor(src/bridge/linear): inline Linear client into api.ts --- src/bridge/linear/api.ts | 24 +++++++++++++++++++++++- src/bridge/linear/client.ts | 23 ----------------------- 2 files changed, 23 insertions(+), 24 deletions(-) delete mode 100644 src/bridge/linear/client.ts diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 0cd0422..9e19001 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -1,4 +1,26 @@ -import { bridgeConfig, linear } from "./client.js"; +import { LinearClient } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +// Returns the validated bridge config, narrowing the optional fields to strings. +// validateLinearBridgeConfig() runs at startup, so these are present whenever the +// bridge is enabled. +function bridgeConfig(): { apiKey: string; teamId: string } { + const { apiKey, teamId } = config.linearBridge; + if (!apiKey || !teamId) { + throw new Error("linearBridge is enabled but apiKey/teamId are missing"); + } + return { apiKey, teamId }; +} + +let client: LinearClient | undefined; + +function linear(): LinearClient { + if (!client) { + client = new LinearClient({ apiKey: bridgeConfig().apiKey }); + } + return client; +} // Metadata stored on the Discord attachment of a mirrored issue. export interface ThreadAttachmentFields { diff --git a/src/bridge/linear/client.ts b/src/bridge/linear/client.ts deleted file mode 100644 index 54c6fdd..0000000 --- a/src/bridge/linear/client.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { LinearClient } from "@linear/sdk"; - -import { config } from "@lib/config.js"; - -// Returns the validated bridge config, narrowing the optional fields to strings. -// validateLinearBridgeConfig() runs at startup, so these are present whenever the -// bridge is enabled. -export function bridgeConfig(): { apiKey: string; teamId: string } { - const { apiKey, teamId } = config.linearBridge; - if (!apiKey || !teamId) { - throw new Error("linearBridge is enabled but apiKey/teamId are missing"); - } - return { apiKey, teamId }; -} - -let client: LinearClient | undefined; - -export function linear(): LinearClient { - if (!client) { - client = new LinearClient({ apiKey: bridgeConfig().apiKey }); - } - return client; -} From 82bbbc027e3d44155a03e7babc37bd82bf41ec37 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:49:32 +0000 Subject: [PATCH 03/62] 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. --- scripts/discord-linear-sync.ts | 9 ++++----- src/bridge/linear/api.ts | 32 +++++++++++++++----------------- src/bridge/linear/index.ts | 34 +++++++++++++++++----------------- src/events/channels.ts | 5 ++--- 4 files changed, 38 insertions(+), 42 deletions(-) diff --git a/scripts/discord-linear-sync.ts b/scripts/discord-linear-sync.ts index 8612fa8..8f24fae 100644 --- a/scripts/discord-linear-sync.ts +++ b/scripts/discord-linear-sync.ts @@ -1,9 +1,8 @@ -// Manual one-shot backfill: mirrors existing #help forum threads (and their -// recent messages) into Linear using the src/bridge/linear lib. Intended for -// seeding; re-running re-adds comments (issues/labels/attachments are -// deduped, comments are not), matching the MVP's per-run message dedup. +// One-shot backfill: mirrors existing #help threads and their recent messages +// into Linear via src/bridge/linear. Re-running re-adds comments (issues, +// labels, and attachments are deduped; comments are not). // -// Run with: bun scripts/discord-linear-sync.ts +// Run: bun run sync:linear import { ChannelType, diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 9e19001..287abf2 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -2,9 +2,7 @@ import { LinearClient } from "@linear/sdk"; import { config } from "@lib/config.js"; -// Returns the validated bridge config, narrowing the optional fields to strings. -// validateLinearBridgeConfig() runs at startup, so these are present whenever the -// bridge is enabled. +// Validated bridge credentials. Present whenever the bridge is enabled. function bridgeConfig(): { apiKey: string; teamId: string } { const { apiKey, teamId } = config.linearBridge; if (!apiKey || !teamId) { @@ -36,9 +34,8 @@ export interface GroupLabel { description?: string; } -// Locates the issue mapped to a Discord thread via the attachment whose URL is -// the thread URL. Returns both the issue and attachment ids so callers can -// update the attachment in place. +// Finds the issue mapped to a thread via its URL attachment, returning the +// issue and attachment ids. export async function findThreadMapping( url: string, ): Promise<{ issueId: string; attachmentId: string } | null> { @@ -68,25 +65,26 @@ export async function createIssue(input: { return issue.id; } -// Creates or updates the single Discord attachment on an issue, keyed by URL. +// Creates the Discord attachment on a freshly created issue. +export async function createThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + await linear().createAttachment({ issueId, ...fields }); +} + +// Updates the issue's Discord attachment in place, or creates it if missing. export async function upsertThreadAttachment( issueId: string, fields: ThreadAttachmentFields, ): Promise { const mapping = await findThreadMapping(fields.url); - - if (mapping) { - await linear().updateAttachment(mapping.attachmentId, { - title: fields.title, - subtitle: fields.subtitle, - metadata: fields.metadata, - }); + if (!mapping) { + await createThreadAttachment(issueId, fields); return; } - await linear().createAttachment({ - issueId, - url: fields.url, + await linear().updateAttachment(mapping.attachmentId, { title: fields.title, subtitle: fields.subtitle, metadata: fields.metadata, diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index a18a1d4..4c5f604 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -37,6 +37,17 @@ function authorLabel(ctx: HelpMessageContext): string { return ctx.isTeam ? `${name} (Coder team)` : name; } +function attachmentFields( + ctx: HelpThreadContext, +): linear.ThreadAttachmentFields { + return { + url: ctx.url, + title: "Discord thread", + subtitle: attachmentSubtitle(ctx), + metadata: threadMetadata(ctx), + }; +} + async function buildDescription(ctx: HelpThreadContext): Promise { const starter = await ctx.thread.fetchStarterMessage().catch(() => null); const body = starter?.content?.trim(); @@ -60,12 +71,7 @@ export async function ensureIssueForThread( title: ctx.title, description: await buildDescription(ctx), }); - await linear.upsertThreadAttachment(issueId, { - url: ctx.url, - title: "Discord thread", - subtitle: attachmentSubtitle(ctx), - metadata: threadMetadata(ctx), - }); + await linear.createThreadAttachment(issueId, attachmentFields(ctx)); return issueId; })(); @@ -89,11 +95,10 @@ async function syncLabels( config.linearBridge.labels.groupName, ); const groupLabels = await linear.getGroupLabels(groupId); - const byTagId = new Map( - groupLabels - .filter((l) => l.description) - .map((l) => [l.description as string, l]), - ); + const byTagId = new Map(); + for (const label of groupLabels) { + if (label.description) byTagId.set(label.description, label); + } const desiredIds: string[] = []; for (const tag of ctx.tags) { @@ -142,12 +147,7 @@ export async function mirrorStatus( ): Promise { const issueId = await ensureIssueForThread(ctx); - await linear.upsertThreadAttachment(issueId, { - url: ctx.url, - title: "Discord thread", - subtitle: attachmentSubtitle(ctx), - metadata: threadMetadata(ctx), - }); + await linear.upsertThreadAttachment(issueId, attachmentFields(ctx)); await syncLabels(issueId, ctx); if (ctx.reason === "closed") { diff --git a/src/events/channels.ts b/src/events/channels.ts index c1629e7..42b7b59 100644 --- a/src/events/channels.ts +++ b/src/events/channels.ts @@ -25,9 +25,8 @@ const handleEvent = debounce( // Remove from map threadUpdateMap.delete(threadId); - // Propagate open/closed transitions to the domain bus. This fires for both - // manual tag edits and command-driven closes (both call setAppliedTags), - // so status is emitted from a single place. + // Propagate open/closed transitions once. Fires for manual tag edits and + // command-driven closes alike, since both call setAppliedTags. const { closedTag } = config.helpChannel; const wasClosed = initialThread.appliedTags.includes(closedTag); const isClosed = newThread.appliedTags.includes(closedTag); From 0973f0825159482e618192fad8a9510c1e590b6a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:06:55 +0000 Subject: [PATCH 04/62] 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. --- AGENTS.md | 18 ++--- scripts/discord-linear-sync.ts | 22 +++--- src/bridge/linear/index.ts | 124 ++++++++++++++++----------------- src/events/bridge.ts | 72 +++++++++++++------ src/events/channels.ts | 20 +----- src/events/messages.ts | 7 +- src/lib/bus.ts | 40 ----------- src/lib/discord/help.ts | 115 +++--------------------------- src/lib/discord/helpThread.ts | 52 ++++++++++++++ 9 files changed, 196 insertions(+), 274 deletions(-) delete mode 100644 src/lib/bus.ts create mode 100644 src/lib/discord/helpThread.ts diff --git a/AGENTS.md b/AGENTS.md index a6e4536..bffa0b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,21 +44,21 @@ src/ linear/ Discord -> Linear mirror (client, api, orchestration). lib/ config.ts Typed config loader + mandatory field list. - bus.ts Typed domain event bus (help thread/message/status). - 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 scripts/...). + discord-linear-sync.ts One-shot Linear backfill (bun run sync:linear). assets/tags.json Canned response text. ``` -## Domain event bus +## Linear bridge -The help/issue-management flow emits enriched domain events on `src/lib/bus.ts` -(`helpThreadCreated`, `helpMessagePosted`, `helpThreadStatusChanged`). -Consumers such as the Linear bridge (`src/events/bridge.ts`) subscribe to these -instead of re-deriving help-post state from raw Discord events. Emit from the -help flow; never make consumers re-run `isHelpPost`. +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 diff --git a/scripts/discord-linear-sync.ts b/scripts/discord-linear-sync.ts index 8f24fae..5628f4b 100644 --- a/scripts/discord-linear-sync.ts +++ b/scripts/discord-linear-sync.ts @@ -12,10 +12,9 @@ import { } from "discord.js"; import { config } from "../src/lib/config.js"; -import { - buildHelpMessageContext, - getHelpThreadContext, -} from "../src/lib/discord/help.js"; +import { isHumanMessage, resolveMember } from "../src/lib/discord/help.js"; +import { HelpThread } from "../src/lib/discord/helpThread.js"; +import { isTeamMember } from "../src/lib/discord/users.js"; import { setIssueState } from "../src/bridge/linear/api.js"; import { ensureIssueForThread, @@ -26,20 +25,21 @@ import { const MESSAGE_LIMIT = 50; async function syncThread(thread: ThreadChannel) { - const ctx = getHelpThreadContext(thread); - console.log(`Syncing "${ctx.title}" (${thread.id})`); + const help = new HelpThread(thread); + console.log(`Syncing "${help.title}" (${thread.id})`); - await mirrorThreadCreated(ctx); - const issueId = await ensureIssueForThread(ctx); + await mirrorThreadCreated(help); + const issueId = await ensureIssueForThread(help); const messages = await thread.messages.fetch({ limit: MESSAGE_LIMIT }); // Oldest first so comments read in order. for (const message of [...messages.values()].reverse()) { - const mctx = await buildHelpMessageContext(message); - if (mctx) await mirrorMessage(mctx); + if (!isHumanMessage(message)) continue; + const member = await resolveMember(message); + await mirrorMessage(help, message, member ? isTeamMember(member) : false); } - if (ctx.status === "closed") { + if (help.status === "closed") { await setIssueState(issueId, "completed"); } } diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 4c5f604..4e455fe 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,94 +1,84 @@ import { config } from "@lib/config.js"; -import { - getHelpThreadContext, - type HelpMessageContext, - type HelpThreadContext, - type HelpThreadStatusContext, -} from "@lib/discord/help.js"; +import type { HelpThread } from "@lib/discord/helpThread.js"; + +import type { Message } from "discord.js"; import * as linear from "./api.js"; -// Caches the create/lookup promise per thread so concurrent events (e.g. thread -// creation racing with the initial waiting-tag change) resolve to one issue. +// Caches the create/lookup promise per thread so concurrent listeners resolve +// to a single issue instead of racing to create duplicates. const issueByThread = new Map>(); -function threadMetadata(ctx: HelpThreadContext): Record { +function threadMetadata(help: HelpThread): Record { return { - threadId: ctx.thread.id, - tagIds: ctx.tags.map((t) => t.id), - tagNames: ctx.tags.map((t) => t.name), - status: ctx.status, - waiting: ctx.waiting, + threadId: help.thread.id, + tagIds: help.tags.map((t) => t.id), + tagNames: help.tags.map((t) => t.name), + status: help.status, + waiting: help.waiting, }; } -function attachmentSubtitle(ctx: HelpThreadContext): string { +function attachmentSubtitle(help: HelpThread): string { const parts = ["#help"]; - if (ctx.status === "closed") parts.push("closed"); - if (ctx.waiting) parts.push(`waiting: ${ctx.waiting}`); - if (ctx.tags.length > 0) { - parts.push(`tags: ${ctx.tags.map((t) => t.name).join(", ")}`); + if (help.status === "closed") parts.push("closed"); + if (help.waiting) parts.push(`waiting: ${help.waiting}`); + if (help.tags.length > 0) { + parts.push(`tags: ${help.tags.map((t) => t.name).join(", ")}`); } return parts.join(" - "); } -function authorLabel(ctx: HelpMessageContext): string { - const name = ctx.member?.displayName ?? ctx.message.author.username; - return ctx.isTeam ? `${name} (Coder team)` : name; -} - -function attachmentFields( - ctx: HelpThreadContext, -): linear.ThreadAttachmentFields { +function attachmentFields(help: HelpThread): linear.ThreadAttachmentFields { return { - url: ctx.url, + url: help.url, title: "Discord thread", - subtitle: attachmentSubtitle(ctx), - metadata: threadMetadata(ctx), + subtitle: attachmentSubtitle(help), + metadata: threadMetadata(help), }; } -async function buildDescription(ctx: HelpThreadContext): Promise { - const starter = await ctx.thread.fetchStarterMessage().catch(() => null); +function authorLabel(message: Message, isTeam: boolean): string { + const name = message.member?.displayName ?? message.author.username; + return isTeam ? `${name} (Coder team)` : name; +} + +async function buildDescription(help: HelpThread): Promise { + const starter = await help.thread.fetchStarterMessage().catch(() => null); const body = starter?.content?.trim(); - const link = `[Discord thread](${ctx.url})`; + const link = `[Discord thread](${help.url})`; return body ? `${body}\n\n${link}` : link; } // Ensures a Linear issue exists for a help thread, reusing an existing mapping // (from the thread-URL attachment) or creating a new issue with its attachment. -export async function ensureIssueForThread( - ctx: HelpThreadContext, -): Promise { - const cached = issueByThread.get(ctx.thread.id); +export async function ensureIssueForThread(help: HelpThread): Promise { + const cached = issueByThread.get(help.thread.id); if (cached) return cached; const pending = (async () => { - const mapping = await linear.findThreadMapping(ctx.url); + const mapping = await linear.findThreadMapping(help.url); if (mapping) return mapping.issueId; const issueId = await linear.createIssue({ - title: ctx.title, - description: await buildDescription(ctx), + title: help.title, + description: await buildDescription(help), }); - await linear.createThreadAttachment(issueId, attachmentFields(ctx)); + await linear.createThreadAttachment(issueId, attachmentFields(help)); return issueId; })(); - issueByThread.set(ctx.thread.id, pending); + issueByThread.set(help.thread.id, pending); try { return await pending; } catch (err) { - issueByThread.delete(ctx.thread.id); + issueByThread.delete(help.thread.id); throw err; } } // Reconciles the issue's group labels to match the thread's current tags. -async function syncLabels( - issueId: string, - ctx: HelpThreadContext, -): Promise { +async function syncLabels(issueId: string, help: HelpThread): Promise { if (!config.linearBridge.labels.enabled) return; const groupId = await linear.ensureLabelGroup( @@ -101,7 +91,7 @@ async function syncLabels( } const desiredIds: string[] = []; - for (const tag of ctx.tags) { + for (const tag of help.tags) { let label = byTagId.get(tag.id); if (!label) { label = await linear.createLabel({ @@ -123,37 +113,43 @@ async function syncLabels( await linear.setIssueGroupLabels(issueId, desiredIds, removedIds); } -export async function mirrorThreadCreated( - ctx: HelpThreadContext, -): Promise { - const issueId = await ensureIssueForThread(ctx); - await syncLabels(issueId, ctx); +export async function mirrorThreadCreated(help: HelpThread): Promise { + const issueId = await ensureIssueForThread(help); + await syncLabels(issueId, help); } -export async function mirrorMessage(ctx: HelpMessageContext): Promise { +export async function mirrorMessage( + help: HelpThread, + message: Message, + isTeam: boolean, +): Promise { // The forum starter message becomes the issue description, not a comment. // Its id equals the thread id for forum posts. - if (ctx.message.id === ctx.thread.id) return; + if (message.id === help.thread.id) return; - const content = ctx.message.content?.trim(); + const content = message.content?.trim(); if (!content) return; - const issueId = await ensureIssueForThread(getHelpThreadContext(ctx.thread)); - await linear.addComment(issueId, `**${authorLabel(ctx)}**\n\n${content}`); + const issueId = await ensureIssueForThread(help); + await linear.addComment( + issueId, + `**${authorLabel(message, isTeam)}**\n\n${content}`, + ); } export async function mirrorStatus( - ctx: HelpThreadStatusContext, + help: HelpThread, + reason?: "closed" | "reopened", ): Promise { - const issueId = await ensureIssueForThread(ctx); + const issueId = await ensureIssueForThread(help); - await linear.upsertThreadAttachment(issueId, attachmentFields(ctx)); - await syncLabels(issueId, ctx); + await linear.upsertThreadAttachment(issueId, attachmentFields(help)); + await syncLabels(issueId, help); - if (ctx.reason === "closed") { + if (reason === "closed") { await linear.setIssueState(issueId, "completed"); await linear.addComment(issueId, "_Thread closed on Discord._"); - } else if (ctx.reason === "reopened") { + } else if (reason === "reopened") { await linear.setIssueState(issueId, "started"); await linear.addComment(issueId, "_Thread reopened on Discord._"); } diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 9d54c2d..0bef2e7 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -1,7 +1,12 @@ -import type { Client } from "discord.js"; +import { debounce } from "throttle-debounce"; + +import { type Client, Events, type ThreadChannel } from "discord.js"; -import { bus } from "@lib/bus.js"; import { config, validateLinearBridgeConfig } from "@lib/config.js"; +import { isHelpPost } from "@lib/discord/channels.js"; +import { isHumanMessage, resolveMember } from "@lib/discord/help.js"; +import { HelpThread } from "@lib/discord/helpThread.js"; +import { isTeamMember } from "@lib/discord/users.js"; import { mirrorMessage, @@ -9,20 +14,13 @@ import { mirrorThreadCreated, } from "@bridge/linear/index.js"; -// Wraps an async bus handler so a Linear failure is logged, never thrown into -// the emitter (which would surface as an unhandled rejection). -function guard( - name: string, - handler: (ctx: T) => Promise, -): (ctx: T) => void { - return (ctx) => { - handler(ctx).catch((err) => - console.error(`Linear bridge "${name}" failed:`, err), - ); - }; +// Runs bridge work in the background, logging failures instead of throwing them +// into a Discord event handler. +function guard(name: string, work: Promise): void { + work.catch((err) => console.error(`Linear bridge "${name}" failed:`, err)); } -export default function registerEvents(_client: Client) { +export default function registerEvents(client: Client) { if (!config.linearBridge.enabled) { console.log("Linear bridge is disabled."); return; @@ -30,12 +28,46 @@ export default function registerEvents(_client: Client) { validateLinearBridgeConfig(); - bus.on("helpThreadCreated", guard("helpThreadCreated", mirrorThreadCreated)); - bus.on("helpMessagePosted", guard("helpMessagePosted", mirrorMessage)); - bus.on( - "helpThreadStatusChanged", - guard("helpThreadStatusChanged", mirrorStatus), - ); + client.on(Events.ThreadCreate, async (thread) => { + if (!(await isHelpPost(thread))) return; + guard("threadCreate", mirrorThreadCreated(new HelpThread(thread))); + }); + + client.on(Events.MessageCreate, async (message) => { + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message)) return; + + const member = await resolveMember(message); + const help = new HelpThread(message.channel as ThreadChannel); + const isTeam = member ? isTeamMember(member) : false; + guard("messageCreate", mirrorMessage(help, message, isTeam)); + }); + + // Coalesce bursts of tag edits, remembering the first "before" state so a + // closed/reopened transition can be detected once the dust settles. + const initialStates = new Map(); + const flush = debounce(1000, (threadId: string, newThread: ThreadChannel) => { + const before = initialStates.get(threadId); + initialStates.delete(threadId); + + const after = new HelpThread(newThread); + const reason = + before && before.status !== after.status + ? after.status === "closed" + ? "closed" + : "reopened" + : undefined; + + guard("threadUpdate", mirrorStatus(after, reason)); + }); + + client.on(Events.ThreadUpdate, async (oldThread, newThread) => { + if (!(await isHelpPost(newThread))) return; + if (!initialStates.has(newThread.id)) { + initialStates.set(newThread.id, new HelpThread(oldThread)); + } + flush(newThread.id, newThread); + }); console.log("Linear bridge is enabled."); } diff --git a/src/events/channels.ts b/src/events/channels.ts index 42b7b59..005864a 100644 --- a/src/events/channels.ts +++ b/src/events/channels.ts @@ -1,12 +1,7 @@ import { config } from "../lib/config.js"; import { getTagsForCloseState } from "../commands/util/close.js"; -import { bus } from "../lib/bus.js"; import { isHelpPost } from "../lib/discord/channels.js"; -import { - applyWaitingTag, - emitStatusChange, - getHelpThreadContext, -} from "../lib/discord/help.js"; +import { applyWaitingTag } from "../lib/discord/help.js"; import { debounce } from "throttle-debounce"; @@ -25,15 +20,6 @@ const handleEvent = debounce( // Remove from map threadUpdateMap.delete(threadId); - // Propagate open/closed transitions once. Fires for manual tag edits and - // command-driven closes alike, since both call setAppliedTags. - const { closedTag } = config.helpChannel; - const wasClosed = initialThread.appliedTags.includes(closedTag); - const isClosed = newThread.appliedTags.includes(closedTag); - if (wasClosed !== isClosed) { - emitStatusChange(newThread, isClosed ? "closed" : "reopened"); - } - // Handle tag additions const addedTags = newThread.appliedTags.filter( (t) => !initialThread.appliedTags.includes(t), @@ -84,10 +70,6 @@ export default function registerEvents(client: Client) { return; } - // Announce the new help post to the domain bus before tagging, so consumers - // (bridges) can create their mirror first. - bus.emit("helpThreadCreated", getHelpThreadContext(thread)); - // A new help post is waiting for the Coder team to respond. await applyWaitingTag(thread, false); }); diff --git a/src/events/messages.ts b/src/events/messages.ts index b93db0b..36e4a3c 100644 --- a/src/events/messages.ts +++ b/src/events/messages.ts @@ -1,6 +1,5 @@ import { type Client, Events, MessageType } from "discord.js"; -import { bus } from "@lib/bus.js"; import { isHelpPost } from "@lib/discord/channels.js"; import { reconcileFromMessage } from "@lib/discord/help.js"; @@ -15,11 +14,9 @@ export default function registerEvents(client: Client) { return; } - // Keep the help posts' waiting tag in sync with the latest interaction, and - // forward the enriched message to the domain bus for consumers (bridges). + // Keep the help posts' waiting tag in sync with the latest interaction. if (message.inGuild() && (await isHelpPost(message.channel))) { - const ctx = await reconcileFromMessage(message); - if (ctx) bus.emit("helpMessagePosted", ctx); + await reconcileFromMessage(message); } }); } diff --git a/src/lib/bus.ts b/src/lib/bus.ts deleted file mode 100644 index 670ff6d..0000000 --- a/src/lib/bus.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EventEmitter } from "node:events"; - -import type { - HelpMessageContext, - HelpThreadContext, - HelpThreadStatusContext, -} from "@lib/discord/help.js"; - -// Enriched, domain-level events emitted by the help/issue-management flow. -// Consumers (e.g. the Linear bridge) subscribe here instead of re-deriving -// help-post state from raw Discord events. -type HelpEvents = { - helpThreadCreated: [HelpThreadContext]; - helpMessagePosted: [HelpMessageContext]; - helpThreadStatusChanged: [HelpThreadStatusContext]; -}; - -// Thin typed wrapper over Node's EventEmitter. -class TypedEmitter> { - private readonly emitter = new EventEmitter(); - - on( - event: K, - listener: (...args: Events[K]) => void, - ): this { - this.emitter.on(event, listener as (...args: unknown[]) => void); - return this; - } - - // Emits without letting a consumer error propagate into the help flow. - emit(event: K, ...args: Events[K]): void { - try { - this.emitter.emit(event, ...args); - } catch (err) { - console.error(`Error in "${event}" handler:`, err); - } - } -} - -export const bus = new TypedEmitter(); diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts index 7577290..58ad4ee 100644 --- a/src/lib/discord/help.ts +++ b/src/lib/discord/help.ts @@ -1,4 +1,3 @@ -import { bus } from "@lib/bus.js"; import { config } from "@lib/config.js"; import { isTeamMember } from "@lib/discord/users.js"; @@ -11,86 +10,11 @@ import { MessageType, } from "discord.js"; -// A Discord forum tag applied to a help post. -export interface HelpTag { - id: string; - name: string; -} - -// Enriched snapshot of a help post, shared by the domain events. -export interface HelpThreadContext { - thread: ThreadChannel; - url: string; - title: string; - status: "open" | "closed"; - waiting: "user" | "team" | null; - // Applied tags (minus open/closed), resolved to id + name. - tags: HelpTag[]; -} - -// A help post message paired with its resolved author. -export interface HelpMessageContext { - thread: ThreadChannel; - message: Message; - member: GuildMember | null; - isTeam: boolean; -} - -// A help post whose status or waiting state just changed. -export interface HelpThreadStatusContext extends HelpThreadContext { - reason: "closed" | "reopened" | "waiting-changed"; -} - -// Resolves applied tag ids to id + name using the parent forum's tag list, -// dropping the open/closed lifecycle tags. -function resolveTags(thread: ThreadChannel): HelpTag[] { - const forum = thread.parent; - const available = - forum && "availableTags" in forum ? forum.availableTags : []; - const nameById = new Map(available.map((t) => [t.id, t.name])); - - const { closedTag, openedTag } = config.helpChannel; - return thread.appliedTags - .filter((id) => id !== closedTag && id !== openedTag) - .map((id) => ({ id, name: nameById.get(id) ?? id })); -} - -// Builds an enriched snapshot of a help post from its applied tags. -export function getHelpThreadContext(thread: ThreadChannel): HelpThreadContext { - const { closedTag, waitingForTeamTag, waitingForUserTag } = - config.helpChannel; - const tags = thread.appliedTags; - - return { - thread, - url: thread.url, - title: thread.name, - status: tags.includes(closedTag) ? "closed" : "open", - waiting: tags.includes(waitingForTeamTag) - ? "team" - : tags.includes(waitingForUserTag) - ? "user" - : null, - tags: resolveTags(thread), - }; -} - -// Emits a status-change event for a help post. -export function emitStatusChange( - thread: ThreadChannel, - reason: HelpThreadStatusContext["reason"], -): void { - bus.emit("helpThreadStatusChanged", { - ...getHelpThreadContext(thread), - reason, - }); -} - // Message types that represent an actual interaction from a person, as opposed // to system notices (pins, joins, etc). const humanMessageTypes = new Set([MessageType.Default, MessageType.Reply]); -function isHumanMessage(message: Message): boolean { +export function isHumanMessage(message: Message): boolean { return !message.author.bot && humanMessageTypes.has(message.type); } @@ -124,36 +48,20 @@ export async function applyWaitingTag( ].slice(0, 5); await thread.setAppliedTags(nextTags, "Help post waiting state"); - - emitStatusChange(thread, "waiting-changed"); } -async function resolveMember(message: Message): Promise { +export async function resolveMember( + message: Message, +): Promise { if (message.member) return message.member; try { - return await message.guild?.members.fetch(message.author.id); + return (await message.guild?.members.fetch(message.author.id)) ?? null; } catch { return null; } } -// Builds a message context for a human help-post message, resolving the author -// once. Returns null for bot/system messages. -export async function buildHelpMessageContext( - message: Message, -): Promise { - if (!isHumanMessage(message)) return null; - - const member = await resolveMember(message); - return { - thread: message.channel as ThreadChannel, - message, - member, - isTeam: member ? isTeamMember(member) : false, - }; -} - // Applies the waiting tag for a help post based on who sent the given message. async function applyWaitingTagFromMessage( thread: ThreadChannel, @@ -163,15 +71,10 @@ async function applyWaitingTagFromMessage( await applyWaitingTag(thread, member ? isTeamMember(member) : false); } -// Reconciles a single help post from a freshly received message, returning the -// message context so callers can forward it to the domain bus. -export async function reconcileFromMessage( - message: Message, -): Promise { - const ctx = await buildHelpMessageContext(message); - if (!ctx) return null; - await applyWaitingTag(ctx.thread, ctx.isTeam); - return ctx; +// Reconciles a single help post from a freshly received message. +export async function reconcileFromMessage(message: Message): Promise { + if (!isHumanMessage(message)) return; + await applyWaitingTagFromMessage(message.channel as ThreadChannel, message); } // Reconciles a help post by inspecting its most recent human message. diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts new file mode 100644 index 0000000..4cdf2aa --- /dev/null +++ b/src/lib/discord/helpThread.ts @@ -0,0 +1,52 @@ +import { config } from "@lib/config.js"; + +import type { ThreadChannel } from "discord.js"; + +// A Discord forum tag applied to a help post. +export interface HelpTag { + id: string; + name: string; +} + +// Enriched view over a #help forum post. Wrap a thread with +// `new HelpThread(thread)` and read its lifecycle state via getters, computed +// lazily from the thread's applied tags (cached or freshly fetched upstream). +export class HelpThread { + constructor(readonly thread: ThreadChannel) {} + + get url(): string { + return this.thread.url; + } + + get title(): string { + return this.thread.name; + } + + get status(): "open" | "closed" { + return this.thread.appliedTags.includes(config.helpChannel.closedTag) + ? "closed" + : "open"; + } + + get waiting(): "user" | "team" | null { + const { waitingForTeamTag, waitingForUserTag } = config.helpChannel; + const tags = this.thread.appliedTags; + if (tags.includes(waitingForTeamTag)) return "team"; + if (tags.includes(waitingForUserTag)) return "user"; + return null; + } + + // Applied tags (minus the open/closed lifecycle tags), resolved to id + name + // from the parent forum's tag list. + get tags(): HelpTag[] { + const forum = this.thread.parent; + const available = + forum && "availableTags" in forum ? forum.availableTags : []; + const nameById = new Map(available.map((t) => [t.id, t.name])); + + const { closedTag, openedTag } = config.helpChannel; + return this.thread.appliedTags + .filter((id) => id !== closedTag && id !== openedTag) + .map((id) => ({ id, name: nameById.get(id) ?? id })); + } +} From 76680e97ffe23adf21c144157c2ae104dc1b97aa Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:53:49 +0000 Subject: [PATCH 05/62] 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. --- scripts/discord-linear-sync.ts | 16 +-- src/bridge/linear/index.ts | 240 ++++++++++++++++----------------- src/events/bridge.ts | 72 +++++----- src/lib/discord/helpThread.ts | 12 +- 4 files changed, 172 insertions(+), 168 deletions(-) diff --git a/scripts/discord-linear-sync.ts b/scripts/discord-linear-sync.ts index 5628f4b..3f8e8da 100644 --- a/scripts/discord-linear-sync.ts +++ b/scripts/discord-linear-sync.ts @@ -16,31 +16,27 @@ import { isHumanMessage, resolveMember } from "../src/lib/discord/help.js"; import { HelpThread } from "../src/lib/discord/helpThread.js"; import { isTeamMember } from "../src/lib/discord/users.js"; import { setIssueState } from "../src/bridge/linear/api.js"; -import { - ensureIssueForThread, - mirrorMessage, - mirrorThreadCreated, -} from "../src/bridge/linear/index.js"; +import { LinearMirror } from "../src/bridge/linear/index.js"; const MESSAGE_LIMIT = 50; async function syncThread(thread: ThreadChannel) { const help = new HelpThread(thread); + const mirror = new LinearMirror(help); console.log(`Syncing "${help.title}" (${thread.id})`); - await mirrorThreadCreated(help); - const issueId = await ensureIssueForThread(help); + await mirror.create(); const messages = await thread.messages.fetch({ limit: MESSAGE_LIMIT }); // Oldest first so comments read in order. for (const message of [...messages.values()].reverse()) { if (!isHumanMessage(message)) continue; const member = await resolveMember(message); - await mirrorMessage(help, message, member ? isTeamMember(member) : false); + await mirror.addMessage(message, member ? isTeamMember(member) : false); } - if (help.status === "closed") { - await setIssueState(issueId, "completed"); + if (help.isClosed) { + await setIssueState(await mirror.ensureIssue(), "completed"); } } diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 4e455fe..f9dea1d 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -9,148 +9,144 @@ import * as linear from "./api.js"; // to a single issue instead of racing to create duplicates. const issueByThread = new Map>(); -function threadMetadata(help: HelpThread): Record { - return { - threadId: help.thread.id, - tagIds: help.tags.map((t) => t.id), - tagNames: help.tags.map((t) => t.name), - status: help.status, - waiting: help.waiting, - }; -} +// Mirrors a single #help thread onto its Linear issue. Wrap a HelpThread and +// call the mirror methods; issue lookup/creation is deduped per thread. +export class LinearMirror { + constructor(private readonly help: HelpThread) {} + + async ensureIssue(): Promise { + const { id } = this.help.thread; + const cached = issueByThread.get(id); + if (cached) return cached; + + const pending = this.findOrCreateIssue(); + issueByThread.set(id, pending); + try { + return await pending; + } catch (err) { + issueByThread.delete(id); + throw err; + } + } -function attachmentSubtitle(help: HelpThread): string { - const parts = ["#help"]; - if (help.status === "closed") parts.push("closed"); - if (help.waiting) parts.push(`waiting: ${help.waiting}`); - if (help.tags.length > 0) { - parts.push(`tags: ${help.tags.map((t) => t.name).join(", ")}`); + // Mirrors a newly created thread: issue (with the opening post as its body), + // linking attachment, and labels. + async create(): Promise { + const issueId = await this.ensureIssue(); + await this.syncLabels(issueId); } - return parts.join(" - "); -} -function attachmentFields(help: HelpThread): linear.ThreadAttachmentFields { - return { - url: help.url, - title: "Discord thread", - subtitle: attachmentSubtitle(help), - metadata: threadMetadata(help), - }; -} + // Mirrors a thread message as an issue comment. + async addMessage(message: Message, isTeam: boolean): Promise { + // The forum starter message is the issue description, not a comment; its id + // equals the thread id for forum posts. + if (message.id === this.help.thread.id) return; -function authorLabel(message: Message, isTeam: boolean): string { - const name = message.member?.displayName ?? message.author.username; - return isTeam ? `${name} (Coder team)` : name; -} + const content = message.content?.trim(); + if (!content) return; -async function buildDescription(help: HelpThread): Promise { - const starter = await help.thread.fetchStarterMessage().catch(() => null); - const body = starter?.content?.trim(); - const link = `[Discord thread](${help.url})`; - return body ? `${body}\n\n${link}` : link; -} + const name = message.member?.displayName ?? message.author.username; + const author = isTeam ? `${name} (Coder team)` : name; + + const issueId = await this.ensureIssue(); + await linear.addComment(issueId, `**${author}**\n\n${content}`); + } -// Ensures a Linear issue exists for a help thread, reusing an existing mapping -// (from the thread-URL attachment) or creating a new issue with its attachment. -export async function ensureIssueForThread(help: HelpThread): Promise { - const cached = issueByThread.get(help.thread.id); - if (cached) return cached; + // Refreshes the attachment metadata and labels, and moves the issue's + // workflow state when the thread was closed or reopened. + async syncStatus(reason?: "closed" | "reopened"): Promise { + const issueId = await this.ensureIssue(); + await linear.upsertThreadAttachment(issueId, this.attachment()); + await this.syncLabels(issueId); + + if (reason === "closed") { + await linear.setIssueState(issueId, "completed"); + await linear.addComment(issueId, "_Thread closed on Discord._"); + } else if (reason === "reopened") { + await linear.setIssueState(issueId, "started"); + await linear.addComment(issueId, "_Thread reopened on Discord._"); + } + } - const pending = (async () => { - const mapping = await linear.findThreadMapping(help.url); + private async findOrCreateIssue(): Promise { + const mapping = await linear.findThreadMapping(this.help.url); if (mapping) return mapping.issueId; const issueId = await linear.createIssue({ - title: help.title, - description: await buildDescription(help), + title: this.help.title, + description: await this.description(), }); - await linear.createThreadAttachment(issueId, attachmentFields(help)); + await linear.createThreadAttachment(issueId, this.attachment()); return issueId; - })(); - - issueByThread.set(help.thread.id, pending); - try { - return await pending; - } catch (err) { - issueByThread.delete(help.thread.id); - throw err; } -} -// Reconciles the issue's group labels to match the thread's current tags. -async function syncLabels(issueId: string, help: HelpThread): Promise { - if (!config.linearBridge.labels.enabled) return; - - const groupId = await linear.ensureLabelGroup( - config.linearBridge.labels.groupName, - ); - const groupLabels = await linear.getGroupLabels(groupId); - const byTagId = new Map(); - for (const label of groupLabels) { - if (label.description) byTagId.set(label.description, label); - } + // Reconciles the issue's group labels to match the thread's current tags. + private async syncLabels(issueId: string): Promise { + if (!config.linearBridge.labels.enabled) return; + + const groupId = await linear.ensureLabelGroup( + config.linearBridge.labels.groupName, + ); + const groupLabels = await linear.getGroupLabels(groupId); + const byTagId = new Map(); + for (const label of groupLabels) { + if (label.description) byTagId.set(label.description, label); + } - const desiredIds: string[] = []; - for (const tag of help.tags) { - let label = byTagId.get(tag.id); - if (!label) { - label = await linear.createLabel({ - name: tag.name, - tagId: tag.id, - groupId, - }); - byTagId.set(tag.id, label); - } else if (label.name !== tag.name) { - await linear.renameLabel(label.id, tag.name); + const desiredIds: string[] = []; + for (const tag of this.help.tags) { + let label = byTagId.get(tag.id); + if (!label) { + label = await linear.createLabel({ + name: tag.name, + tagId: tag.id, + groupId, + }); + byTagId.set(tag.id, label); + } else if (label.name !== tag.name) { + await linear.renameLabel(label.id, tag.name); + } + desiredIds.push(label.id); } - desiredIds.push(label.id); - } - const removedIds = groupLabels - .map((l) => l.id) - .filter((id) => !desiredIds.includes(id)); + const removedIds = groupLabels + .map((l) => l.id) + .filter((id) => !desiredIds.includes(id)); - await linear.setIssueGroupLabels(issueId, desiredIds, removedIds); -} + await linear.setIssueGroupLabels(issueId, desiredIds, removedIds); + } -export async function mirrorThreadCreated(help: HelpThread): Promise { - const issueId = await ensureIssueForThread(help); - await syncLabels(issueId, help); -} + private attachment(): linear.ThreadAttachmentFields { + return { + url: this.help.url, + title: "Discord thread", + subtitle: this.subtitle(), + metadata: { + threadId: this.help.thread.id, + tagIds: this.help.tags.map((t) => t.id), + tagNames: this.help.tags.map((t) => t.name), + status: this.help.status, + waiting: this.help.waiting, + }, + }; + } -export async function mirrorMessage( - help: HelpThread, - message: Message, - isTeam: boolean, -): Promise { - // The forum starter message becomes the issue description, not a comment. - // Its id equals the thread id for forum posts. - if (message.id === help.thread.id) return; - - const content = message.content?.trim(); - if (!content) return; - - const issueId = await ensureIssueForThread(help); - await linear.addComment( - issueId, - `**${authorLabel(message, isTeam)}**\n\n${content}`, - ); -} + private subtitle(): string { + const parts = ["#help"]; + if (this.help.isClosed) parts.push("closed"); + if (this.help.waiting) parts.push(`waiting: ${this.help.waiting}`); + if (this.help.tags.length > 0) { + parts.push(`tags: ${this.help.tags.map((t) => t.name).join(", ")}`); + } + return parts.join(" - "); + } -export async function mirrorStatus( - help: HelpThread, - reason?: "closed" | "reopened", -): Promise { - const issueId = await ensureIssueForThread(help); - - await linear.upsertThreadAttachment(issueId, attachmentFields(help)); - await syncLabels(issueId, help); - - if (reason === "closed") { - await linear.setIssueState(issueId, "completed"); - await linear.addComment(issueId, "_Thread closed on Discord._"); - } else if (reason === "reopened") { - await linear.setIssueState(issueId, "started"); - await linear.addComment(issueId, "_Thread reopened on Discord._"); + private async description(): Promise { + const starter = await this.help.thread + .fetchStarterMessage() + .catch(() => null); + const body = starter?.content?.trim(); + const link = `[Discord thread](${this.help.url})`; + return body ? `${body}\n\n${link}` : link; } } diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 0bef2e7..44f89c3 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -8,17 +8,7 @@ import { isHumanMessage, resolveMember } from "@lib/discord/help.js"; import { HelpThread } from "@lib/discord/helpThread.js"; import { isTeamMember } from "@lib/discord/users.js"; -import { - mirrorMessage, - mirrorStatus, - mirrorThreadCreated, -} from "@bridge/linear/index.js"; - -// Runs bridge work in the background, logging failures instead of throwing them -// into a Discord event handler. -function guard(name: string, work: Promise): void { - work.catch((err) => console.error(`Linear bridge "${name}" failed:`, err)); -} +import { LinearMirror } from "@bridge/linear/index.js"; export default function registerEvents(client: Client) { if (!config.linearBridge.enabled) { @@ -30,41 +20,57 @@ export default function registerEvents(client: Client) { client.on(Events.ThreadCreate, async (thread) => { if (!(await isHelpPost(thread))) return; - guard("threadCreate", mirrorThreadCreated(new HelpThread(thread))); + try { + await new LinearMirror(new HelpThread(thread)).create(); + } catch (err) { + console.error("Linear bridge: thread create failed:", err); + } }); client.on(Events.MessageCreate, async (message) => { if (!message.inGuild() || !(await isHelpPost(message.channel))) return; if (!isHumanMessage(message)) return; - - const member = await resolveMember(message); - const help = new HelpThread(message.channel as ThreadChannel); - const isTeam = member ? isTeamMember(member) : false; - guard("messageCreate", mirrorMessage(help, message, isTeam)); + try { + const member = await resolveMember(message); + const help = new HelpThread(message.channel as ThreadChannel); + await new LinearMirror(help).addMessage( + message, + member ? isTeamMember(member) : false, + ); + } catch (err) { + console.error("Linear bridge: message create failed:", err); + } }); - // Coalesce bursts of tag edits, remembering the first "before" state so a + // Coalesce bursts of tag edits, keeping the first "before" state so a // closed/reopened transition can be detected once the dust settles. - const initialStates = new Map(); - const flush = debounce(1000, (threadId: string, newThread: ThreadChannel) => { - const before = initialStates.get(threadId); - initialStates.delete(threadId); + const beforeStates = new Map(); + const flush = debounce( + 1000, + async (threadId: string, newThread: ThreadChannel) => { + const before = beforeStates.get(threadId); + beforeStates.delete(threadId); - const after = new HelpThread(newThread); - const reason = - before && before.status !== after.status - ? after.status === "closed" - ? "closed" - : "reopened" - : undefined; + const help = new HelpThread(newThread); + const reason = + before && before.isClosed !== help.isClosed + ? help.isClosed + ? "closed" + : "reopened" + : undefined; - guard("threadUpdate", mirrorStatus(after, reason)); - }); + try { + await new LinearMirror(help).syncStatus(reason); + } catch (err) { + console.error("Linear bridge: thread update failed:", err); + } + }, + ); client.on(Events.ThreadUpdate, async (oldThread, newThread) => { if (!(await isHelpPost(newThread))) return; - if (!initialStates.has(newThread.id)) { - initialStates.set(newThread.id, new HelpThread(oldThread)); + if (!beforeStates.has(newThread.id)) { + beforeStates.set(newThread.id, new HelpThread(oldThread)); } flush(newThread.id, newThread); }); diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts index 4cdf2aa..cea60cc 100644 --- a/src/lib/discord/helpThread.ts +++ b/src/lib/discord/helpThread.ts @@ -23,9 +23,15 @@ export class HelpThread { } get status(): "open" | "closed" { - return this.thread.appliedTags.includes(config.helpChannel.closedTag) - ? "closed" - : "open"; + return this.isClosed ? "closed" : "open"; + } + + get isClosed(): boolean { + return this.thread.appliedTags.includes(config.helpChannel.closedTag); + } + + get isOpen(): boolean { + return !this.isClosed; } get waiting(): "user" | "team" | null { From 32c28845a95f176c75eeb4eee548d05a076f8965 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:49:28 +0000 Subject: [PATCH 06/62] 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. --- src/bridge/linear/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index f9dea1d..fc7af33 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -141,12 +141,12 @@ export class LinearMirror { return parts.join(" - "); } + // The issue body is the opening post's content. The Discord link lives on the + // attachment, not inline in the description. private async description(): Promise { const starter = await this.help.thread .fetchStarterMessage() .catch(() => null); - const body = starter?.content?.trim(); - const link = `[Discord thread](${this.help.url})`; - return body ? `${body}\n\n${link}` : link; + return starter?.content?.trim() ?? ""; } } From a094a16390f7f79ad4c1dd8c3e8ed6f8c7fbfee8 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:51:45 +0000 Subject: [PATCH 07/62] 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. --- src/bridge/linear/api.ts | 16 ++++++++++++++-- src/bridge/linear/index.ts | 9 +++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 287abf2..3f5a8f4 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -91,8 +91,20 @@ export async function upsertThreadAttachment( }); } -export async function addComment(issueId: string, body: string): Promise { - await linear().createComment({ issueId, body }); +export async function addComment( + issueId: string, + body: string, + author?: { name: string; iconUrl?: string }, +): Promise { + await linear().createComment({ + issueId, + body, + // Attributes the comment to an external (non-Linear) author. Only takes + // effect when authenticated as an OAuth application; ignored for personal + // API keys. + createAsUser: author?.name, + displayIconUrl: author?.iconUrl, + }); } // Moves an issue to the first workflow state of the given type in the team. diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index fc7af33..4412dc9 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -46,10 +46,15 @@ export class LinearMirror { if (!content) return; const name = message.member?.displayName ?? message.author.username; - const author = isTeam ? `${name} (Coder team)` : name; + const iconUrl = + message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(); + const label = isTeam ? `${name} (Coder team)` : name; const issueId = await this.ensureIssue(); - await linear.addComment(issueId, `**${author}**\n\n${content}`); + await linear.addComment(issueId, `**${label}**\n\n${content}`, { + name, + iconUrl, + }); } // Refreshes the attachment metadata and labels, and moves the issue's From 3fcbcaa1dc9aa00a3510684fbfe0efd46f713462 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:45:40 +0000 Subject: [PATCH 08/62] 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. --- src/bridge/linear/api.ts | 13 +++++++++---- src/bridge/linear/index.ts | 18 ++++++++++++------ src/lib/config.ts | 5 +++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 3f5a8f4..58cd005 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -15,7 +15,12 @@ let client: LinearClient | undefined; function linear(): LinearClient { if (!client) { - client = new LinearClient({ apiKey: bridgeConfig().apiKey }); + const { apiKey } = bridgeConfig(); + // App-actor (OAuth) tokens must be sent as Bearer tokens via accessToken; + // personal API keys are sent verbatim via apiKey. + client = new LinearClient( + config.linearBridge.createAsUser ? { accessToken: apiKey } : { apiKey }, + ); } return client; } @@ -99,9 +104,9 @@ export async function addComment( await linear().createComment({ issueId, body, - // Attributes the comment to an external (non-Linear) author. Only takes - // effect when authenticated as an OAuth application; ignored for personal - // API keys. + // Attributes the comment to an external Discord author. Requires OAuth + // app-actor auth; Linear rejects these fields for personal API keys, so the + // caller only supplies an author when that mode is configured. createAsUser: author?.name, displayIconUrl: author?.iconUrl, }); diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 4412dc9..3237f09 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -46,15 +46,21 @@ export class LinearMirror { if (!content) return; const name = message.member?.displayName ?? message.author.username; - const iconUrl = - message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(); const label = isTeam ? `${name} (Coder team)` : name; + // createAsUser/displayIconUrl require OAuth app-actor auth; a personal API + // key rejects them, so only attribute to the Discord author when enabled. + const author = config.linearBridge.createAsUser + ? { + name, + iconUrl: + message.member?.displayAvatarURL() ?? + message.author.displayAvatarURL(), + } + : undefined; + const issueId = await this.ensureIssue(); - await linear.addComment(issueId, `**${label}**\n\n${content}`, { - name, - iconUrl, - }); + await linear.addComment(issueId, `**${label}**\n\n${content}`, author); } // Refreshes the attachment metadata and labels, and moves the issue's diff --git a/src/lib/config.ts b/src/lib/config.ts index 842fce9..3461253 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -40,6 +40,10 @@ interface Config { enabled: boolean; apiKey?: string; teamId?: string; + // Attribute mirrored comments to the Discord author via Linear's + // createAsUser. Requires OAuth app-actor auth; a personal API key rejects + // it, so leave this off unless the key runs in actor=app mode. + createAsUser: boolean; labels: { enabled: boolean; groupName: string; @@ -64,6 +68,7 @@ export const { config, layers } = await loadConfig({ startupCatchupLimit: 20, linearBridge: { enabled: false, + createAsUser: false, labels: { enabled: true, groupName: "Discord (#help)", From 5bdbfb46c86ccb660c25acdc057aba4825e40b6a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:01:54 +0000 Subject: [PATCH 09/62] 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. --- scripts/discord-linear-sync.ts | 6 ++---- src/bridge/linear/api.ts | 5 +++++ src/bridge/linear/index.ts | 16 ++++++++++------ src/events/bridge.ts | 18 +++++++++++------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/scripts/discord-linear-sync.ts b/scripts/discord-linear-sync.ts index 3f8e8da..e6e5961 100644 --- a/scripts/discord-linear-sync.ts +++ b/scripts/discord-linear-sync.ts @@ -12,9 +12,8 @@ import { } from "discord.js"; import { config } from "../src/lib/config.js"; -import { isHumanMessage, resolveMember } from "../src/lib/discord/help.js"; +import { isHumanMessage } from "../src/lib/discord/help.js"; import { HelpThread } from "../src/lib/discord/helpThread.js"; -import { isTeamMember } from "../src/lib/discord/users.js"; import { setIssueState } from "../src/bridge/linear/api.js"; import { LinearMirror } from "../src/bridge/linear/index.js"; @@ -31,8 +30,7 @@ async function syncThread(thread: ThreadChannel) { // Oldest first so comments read in order. for (const message of [...messages.values()].reverse()) { if (!isHumanMessage(message)) continue; - const member = await resolveMember(message); - await mirror.addMessage(message, member ? isTeamMember(member) : false); + await mirror.addMessage(message); } if (help.isClosed) { diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 58cd005..d1d11d5 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -112,6 +112,11 @@ export async function addComment( }); } +// Trashes an issue (recoverable in Linear). +export async function deleteIssue(issueId: string): Promise { + await linear().deleteIssue(issueId); +} + // Moves an issue to the first workflow state of the given type in the team. export async function setIssueState( issueId: string, diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 3237f09..8558ea4 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -37,7 +37,7 @@ export class LinearMirror { } // Mirrors a thread message as an issue comment. - async addMessage(message: Message, isTeam: boolean): Promise { + async addMessage(message: Message): Promise { // The forum starter message is the issue description, not a comment; its id // equals the thread id for forum posts. if (message.id === this.help.thread.id) return; @@ -45,14 +45,11 @@ export class LinearMirror { const content = message.content?.trim(); if (!content) return; - const name = message.member?.displayName ?? message.author.username; - const label = isTeam ? `${name} (Coder team)` : name; - // createAsUser/displayIconUrl require OAuth app-actor auth; a personal API // key rejects them, so only attribute to the Discord author when enabled. const author = config.linearBridge.createAsUser ? { - name, + name: message.member?.displayName ?? message.author.username, iconUrl: message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), @@ -60,7 +57,7 @@ export class LinearMirror { : undefined; const issueId = await this.ensureIssue(); - await linear.addComment(issueId, `**${label}**\n\n${content}`, author); + await linear.addComment(issueId, content, author); } // Refreshes the attachment metadata and labels, and moves the issue's @@ -79,6 +76,13 @@ export class LinearMirror { } } + // Trashes the mirrored issue when its Discord thread is deleted. + async delete(): Promise { + const mapping = await linear.findThreadMapping(this.help.url); + if (mapping) await linear.deleteIssue(mapping.issueId); + issueByThread.delete(this.help.thread.id); + } + private async findOrCreateIssue(): Promise { const mapping = await linear.findThreadMapping(this.help.url); if (mapping) return mapping.issueId; diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 44f89c3..3d7db95 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -4,9 +4,8 @@ import { type Client, Events, type ThreadChannel } from "discord.js"; import { config, validateLinearBridgeConfig } from "@lib/config.js"; import { isHelpPost } from "@lib/discord/channels.js"; -import { isHumanMessage, resolveMember } from "@lib/discord/help.js"; +import { isHumanMessage } from "@lib/discord/help.js"; import { HelpThread } from "@lib/discord/helpThread.js"; -import { isTeamMember } from "@lib/discord/users.js"; import { LinearMirror } from "@bridge/linear/index.js"; @@ -27,16 +26,21 @@ export default function registerEvents(client: Client) { } }); + client.on(Events.ThreadDelete, async (thread) => { + if (!(await isHelpPost(thread))) return; + try { + await new LinearMirror(new HelpThread(thread)).delete(); + } catch (err) { + console.error("Linear bridge: thread delete failed:", err); + } + }); + client.on(Events.MessageCreate, async (message) => { if (!message.inGuild() || !(await isHelpPost(message.channel))) return; if (!isHumanMessage(message)) return; try { - const member = await resolveMember(message); const help = new HelpThread(message.channel as ThreadChannel); - await new LinearMirror(help).addMessage( - message, - member ? isTeamMember(member) : false, - ); + await new LinearMirror(help).addMessage(message); } catch (err) { console.error("Linear bridge: message create failed:", err); } From 632ab43b8ee08659fc029333ea1bff07ae4037a4 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:08:42 +0000 Subject: [PATCH 10/62] 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. --- src/bridge/linear/api.ts | 5 +++++ src/bridge/linear/index.ts | 39 +++++++++++++++++++------------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index d1d11d5..39dfd23 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -58,11 +58,16 @@ export async function findThreadMapping( export async function createIssue(input: { title: string; description: string; + author?: { name: string; iconUrl?: string }; }): Promise { const payload = await linear().createIssue({ teamId: bridgeConfig().teamId, title: input.title, description: input.description, + // Attributes the issue to an external Discord author under app-actor auth; + // ignored fields are safe to omit for personal keys (author is undefined). + createAsUser: input.author?.name, + displayIconUrl: input.author?.iconUrl, }); const issue = await payload.issue; diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 8558ea4..42053de 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -45,19 +45,8 @@ export class LinearMirror { const content = message.content?.trim(); if (!content) return; - // createAsUser/displayIconUrl require OAuth app-actor auth; a personal API - // key rejects them, so only attribute to the Discord author when enabled. - const author = config.linearBridge.createAsUser - ? { - name: message.member?.displayName ?? message.author.username, - iconUrl: - message.member?.displayAvatarURL() ?? - message.author.displayAvatarURL(), - } - : undefined; - const issueId = await this.ensureIssue(); - await linear.addComment(issueId, content, author); + await linear.addComment(issueId, content, this.author(message)); } // Refreshes the attachment metadata and labels, and moves the issue's @@ -87,9 +76,15 @@ export class LinearMirror { const mapping = await linear.findThreadMapping(this.help.url); if (mapping) return mapping.issueId; + // The opening post is the issue body, and its author owns the issue. + const starter = await this.help.thread + .fetchStarterMessage() + .catch(() => null); + const issueId = await linear.createIssue({ title: this.help.title, - description: await this.description(), + description: starter?.content?.trim() ?? "", + author: this.author(starter), }); await linear.createThreadAttachment(issueId, this.attachment()); return issueId; @@ -156,12 +151,16 @@ export class LinearMirror { return parts.join(" - "); } - // The issue body is the opening post's content. The Discord link lives on the - // attachment, not inline in the description. - private async description(): Promise { - const starter = await this.help.thread - .fetchStarterMessage() - .catch(() => null); - return starter?.content?.trim() ?? ""; + // External-author fields for app-actor attribution, or undefined when the + // mode is off or the author is unknown. A personal API key rejects these. + private author( + message: Message | null, + ): { name: string; iconUrl?: string } | undefined { + if (!config.linearBridge.createAsUser || !message) return undefined; + return { + name: message.member?.displayName ?? message.author.username, + iconUrl: + message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), + }; } } From 364ffbc4b391d65df895bb85c9faf7cf6c810798 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:17:20 +0000 Subject: [PATCH 11/62] 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. --- src/bridge/linear/api.ts | 5 +++-- src/bridge/linear/index.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 39dfd23..df3f204 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -122,10 +122,11 @@ export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); } -// Moves an issue to the first workflow state of the given type in the team. +// Moves an issue to the first workflow state of the given type in the team +// (e.g. completed -> "Done", triage -> "Triage"). export async function setIssueState( issueId: string, - type: "completed" | "started", + type: "completed" | "triage", ): Promise { const stateId = await findStateId(type); if (!stateId) return; diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 42053de..bca7aef 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -60,7 +60,7 @@ export class LinearMirror { await linear.setIssueState(issueId, "completed"); await linear.addComment(issueId, "_Thread closed on Discord._"); } else if (reason === "reopened") { - await linear.setIssueState(issueId, "started"); + await linear.setIssueState(issueId, "triage"); await linear.addComment(issueId, "_Thread reopened on Discord._"); } } From 7f68008f3bd521c10f27d3ee3e8a88936a57e451 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:22:20 +0000 Subject: [PATCH 12/62] 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. --- src/bridge/linear/api.ts | 9 ++++++++ src/bridge/linear/index.ts | 13 ++++++----- src/events/bridge.ts | 45 ++++++++++++++------------------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index df3f204..b01b7c5 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -122,6 +122,15 @@ export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); } +// Returns the workflow state type of an issue (e.g. "triage", "completed"). +export async function getIssueStateType( + issueId: string, +): Promise { + const issue = await linear().issue(issueId); + const state = await issue.state; + return state?.type ?? null; +} + // Moves an issue to the first workflow state of the given type in the team // (e.g. completed -> "Done", triage -> "Triage"). export async function setIssueState( diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index bca7aef..19f7421 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -49,17 +49,20 @@ export class LinearMirror { await linear.addComment(issueId, content, this.author(message)); } - // Refreshes the attachment metadata and labels, and moves the issue's - // workflow state when the thread was closed or reopened. - async syncStatus(reason?: "closed" | "reopened"): Promise { + // Refreshes the attachment metadata and labels, then moves the issue between + // Done and Triage to match the thread. Transitions are decided against the + // Linear issue state, not a Discord old/new diff, so bot-initiated closes + // (e.g. the /close command) are detected reliably. + async syncStatus(): Promise { const issueId = await this.ensureIssue(); await linear.upsertThreadAttachment(issueId, this.attachment()); await this.syncLabels(issueId); - if (reason === "closed") { + const stateType = await linear.getIssueStateType(issueId); + if (this.help.isClosed && stateType !== "completed") { await linear.setIssueState(issueId, "completed"); await linear.addComment(issueId, "_Thread closed on Discord._"); - } else if (reason === "reopened") { + } else if (this.help.isOpen && stateType === "completed") { await linear.setIssueState(issueId, "triage"); await linear.addComment(issueId, "_Thread reopened on Discord._"); } diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 3d7db95..9de0dc8 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -46,37 +46,26 @@ export default function registerEvents(client: Client) { } }); - // Coalesce bursts of tag edits, keeping the first "before" state so a - // closed/reopened transition can be detected once the dust settles. - const beforeStates = new Map(); - const flush = debounce( - 1000, - async (threadId: string, newThread: ThreadChannel) => { - const before = beforeStates.get(threadId); - beforeStates.delete(threadId); + // Coalesce bursts of tag edits per thread. syncStatus is idempotent and + // reconciles against the Linear issue state, so no before/after diff is kept. + const flushers = new Map void>(); - const help = new HelpThread(newThread); - const reason = - before && before.isClosed !== help.isClosed - ? help.isClosed - ? "closed" - : "reopened" - : undefined; - - try { - await new LinearMirror(help).syncStatus(reason); - } catch (err) { - console.error("Linear bridge: thread update failed:", err); - } - }, - ); - - client.on(Events.ThreadUpdate, async (oldThread, newThread) => { + client.on(Events.ThreadUpdate, async (_oldThread, newThread) => { if (!(await isHelpPost(newThread))) return; - if (!beforeStates.has(newThread.id)) { - beforeStates.set(newThread.id, new HelpThread(oldThread)); + + let flush = flushers.get(newThread.id); + if (!flush) { + flush = debounce(1000, async (thread: ThreadChannel) => { + flushers.delete(thread.id); + try { + await new LinearMirror(new HelpThread(thread)).syncStatus(); + } catch (err) { + console.error("Linear bridge: thread update failed:", err); + } + }); + flushers.set(newThread.id, flush); } - flush(newThread.id, newThread); + flush(newThread); }); console.log("Linear bridge is enabled."); From 00576ce6f6f3663b4a9872bff903fc62eae0d72f Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:06:25 +0000 Subject: [PATCH 13/62] 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. --- src/lib/config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index 3461253..cbe69fd 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -70,7 +70,10 @@ export const { config, layers } = await loadConfig({ enabled: false, createAsUser: false, labels: { - enabled: true, + // Off by default: Linear app-actor tokens cannot create team labels + // (that needs a team owner, which apps cannot be). Enable only with a + // credential allowed to manage the team's labels. + enabled: false, groupName: "Discord (#help)", }, }, From f50399d8ffb5ad3e8eaf78f42298d132a1add872 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:16:48 +0000 Subject: [PATCH 14/62] 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. --- src/bridge/linear/api.ts | 63 +++++++++++++++++++++++++++++++++++++- src/bridge/linear/index.ts | 24 ++++++++++++++- src/events/bridge.ts | 26 ++++++++++++++++ src/index.ts | 10 +++++- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index b01b7c5..e9ec57c 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -101,14 +101,28 @@ export async function upsertThreadAttachment( }); } +// Invisible marker (an unused markdown reference-link definition) appended to +// mirrored comments so a later Discord edit/delete can locate the right +// comment. It renders as nothing in Linear but round-trips in the raw body. +const MSG_MARKER = "discord-msg"; + +function withMarker(body: string, messageId: string): string { + return `${body}\n\n[${MSG_MARKER}]: ${messageId}`; +} + +function markerMessageId(body: string): string | null { + return body.match(/^\[discord-msg\]:\s*(\S+)/m)?.[1] ?? null; +} + export async function addComment( issueId: string, body: string, author?: { name: string; iconUrl?: string }, + messageId?: string, ): Promise { await linear().createComment({ issueId, - body, + body: messageId ? withMarker(body, messageId) : body, // Attributes the comment to an external Discord author. Requires OAuth // app-actor auth; Linear rejects these fields for personal API keys, so the // caller only supplies an author when that mode is configured. @@ -117,6 +131,53 @@ export async function addComment( }); } +// Updates the mirrored comment for a Discord message. Returns false if the +// message has no mirrored comment. +export async function editComment( + issueId: string, + messageId: string, + body: string, +): Promise { + const commentId = await findCommentId(issueId, messageId); + if (!commentId) return false; + await linear().updateComment(commentId, { + body: withMarker(body, messageId), + }); + return true; +} + +// Deletes the mirrored comment for a Discord message. Returns false if the +// message has no mirrored comment. +export async function deleteComment( + issueId: string, + messageId: string, +): Promise { + const commentId = await findCommentId(issueId, messageId); + if (!commentId) return false; + await linear().deleteComment(commentId); + return true; +} + +async function findCommentId( + issueId: string, + messageId: string, +): Promise { + const issue = await linear().issue(issueId); + const { nodes } = await issue.comments(); + for (const comment of nodes) { + if (markerMessageId(comment.body) === messageId) return comment.id; + } + return null; +} + +// Replaces an issue's description, used when the opening post is edited. +export async function setIssueDescription( + issueId: string, + description: string, +): Promise { + await linear().updateIssue(issueId, { description }); +} + // Trashes an issue (recoverable in Linear). export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 19f7421..7d669f2 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -46,7 +46,29 @@ export class LinearMirror { if (!content) return; const issueId = await this.ensureIssue(); - await linear.addComment(issueId, content, this.author(message)); + await linear.addComment(issueId, content, this.author(message), message.id); + } + + // Reflects a Discord message edit onto its mirrored comment, or the issue + // description for the opening post. No-op if the thread isn't mirrored. + async editMessage(message: Message): Promise { + const mapping = await linear.findThreadMapping(this.help.url); + if (!mapping) return; + + const content = message.content?.trim() ?? ""; + if (message.id === this.help.thread.id) { + await linear.setIssueDescription(mapping.issueId, content); + } else if (content) { + await linear.editComment(mapping.issueId, message.id, content); + } + } + + // Deletes the mirrored comment for a deleted Discord message. The opening + // post maps to the description, so it is left untouched here. + async deleteMessage(messageId: string): Promise { + if (messageId === this.help.thread.id) return; + const mapping = await linear.findThreadMapping(this.help.url); + if (mapping) await linear.deleteComment(mapping.issueId, messageId); } // Refreshes the attachment metadata and labels, then moves the issue between diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 9de0dc8..22c5420 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -46,6 +46,32 @@ export default function registerEvents(client: Client) { } }); + client.on(Events.MessageUpdate, async (oldMessage, newMessage) => { + try { + const message = newMessage.partial + ? await newMessage.fetch() + : newMessage; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message)) return; + // Ignore non-content edits (embeds, pins) when the old content is known. + if (!oldMessage.partial && oldMessage.content === message.content) return; + const help = new HelpThread(message.channel as ThreadChannel); + await new LinearMirror(help).editMessage(message); + } catch (err) { + console.error("Linear bridge: message update failed:", err); + } + }); + + client.on(Events.MessageDelete, async (message) => { + try { + const channel = message.channel; + if (!channel.isThread() || !(await isHelpPost(channel))) return; + await new LinearMirror(new HelpThread(channel)).deleteMessage(message.id); + } catch (err) { + console.error("Linear bridge: message delete failed:", err); + } + }); + // Coalesce bursts of tag edits per thread. syncStatus is idempotent and // reconciles against the Linear issue state, so no before/after diff is kept. const flushers = new Map void>(); diff --git a/src/index.ts b/src/index.ts index 63acec2..7a8e2d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,13 @@ import registerMessageEvents from "./events/messages.js"; import registerChannelEvents from "./events/channels.js"; import registerBridgeEvents from "./events/bridge.js"; -import { Client, Events, GatewayIntentBits, ActivityType } from "discord.js"; +import { + Client, + Events, + GatewayIntentBits, + ActivityType, + Partials, +} from "discord.js"; const client = new Client({ intents: [ @@ -15,6 +21,8 @@ const client = new Client({ GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], + // Needed so edits/deletes of uncached messages still emit events. + partials: [Partials.Message, Partials.Channel], }); const presenceList = [ From a8e788b84f75762f467a8ef3caa99ae20f435cb5 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:22:56 +0000 Subject: [PATCH 15/62] 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. --- src/bridge/linear/api.ts | 21 ++++++++++++++++----- src/bridge/linear/index.ts | 23 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index e9ec57c..b40dadb 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -119,8 +119,9 @@ export async function addComment( body: string, author?: { name: string; iconUrl?: string }, messageId?: string, + parentId?: string, ): Promise { - await linear().createComment({ + const input = { issueId, body: messageId ? withMarker(body, messageId) : body, // Attributes the comment to an external Discord author. Requires OAuth @@ -128,7 +129,16 @@ export async function addComment( // caller only supplies an author when that mode is configured. createAsUser: author?.name, displayIconUrl: author?.iconUrl, - }); + }; + + try { + await linear().createComment({ ...input, parentId }); + } catch (err) { + // Linear threads are one level deep; if the parent is itself a reply, fall + // back to a top-level comment rather than dropping the message. + if (!parentId) throw err; + await linear().createComment(input); + } } // Updates the mirrored comment for a Discord message. Returns false if the @@ -138,7 +148,7 @@ export async function editComment( messageId: string, body: string, ): Promise { - const commentId = await findCommentId(issueId, messageId); + const commentId = await findCommentByMessage(issueId, messageId); if (!commentId) return false; await linear().updateComment(commentId, { body: withMarker(body, messageId), @@ -152,13 +162,14 @@ export async function deleteComment( issueId: string, messageId: string, ): Promise { - const commentId = await findCommentId(issueId, messageId); + const commentId = await findCommentByMessage(issueId, messageId); if (!commentId) return false; await linear().deleteComment(commentId); return true; } -async function findCommentId( +// Finds the mirrored comment id for a Discord message id, or null. +export async function findCommentByMessage( issueId: string, messageId: string, ): Promise { diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 7d669f2..b6027d9 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -46,7 +46,14 @@ export class LinearMirror { if (!content) return; const issueId = await this.ensureIssue(); - await linear.addComment(issueId, content, this.author(message), message.id); + const parentId = await this.replyParent(issueId, message); + await linear.addComment( + issueId, + content, + this.author(message), + message.id, + parentId, + ); } // Reflects a Discord message edit onto its mirrored comment, or the issue @@ -176,6 +183,20 @@ export class LinearMirror { return parts.join(" - "); } + // Resolves the parent Linear comment for a Discord reply, when the referenced + // message was mirrored as a comment. Returns undefined otherwise (e.g. a + // reply to the opening post, which is the issue description). + private async replyParent( + issueId: string, + message: Message, + ): Promise { + const referencedId = message.reference?.messageId; + if (!referencedId) return undefined; + return ( + (await linear.findCommentByMessage(issueId, referencedId)) ?? undefined + ); + } + // External-author fields for app-actor attribution, or undefined when the // mode is off or the author is unknown. A personal API key rejects these. private author( From 57efec5c44994b6c48a9bbb812f534b86fc79e55 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:26:19 +0000 Subject: [PATCH 16/62] 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. --- src/bridge/linear/api.ts | 31 ++++++++++++++++++++++++++----- src/bridge/linear/index.ts | 2 +- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index b40dadb..b08bfb4 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -1,4 +1,4 @@ -import { LinearClient } from "@linear/sdk"; +import { LinearClient, type Comment } from "@linear/sdk"; import { config } from "@lib/config.js"; @@ -168,19 +168,40 @@ export async function deleteComment( return true; } -// Finds the mirrored comment id for a Discord message id, or null. -export async function findCommentByMessage( +// Finds the mirrored comment node for a Discord message id, or null. +async function findCommentNode( issueId: string, messageId: string, -): Promise { +): Promise { const issue = await linear().issue(issueId); const { nodes } = await issue.comments(); for (const comment of nodes) { - if (markerMessageId(comment.body) === messageId) return comment.id; + if (markerMessageId(comment.body) === messageId) return comment; } return null; } +// Finds the mirrored comment id for a Discord message id, or null. +export async function findCommentByMessage( + issueId: string, + messageId: string, +): Promise { + return (await findCommentNode(issueId, messageId))?.id ?? null; +} + +// Resolves the comment a Discord reply should attach to: the mirrored comment +// of the referenced message, collapsed to its thread root since Linear threads +// are only one level deep. Returns null when the reference isn't mirrored. +export async function resolveReplyParent( + issueId: string, + messageId: string, +): Promise { + const node = await findCommentNode(issueId, messageId); + if (!node) return null; + const parent = await node.parent; + return parent?.id ?? node.id; +} + // Replaces an issue's description, used when the opening post is edited. export async function setIssueDescription( issueId: string, diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index b6027d9..b92bf98 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -193,7 +193,7 @@ export class LinearMirror { const referencedId = message.reference?.messageId; if (!referencedId) return undefined; return ( - (await linear.findCommentByMessage(issueId, referencedId)) ?? undefined + (await linear.resolveReplyParent(issueId, referencedId)) ?? undefined ); } From 35d1e61e050e1fb2a483e26ebe5bad0c9f1c361f Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:30:17 +0000 Subject: [PATCH 17/62] 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. --- src/bridge/linear/index.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index b92bf98..c16fa59 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -42,14 +42,14 @@ export class LinearMirror { // equals the thread id for forum posts. if (message.id === this.help.thread.id) return; - const content = message.content?.trim(); - if (!content) return; + const body = this.body(message); + if (!body) return; const issueId = await this.ensureIssue(); const parentId = await this.replyParent(issueId, message); await linear.addComment( issueId, - content, + body, this.author(message), message.id, parentId, @@ -62,11 +62,11 @@ export class LinearMirror { const mapping = await linear.findThreadMapping(this.help.url); if (!mapping) return; - const content = message.content?.trim() ?? ""; + const body = this.body(message); if (message.id === this.help.thread.id) { - await linear.setIssueDescription(mapping.issueId, content); - } else if (content) { - await linear.editComment(mapping.issueId, message.id, content); + await linear.setIssueDescription(mapping.issueId, body); + } else if (body) { + await linear.editComment(mapping.issueId, message.id, body); } } @@ -183,6 +183,20 @@ export class LinearMirror { return parts.join(" - "); } + // Renders a Discord message as markdown: its text plus any attachments + // (images inline, other files as links). + private body(message: Message): string { + const parts: string[] = []; + const text = message.content?.trim(); + if (text) parts.push(text); + for (const attachment of message.attachments.values()) { + const link = `[${attachment.name}](${attachment.url})`; + const isImage = attachment.contentType?.startsWith("image/") ?? false; + parts.push(isImage ? `!${link}` : link); + } + return parts.join("\n\n"); + } + // Resolves the parent Linear comment for a Discord reply, when the referenced // message was mirrored as a comment. Returns undefined otherwise (e.g. a // reply to the opening post, which is the issue description). From 04033174d44c7edf4c837f7acdbe0b762162bbc4 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:33:46 +0000 Subject: [PATCH 18/62] 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. --- src/bridge/linear/api.ts | 30 +++++++++++++++++++++++++ src/bridge/linear/index.ts | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index b08bfb4..60f3787 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -210,6 +210,36 @@ export async function setIssueDescription( await linear().updateIssue(issueId, { description }); } +// Re-hosts a remote file in Linear's storage and returns its permanent asset +// URL, or null if the upload fails (caller falls back to the source URL). +export async function uploadFile( + sourceUrl: string, + filename: string, + contentType: string | null, + size: number, +): Promise { + try { + const type = contentType || "application/octet-stream"; + const upload = (await linear().fileUpload(type, filename, size)).uploadFile; + if (!upload) return null; + + const source = await fetch(sourceUrl); + if (!source.ok) return null; + + const headers = new Headers({ "Content-Type": type }); + for (const { key, value } of upload.headers) headers.set(key, value); + + const put = await fetch(upload.uploadUrl, { + method: "PUT", + headers, + body: await source.arrayBuffer(), + }); + return put.ok ? upload.assetUrl : null; + } catch { + return null; + } +} + // Trashes an issue (recoverable in Linear). export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index c16fa59..d6d9e8a 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,7 +1,7 @@ import { config } from "@lib/config.js"; import type { HelpThread } from "@lib/discord/helpThread.js"; -import type { Message } from "discord.js"; +import type { Attachment, Message } from "discord.js"; import * as linear from "./api.js"; @@ -54,6 +54,16 @@ export class LinearMirror { message.id, parentId, ); + + // Attachments mirror instantly as Discord CDN links (which expire), then the + // comment is edited to swap in permanent Linear-hosted URLs. + if (message.attachments.size > 0) { + await linear.editComment( + issueId, + message.id, + await this.durableBody(message), + ); + } } // Reflects a Discord message edit onto its mirrored comment, or the issue @@ -62,7 +72,10 @@ export class LinearMirror { const mapping = await linear.findThreadMapping(this.help.url); if (!mapping) return; - const body = this.body(message); + const body = + message.attachments.size > 0 + ? await this.durableBody(message) + : this.body(message); if (message.id === this.help.thread.id) { await linear.setIssueDescription(mapping.issueId, body); } else if (body) { @@ -183,20 +196,41 @@ export class LinearMirror { return parts.join(" - "); } - // Renders a Discord message as markdown: its text plus any attachments - // (images inline, other files as links). - private body(message: Message): string { + // Renders a Discord message as markdown: its text plus attachments (images + // inline, other files as links), resolving each attachment URL via urlFor. + private render(message: Message, urlFor: (a: Attachment) => string): string { const parts: string[] = []; const text = message.content?.trim(); if (text) parts.push(text); for (const attachment of message.attachments.values()) { - const link = `[${attachment.name}](${attachment.url})`; + const link = `[${attachment.name}](${urlFor(attachment)})`; const isImage = attachment.contentType?.startsWith("image/") ?? false; parts.push(isImage ? `!${link}` : link); } return parts.join("\n\n"); } + // Fast body using Discord CDN URLs, which expire after roughly a day. + private body(message: Message): string { + return this.render(message, (a) => a.url); + } + + // Body with attachments re-hosted in Linear for permanence, falling back to + // the CDN URL for any upload that fails. + private async durableBody(message: Message): Promise { + const assetByUrl = new Map(); + for (const a of message.attachments.values()) { + const asset = await linear.uploadFile( + a.url, + a.name, + a.contentType, + a.size, + ); + if (asset) assetByUrl.set(a.url, asset); + } + return this.render(message, (a) => assetByUrl.get(a.url) ?? a.url); + } + // Resolves the parent Linear comment for a Discord reply, when the referenced // message was mirrored as a comment. Returns undefined otherwise (e.g. a // reply to the opening post, which is the issue description). From 7f64c797a1490cd03c5c01df6aedd546e5eba123 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:39:42 +0000 Subject: [PATCH 19/62] 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. --- src/events/bridge.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 22c5420..dd59a37 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -53,8 +53,16 @@ export default function registerEvents(client: Client) { : newMessage; if (!message.inGuild() || !(await isHelpPost(message.channel))) return; if (!isHumanMessage(message)) return; - // Ignore non-content edits (embeds, pins) when the old content is known. - if (!oldMessage.partial && oldMessage.content === message.content) return; + // Ignore edits that changed neither text nor attachments (e.g. an embed + // unfurling or a pin) when the previous state is known. + if ( + !oldMessage.partial && + oldMessage.content === message.content && + oldMessage.attachments.size === message.attachments.size && + oldMessage.attachments.every((_a, id) => message.attachments.has(id)) + ) { + return; + } const help = new HelpThread(message.channel as ThreadChannel); await new LinearMirror(help).editMessage(message); } catch (err) { From a4001783aa7d222c344fa3242fc95343c5763237 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:43:47 +0000 Subject: [PATCH 20/62] chore(src): temporary bridge diagnostics for message edit/delete --- src/bridge/linear/index.ts | 9 ++++++++- src/events/bridge.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index d6d9e8a..b7d3ec9 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -88,7 +88,14 @@ export class LinearMirror { async deleteMessage(messageId: string): Promise { if (messageId === this.help.thread.id) return; const mapping = await linear.findThreadMapping(this.help.url); - if (mapping) await linear.deleteComment(mapping.issueId, messageId); + if (!mapping) { + console.log( + `[bridge] deleteMessage: no issue mapping for ${this.help.url}`, + ); + return; + } + const ok = await linear.deleteComment(mapping.issueId, messageId); + console.log(`[bridge] deleteMessage msg=${messageId} deleted=${ok}`); } // Refreshes the attachment metadata and labels, then moves the issue between diff --git a/src/events/bridge.ts b/src/events/bridge.ts index dd59a37..6996ca1 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -51,6 +51,9 @@ export default function registerEvents(client: Client) { const message = newMessage.partial ? await newMessage.fetch() : newMessage; + console.log( + `[bridge] MessageUpdate id=${message.id} oldPartial=${oldMessage.partial} attNew=${message.attachments.size}`, + ); if (!message.inGuild() || !(await isHelpPost(message.channel))) return; if (!isHumanMessage(message)) return; // Ignore edits that changed neither text nor attachments (e.g. an embed @@ -61,6 +64,9 @@ export default function registerEvents(client: Client) { oldMessage.attachments.size === message.attachments.size && oldMessage.attachments.every((_a, id) => message.attachments.has(id)) ) { + console.log( + "[bridge] MessageUpdate skipped (no content/attachment change)", + ); return; } const help = new HelpThread(message.channel as ThreadChannel); @@ -73,6 +79,9 @@ export default function registerEvents(client: Client) { client.on(Events.MessageDelete, async (message) => { try { const channel = message.channel; + console.log( + `[bridge] MessageDelete id=${message.id} chanType=${channel?.type} isThread=${channel?.isThread?.()}`, + ); if (!channel.isThread() || !(await isHelpPost(channel))) return; await new LinearMirror(new HelpThread(channel)).deleteMessage(message.id); } catch (err) { From acbaa805d144f0f986def9cc59e641e81b749e5c Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:48:07 +0000 Subject: [PATCH 21/62] feat(src/bridge/linear): render Discord custom emojis in Linear Rewrite custom emoji tokens (<:name:id>, ) to image markdown against the permanent Discord emoji CDN so they display in mirrored comments instead of showing as raw text. --- src/bridge/linear/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index b7d3ec9..817ae0e 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -207,7 +207,7 @@ export class LinearMirror { // inline, other files as links), resolving each attachment URL via urlFor. private render(message: Message, urlFor: (a: Attachment) => string): string { const parts: string[] = []; - const text = message.content?.trim(); + const text = this.emojis(message.content ?? "").trim(); if (text) parts.push(text); for (const attachment of message.attachments.values()) { const link = `[${attachment.name}](${urlFor(attachment)})`; @@ -217,6 +217,16 @@ export class LinearMirror { return parts.join("\n\n"); } + // Rewrites Discord custom emojis (<:name:id>, ) as image markdown + // pointing at the permanent emoji CDN so they render in Linear. + private emojis(content: string): string { + return content.replace( + /<(a?):(\w+):(\d+)>/g, + (_match, animated, name, id) => + `![${name}](https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"})`, + ); + } + // Fast body using Discord CDN URLs, which expire after roughly a day. private body(message: Message): string { return this.render(message, (a) => a.url); From e4d43b928898e83504b7cd85cadc9194f7cf4fc6 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:02:56 +0000 Subject: [PATCH 22/62] feat(src/bridge/linear): mirror Discord reactions and use emoji shortcodes --- src/bridge/linear/api.ts | 71 ++++++++++++++++++++++++++++++++++++++ src/bridge/linear/index.ts | 67 +++++++++++++++++++++++++++++++---- src/events/bridge.ts | 41 ++++++++++++++++++++-- src/index.ts | 10 ++++-- 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 60f3787..5ff6430 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -281,6 +281,77 @@ async function findStateId(type: string): Promise { return id; } +// --- Emojis & reactions --------------------------------------------------- + +const ensuredEmojis = new Set(); + +// Registers a Discord custom emoji as a workspace emoji named discord- so +// that :discord-: renders inline. Idempotent and cached; a duplicate-name +// error just means it already exists. +export async function ensureEmoji( + id: string, + animated: boolean, +): Promise { + if (ensuredEmojis.has(id)) return; + const ext = animated ? "gif" : "png"; + try { + await linear().createEmoji({ + name: `discord-${id}`, + url: `https://cdn.discordapp.com/emojis/${id}.${ext}`, + }); + } catch { + // Already exists or a transient failure; treat it as present. + } + ensuredEmojis.add(id); +} + +export type ReactionTarget = { issueId: string } | { commentId: string }; + +// Reaction ids we created, keyed by target+emoji, so a later removal can delete +// the exact reaction even for unicode emojis whose stored name differs from the +// input we sent. +const reactionIds = new Map(); + +function reactionKey(target: ReactionTarget, emoji: string): string { + const scope = + "issueId" in target ? `i:${target.issueId}` : `c:${target.commentId}`; + return `${scope}|${emoji}`; +} + +export async function addReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const payload = await linear().createReaction({ ...target, emoji }); + const reaction = await payload.reaction; + if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); +} + +export async function removeReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const key = reactionKey(target, emoji); + const id = reactionIds.get(key) ?? (await findReaction(target, emoji)); + if (!id) return; + await linear().deleteReaction(id); + reactionIds.delete(key); +} + +// Finds a reaction on the target whose stored emoji matches, used as a fallback +// when the created id is not cached (e.g. after a restart). Reliable for custom +// emojis; unicode names are normalized by Linear so may not match. +async function findReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const reactions = + "issueId" in target + ? (await linear().issue(target.issueId)).reactions + : (await linear().comment({ id: target.commentId })).reactions; + return reactions.find((r) => r.emoji === emoji)?.id ?? null; +} + // --- Labels --------------------------------------------------------------- let groupIdCache: string | undefined; diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 817ae0e..123de99 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,7 +1,7 @@ import { config } from "@lib/config.js"; import type { HelpThread } from "@lib/discord/helpThread.js"; -import type { Attachment, Message } from "discord.js"; +import type { Attachment, Emoji, Message } from "discord.js"; import * as linear from "./api.js"; @@ -42,6 +42,7 @@ export class LinearMirror { // equals the thread id for forum posts. if (message.id === this.help.thread.id) return; + await this.ensureEmojis(message.content ?? ""); const body = this.body(message); if (!body) return; @@ -72,6 +73,7 @@ export class LinearMirror { const mapping = await linear.findThreadMapping(this.help.url); if (!mapping) return; + await this.ensureEmojis(message.content ?? ""); const body = message.attachments.size > 0 ? await this.durableBody(message) @@ -124,6 +126,45 @@ export class LinearMirror { issueByThread.delete(this.help.thread.id); } + // Mirrors a Discord reaction onto the mapped issue (opening post) or comment. + async addReaction(message: Message, emoji: Emoji): Promise { + const target = await this.reactionTarget(message); + if (target) await linear.addReaction(target, await this.emojiKey(emoji)); + } + + // Removes a previously mirrored reaction from its issue or comment. + async removeReaction(message: Message, emoji: Emoji): Promise { + const target = await this.reactionTarget(message); + if (target) await linear.removeReaction(target, await this.emojiKey(emoji)); + } + + // Resolves the Linear reaction target for a Discord message: the issue for the + // opening post, otherwise its mirrored comment. Null when unmapped. + private async reactionTarget( + message: Message, + ): Promise { + const mapping = await linear.findThreadMapping(this.help.url); + if (!mapping) return null; + if (message.id === this.help.thread.id) { + return { issueId: mapping.issueId }; + } + const commentId = await linear.findCommentByMessage( + mapping.issueId, + message.id, + ); + return commentId ? { commentId } : null; + } + + // Maps a Discord emoji to a Linear reaction emoji: a registered discord- + // shortcode for custom emojis, or the unicode character for standard ones. + private async emojiKey(emoji: Emoji): Promise { + if (emoji.id) { + await linear.ensureEmoji(emoji.id, emoji.animated ?? false); + return `discord-${emoji.id}`; + } + return emoji.name ?? ""; + } + private async findOrCreateIssue(): Promise { const mapping = await linear.findThreadMapping(this.help.url); if (mapping) return mapping.issueId; @@ -133,9 +174,11 @@ export class LinearMirror { .fetchStarterMessage() .catch(() => null); + const starterContent = starter?.content ?? ""; + await this.ensureEmojis(starterContent); const issueId = await linear.createIssue({ title: this.help.title, - description: starter?.content?.trim() ?? "", + description: this.emojis(starterContent).trim(), author: this.author(starter), }); await linear.createThreadAttachment(issueId, this.attachment()); @@ -217,16 +260,26 @@ export class LinearMirror { return parts.join("\n\n"); } - // Rewrites Discord custom emojis (<:name:id>, ) as image markdown - // pointing at the permanent emoji CDN so they render in Linear. + // Rewrites Discord custom emojis (<:name:id>, ) as :discord-: + // shortcodes that resolve to the registered Linear workspace emojis. private emojis(content: string): string { return content.replace( - /<(a?):(\w+):(\d+)>/g, - (_match, animated, name, id) => - `![${name}](https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"})`, + //g, + (_match, id) => `:discord-${id}:`, ); } + // Registers every Discord custom emoji referenced in the content as a Linear + // workspace emoji so the shortcodes render. + private async ensureEmojis(content: string): Promise { + const seen = new Set(); + for (const [, animated, id] of content.matchAll(/<(a?):\w+:(\d+)>/g)) { + if (seen.has(id)) continue; + seen.add(id); + await linear.ensureEmoji(id, animated === "a"); + } + } + // Fast body using Discord CDN URLs, which expire after roughly a day. private body(message: Message): string { return this.render(message, (a) => a.url); diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 6996ca1..956e919 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -64,9 +64,6 @@ export default function registerEvents(client: Client) { oldMessage.attachments.size === message.attachments.size && oldMessage.attachments.every((_a, id) => message.attachments.has(id)) ) { - console.log( - "[bridge] MessageUpdate skipped (no content/attachment change)", - ); return; } const help = new HelpThread(message.channel as ThreadChannel); @@ -89,6 +86,44 @@ export default function registerEvents(client: Client) { } }); + client.on(Events.MessageReactionAdd, async (reaction, user) => { + try { + if (user.bot) return; + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // The app aggregates reactions under one identity, so only the first + // Discord reaction of an emoji is mirrored. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved?.count !== 1) return; + const help = new HelpThread(message.channel as ThreadChannel); + await new LinearMirror(help).addReaction(message, reaction.emoji); + } catch (err) { + console.error("Linear bridge: reaction add failed:", err); + } + }); + + client.on(Events.MessageReactionRemove, async (reaction) => { + try { + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // Only remove the mirrored reaction once the last Discord user removes it. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved && resolved.count > 0) return; + const help = new HelpThread(message.channel as ThreadChannel); + await new LinearMirror(help).removeReaction(message, reaction.emoji); + } catch (err) { + console.error("Linear bridge: reaction remove failed:", err); + } + }); + // Coalesce bursts of tag edits per thread. syncStatus is idempotent and // reconciles against the Linear issue state, so no before/after diff is kept. const flushers = new Map void>(); diff --git a/src/index.ts b/src/index.ts index 7a8e2d1..d484f48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,10 +19,16 @@ const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, ], - // Needed so edits/deletes of uncached messages still emit events. - partials: [Partials.Message, Partials.Channel], + // Needed so edits/deletes/reactions on uncached messages still emit events. + partials: [ + Partials.Message, + Partials.Channel, + Partials.Reaction, + Partials.User, + ], }); const presenceList = [ From 2b81b58e060660fc6e204863bcfcb73a074301fa Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:04:48 +0000 Subject: [PATCH 23/62] feat(src/bridge/linear): clear issue description when opening post is deleted --- src/bridge/linear/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 123de99..a61cd79 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -85,10 +85,9 @@ export class LinearMirror { } } - // Deletes the mirrored comment for a deleted Discord message. The opening - // post maps to the description, so it is left untouched here. + // Removes a deleted Discord message from Linear. Regular messages map to + // comments; the opening post maps to the issue description, which is cleared. async deleteMessage(messageId: string): Promise { - if (messageId === this.help.thread.id) return; const mapping = await linear.findThreadMapping(this.help.url); if (!mapping) { console.log( @@ -96,6 +95,10 @@ export class LinearMirror { ); return; } + if (messageId === this.help.thread.id) { + await linear.setIssueDescription(mapping.issueId, ""); + return; + } const ok = await linear.deleteComment(mapping.issueId, messageId); console.log(`[bridge] deleteMessage msg=${messageId} deleted=${ok}`); } From ede29272dff64b3188da2e954253aaaa93cee4e7 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:31:00 +0000 Subject: [PATCH 24/62] feat(src/bridge/linear): use a user token for emoji and label creation --- src/bridge/linear/api.ts | 85 ++++++++++++++++++++++++++++------------ src/lib/config.ts | 18 +++++---- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 5ff6430..97505af 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -3,26 +3,39 @@ import { LinearClient, type Comment } from "@linear/sdk"; import { config } from "@lib/config.js"; // Validated bridge credentials. Present whenever the bridge is enabled. -function bridgeConfig(): { apiKey: string; teamId: string } { - const { apiKey, teamId } = config.linearBridge; - if (!apiKey || !teamId) { - throw new Error("linearBridge is enabled but apiKey/teamId are missing"); +function bridgeConfig(): { + appToken: string; + userToken: string; + teamId: string; +} { + const { appToken, userToken, teamId } = config.linearBridge; + if (!appToken || !userToken || !teamId) { + throw new Error( + "linearBridge is enabled but appToken/userToken/teamId are missing", + ); } - return { apiKey, teamId }; + return { appToken, userToken, teamId }; } -let client: LinearClient | undefined; +let appClient: LinearClient | undefined; +let userClient: LinearClient | undefined; +// App-actor client. Issues, comments and reactions run here so they are +// attributed to the external Discord author (OAuth tokens use accessToken). function linear(): LinearClient { - if (!client) { - const { apiKey } = bridgeConfig(); - // App-actor (OAuth) tokens must be sent as Bearer tokens via accessToken; - // personal API keys are sent verbatim via apiKey. - client = new LinearClient( - config.linearBridge.createAsUser ? { accessToken: apiKey } : { apiKey }, - ); + if (!appClient) { + appClient = new LinearClient({ accessToken: bridgeConfig().appToken }); + } + return appClient; +} + +// Personal-key client for writes the app actor cannot make: creating custom +// emojis and labels. +function linearUser(): LinearClient { + if (!userClient) { + userClient = new LinearClient({ apiKey: bridgeConfig().userToken }); } - return client; + return userClient; } // Metadata stored on the Discord attachment of a mirrored issue. @@ -283,26 +296,46 @@ async function findStateId(type: string): Promise { // --- Emojis & reactions --------------------------------------------------- -const ensuredEmojis = new Set(); +// Names of the workspace's custom emojis, loaded once and updated as we create +// new ones, so we don't recreate existing emojis or spam duplicate errors. +let emojiNames: Set | undefined; + +async function loadEmojiNames(): Promise> { + if (emojiNames) return emojiNames; + const names = new Set(); + let after: string | undefined; + do { + const page = await linearUser().emojis({ first: 250, after }); + for (const e of page.nodes) names.add(e.name); + after = page.pageInfo.hasNextPage + ? (page.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + emojiNames = names; + return names; +} // Registers a Discord custom emoji as a workspace emoji named discord- so -// that :discord-: renders inline. Idempotent and cached; a duplicate-name -// error just means it already exists. +// that :discord-: renders inline. Idempotent: skips emojis that already +// exist and needs the user token, as the app actor cannot create emojis. export async function ensureEmoji( id: string, animated: boolean, ): Promise { - if (ensuredEmojis.has(id)) return; + const name = `discord-${id}`; + const names = await loadEmojiNames(); + if (names.has(name)) return; + const ext = animated ? "gif" : "png"; try { - await linear().createEmoji({ - name: `discord-${id}`, + await linearUser().createEmoji({ + name, url: `https://cdn.discordapp.com/emojis/${id}.${ext}`, }); - } catch { - // Already exists or a transient failure; treat it as present. + names.add(name); + } catch (err) { + console.error(`[bridge] ensureEmoji ${name} failed:`, err); } - ensuredEmojis.add(id); } export type ReactionTarget = { issueId: string } | { commentId: string }; @@ -371,7 +404,7 @@ export async function ensureLabelGroup(name: string): Promise { return found.id; } - const payload = await linear().createIssueLabel({ + const payload = await linearUser().createIssueLabel({ name, teamId, isGroup: true, @@ -403,7 +436,7 @@ export async function createLabel(input: { tagId: string; groupId: string; }): Promise { - const payload = await linear().createIssueLabel({ + const payload = await linearUser().createIssueLabel({ name: input.name, description: input.tagId, teamId: bridgeConfig().teamId, @@ -416,7 +449,7 @@ export async function createLabel(input: { } export async function renameLabel(id: string, name: string): Promise { - await linear().updateIssueLabel(id, { name }); + await linearUser().updateIssueLabel(id, { name }); } // Reconciles an issue's group labels to exactly match the given tag set, adding diff --git a/src/lib/config.ts b/src/lib/config.ts index cbe69fd..ff275db 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -38,11 +38,15 @@ interface Config { // One-way Discord -> Linear bridge for #help threads. Disabled by default. linearBridge: { enabled: boolean; - apiKey?: string; + // OAuth app-actor token. Used for issues, comments and reactions so they + // are attributed to the external Discord author. + appToken?: string; + // Personal API key. Used for workspace/team admin writes the app actor is + // not allowed to make: creating custom emojis and labels. + userToken?: string; teamId?: string; // Attribute mirrored comments to the Discord author via Linear's - // createAsUser. Requires OAuth app-actor auth; a personal API key rejects - // it, so leave this off unless the key runs in actor=app mode. + // createAsUser. Requires the app-actor token; turn off to post as the app. createAsUser: boolean; labels: { enabled: boolean; @@ -70,9 +74,8 @@ export const { config, layers } = await loadConfig({ enabled: false, createAsUser: false, labels: { - // Off by default: Linear app-actor tokens cannot create team labels - // (that needs a team owner, which apps cannot be). Enable only with a - // credential allowed to manage the team's labels. + // Off by default. Label creation runs on the user token, which can + // manage the team's labels; enable to mirror #help tags as labels. enabled: false, groupName: "Discord (#help)", }, @@ -110,7 +113,8 @@ export function validateLinearBridgeConfig(): void { if (!linearBridge.enabled) return; const missing: string[] = []; - if (!linearBridge.apiKey) missing.push("linearBridge.apiKey"); + if (!linearBridge.appToken) missing.push("linearBridge.appToken"); + if (!linearBridge.userToken) missing.push("linearBridge.userToken"); if (!linearBridge.teamId) missing.push("linearBridge.teamId"); if (missing.length > 0) { From d97c86040d8180f037ac62b8514aebcf40cf3c5b Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:43:48 +0000 Subject: [PATCH 25/62] chore(src/lib/config): enable Linear label sync by default --- src/lib/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index ff275db..0f07419 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -74,9 +74,9 @@ export const { config, layers } = await loadConfig({ enabled: false, createAsUser: false, labels: { - // Off by default. Label creation runs on the user token, which can - // manage the team's labels; enable to mirror #help tags as labels. - enabled: false, + // Label creation runs on the user token, which can manage the team's + // labels, so #help tags mirror as labels. + enabled: true, groupName: "Discord (#help)", }, }, From d8204dfd6b2b755f545025155fab380c4db211ba Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:43:48 +0000 Subject: [PATCH 26/62] fix(src/bridge/linear): re-host custom emojis in Linear before registering --- src/bridge/linear/api.ts | 91 +++++++++++++++++++++++++++----------- src/bridge/linear/index.ts | 7 +-- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 97505af..5df35e5 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -38,6 +38,20 @@ function linearUser(): LinearClient { return userClient; } +// Extracts a readable message from a Linear SDK error, whose default string +// form is unhelpful ("[object Object]"). +function linearError(err: unknown): string { + const e = err as { errors?: { message?: string }[]; message?: string }; + return ( + e?.errors + ?.map((x) => x.message) + .filter(Boolean) + .join("; ") || + e?.message || + String(err) + ); +} + // Metadata stored on the Discord attachment of a mirrored issue. export interface ThreadAttachmentFields { url: string; @@ -229,30 +243,45 @@ export async function uploadFile( sourceUrl: string, filename: string, contentType: string | null, - size: number, ): Promise { try { - const type = contentType || "application/octet-stream"; - const upload = (await linear().fileUpload(type, filename, size)).uploadFile; - if (!upload) return null; - - const source = await fetch(sourceUrl); - if (!source.ok) return null; - - const headers = new Headers({ "Content-Type": type }); - for (const { key, value } of upload.headers) headers.set(key, value); - - const put = await fetch(upload.uploadUrl, { - method: "PUT", - headers, - body: await source.arrayBuffer(), - }); - return put.ok ? upload.assetUrl : null; + return await rehost( + sourceUrl, + filename, + contentType || "application/octet-stream", + ); } catch { return null; } } +// Fetches a remote file and uploads its bytes to Linear storage, returning the +// permanent asset URL. Linear only accepts asset URLs on its own upload domain, +// so emojis and attachments must be re-hosted here rather than hotlinked. +async function rehost( + sourceUrl: string, + filename: string, + type: string, +): Promise { + const source = await fetch(sourceUrl); + if (!source.ok) return null; + const bytes = await source.arrayBuffer(); + + const upload = (await linear().fileUpload(type, filename, bytes.byteLength)) + .uploadFile; + if (!upload) return null; + + const headers = new Headers({ "Content-Type": type }); + for (const { key, value } of upload.headers) headers.set(key, value); + + const put = await fetch(upload.uploadUrl, { + method: "PUT", + headers, + body: bytes, + }); + return put.ok ? upload.assetUrl : null; +} + // Trashes an issue (recoverable in Linear). export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); @@ -327,14 +356,22 @@ export async function ensureEmoji( if (names.has(name)) return; const ext = animated ? "gif" : "png"; + // Linear rejects external image URLs, so re-host the Discord emoji first. + const asset = await rehost( + `https://cdn.discordapp.com/emojis/${id}.${ext}`, + `${name}.${ext}`, + animated ? "image/gif" : "image/png", + ).catch(() => null); + if (!asset) { + console.error(`[bridge] ensureEmoji ${name}: upload failed`); + return; + } + try { - await linearUser().createEmoji({ - name, - url: `https://cdn.discordapp.com/emojis/${id}.${ext}`, - }); + await linearUser().createEmoji({ name, url: asset }); names.add(name); } catch (err) { - console.error(`[bridge] ensureEmoji ${name} failed:`, err); + console.error(`[bridge] ensureEmoji ${name} failed:`, linearError(err)); } } @@ -355,9 +392,13 @@ export async function addReaction( target: ReactionTarget, emoji: string, ): Promise { - const payload = await linear().createReaction({ ...target, emoji }); - const reaction = await payload.reaction; - if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); + try { + const payload = await linear().createReaction({ ...target, emoji }); + const reaction = await payload.reaction; + if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); + } catch (err) { + console.error(`[bridge] addReaction ${emoji} failed:`, linearError(err)); + } } export async function removeReaction( diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index a61cd79..fdef26e 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -293,12 +293,7 @@ export class LinearMirror { private async durableBody(message: Message): Promise { const assetByUrl = new Map(); for (const a of message.attachments.values()) { - const asset = await linear.uploadFile( - a.url, - a.name, - a.contentType, - a.size, - ); + const asset = await linear.uploadFile(a.url, a.name, a.contentType); if (asset) assetByUrl.set(a.url, asset); } return this.render(message, (a) => assetByUrl.get(a.url) ?? a.url); From b20c9ad3523b74b439f8fad88bec6c0a6cadf02a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:45:01 +0000 Subject: [PATCH 27/62] feat(src/bridge/linear): file mirrored issues under a configurable project --- src/bridge/linear/api.ts | 1 + src/lib/config.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 5df35e5..aa6c7d8 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -89,6 +89,7 @@ export async function createIssue(input: { }): Promise { const payload = await linear().createIssue({ teamId: bridgeConfig().teamId, + projectId: config.linearBridge.projectId, title: input.title, description: input.description, // Attributes the issue to an external Discord author under app-actor auth; diff --git a/src/lib/config.ts b/src/lib/config.ts index 0f07419..1d60df6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -45,6 +45,8 @@ interface Config { // not allowed to make: creating custom emojis and labels. userToken?: string; teamId?: string; + // Optional Linear project that mirrored thread issues are filed under. + projectId?: string; // Attribute mirrored comments to the Discord author via Linear's // createAsUser. Requires the app-actor token; turn off to post as the app. createAsUser: boolean; From 76c7e6b4c3a9078cb1cc1e7e98d6ef0350b9f2b1 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:46:35 +0000 Subject: [PATCH 28/62] feat(src/bridge/linear): move issue to In Progress on a team reply --- src/bridge/linear/api.ts | 4 ++-- src/bridge/linear/index.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index aa6c7d8..37ace58 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -298,10 +298,10 @@ export async function getIssueStateType( } // Moves an issue to the first workflow state of the given type in the team -// (e.g. completed -> "Done", triage -> "Triage"). +// (e.g. completed -> "Done", triage -> "Triage", started -> "In Progress"). export async function setIssueState( issueId: string, - type: "completed" | "triage", + type: "completed" | "triage" | "started", ): Promise { const stateId = await findStateId(type); if (!stateId) return; diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index fdef26e..0f92238 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,5 +1,7 @@ import { config } from "@lib/config.js"; import type { HelpThread } from "@lib/discord/helpThread.js"; +import { resolveMember } from "@lib/discord/help.js"; +import { isTeamMember } from "@lib/discord/users.js"; import type { Attachment, Emoji, Message } from "discord.js"; @@ -65,6 +67,23 @@ export class LinearMirror { await this.durableBody(message), ); } + + await this.markInProgressIfTeam(issueId, message); + } + + // Moves the issue to In Progress when a Coder team member replies, unless the + // thread is closed or the issue is already started or done. + private async markInProgressIfTeam( + issueId: string, + message: Message, + ): Promise { + if (this.help.isClosed) return; + const member = await resolveMember(message); + if (!member || !isTeamMember(member)) return; + + const stateType = await linear.getIssueStateType(issueId); + if (stateType === "started" || stateType === "completed") return; + await linear.setIssueState(issueId, "started"); } // Reflects a Discord message edit onto its mirrored comment, or the issue From b311efe3c8716c3731fc16bbab1d662839dbca7d Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:51:12 +0000 Subject: [PATCH 29/62] feat(src/bridge): cross-link mentioned threads and GitHub issues in Linear --- src/bridge/linear/api.ts | 44 ++++++++++++++++++++++++++ src/bridge/linear/index.ts | 65 +++++++++++++++++++++++++++++++++++--- src/bridge/references.ts | 49 ++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/bridge/references.ts diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 37ace58..a16a686 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -1,4 +1,5 @@ import { LinearClient, type Comment } from "@linear/sdk"; +import { IssueRelationType } from "@linear/sdk"; import { config } from "@lib/config.js"; @@ -503,3 +504,46 @@ export async function setIssueGroupLabels( ): Promise { await linear().updateIssue(issueId, { addedLabelIds, removedLabelIds }); } + +// --- Cross-links ---------------------------------------------------------- + +export interface LinkedIssue { + id: string; + identifier: string; + url: string; +} + +// Finds the Linear issue mapped to a URL via its attachments (a mirrored +// Discord thread, or a GitHub issue linked through Linear's integration). +export async function resolveIssueByUrl( + url: string, +): Promise { + const attachments = await linear().attachmentsForURL(url); + const issue = await attachments.nodes[0]?.issue; + if (!issue) return null; + return { id: issue.id, identifier: issue.identifier, url: issue.url }; +} + +// Relation pairs created this session, to avoid duplicate "related" links when +// the same issue is mentioned more than once. +const relatedPairs = new Set(); + +// Marks two issues as related. Idempotent within a session and tolerant of +// Linear rejecting an existing relation. +export async function relateIssues( + issueId: string, + relatedIssueId: string, +): Promise { + const key = [issueId, relatedIssueId].sort().join("|"); + if (relatedPairs.has(key)) return; + relatedPairs.add(key); + try { + await linear().createIssueRelation({ + issueId, + relatedIssueId, + type: IssueRelationType.Related, + }); + } catch (err) { + console.error(`[bridge] relateIssues ${key} failed:`, linearError(err)); + } +} diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 0f92238..af8d90e 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -3,6 +3,12 @@ import type { HelpThread } from "@lib/discord/helpThread.js"; import { resolveMember } from "@lib/discord/help.js"; import { isTeamMember } from "@lib/discord/users.js"; +import { + dedupeReferences, + discordThreadReferences, + githubReferences, +} from "@bridge/references.js"; + import type { Attachment, Emoji, Message } from "discord.js"; import * as linear from "./api.js"; @@ -36,6 +42,7 @@ export class LinearMirror { async create(): Promise { const issueId = await this.ensureIssue(); await this.syncLabels(issueId); + await this.linkStarterReferences(issueId); } // Mirrors a thread message as an issue comment. @@ -44,15 +51,17 @@ export class LinearMirror { // equals the thread id for forum posts. if (message.id === this.help.thread.id) return; - await this.ensureEmojis(message.content ?? ""); - const body = this.body(message); - if (!body) return; + const content = message.content ?? ""; + await this.ensureEmojis(content); + const rendered = this.body(message); + if (!rendered) return; const issueId = await this.ensureIssue(); + const rewrites = await this.linkReferences(issueId, content); const parentId = await this.replyParent(issueId, message); await linear.addComment( issueId, - body, + this.applyRewrites(rendered, rewrites), this.author(message), message.id, parentId, @@ -64,7 +73,7 @@ export class LinearMirror { await linear.editComment( issueId, message.id, - await this.durableBody(message), + this.applyRewrites(await this.durableBody(message), rewrites), ); } @@ -187,6 +196,52 @@ export class LinearMirror { return emoji.name ?? ""; } + // Finds other threads or GitHub issues mentioned in the content that map to a + // Linear issue, relates them to this issue, and returns token -> markdown link + // rewrites that turn each mention into a link to the mapped issue. + private async linkReferences( + issueId: string, + content: string, + ): Promise> { + const refs = dedupeReferences([ + ...githubReferences(content), + ...discordThreadReferences(content, config.serverId), + ]); + + const rewrites = new Map(); + for (const ref of refs) { + const target = await linear.resolveIssueByUrl(ref.url); + if (!target || target.id === issueId) continue; + await linear.relateIssues(issueId, target.id); + rewrites.set(ref.token, `[${target.identifier}](${target.url})`); + } + return rewrites; + } + + private applyRewrites(body: string, rewrites: Map): string { + for (const [token, replacement] of rewrites) { + body = body.split(token).join(replacement); + } + return body; + } + + // Links references found in the opening post and, if any resolved, rewrites + // the issue description to point at the mapped issues. + private async linkStarterReferences(issueId: string): Promise { + const starter = await this.help.thread + .fetchStarterMessage() + .catch(() => null); + const content = starter?.content ?? ""; + const rewrites = await this.linkReferences(issueId, content); + if (rewrites.size === 0) return; + + const description = this.applyRewrites( + this.emojis(content).trim(), + rewrites, + ); + await linear.setIssueDescription(issueId, description); + } + private async findOrCreateIssue(): Promise { const mapping = await linear.findThreadMapping(this.help.url); if (mapping) return mapping.issueId; diff --git a/src/bridge/references.ts b/src/bridge/references.ts new file mode 100644 index 0000000..fe17f6a --- /dev/null +++ b/src/bridge/references.ts @@ -0,0 +1,49 @@ +// Cross-linking of entities mentioned in mirrored content. A bridge extracts +// references from a message, resolves each to a Linear issue (via its +// attachments) and links them. The extractors are source-agnostic so other +// bridges (e.g. a future GitHub Discussions bridge) can reuse them. + +// A reference to another entity found in mirrored content. `url` is the +// canonical link used to locate a matching Linear issue; `token` is the exact +// substring in the content to rewrite when a match is found. +export interface Reference { + url: string; + token: string; +} + +// Extracts GitHub issue and pull request references (full URLs). +export function githubReferences(content: string): Reference[] { + const re = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g; + return [...content.matchAll(re)].map((m) => ({ url: m[0], token: m[0] })); +} + +// Extracts Discord thread references, both channel mentions (<#id>) and +// message/thread URLs, normalized to the canonical thread URL for the guild. +export function discordThreadReferences( + content: string, + guildId: string, +): Reference[] { + const refs: Reference[] = []; + for (const m of content.matchAll(/<#(\d+)>/g)) { + refs.push({ token: m[0], url: threadUrl(guildId, m[1]) }); + } + const urlRe = + /https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(\d+)\/(\d+)(?:\/\d+)?/g; + for (const m of content.matchAll(urlRe)) { + refs.push({ token: m[0], url: threadUrl(m[1], m[2]) }); + } + return refs; +} + +function threadUrl(guildId: string, threadId: string): string { + return `https://discord.com/channels/${guildId}/${threadId}`; +} + +// Removes duplicate references that share a token, keeping the first. +export function dedupeReferences(refs: Reference[]): Reference[] { + const byToken = new Map(); + for (const ref of refs) { + if (!byToken.has(ref.token)) byToken.set(ref.token, ref); + } + return [...byToken.values()]; +} From 8f1b0a5ff2f91f004edbe90aa1b0bcb01a628cd6 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:52:33 +0000 Subject: [PATCH 30/62] fix(src/bridge/linear): target the In Progress state by name on team reply --- src/bridge/linear/api.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index a16a686..0eaf569 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -298,21 +298,32 @@ export async function getIssueStateType( return state?.type ?? null; } -// Moves an issue to the first workflow state of the given type in the team -// (e.g. completed -> "Done", triage -> "Triage", started -> "In Progress"). +// Moves an issue to a workflow state of the given type in the team. The started +// type has several states (In Progress, Blocked, In Review), so it targets "In +// Progress" by name; the others take the first state of their type. export async function setIssueState( issueId: string, type: "completed" | "triage" | "started", ): Promise { - const stateId = await findStateId(type); + const stateId = await findStateId( + type, + type === "started" ? "In Progress" : undefined, + ); if (!stateId) return; await linear().updateIssue(issueId, { stateId }); } const stateIdByType = new Map(); -async function findStateId(type: string): Promise { - const cached = stateIdByType.get(type); +// Finds a workflow state of the given type in the team. When preferredName is +// set, a state with that name wins; otherwise the lowest-position state of the +// type is used, since Linear does not order the results. +async function findStateId( + type: string, + preferredName?: string, +): Promise { + const cacheKey = preferredName ? `${type}:${preferredName}` : type; + const cached = stateIdByType.get(cacheKey); if (cached) return cached; const { teamId } = bridgeConfig(); @@ -320,8 +331,14 @@ async function findStateId(type: string): Promise { filter: { team: { id: { eq: teamId } }, type: { eq: type } }, }); - const id = states.nodes[0]?.id ?? null; - if (id) stateIdByType.set(type, id); + const named = + preferredName && + states.nodes.find( + (s) => s.name.toLowerCase() === preferredName.toLowerCase(), + ); + const byPosition = [...states.nodes].sort((a, b) => a.position - b.position); + const id = (named || byPosition[0])?.id ?? null; + if (id) stateIdByType.set(cacheKey, id); return id; } From b4d1dbd4c9d917a6e71ac6dd2c4c39e0daaa58ab Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:53:24 +0000 Subject: [PATCH 31/62] fix(src/bridge/linear): keep replies when deleting a mirrored comment --- src/bridge/linear/api.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 0eaf569..94c6501 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -186,14 +186,24 @@ export async function editComment( } // Deletes the mirrored comment for a Discord message. Returns false if the -// message has no mirrored comment. +// message has no mirrored comment. If the comment has replies, its body is +// blanked instead of deleted, since Linear removes a comment's replies along +// with it. export async function deleteComment( issueId: string, messageId: string, ): Promise { - const commentId = await findCommentByMessage(issueId, messageId); - if (!commentId) return false; - await linear().deleteComment(commentId); + const node = await findCommentNode(issueId, messageId); + if (!node) return false; + + const children = await node.children(); + if (children.nodes.length > 0) { + await linear().updateComment(node.id, { + body: withMarker("_Message deleted._", messageId), + }); + } else { + await linear().deleteComment(node.id); + } return true; } From 14991c41bd3e0ac1137e9a98ae051816d7cf505b Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:08:30 +0000 Subject: [PATCH 32/62] fix(src/bridge/linear): run all label ops on the user token --- src/bridge/linear/api.ts | 12 +++++++----- src/bridge/linear/index.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 94c6501..5f7dd62 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -41,7 +41,7 @@ function linearUser(): LinearClient { // Extracts a readable message from a Linear SDK error, whose default string // form is unhelpful ("[object Object]"). -function linearError(err: unknown): string { +export function linearError(err: unknown): string { const e = err as { errors?: { message?: string }[]; message?: string }; return ( e?.errors @@ -464,7 +464,7 @@ export async function ensureLabelGroup(name: string): Promise { if (groupIdCache) return groupIdCache; const { teamId } = bridgeConfig(); - const existing = await linear().issueLabels({ + const existing = await linearUser().issueLabels({ filter: { name: { eq: name }, team: { id: { eq: teamId } } }, }); @@ -489,7 +489,7 @@ export async function ensureLabelGroup(name: string): Promise { // Lists the child labels of a group in the team. export async function getGroupLabels(groupId: string): Promise { const { teamId } = bridgeConfig(); - const labels = await linear().issueLabels({ + const labels = await linearUser().issueLabels({ filter: { parent: { id: { eq: groupId } }, team: { id: { eq: teamId } } }, }); @@ -523,13 +523,15 @@ export async function renameLabel(id: string, name: string): Promise { } // Reconciles an issue's group labels to exactly match the given tag set, adding -// missing ones and removing stale ones without touching non-group labels. +// missing ones and removing stale ones without touching non-group labels. Runs +// on the user token, the same one that owns the labels, so a freshly created +// label is guaranteed to be visible when assigned. export async function setIssueGroupLabels( issueId: string, addedLabelIds: string[], removedLabelIds: string[], ): Promise { - await linear().updateIssue(issueId, { addedLabelIds, removedLabelIds }); + await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); } // --- Cross-links ---------------------------------------------------------- diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index af8d90e..1478c18 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -265,7 +265,17 @@ export class LinearMirror { // Reconciles the issue's group labels to match the thread's current tags. private async syncLabels(issueId: string): Promise { if (!config.linearBridge.labels.enabled) return; + try { + await this.reconcileLabels(issueId); + } catch (err) { + console.error( + "Linear bridge: label sync failed:", + linear.linearError(err), + ); + } + } + private async reconcileLabels(issueId: string): Promise { const groupId = await linear.ensureLabelGroup( config.linearBridge.labels.groupName, ); From bf122169b0d5958ca79712ff84ac833affc9a8f1 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:14:16 +0000 Subject: [PATCH 33/62] fix(src/bridge/linear): mirror tags as flat namespaced labels --- src/bridge/linear/api.ts | 111 ++++++++++++++----------------------- src/bridge/linear/index.ts | 30 +--------- src/lib/config.ts | 7 ++- 3 files changed, 50 insertions(+), 98 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 5f7dd62..5ff5c01 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -61,12 +61,6 @@ export interface ThreadAttachmentFields { metadata: Record; } -export interface GroupLabel { - id: string; - name: string; - description?: string; -} - // Finds the issue mapped to a thread via its URL attachment, returning the // issue and attachment ids. export async function findThreadMapping( @@ -457,80 +451,61 @@ async function findReaction( // --- Labels --------------------------------------------------------------- -let groupIdCache: string | undefined; - -// Finds or creates the team-scoped label group that holds Discord tag labels. -export async function ensureLabelGroup(name: string): Promise { - if (groupIdCache) return groupIdCache; +// Flat (ungrouped) labels are used rather than a label group because Linear +// allows only one label per group on an issue, while a help thread can carry +// several tags. Each label is namespaced by name, e.g. "Discord (#help) > tag". +const labelIdByName = new Map(); + +// Finds or creates a team label with the given name, tagging its description +// with the Discord tag id. Cached by name. Runs on the user token, which owns +// label management. +export async function ensureLabel( + name: string, + tagId: string, +): Promise { + const cached = labelIdByName.get(name); + if (cached) return cached; const { teamId } = bridgeConfig(); const existing = await linearUser().issueLabels({ filter: { name: { eq: name }, team: { id: { eq: teamId } } }, }); - const found = existing.nodes[0]; - if (found) { - groupIdCache = found.id; - return found.id; + let id = existing.nodes[0]?.id; + if (!id) { + const payload = await linearUser().createIssueLabel({ + name, + description: tagId, + teamId, + }); + const label = await payload.issueLabel; + if (!label) throw new Error("Linear did not return the created label"); + id = label.id; } - const payload = await linearUser().createIssueLabel({ - name, - teamId, - isGroup: true, - }); - const label = await payload.issueLabel; - if (!label) throw new Error("Linear did not return the created label group"); - - groupIdCache = label.id; - return label.id; -} - -// Lists the child labels of a group in the team. -export async function getGroupLabels(groupId: string): Promise { - const { teamId } = bridgeConfig(); - const labels = await linearUser().issueLabels({ - filter: { parent: { id: { eq: groupId } }, team: { id: { eq: teamId } } }, - }); - - return labels.nodes.map((l) => ({ - id: l.id, - name: l.name, - description: l.description ?? undefined, - })); -} - -// Creates a child label whose description is the Discord tag id. -export async function createLabel(input: { - name: string; - tagId: string; - groupId: string; -}): Promise { - const payload = await linearUser().createIssueLabel({ - name: input.name, - description: input.tagId, - teamId: bridgeConfig().teamId, - parentId: input.groupId, - }); - - const label = await payload.issueLabel; - if (!label) throw new Error("Linear did not return the created label"); - return { id: label.id, name: label.name, description: input.tagId }; -} - -export async function renameLabel(id: string, name: string): Promise { - await linearUser().updateIssueLabel(id, { name }); + labelIdByName.set(name, id); + return id; } -// Reconciles an issue's group labels to exactly match the given tag set, adding -// missing ones and removing stale ones without touching non-group labels. Runs -// on the user token, the same one that owns the labels, so a freshly created -// label is guaranteed to be visible when assigned. -export async function setIssueGroupLabels( +// Reconciles the issue's namespaced labels to exactly match desiredIds, adding +// missing ones and removing only stale labels that share the namespace prefix +// (so unrelated labels are never touched, and labels already absent are never +// "removed"). Runs on the user token that owns the labels. +export async function setNamespacedLabels( issueId: string, - addedLabelIds: string[], - removedLabelIds: string[], + prefix: string, + desiredIds: string[], ): Promise { + const issue = await linearUser().issue(issueId); + const current = (await issue.labels()).nodes; + const ours = current + .filter((l) => l.name.startsWith(prefix)) + .map((l) => l.id); + + const addedLabelIds = desiredIds.filter((id) => !ours.includes(id)); + const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); + if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; + await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); } diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 1478c18..7bde8a3 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -276,36 +276,12 @@ export class LinearMirror { } private async reconcileLabels(issueId: string): Promise { - const groupId = await linear.ensureLabelGroup( - config.linearBridge.labels.groupName, - ); - const groupLabels = await linear.getGroupLabels(groupId); - const byTagId = new Map(); - for (const label of groupLabels) { - if (label.description) byTagId.set(label.description, label); - } - + const prefix = `${config.linearBridge.labels.namespace} > `; const desiredIds: string[] = []; for (const tag of this.help.tags) { - let label = byTagId.get(tag.id); - if (!label) { - label = await linear.createLabel({ - name: tag.name, - tagId: tag.id, - groupId, - }); - byTagId.set(tag.id, label); - } else if (label.name !== tag.name) { - await linear.renameLabel(label.id, tag.name); - } - desiredIds.push(label.id); + desiredIds.push(await linear.ensureLabel(`${prefix}${tag.name}`, tag.id)); } - - const removedIds = groupLabels - .map((l) => l.id) - .filter((id) => !desiredIds.includes(id)); - - await linear.setIssueGroupLabels(issueId, desiredIds, removedIds); + await linear.setNamespacedLabels(issueId, prefix, desiredIds); } private attachment(): linear.ThreadAttachmentFields { diff --git a/src/lib/config.ts b/src/lib/config.ts index 1d60df6..995a65d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -52,7 +52,7 @@ interface Config { createAsUser: boolean; labels: { enabled: boolean; - groupName: string; + namespace: string; }; }; @@ -77,9 +77,10 @@ export const { config, layers } = await loadConfig({ createAsUser: false, labels: { // Label creation runs on the user token, which can manage the team's - // labels, so #help tags mirror as labels. + // labels. Each #help tag becomes a flat label named " > tag"; + // groups are avoided since Linear allows only one group label per issue. enabled: true, - groupName: "Discord (#help)", + namespace: "Discord (#help)", }, }, }, From b499303787aeaeb6b5fe68e15cb9dc3d864f8fb2 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:15:20 +0000 Subject: [PATCH 34/62] chore(src/lib/config): name mirrored labels "#help > tag" --- src/bridge/linear/api.ts | 2 +- src/lib/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 5ff5c01..1923e73 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -453,7 +453,7 @@ async function findReaction( // Flat (ungrouped) labels are used rather than a label group because Linear // allows only one label per group on an issue, while a help thread can carry -// several tags. Each label is namespaced by name, e.g. "Discord (#help) > tag". +// several tags. Each label is namespaced by name, e.g. "#help > tag". const labelIdByName = new Map(); // Finds or creates a team label with the given name, tagging its description diff --git a/src/lib/config.ts b/src/lib/config.ts index 995a65d..0acb1de 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -80,7 +80,7 @@ export const { config, layers } = await loadConfig({ // labels. Each #help tag becomes a flat label named " > tag"; // groups are avoided since Linear allows only one group label per issue. enabled: true, - namespace: "Discord (#help)", + namespace: "#help", }, }, }, From ec48a01666c5e6b822160f9a879522b1a7da213c Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:18:42 +0000 Subject: [PATCH 35/62] feat(src/bridge/linear): drive issue state from the thread waiting tag --- src/bridge/linear/api.ts | 19 ++++++----- src/bridge/linear/index.ts | 65 +++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 1923e73..efb2382 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -293,26 +293,25 @@ export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); } -// Returns the workflow state type of an issue (e.g. "triage", "completed"). -export async function getIssueStateType( +// Returns the workflow state type and name of an issue (e.g. type "started", +// name "In Progress"). +export async function getIssueState( issueId: string, -): Promise { +): Promise<{ type: string; name: string } | null> { const issue = await linear().issue(issueId); const state = await issue.state; - return state?.type ?? null; + return state ? { type: state.type, name: state.name } : null; } // Moves an issue to a workflow state of the given type in the team. The started -// type has several states (In Progress, Blocked, In Review), so it targets "In -// Progress" by name; the others take the first state of their type. +// type has several states (In Progress, Blocked, In Review), so pass the state +// name; without one, the lowest-position state of the type is used. export async function setIssueState( issueId: string, type: "completed" | "triage" | "started", + preferredName?: string, ): Promise { - const stateId = await findStateId( - type, - type === "started" ? "In Progress" : undefined, - ); + const stateId = await findStateId(type, preferredName); if (!stateId) return; await linear().updateIssue(issueId, { stateId }); } diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 7bde8a3..00b98fa 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,7 +1,5 @@ import { config } from "@lib/config.js"; import type { HelpThread } from "@lib/discord/helpThread.js"; -import { resolveMember } from "@lib/discord/help.js"; -import { isTeamMember } from "@lib/discord/users.js"; import { dedupeReferences, @@ -76,23 +74,6 @@ export class LinearMirror { this.applyRewrites(await this.durableBody(message), rewrites), ); } - - await this.markInProgressIfTeam(issueId, message); - } - - // Moves the issue to In Progress when a Coder team member replies, unless the - // thread is closed or the issue is already started or done. - private async markInProgressIfTeam( - issueId: string, - message: Message, - ): Promise { - if (this.help.isClosed) return; - const member = await resolveMember(message); - if (!member || !isTeamMember(member)) return; - - const stateType = await linear.getIssueStateType(issueId); - if (stateType === "started" || stateType === "completed") return; - await linear.setIssueState(issueId, "started"); } // Reflects a Discord message edit onto its mirrored comment, or the issue @@ -131,23 +112,49 @@ export class LinearMirror { console.log(`[bridge] deleteMessage msg=${messageId} deleted=${ok}`); } - // Refreshes the attachment metadata and labels, then moves the issue between - // Done and Triage to match the thread. Transitions are decided against the - // Linear issue state, not a Discord old/new diff, so bot-initiated closes - // (e.g. the /close command) are detected reliably. + // Refreshes the attachment metadata and labels, then reconciles the issue's + // workflow state. Transitions are decided against the Linear issue state, not + // a Discord old/new diff, so bot-initiated changes (e.g. the /close command) + // are detected reliably. async syncStatus(): Promise { const issueId = await this.ensureIssue(); await linear.upsertThreadAttachment(issueId, this.attachment()); await this.syncLabels(issueId); + await this.syncState(issueId); + } - const stateType = await linear.getIssueStateType(issueId); - if (this.help.isClosed && stateType !== "completed") { - await linear.setIssueState(issueId, "completed"); - await linear.addComment(issueId, "_Thread closed on Discord._"); - } else if (this.help.isOpen && stateType === "completed") { - await linear.setIssueState(issueId, "triage"); + // Maps the thread's lifecycle onto the Linear workflow state: closed -> Done, + // waiting on the user -> Blocked, waiting on the team -> In Progress. A + // reopened issue with no waiting signal falls back to Triage. + private async syncState(issueId: string): Promise { + const state = await linear.getIssueState(issueId); + + if (this.help.isClosed) { + if (state?.type !== "completed") { + await linear.setIssueState(issueId, "completed"); + await linear.addComment(issueId, "_Thread closed on Discord._"); + } + return; + } + + if (state?.type === "completed") { await linear.addComment(issueId, "_Thread reopened on Discord._"); } + + const target = + this.help.waiting === "user" + ? "Blocked" + : this.help.waiting === "team" + ? "In Progress" + : null; + + if (target) { + if (state?.name !== target) { + await linear.setIssueState(issueId, "started", target); + } + } else if (state?.type === "completed") { + await linear.setIssueState(issueId, "triage"); + } } // Trashes the mirrored issue when its Discord thread is deleted. From 330c5c32351298d9939e8457b609fca40ab3755f Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:23:08 +0000 Subject: [PATCH 36/62] feat(src/bridge/linear): keep new threads in Triage until the team engages --- src/bridge/linear/index.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 00b98fa..bfdf384 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -124,8 +124,9 @@ export class LinearMirror { } // Maps the thread's lifecycle onto the Linear workflow state: closed -> Done, - // waiting on the user -> Blocked, waiting on the team -> In Progress. A - // reopened issue with no waiting signal falls back to Triage. + // waiting on the user -> Blocked, waiting on the team -> In Progress. A new + // thread stays in Triage until the team first engages (moves it out of + // Triage); reopened issues with no waiting signal fall back to Triage. private async syncState(issueId: string): Promise { const state = await linear.getIssueState(issueId); @@ -141,18 +142,24 @@ export class LinearMirror { await linear.addComment(issueId, "_Thread reopened on Discord._"); } - const target = - this.help.waiting === "user" - ? "Blocked" - : this.help.waiting === "team" - ? "In Progress" - : null; + if (this.help.waiting === "user") { + if (state?.name !== "Blocked") { + await linear.setIssueState(issueId, "started", "Blocked"); + } + return; + } - if (target) { - if (state?.name !== target) { - await linear.setIssueState(issueId, "started", target); + if (this.help.waiting === "team") { + // Leave brand-new, un-triaged threads in Triage until the team engages. + if (state?.type === "triage") return; + if (state?.name !== "In Progress") { + await linear.setIssueState(issueId, "started", "In Progress"); } - } else if (state?.type === "completed") { + return; + } + + // No waiting signal: send a reopened issue back to Triage. + if (state?.type === "completed") { await linear.setIssueState(issueId, "triage"); } } From 4c79dcb2d45e6e495425cdc0e8bd7cfd50961eca Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:25:30 +0000 Subject: [PATCH 37/62] feat(src/lib/discord): exclude waiting-for tags from mirrored labels --- src/lib/discord/helpThread.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts index cea60cc..a237a50 100644 --- a/src/lib/discord/helpThread.ts +++ b/src/lib/discord/helpThread.ts @@ -42,17 +42,25 @@ export class HelpThread { return null; } - // Applied tags (minus the open/closed lifecycle tags), resolved to id + name - // from the parent forum's tag list. + // Applied tags (minus the lifecycle tags: open/closed and the waiting-for + // tags, which are surfaced as the mirrored issue's status), resolved to + // id + name from the parent forum's tag list. get tags(): HelpTag[] { const forum = this.thread.parent; const available = forum && "availableTags" in forum ? forum.availableTags : []; const nameById = new Map(available.map((t) => [t.id, t.name])); - const { closedTag, openedTag } = config.helpChannel; + const { closedTag, openedTag, waitingForUserTag, waitingForTeamTag } = + config.helpChannel; + const hidden = new Set([ + closedTag, + openedTag, + waitingForUserTag, + waitingForTeamTag, + ]); return this.thread.appliedTags - .filter((id) => id !== closedTag && id !== openedTag) + .filter((id) => !hidden.has(id)) .map((id) => ({ id, name: nameById.get(id) ?? id })); } } From b67295d69e80716b63b8f8922a5833e34886f369 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:28:38 +0000 Subject: [PATCH 38/62] feat(src/bridge/linear): keep issues in the configured project --- src/bridge/linear/api.ts | 11 +++++++++++ src/bridge/linear/index.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index efb2382..6001d9c 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -293,6 +293,17 @@ export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); } +// Ensures the issue belongs to the configured project, when one is set. +// Idempotent: only writes when the project differs. Keeps issues in the project +// even if created before it was configured or moved out manually. +export async function ensureIssueProject(issueId: string): Promise { + const { projectId } = config.linearBridge; + if (!projectId) return; + const issue = await linear().issue(issueId); + if (issue.projectId === projectId) return; + await linear().updateIssue(issueId, { projectId }); +} + // Returns the workflow state type and name of an issue (e.g. type "started", // name "In Progress"). export async function getIssueState( diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index bfdf384..8dc8f64 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -119,6 +119,7 @@ export class LinearMirror { async syncStatus(): Promise { const issueId = await this.ensureIssue(); await linear.upsertThreadAttachment(issueId, this.attachment()); + await linear.ensureIssueProject(issueId); await this.syncLabels(issueId); await this.syncState(issueId); } From e8c0fc8403f2763a29670113180290c1598ec4ed Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:30:09 +0000 Subject: [PATCH 39/62] feat(src/bridge/linear): sync issue title when a thread is renamed --- src/bridge/linear/api.ts | 21 ++++++++++++++------- src/bridge/linear/index.ts | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 6001d9c..8c29982 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -293,15 +293,22 @@ export async function deleteIssue(issueId: string): Promise { await linear().deleteIssue(issueId); } -// Ensures the issue belongs to the configured project, when one is set. -// Idempotent: only writes when the project differs. Keeps issues in the project -// even if created before it was configured or moved out manually. -export async function ensureIssueProject(issueId: string): Promise { +// Reconciles the issue's title and project against the thread, when they drift +// (e.g. the thread was renamed, or created before the project was configured). +// One fetch, one update, only when something actually changed. +export async function reconcileIssue( + issueId: string, + title: string, +): Promise { const { projectId } = config.linearBridge; - if (!projectId) return; const issue = await linear().issue(issueId); - if (issue.projectId === projectId) return; - await linear().updateIssue(issueId, { projectId }); + + const update: { title?: string; projectId?: string } = {}; + if (issue.title !== title) update.title = title; + if (projectId && issue.projectId !== projectId) update.projectId = projectId; + if (Object.keys(update).length === 0) return; + + await linear().updateIssue(issueId, update); } // Returns the workflow state type and name of an issue (e.g. type "started", diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 8dc8f64..9a9edc4 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -119,7 +119,7 @@ export class LinearMirror { async syncStatus(): Promise { const issueId = await this.ensureIssue(); await linear.upsertThreadAttachment(issueId, this.attachment()); - await linear.ensureIssueProject(issueId); + await linear.reconcileIssue(issueId, this.help.title); await this.syncLabels(issueId); await this.syncState(issueId); } From 911c2972791a12119db62aa06b1a7e2ab3e3c488 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:42:06 +0000 Subject: [PATCH 40/62] feat(src/bridge/linear): backfill recent help threads on startup --- src/bridge/linear/index.ts | 5 ++++ src/events/bridge.ts | 54 +++++++++++++++++++++++++++++++++++++- src/index.ts | 6 ++++- src/lib/config.ts | 4 +++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 9a9edc4..caf7bd9 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -165,6 +165,11 @@ export class LinearMirror { } } + // Whether this thread already has a mirrored Linear issue. + async isMirrored(): Promise { + return (await linear.findThreadMapping(this.help.url)) !== null; + } + // Trashes the mirrored issue when its Discord thread is deleted. async delete(): Promise { const mapping = await linear.findThreadMapping(this.help.url); diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 956e919..d4f9456 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -1,6 +1,11 @@ import { debounce } from "throttle-debounce"; -import { type Client, Events, type ThreadChannel } from "discord.js"; +import { + ChannelType, + type Client, + Events, + type ThreadChannel, +} from "discord.js"; import { config, validateLinearBridgeConfig } from "@lib/config.js"; import { isHelpPost } from "@lib/discord/channels.js"; @@ -148,3 +153,50 @@ export default function registerEvents(client: Client) { console.log("Linear bridge is enabled."); } + +// Mirrors the most recently active help threads that aren't in Linear yet, so +// threads created while the bridge was off still land as issues. Runs in the +// background on startup; already-mirrored threads are skipped. +export async function backfillHelpThreads(client: Client): Promise { + if (!config.linearBridge.enabled || config.linearBridge.backfillLimit <= 0) { + return; + } + + const forum = await client.channels.fetch(config.helpChannel.id); + if (!forum || forum.type !== ChannelType.GuildForum) return; + + const { threads } = await forum.threads.fetchActive(); + const recent = [...threads.values()] + .sort((a, b) => + (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), + ) + .slice(0, config.linearBridge.backfillLimit); + + for (const thread of recent) { + try { + await backfillThread(thread); + } catch (err) { + console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); + } + } +} + +// Mirrors a single thread from scratch: issue, every human message, then state. +async function backfillThread(thread: ThreadChannel): Promise { + const mirror = new LinearMirror(new HelpThread(thread)); + if (await mirror.isMirrored()) return; + + await mirror.create(); + + const messages = await thread.messages.fetch({ limit: 100 }); + for (const message of [...messages.values()].reverse()) { + if (!isHumanMessage(message)) continue; + try { + await mirror.addMessage(message); + } catch (err) { + console.error(`Linear bridge: backfill message ${message.id}:`, err); + } + } + + await mirror.syncStatus(); +} diff --git a/src/index.ts b/src/index.ts index d484f48..f1cca1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import registerCommandEvents from "./events/commands.js"; import registerWalkthroughEvents from "./events/walkthrough.js"; import registerMessageEvents from "./events/messages.js"; import registerChannelEvents from "./events/channels.js"; -import registerBridgeEvents from "./events/bridge.js"; +import registerBridgeEvents, { backfillHelpThreads } from "./events/bridge.js"; import { Client, @@ -67,6 +67,10 @@ client.once(Events.ClientReady, () => { catchUpHelpPosts(client).catch((err) => console.error("Failed to catch up on help posts:", err), ); + + backfillHelpThreads(client).catch((err) => + console.error("Linear bridge: backfill failed:", err), + ); }); client.login(config.token); diff --git a/src/lib/config.ts b/src/lib/config.ts index 0acb1de..c617e3d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -47,6 +47,9 @@ interface Config { teamId?: string; // Optional Linear project that mirrored thread issues are filed under. projectId?: string; + // Number of most recently active help threads to mirror on startup. 0 to + // disable. Threads already mirrored are skipped. + backfillLimit: number; // Attribute mirrored comments to the Discord author via Linear's // createAsUser. Requires the app-actor token; turn off to post as the app. createAsUser: boolean; @@ -75,6 +78,7 @@ export const { config, layers } = await loadConfig({ linearBridge: { enabled: false, createAsUser: false, + backfillLimit: 20, labels: { // Label creation runs on the user token, which can manage the team's // labels. Each #help tag becomes a flat label named " > tag"; From 384d7ec0cb8a0313c238b1e573382d87e013fc5f Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:43:53 +0000 Subject: [PATCH 41/62] feat(src/bridge/linear): announce the mirrored issue link in the thread --- src/bridge/linear/api.ts | 8 ++++++++ src/bridge/linear/index.ts | 19 +++++++++++++++++-- src/events/bridge.ts | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 8c29982..27f6434 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -311,6 +311,14 @@ export async function reconcileIssue( await linear().updateIssue(issueId, update); } +// Returns an issue's identifier and URL, e.g. for linking back from Discord. +export async function getIssueRef( + issueId: string, +): Promise<{ identifier: string; url: string }> { + const issue = await linear().issue(issueId); + return { identifier: issue.identifier, url: issue.url }; +} + // Returns the workflow state type and name of an issue (e.g. type "started", // name "In Progress"). export async function getIssueState( diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index caf7bd9..c152442 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -36,11 +36,26 @@ export class LinearMirror { } // Mirrors a newly created thread: issue (with the opening post as its body), - // linking attachment, and labels. - async create(): Promise { + // linking attachment, and labels. Announces the issue back in the thread + // unless suppressed (e.g. during startup backfill of old threads). + async create(announce = true): Promise { + const existed = await this.isMirrored(); const issueId = await this.ensureIssue(); await this.syncLabels(issueId); await this.linkStarterReferences(issueId); + if (announce && !existed) await this.announceIssue(issueId); + } + + // Posts a link to the mirrored Linear issue in the Discord thread. + private async announceIssue(issueId: string): Promise { + try { + const ref = await linear.getIssueRef(issueId); + await this.help.thread.send({ + embeds: [{ description: `[${ref.identifier}](${ref.url})` }], + }); + } catch (err) { + console.error("Linear bridge: issue announce failed:", err); + } } // Mirrors a thread message as an issue comment. diff --git a/src/events/bridge.ts b/src/events/bridge.ts index d4f9456..aad790a 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -186,7 +186,7 @@ async function backfillThread(thread: ThreadChannel): Promise { const mirror = new LinearMirror(new HelpThread(thread)); if (await mirror.isMirrored()) return; - await mirror.create(); + await mirror.create(false); const messages = await thread.messages.fetch({ limit: 100 }); for (const message of [...messages.values()].reverse()) { From 584258a90daeace81d3631817fb78e35c8d3f142 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:52:02 +0000 Subject: [PATCH 42/62] feat(src/bridge/linear): resolve Discord mentions to profile links --- src/bridge/linear/index.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index c152442..192cefb 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -271,7 +271,7 @@ export class LinearMirror { if (rewrites.size === 0) return; const description = this.applyRewrites( - this.emojis(content).trim(), + this.formatText(starter).trim(), rewrites, ); await linear.setIssueDescription(issueId, description); @@ -290,7 +290,7 @@ export class LinearMirror { await this.ensureEmojis(starterContent); const issueId = await linear.createIssue({ title: this.help.title, - description: this.emojis(starterContent).trim(), + description: this.formatText(starter).trim(), author: this.author(starter), }); await linear.createThreadAttachment(issueId, this.attachment()); @@ -348,7 +348,7 @@ export class LinearMirror { // inline, other files as links), resolving each attachment URL via urlFor. private render(message: Message, urlFor: (a: Attachment) => string): string { const parts: string[] = []; - const text = this.emojis(message.content ?? "").trim(); + const text = this.formatText(message).trim(); if (text) parts.push(text); for (const attachment of message.attachments.values()) { const link = `[${attachment.name}](${urlFor(attachment)})`; @@ -358,6 +358,29 @@ export class LinearMirror { return parts.join("\n\n"); } + // Message text with mentions and custom emojis resolved for Linear. + private formatText(message: Message | null): string { + return this.emojis(this.mentions(message)); + } + + // Resolves Discord user and role mentions, which Linear can't resolve from + // ids. User mentions become a link to the Discord profile; role mentions + // become @name. Channel mentions are left for reference linking. + private mentions(message: Message | null): string { + if (!message) return ""; + return (message.content ?? "") + .replace(/<@!?(\d+)>/g, (m, id) => { + const name = + message.mentions.members?.get(id)?.displayName ?? + message.mentions.users.get(id)?.username; + return name ? `[@${name}](https://discord.com/users/${id})` : m; + }) + .replace(/<@&(\d+)>/g, (m, id) => { + const role = message.mentions.roles.get(id); + return role ? `@${role.name}` : m; + }); + } + // Rewrites Discord custom emojis (<:name:id>, ) as :discord-: // shortcodes that resolve to the registered Linear workspace emojis. private emojis(content: string): string { From ad2690cdb0d246efe7b47c401490775918936d20 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:54:54 +0000 Subject: [PATCH 43/62] feat(src/bridge/linear): backfill missing messages and set original timestamps --- src/bridge/linear/api.ts | 27 +++++++++++++++++++++++++++ src/bridge/linear/index.ts | 15 +++++++++++++++ src/events/bridge.ts | 16 +++++----------- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 27f6434..3c22cec 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -81,12 +81,14 @@ export async function createIssue(input: { title: string; description: string; author?: { name: string; iconUrl?: string }; + createdAt?: Date; }): Promise { const payload = await linear().createIssue({ teamId: bridgeConfig().teamId, projectId: config.linearBridge.projectId, title: input.title, description: input.description, + createdAt: input.createdAt, // Attributes the issue to an external Discord author under app-actor auth; // ignored fields are safe to omit for personal keys (author is undefined). createAsUser: input.author?.name, @@ -143,10 +145,12 @@ export async function addComment( author?: { name: string; iconUrl?: string }, messageId?: string, parentId?: string, + createdAt?: Date, ): Promise { const input = { issueId, body: messageId ? withMarker(body, messageId) : body, + createdAt, // Attributes the comment to an external Discord author. Requires OAuth // app-actor auth; Linear rejects these fields for personal API keys, so the // caller only supplies an author when that mode is configured. @@ -164,6 +168,29 @@ export async function addComment( } } +// Returns the Discord message ids already mirrored as comments on the issue, +// read from the invisible markers, so a backfill can skip them. +export async function mirroredMessageIds( + issueId: string, +): Promise> { + const issue = await linear().issue(issueId); + const ids = new Set(); + + let page = await issue.comments({ first: 100 }); + while (true) { + for (const comment of page.nodes) { + const id = markerMessageId(comment.body); + if (id) ids.add(id); + } + if (!page.pageInfo.hasNextPage) break; + page = await issue.comments({ + first: 100, + after: page.pageInfo.endCursor ?? undefined, + }); + } + return ids; +} + // Updates the mirrored comment for a Discord message. Returns false if the // message has no mirrored comment. export async function editComment( diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 192cefb..268c6e5 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -78,6 +78,7 @@ export class LinearMirror { this.author(message), message.id, parentId, + message.createdAt, ); // Attachments mirror instantly as Discord CDN links (which expire), then the @@ -180,6 +181,19 @@ export class LinearMirror { } } + // Mirrors thread messages that aren't already comments on the issue, in the + // order given. Idempotent: existing messages (matched by marker) are skipped, + // so it is safe to re-run over already-mirrored threads. + async backfillMessages(messages: Message[]): Promise { + const issueId = await this.ensureIssue(); + const mirrored = await linear.mirroredMessageIds(issueId); + for (const message of messages) { + if (message.id === this.help.thread.id) continue; + if (mirrored.has(message.id)) continue; + await this.addMessage(message); + } + } + // Whether this thread already has a mirrored Linear issue. async isMirrored(): Promise { return (await linear.findThreadMapping(this.help.url)) !== null; @@ -292,6 +306,7 @@ export class LinearMirror { title: this.help.title, description: this.formatText(starter).trim(), author: this.author(starter), + createdAt: starter?.createdAt, }); await linear.createThreadAttachment(issueId, this.attachment()); return issueId; diff --git a/src/events/bridge.ts b/src/events/bridge.ts index aad790a..fa71792 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -181,22 +181,16 @@ export async function backfillHelpThreads(client: Client): Promise { } } -// Mirrors a single thread from scratch: issue, every human message, then state. +// Mirrors a thread: ensures the issue exists, fills in any messages missing +// from Linear, then reconciles state. Safe to run over already-mirrored +// threads (existing messages are skipped). async function backfillThread(thread: ThreadChannel): Promise { const mirror = new LinearMirror(new HelpThread(thread)); - if (await mirror.isMirrored()) return; - await mirror.create(false); const messages = await thread.messages.fetch({ limit: 100 }); - for (const message of [...messages.values()].reverse()) { - if (!isHumanMessage(message)) continue; - try { - await mirror.addMessage(message); - } catch (err) { - console.error(`Linear bridge: backfill message ${message.id}:`, err); - } - } + const human = [...messages.values()].reverse().filter(isHumanMessage); + await mirror.backfillMessages(human); await mirror.syncStatus(); } From 50614b6112bad7bb5d612543698d162e7a10d27a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:58:37 +0000 Subject: [PATCH 44/62] fix(src/bridge/linear): apply waiting-for-team status during backfill --- src/bridge/linear/index.ts | 15 +++++++++------ src/events/bridge.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 268c6e5..2afd343 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -132,19 +132,21 @@ export class LinearMirror { // workflow state. Transitions are decided against the Linear issue state, not // a Discord old/new diff, so bot-initiated changes (e.g. the /close command) // are detected reliably. - async syncStatus(): Promise { + async syncStatus(backfill = false): Promise { const issueId = await this.ensureIssue(); await linear.upsertThreadAttachment(issueId, this.attachment()); await linear.reconcileIssue(issueId, this.help.title); await this.syncLabels(issueId); - await this.syncState(issueId); + await this.syncState(issueId, backfill); } // Maps the thread's lifecycle onto the Linear workflow state: closed -> Done, // waiting on the user -> Blocked, waiting on the team -> In Progress. A new // thread stays in Triage until the team first engages (moves it out of - // Triage); reopened issues with no waiting signal fall back to Triage. - private async syncState(issueId: string): Promise { + // Triage); reopened issues with no waiting signal fall back to Triage. During + // backfill the issue is freshly created in Triage, so the "team engaged" gate + // is skipped and the waiting tag drives the state directly. + private async syncState(issueId: string, backfill = false): Promise { const state = await linear.getIssueState(issueId); if (this.help.isClosed) { @@ -167,8 +169,9 @@ export class LinearMirror { } if (this.help.waiting === "team") { - // Leave brand-new, un-triaged threads in Triage until the team engages. - if (state?.type === "triage") return; + // Leave brand-new, un-triaged live threads in Triage until the team + // engages. Backfill applies the waiting tag directly. + if (!backfill && state?.type === "triage") return; if (state?.name !== "In Progress") { await linear.setIssueState(issueId, "started", "In Progress"); } diff --git a/src/events/bridge.ts b/src/events/bridge.ts index fa71792..f9e5fac 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -192,5 +192,5 @@ async function backfillThread(thread: ThreadChannel): Promise { const human = [...messages.values()].reverse().filter(isHumanMessage); await mirror.backfillMessages(human); - await mirror.syncStatus(); + await mirror.syncStatus(true); } From 0e55a6dd95107a889106fa86ed8a627a30b7b0a0 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:01:37 +0000 Subject: [PATCH 45/62] feat(src/bridge/linear): include archived threads in startup backfill --- src/events/bridge.ts | 22 +++++++++++++++++----- src/lib/config.ts | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/events/bridge.ts b/src/events/bridge.ts index f9e5fac..462170a 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -154,9 +154,9 @@ export default function registerEvents(client: Client) { console.log("Linear bridge is enabled."); } -// Mirrors the most recently active help threads that aren't in Linear yet, so -// threads created while the bridge was off still land as issues. Runs in the -// background on startup; already-mirrored threads are skipped. +// Mirrors the most recently active help threads that aren't fully in Linear +// yet, so threads and messages from while the bridge was off still land as +// issues. Runs in the background on startup. export async function backfillHelpThreads(client: Client): Promise { if (!config.linearBridge.enabled || config.linearBridge.backfillLimit <= 0) { return; @@ -165,8 +165,20 @@ export async function backfillHelpThreads(client: Client): Promise { const forum = await client.channels.fetch(config.helpChannel.id); if (!forum || forum.type !== ChannelType.GuildForum) return; - const { threads } = await forum.threads.fetchActive(); - const recent = [...threads.values()] + // Include archived threads so older posts are covered, not just active ones. + const [active, archived] = await Promise.all([ + forum.threads.fetchActive(), + forum.threads.fetchArchived({ limit: config.linearBridge.backfillLimit }), + ]); + const byId = new Map(); + for (const thread of [ + ...active.threads.values(), + ...archived.threads.values(), + ]) { + byId.set(thread.id, thread); + } + + const recent = [...byId.values()] .sort((a, b) => (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), ) diff --git a/src/lib/config.ts b/src/lib/config.ts index c617e3d..c298064 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -78,7 +78,7 @@ export const { config, layers } = await loadConfig({ linearBridge: { enabled: false, createAsUser: false, - backfillLimit: 20, + backfillLimit: 50, labels: { // Label creation runs on the user token, which can manage the team's // labels. Each #help tag becomes a flat label named " > tag"; From 38c1db21193080623c7ea63d309f18300e1bd340 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:10:29 +0000 Subject: [PATCH 46/62] feat(src/events/bridge): apply a waiting tag to backfilled threads missing one --- src/events/bridge.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 462170a..43434ab 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -9,7 +9,7 @@ import { import { config, validateLinearBridgeConfig } from "@lib/config.js"; import { isHelpPost } from "@lib/discord/channels.js"; -import { isHumanMessage } from "@lib/discord/help.js"; +import { isHumanMessage, reconcileThread } from "@lib/discord/help.js"; import { HelpThread } from "@lib/discord/helpThread.js"; import { LinearMirror } from "@bridge/linear/index.js"; @@ -197,7 +197,16 @@ export async function backfillHelpThreads(client: Client): Promise { // from Linear, then reconciles state. Safe to run over already-mirrored // threads (existing messages are skipped). async function backfillThread(thread: ThreadChannel): Promise { - const mirror = new LinearMirror(new HelpThread(thread)); + const help = new HelpThread(thread); + + // Older threads may predate the waiting-tag automation. If an open thread + // has no waiting tag, derive one from its last message so the mirrored + // issue gets a meaningful status. + if (help.isOpen && help.waiting === null) { + await reconcileThread(thread); + } + + const mirror = new LinearMirror(help); await mirror.create(false); const messages = await thread.messages.fetch({ limit: 100 }); From dfca53762c0901e47cc452944bd66e69834de8f3 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:14:21 +0000 Subject: [PATCH 47/62] fix(src/bridge/linear): scope URL issue lookups to the configured team --- src/bridge/linear/api.ts | 41 ++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 3c22cec..b577d63 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -1,4 +1,9 @@ -import { LinearClient, type Comment } from "@linear/sdk"; +import { + LinearClient, + type Attachment, + type Comment, + type Issue, +} from "@linear/sdk"; import { IssueRelationType } from "@linear/sdk"; import { config } from "@lib/config.js"; @@ -61,19 +66,31 @@ export interface ThreadAttachmentFields { metadata: Record; } +// Returns the first attachment on the URL whose issue lives in the configured +// team. Attachments match across the whole workspace, so scoping to the team +// keeps lookups from touching issues in unrelated Linear teams. +async function attachmentInTeam( + url: string, +): Promise<{ attachment: Attachment; issue: Issue } | null> { + const { teamId } = bridgeConfig(); + const attachments = await linear().attachmentsForURL(url); + for (const attachment of attachments.nodes) { + const issue = await attachment.issue; + if (!issue) continue; + const team = await issue.team; + if (team?.id === teamId) return { attachment, issue }; + } + return null; +} + // Finds the issue mapped to a thread via its URL attachment, returning the // issue and attachment ids. export async function findThreadMapping( url: string, ): Promise<{ issueId: string; attachmentId: string } | null> { - const attachments = await linear().attachmentsForURL(url); - const node = attachments.nodes[0]; - if (!node) return null; - - const issue = await node.issue; - if (!issue) return null; - - return { issueId: issue.id, attachmentId: node.id }; + const match = await attachmentInTeam(url); + if (!match) return null; + return { issueId: match.issue.id, attachmentId: match.attachment.id }; } // Creates an issue in the configured team and returns its id. @@ -574,9 +591,9 @@ export interface LinkedIssue { export async function resolveIssueByUrl( url: string, ): Promise { - const attachments = await linear().attachmentsForURL(url); - const issue = await attachments.nodes[0]?.issue; - if (!issue) return null; + const match = await attachmentInTeam(url); + if (!match) return null; + const { issue } = match; return { id: issue.id, identifier: issue.identifier, url: issue.url }; } From ace1c2dcd01d42a0c1abea4c9c41cb255614baea Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:15:36 +0000 Subject: [PATCH 48/62] fix(src/lib/discord): treat archived help threads as closed --- src/lib/discord/helpThread.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts index a237a50..56a03f6 100644 --- a/src/lib/discord/helpThread.ts +++ b/src/lib/discord/helpThread.ts @@ -27,7 +27,12 @@ export class HelpThread { } get isClosed(): boolean { - return this.thread.appliedTags.includes(config.helpChannel.closedTag); + // An archived post is inactive and can't take new messages, so treat it as + // closed alongside an explicit closed tag. + return ( + this.thread.archived === true || + this.thread.appliedTags.includes(config.helpChannel.closedTag) + ); } get isOpen(): boolean { From ef94c5506a566ec22f748dc633fb82a3e12287ea Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:18:51 +0000 Subject: [PATCH 49/62] chore(src/bridge/linear): log mirror operations and backfill progress --- src/bridge/linear/api.ts | 36 +++++++++++++++++++++++++++++++++++- src/bridge/linear/index.ts | 9 +++++++++ src/events/bridge.ts | 5 +++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index b577d63..5da552d 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -114,6 +114,7 @@ export async function createIssue(input: { const issue = await payload.issue; if (!issue) throw new Error("Linear did not return the created issue"); + console.log(`[bridge] created issue ${issue.identifier} "${input.title}"`); return issue.id; } @@ -123,6 +124,7 @@ export async function createThreadAttachment( fields: ThreadAttachmentFields, ): Promise { await linear().createAttachment({ issueId, ...fields }); + console.log(`[bridge] created attachment on ${issueId} -> ${fields.url}`); } // Updates the issue's Discord attachment in place, or creates it if missing. @@ -141,6 +143,7 @@ export async function upsertThreadAttachment( subtitle: fields.subtitle, metadata: fields.metadata, }); + console.log(`[bridge] updated attachment on ${issueId}`); } // Invisible marker (an unused markdown reference-link definition) appended to @@ -175,6 +178,12 @@ export async function addComment( displayIconUrl: author?.iconUrl, }; + console.log( + `[bridge] adding comment on ${issueId}` + + `${messageId ? ` for msg ${messageId}` : ""}` + + `${parentId ? ` (reply to ${parentId})` : ""}`, + ); + try { await linear().createComment({ ...input, parentId }); } catch (err) { @@ -217,6 +226,7 @@ export async function editComment( ): Promise { const commentId = await findCommentByMessage(issueId, messageId); if (!commentId) return false; + console.log(`[bridge] editing comment for msg ${messageId} on ${issueId}`); await linear().updateComment(commentId, { body: withMarker(body, messageId), }); @@ -236,10 +246,14 @@ export async function deleteComment( const children = await node.children(); if (children.nodes.length > 0) { + console.log( + `[bridge] tombstoning comment for msg ${messageId} on ${issueId} (has replies)`, + ); await linear().updateComment(node.id, { body: withMarker("_Message deleted._", messageId), }); } else { + console.log(`[bridge] deleting comment for msg ${messageId} on ${issueId}`); await linear().deleteComment(node.id); } return true; @@ -284,6 +298,7 @@ export async function setIssueDescription( issueId: string, description: string, ): Promise { + console.log(`[bridge] updating description on ${issueId}`); await linear().updateIssue(issueId, { description }); } @@ -295,11 +310,15 @@ export async function uploadFile( contentType: string | null, ): Promise { try { - return await rehost( + const asset = await rehost( sourceUrl, filename, contentType || "application/octet-stream", ); + console.log( + `[bridge] uploaded file ${filename} -> ${asset ? "ok" : "failed"}`, + ); + return asset; } catch { return null; } @@ -334,6 +353,7 @@ async function rehost( // Trashes an issue (recoverable in Linear). export async function deleteIssue(issueId: string): Promise { + console.log(`[bridge] trashing issue ${issueId}`); await linear().deleteIssue(issueId); } @@ -352,6 +372,9 @@ export async function reconcileIssue( if (projectId && issue.projectId !== projectId) update.projectId = projectId; if (Object.keys(update).length === 0) return; + console.log( + `[bridge] reconciling issue ${issueId}: ${Object.keys(update).join(", ")}`, + ); await linear().updateIssue(issueId, update); } @@ -383,6 +406,9 @@ export async function setIssueState( ): Promise { const stateId = await findStateId(type, preferredName); if (!stateId) return; + console.log( + `[bridge] setting issue ${issueId} state -> ${preferredName ?? type}`, + ); await linear().updateIssue(issueId, { stateId }); } @@ -462,6 +488,7 @@ export async function ensureEmoji( try { await linearUser().createEmoji({ name, url: asset }); names.add(name); + console.log(`[bridge] registered emoji ${name}`); } catch (err) { console.error(`[bridge] ensureEmoji ${name} failed:`, linearError(err)); } @@ -488,6 +515,7 @@ export async function addReaction( const payload = await linear().createReaction({ ...target, emoji }); const reaction = await payload.reaction; if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); + console.log(`[bridge] added reaction ${reactionKey(target, emoji)}`); } catch (err) { console.error(`[bridge] addReaction ${emoji} failed:`, linearError(err)); } @@ -502,6 +530,7 @@ export async function removeReaction( if (!id) return; await linear().deleteReaction(id); reactionIds.delete(key); + console.log(`[bridge] removed reaction ${key}`); } // Finds a reaction on the target whose stored emoji matches, used as a fallback @@ -550,6 +579,7 @@ export async function ensureLabel( const label = await payload.issueLabel; if (!label) throw new Error("Linear did not return the created label"); id = label.id; + console.log(`[bridge] created label "${name}"`); } labelIdByName.set(name, id); @@ -575,6 +605,9 @@ export async function setNamespacedLabels( const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; + console.log( + `[bridge] updating labels on ${issueId}: +${addedLabelIds.length} -${removedLabelIds.length}`, + ); await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); } @@ -616,6 +649,7 @@ export async function relateIssues( relatedIssueId, type: IssueRelationType.Related, }); + console.log(`[bridge] related issues ${key}`); } catch (err) { console.error(`[bridge] relateIssues ${key} failed:`, linearError(err)); } diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 2afd343..98db214 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -39,6 +39,9 @@ export class LinearMirror { // linking attachment, and labels. Announces the issue back in the thread // unless suppressed (e.g. during startup backfill of old threads). async create(announce = true): Promise { + console.log( + `[bridge] mirroring thread ${this.help.thread.id} "${this.help.title}"`, + ); const existed = await this.isMirrored(); const issueId = await this.ensureIssue(); await this.syncLabels(issueId); @@ -53,6 +56,9 @@ export class LinearMirror { await this.help.thread.send({ embeds: [{ description: `[${ref.identifier}](${ref.url})` }], }); + console.log( + `[bridge] announced ${ref.identifier} in thread ${this.help.thread.id}`, + ); } catch (err) { console.error("Linear bridge: issue announce failed:", err); } @@ -190,6 +196,9 @@ export class LinearMirror { async backfillMessages(messages: Message[]): Promise { const issueId = await this.ensureIssue(); const mirrored = await linear.mirroredMessageIds(issueId); + console.log( + `[bridge] backfilling ${messages.length} message(s) for issue ${issueId} (${mirrored.size} already mirrored)`, + ); for (const message of messages) { if (message.id === this.help.thread.id) continue; if (mirrored.has(message.id)) continue; diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 43434ab..9fc23dd 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -184,6 +184,9 @@ export async function backfillHelpThreads(client: Client): Promise { ) .slice(0, config.linearBridge.backfillLimit); + console.log( + `[bridge] startup backfill: ${recent.length} thread(s) of ${byId.size} fetched (limit ${config.linearBridge.backfillLimit})`, + ); for (const thread of recent) { try { await backfillThread(thread); @@ -191,12 +194,14 @@ export async function backfillHelpThreads(client: Client): Promise { console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); } } + console.log("[bridge] startup backfill complete"); } // Mirrors a thread: ensures the issue exists, fills in any messages missing // from Linear, then reconciles state. Safe to run over already-mirrored // threads (existing messages are skipped). async function backfillThread(thread: ThreadChannel): Promise { + console.log(`[bridge] backfilling thread ${thread.id} "${thread.name}"`); const help = new HelpThread(thread); // Older threads may predate the waiting-tag automation. If an open thread From b74b828f10428a540fcb0b3638b610a023098f17 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:19:52 +0000 Subject: [PATCH 50/62] fix(src/bridge/linear): timestamp the thread-closed comment from the archive time --- src/bridge/linear/index.ts | 9 ++++++++- src/lib/discord/helpThread.ts | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index 98db214..e0d4c26 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -158,7 +158,14 @@ export class LinearMirror { if (this.help.isClosed) { if (state?.type !== "completed") { await linear.setIssueState(issueId, "completed"); - await linear.addComment(issueId, "_Thread closed on Discord._"); + await linear.addComment( + issueId, + "_Thread closed on Discord._", + undefined, + undefined, + undefined, + this.help.closedAt ?? undefined, + ); } return; } diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts index 56a03f6..ed50fb0 100644 --- a/src/lib/discord/helpThread.ts +++ b/src/lib/discord/helpThread.ts @@ -39,6 +39,12 @@ export class HelpThread { return !this.isClosed; } + // Best-effort time the post was closed, from the archive timestamp. Null when + // open, or closed via tag without archiving. + get closedAt(): Date | null { + return this.isClosed ? (this.thread.archivedAt ?? null) : null; + } + get waiting(): "user" | "team" | null { const { waitingForTeamTag, waitingForUserTag } = config.helpChannel; const tags = this.thread.appliedTags; From 81528ee5446d4aa403871eba34462fab8a543cb1 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:24:27 +0000 Subject: [PATCH 51/62] feat(src/events/bridge): add backfillAll to import every thread through rate limits --- src/bridge/linear/api.ts | 15 ++++++++ src/events/bridge.ts | 81 +++++++++++++++++++++++++++------------- src/lib/config.ts | 5 +++ 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts index 5da552d..314bd9a 100644 --- a/src/bridge/linear/api.ts +++ b/src/bridge/linear/api.ts @@ -58,6 +58,21 @@ export function linearError(err: unknown): string { ); } +// Whether an error is a Linear rate-limit rejection, so a bulk import can wait +// and retry rather than abort. +export function isRateLimited(err: unknown): boolean { + const e = err as { + type?: string; + status?: number; + errors?: { extensions?: { type?: string } }[]; + }; + return ( + e?.type === "Ratelimited" || + e?.status === 429 || + (e?.errors?.some((x) => x.extensions?.type === "Ratelimited") ?? false) + ); +} + // Metadata stored on the Discord attachment of a mirrored issue. export interface ThreadAttachmentFields { url: string; diff --git a/src/events/bridge.ts b/src/events/bridge.ts index 9fc23dd..d9fc5bc 100644 --- a/src/events/bridge.ts +++ b/src/events/bridge.ts @@ -13,6 +13,7 @@ import { isHumanMessage, reconcileThread } from "@lib/discord/help.js"; import { HelpThread } from "@lib/discord/helpThread.js"; import { LinearMirror } from "@bridge/linear/index.js"; +import { isRateLimited } from "@bridge/linear/api.js"; export default function registerEvents(client: Client) { if (!config.linearBridge.enabled) { @@ -154,42 +155,50 @@ export default function registerEvents(client: Client) { console.log("Linear bridge is enabled."); } -// Mirrors the most recently active help threads that aren't fully in Linear -// yet, so threads and messages from while the bridge was off still land as -// issues. Runs in the background on startup. +// Mirrors help threads that aren't fully in Linear yet, so threads and messages +// from while the bridge was off still land as issues. Runs in the background on +// startup. With backfillAll it imports every thread, paging through all +// archived threads and waiting out Linear rate limits. export async function backfillHelpThreads(client: Client): Promise { - if (!config.linearBridge.enabled || config.linearBridge.backfillLimit <= 0) { - return; - } + const { enabled, backfillAll, backfillLimit } = config.linearBridge; + if (!enabled) return; + if (!backfillAll && backfillLimit <= 0) return; const forum = await client.channels.fetch(config.helpChannel.id); if (!forum || forum.type !== ChannelType.GuildForum) return; - // Include archived threads so older posts are covered, not just active ones. - const [active, archived] = await Promise.all([ - forum.threads.fetchActive(), - forum.threads.fetchArchived({ limit: config.linearBridge.backfillLimit }), - ]); const byId = new Map(); - for (const thread of [ - ...active.threads.values(), - ...archived.threads.values(), - ]) { - byId.set(thread.id, thread); - } - - const recent = [...byId.values()] - .sort((a, b) => - (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), - ) - .slice(0, config.linearBridge.backfillLimit); + const active = await forum.threads.fetchActive(); + for (const thread of active.threads.values()) byId.set(thread.id, thread); + + // Pull archived threads too. For a full import, page through every archived + // thread; otherwise a single page bounded by the limit is enough. + let before: Date | undefined; + do { + const page = await forum.threads.fetchArchived({ + limit: backfillAll ? 100 : backfillLimit, + before, + }); + const last = [...page.threads.values()].at(-1); + for (const thread of page.threads.values()) byId.set(thread.id, thread); + before = + backfillAll && page.hasMore ? (last?.archivedAt ?? undefined) : undefined; + } while (before); + + const sorted = [...byId.values()].sort((a, b) => + (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), + ); + const threads = backfillAll ? sorted : sorted.slice(0, backfillLimit); console.log( - `[bridge] startup backfill: ${recent.length} thread(s) of ${byId.size} fetched (limit ${config.linearBridge.backfillLimit})`, + `[bridge] startup backfill: ${threads.length} thread(s)` + + (backfillAll + ? " (full import)" + : ` of ${byId.size} fetched (limit ${backfillLimit})`), ); - for (const thread of recent) { + for (const thread of threads) { try { - await backfillThread(thread); + await withRateLimitRetry(() => backfillThread(thread)); } catch (err) { console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); } @@ -197,6 +206,26 @@ export async function backfillHelpThreads(client: Client): Promise { console.log("[bridge] startup backfill complete"); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Retries an operation through Linear rate limits. Linear's limits reset on a +// rolling window, so back off and keep waiting rather than dropping work. +async function withRateLimitRetry(fn: () => Promise): Promise { + 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); + } + } +} + // Mirrors a thread: ensures the issue exists, fills in any messages missing // from Linear, then reconciles state. Safe to run over already-mirrored // threads (existing messages are skipped). diff --git a/src/lib/config.ts b/src/lib/config.ts index c298064..0d03e72 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -50,6 +50,10 @@ interface Config { // Number of most recently active help threads to mirror on startup. 0 to // disable. Threads already mirrored are skipped. backfillLimit: number; + // Mirror every #help thread on startup (all archived pages, ignoring + // backfillLimit), retrying through Linear rate limits. Slow; intended for + // the initial bulk import. + backfillAll: boolean; // Attribute mirrored comments to the Discord author via Linear's // createAsUser. Requires the app-actor token; turn off to post as the app. createAsUser: boolean; @@ -79,6 +83,7 @@ export const { config, layers } = await loadConfig({ enabled: false, createAsUser: false, backfillLimit: 50, + backfillAll: false, labels: { // Label creation runs on the user token, which can manage the team's // labels. Each #help tag becomes a flat label named " > tag"; From 4531a54f9a6d3a00a040e00b227ead1ca0a82ce8 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:05:14 +0000 Subject: [PATCH 52/62] 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). --- package.json | 1 - scripts/discord-linear-sync.ts | 79 ---- src/bridge/ARCHITECTURE.md | 87 ++++ src/bridge/core/backfill.ts | 22 + src/bridge/core/bridge.ts | 27 ++ src/bridge/core/connector.ts | 82 ++++ src/bridge/core/mirror.ts | 200 +++++++++ src/bridge/core/model.ts | 123 +++++ src/bridge/core/reconciler.ts | 57 +++ src/bridge/{ => core}/references.ts | 24 +- src/bridge/discord/index.ts | 297 ++++++++++++ src/bridge/discord/map.ts | 168 +++++++ src/bridge/linear/api.ts | 671 ---------------------------- src/bridge/linear/assets.ts | 50 +++ src/bridge/linear/attachments.ts | 67 +++ src/bridge/linear/client.ts | 68 +++ src/bridge/linear/comments.ts | 159 +++++++ src/bridge/linear/emojis.ts | 53 +++ src/bridge/linear/index.ts | 583 +++++++----------------- src/bridge/linear/issues.ts | 117 +++++ src/bridge/linear/labels.ts | 63 +++ src/bridge/linear/reactions.ts | 54 +++ src/bridge/linear/state.ts | 56 +++ src/events/bridge.ts | 251 ----------- src/index.ts | 6 +- 25 files changed, 1933 insertions(+), 1432 deletions(-) delete mode 100644 scripts/discord-linear-sync.ts create mode 100644 src/bridge/ARCHITECTURE.md create mode 100644 src/bridge/core/backfill.ts create mode 100644 src/bridge/core/bridge.ts create mode 100644 src/bridge/core/connector.ts create mode 100644 src/bridge/core/mirror.ts create mode 100644 src/bridge/core/model.ts create mode 100644 src/bridge/core/reconciler.ts rename src/bridge/{ => core}/references.ts (59%) create mode 100644 src/bridge/discord/index.ts create mode 100644 src/bridge/discord/map.ts delete mode 100644 src/bridge/linear/api.ts create mode 100644 src/bridge/linear/assets.ts create mode 100644 src/bridge/linear/attachments.ts create mode 100644 src/bridge/linear/client.ts create mode 100644 src/bridge/linear/comments.ts create mode 100644 src/bridge/linear/emojis.ts create mode 100644 src/bridge/linear/issues.ts create mode 100644 src/bridge/linear/labels.ts create mode 100644 src/bridge/linear/reactions.ts create mode 100644 src/bridge/linear/state.ts delete mode 100644 src/events/bridge.ts diff --git a/package.json b/package.json index cbb99f2..53584d3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "start": "bun .", "watch": "bun --watch .", - "sync:linear": "bun scripts/discord-linear-sync.ts", "format": "biome format --write", "format:ci": "biome format --reporter=github --verbose", "lint": "biome lint --write", diff --git a/scripts/discord-linear-sync.ts b/scripts/discord-linear-sync.ts deleted file mode 100644 index e6e5961..0000000 --- a/scripts/discord-linear-sync.ts +++ /dev/null @@ -1,79 +0,0 @@ -// One-shot backfill: mirrors existing #help threads and their recent messages -// into Linear via src/bridge/linear. Re-running re-adds comments (issues, -// labels, and attachments are deduped; comments are not). -// -// Run: bun run sync:linear - -import { - ChannelType, - Client, - GatewayIntentBits, - type ThreadChannel, -} from "discord.js"; - -import { config } from "../src/lib/config.js"; -import { isHumanMessage } from "../src/lib/discord/help.js"; -import { HelpThread } from "../src/lib/discord/helpThread.js"; -import { setIssueState } from "../src/bridge/linear/api.js"; -import { LinearMirror } from "../src/bridge/linear/index.js"; - -const MESSAGE_LIMIT = 50; - -async function syncThread(thread: ThreadChannel) { - const help = new HelpThread(thread); - const mirror = new LinearMirror(help); - console.log(`Syncing "${help.title}" (${thread.id})`); - - await mirror.create(); - - const messages = await thread.messages.fetch({ limit: MESSAGE_LIMIT }); - // Oldest first so comments read in order. - for (const message of [...messages.values()].reverse()) { - if (!isHumanMessage(message)) continue; - await mirror.addMessage(message); - } - - if (help.isClosed) { - await setIssueState(await mirror.ensureIssue(), "completed"); - } -} - -async function main() { - const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - ], - }); - - await client.login(config.token); - - const forum = await client.channels.fetch(config.helpChannel.id); - if (!forum || forum.type !== ChannelType.GuildForum) { - throw new Error("helpChannel is not a forum channel"); - } - - const active = await forum.threads.fetchActive(); - const archived = await forum.threads.fetchArchived({ - limit: config.startupCatchupLimit, - }); - const threads = [...active.threads.values(), ...archived.threads.values()]; - - console.log(`Found ${threads.length} help threads to sync.`); - for (const thread of threads) { - try { - await syncThread(thread); - } catch (err) { - console.error(`Failed to sync thread ${thread.id}:`, err); - } - } - - await client.destroy(); - console.log("Done."); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/bridge/ARCHITECTURE.md b/src/bridge/ARCHITECTURE.md new file mode 100644 index 0000000..59902aa --- /dev/null +++ b/src/bridge/ARCHITECTURE.md @@ -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 +`[-msg]: `, 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`. diff --git a/src/bridge/core/backfill.ts b/src/bridge/core/backfill.ts new file mode 100644 index 0000000..b164daf --- /dev/null +++ b/src/bridge/core/backfill.ts @@ -0,0 +1,22 @@ +function sleep(ms: number): Promise { + 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( + fn: () => Promise, + isRateLimited: (err: unknown) => boolean, +): Promise { + 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); + } + } +} diff --git a/src/bridge/core/bridge.ts b/src/bridge/core/bridge.ts new file mode 100644 index 0000000..20769bd --- /dev/null +++ b/src/bridge/core/bridge.ts @@ -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("Linear bridge is disabled."); + return; + } + validateLinearBridgeConfig(); + connector = new DiscordConnector(client, new LinearConnector()); + connector.register(); +} + +export async function backfillBridge(client: Client): Promise { + if (!config.linearBridge.enabled) return; + const source = + connector ?? new DiscordConnector(client, new LinearConnector()); + await source.backfill(); +} diff --git a/src/bridge/core/connector.ts b/src/bridge/core/connector.ts new file mode 100644 index 0000000..a45b579 --- /dev/null +++ b/src/bridge/core/connector.ts @@ -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; + ensureIssue(post: Post): Promise; + deleteIssue(ref: ExternalRef): Promise; + + // Refresh linking attachment, title, project and labels from the post. + reconcile(issueId: string, post: Post): Promise; + syncLabels(issueId: string, post: Post): Promise; + + setDescription(issueId: string, text: string): Promise; + updateDescription(issueId: string, message: Message): Promise; + + addComment( + issueId: string, + message: Message, + parentId?: string, + ): Promise; + editComment(issueId: string, message: Message): Promise; + deleteComment(issueId: string, ref: ExternalRef): Promise; + mirroredMessageIds(issueId: string): Promise>; + resolveReplyParent( + issueId: string, + messageId: string, + ): Promise; + findCommentId(issueId: string, messageId: string): Promise; + + // Plain system note (no marker), e.g. "thread closed". + note(issueId: string, body: string, createdAt?: Date): Promise; + + getState(issueId: string): Promise; + setState( + issueId: string, + type: "completed" | "triage" | "started", + name?: string, + ): Promise; + + addReaction(target: ReactionTarget, reaction: Reaction): Promise; + removeReaction(target: ReactionTarget, reaction: Reaction): Promise; + + resolveByUrl(url: string): Promise; + relate(issueId: string, otherId: string): Promise; + + 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; + announce( + post: Post, + issue: { identifier: string; url: string }, + ): Promise; +} diff --git a/src/bridge/core/mirror.ts b/src/bridge/core/mirror.ts new file mode 100644 index 0000000..23177db --- /dev/null +++ b/src/bridge/core/mirror.ts @@ -0,0 +1,200 @@ +import type { + ExternalRef, + Message, + Post, + Reaction, +} from "@bridge/core/model.js"; +import type { Source, Target } from "@bridge/core/connector.js"; +import { dedupeReferences, type Reference } from "@bridge/core/references.js"; +import { syncState } from "@bridge/core/reconciler.js"; + +// Orchestrates one source against the hub. All logic here is source-agnostic: +// it consumes model objects a connector produces and drives the Target. A +// connector holds a Mirror and feeds it from its own listeners and backfill. +export class Mirror { + // Caches the ensure-issue promise per post so concurrent events resolve to a + // single issue instead of racing to create duplicates. + private readonly issueByRef = new Map>(); + + constructor( + private readonly target: Target, + private readonly source: Pick, + ) {} + + // Mirrors a post: issue (with the opening message as its body), linking + // attachment, and labels. Announces the issue back to the source unless + // suppressed (e.g. during startup backfill of old posts). + async createPost(post: Post, announce = true): Promise { + console.log(`[bridge] mirroring post ${post.ref.id} "${post.title}"`); + const existed = (await this.target.findIssueId(post.ref)) !== null; + const issueId = await this.ensureIssue(post); + await this.target.syncLabels(issueId, post); + + const rewrites = await this.resolveReferences(issueId, post.references); + if (rewrites.size > 0) { + await this.target.setDescription( + issueId, + applyRewrites(post.body, rewrites), + ); + } + + if (announce && !existed) { + try { + await this.source.announce(post, await this.target.issueRef(issueId)); + } catch (err) { + console.error("Linear bridge: issue announce failed:", err); + } + } + } + + // Mirrors a message as an issue comment. + async addMessage(post: Post, message: Message): Promise { + // Skip messages with no text and no attachments (e.g. a sticker-only post). + if (!message.text.trim() && message.attachments.length === 0) return; + const issueId = await this.ensureIssue(post); + const rewrites = await this.resolveReferences(issueId, message.references); + const parentId = message.replyToId + ? ((await this.target.resolveReplyParent(issueId, message.replyToId)) ?? + undefined) + : undefined; + await this.target.addComment( + issueId, + { ...message, text: applyRewrites(message.text, rewrites) }, + parentId, + ); + } + + // Reflects a message edit onto its mirrored comment, or the issue description + // for the opening message. No-op if the post isn't mirrored. + async editMessage( + post: Post, + message: Message, + isStarter: boolean, + ): Promise { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) return; + if (isStarter) { + await this.target.updateDescription(issueId, message); + } else { + await this.target.editComment(issueId, message); + } + } + + // Removes a deleted message from the hub. Regular messages map to comments; + // the opening message maps to the issue description, which is cleared. + async deleteMessage( + post: Post, + ref: ExternalRef, + isStarter: boolean, + ): Promise { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) { + console.log( + `[bridge] deleteMessage: no issue mapping for ${post.ref.url}`, + ); + return; + } + if (isStarter) { + await this.target.setDescription(issueId, ""); + return; + } + const ok = await this.target.deleteComment(issueId, ref); + console.log(`[bridge] deleteMessage msg=${ref.id} deleted=${ok}`); + } + + // Refreshes attachment metadata, title, project and labels, then reconciles + // the workflow state. + async syncStatus(post: Post, backfill = false): Promise { + const issueId = await this.ensureIssue(post); + await this.target.reconcile(issueId, post); + await syncState(this.target, issueId, post, backfill); + } + + // Trashes the mirrored issue when its source post is deleted. + async deletePost(post: Post): Promise { + await this.target.deleteIssue(post.ref); + this.issueByRef.delete(post.ref.id); + } + + // Mirrors a reaction onto the mapped issue (opening message) or comment. + // A null message ref targets the issue itself. + async addReaction( + post: Post, + message: ExternalRef | null, + reaction: Reaction, + ): Promise { + const target = await this.reactionTarget(post, message); + if (target) await this.target.addReaction(target, reaction); + } + + async removeReaction( + post: Post, + message: ExternalRef | null, + reaction: Reaction, + ): Promise { + const target = await this.reactionTarget(post, message); + if (target) await this.target.removeReaction(target, reaction); + } + + // Mirrors messages not already on the issue, in the order given. Idempotent: + // existing messages (matched by marker) are skipped, so it is safe to re-run. + async backfillMessages(post: Post, messages: Message[]): Promise { + const issueId = await this.ensureIssue(post); + const mirrored = await this.target.mirroredMessageIds(issueId); + console.log( + `[bridge] backfilling ${messages.length} message(s) for issue ${issueId} (${mirrored.size} already mirrored)`, + ); + for (const message of messages) { + if (mirrored.has(message.ref.id)) continue; + await this.addMessage(post, message); + } + } + + private async reactionTarget( + post: Post, + message: ExternalRef | null, + ): Promise<{ issueId: string } | { commentId: string } | null> { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) return null; + if (!message) return { issueId }; + const commentId = await this.target.findCommentId(issueId, message.id); + return commentId ? { commentId } : null; + } + + private async ensureIssue(post: Post): Promise { + const cached = this.issueByRef.get(post.ref.id); + if (cached) return cached; + + const pending = this.target.ensureIssue(post); + this.issueByRef.set(post.ref.id, pending); + try { + return await pending; + } catch (err) { + this.issueByRef.delete(post.ref.id); + throw err; + } + } + + // Resolves each reference to a hub issue, relates it, and returns token -> + // markdown link rewrites that turn each mention into a link to the issue. + private async resolveReferences( + issueId: string, + references: Reference[], + ): Promise> { + const rewrites = new Map(); + for (const ref of dedupeReferences(references)) { + const target = await this.target.resolveByUrl(ref.url); + if (!target || target.id === issueId) continue; + await this.target.relate(issueId, target.id); + rewrites.set(ref.token, `[${target.identifier}](${target.url})`); + } + return rewrites; + } +} + +function applyRewrites(body: string, rewrites: Map): string { + for (const [token, replacement] of rewrites) { + body = body.split(token).join(replacement); + } + return body; +} diff --git a/src/bridge/core/model.ts b/src/bridge/core/model.ts new file mode 100644 index 0000000..8829829 --- /dev/null +++ b/src/bridge/core/model.ts @@ -0,0 +1,123 @@ +// Source-agnostic domain model shared by every bridge connector. A connector +// maps its platform's native objects onto these types; the core orchestrator and +// the hub (Linear) speak only this vocabulary, so a new platform is just another +// connector rather than changes threaded through the whole bridge. + +// Platforms the bridge knows about. Discord and Linear exist today; github is +// reserved for the planned GitHub Discussions connector. +export type SourceId = "discord" | "linear" | "github"; + +// Identifies an entity on its origin platform. `url` is the canonical link used +// to locate the matching hub issue (via its attachments); `id` is the native id. +export interface ExternalRef { + source: SourceId; + id: string; + url: string; +} + +// External author of mirrored content, for attribution on the hub. +export interface Author { + name: string; + iconUrl?: string; +} + +// A custom emoji that the hub must register before its shortcode renders. +export interface CustomEmoji { + id: string; + animated: boolean; +} + +export interface Attachment { + name: string; + url: string; + contentType: string | null; + isImage: boolean; +} + +// A reaction normalized to the hub's key: a registered `discord-` shortcode +// for custom emojis, or the unicode character for standard ones. `custom` is set +// when the hub must register the emoji first. +export interface Reaction { + key: string; + custom?: CustomEmoji; +} + +// A source tag mirrored as a hub label. `id` is the source tag id, stored in the +// label description so the mapping survives restarts. +export interface Label { + id: string; + name: string; +} + +export type Lifecycle = "open" | "closed"; +export type Waiting = "user" | "team" | null; + +// Descriptor for the hub attachment that links an issue back to its source +// conversation. The source supplies the exact strings/metadata so the hub only +// stores them, keeping the on-the-wire shape owned by the connector. +export interface SourceAttachment { + title: string; + subtitle: string; + metadata: Record; +} + +// The opening entity of a mirrored conversation (a Discord forum post, later a +// GitHub discussion). Maps to one hub issue. +export interface Post { + ref: ExternalRef; + title: string; + // Rendered starter text (mentions and emojis resolved), used verbatim as the + // issue description. Attachments are intentionally not composed in here to + // match the hub's existing description shape. + body: string; + author?: Author; + customEmojis: CustomEmoji[]; + references: Reference[]; + labels: Label[]; + lifecycle: Lifecycle; + waiting: Waiting; + closedAt: Date | null; + createdAt?: Date; + attachment: SourceAttachment; +} + +// A single message within a conversation. The starter message is represented by +// the Post, not a Message. +export interface Message { + ref: ExternalRef; + author?: Author; + // Rendered text (mentions and emojis resolved, reference tokens still present + // for the orchestrator to rewrite). Attachments are composed by the hub. + text: string; + attachments: Attachment[]; + customEmojis: CustomEmoji[]; + references: Reference[]; + replyToId?: string; + createdAt?: Date; +} + +// A reference to another entity found in mirrored content. `url` is the canonical +// link used to locate a matching hub issue; `token` is the exact substring in +// the content to rewrite when a match is found. +export interface Reference { + url: string; + token: string; +} + +// Composes a message body from its text and attachments, resolving each +// attachment URL via urlFor (a CDN link for the fast path, a re-hosted asset URL +// for the durable path). Images render inline, other files as links. +export function composeBody( + text: string, + attachments: Attachment[], + urlFor: (a: Attachment) => string, +): string { + const parts: string[] = []; + const trimmed = text.trim(); + if (trimmed) parts.push(trimmed); + for (const a of attachments) { + const link = `[${a.name}](${urlFor(a)})`; + parts.push(a.isImage ? `!${link}` : link); + } + return parts.join("\n\n"); +} diff --git a/src/bridge/core/reconciler.ts b/src/bridge/core/reconciler.ts new file mode 100644 index 0000000..2a2f775 --- /dev/null +++ b/src/bridge/core/reconciler.ts @@ -0,0 +1,57 @@ +import type { Post } from "@bridge/core/model.js"; +import type { Target } from "@bridge/core/connector.js"; + +// Maps a post's lifecycle onto the hub's workflow state. The source is +// authoritative for its own lifecycle: closed -> Done, waiting on the user -> +// Blocked, waiting on the team -> In Progress. A new post stays in Triage until +// the team first engages (moves it out of Triage); a reopened issue with no +// waiting signal falls back to Triage. During backfill the issue is freshly +// created in Triage, so the "team engaged" gate is skipped and the waiting +// signal drives the state directly. +// +// Transitions are decided against the current hub state, not a source old/new +// diff, so out-of-band changes (e.g. a /close command) are detected reliably. +export async function syncState( + target: Target, + issueId: string, + post: Post, + backfill: boolean, +): Promise { + const state = await target.getState(issueId); + + if (post.lifecycle === "closed") { + if (state?.type !== "completed") { + await target.setState(issueId, "completed"); + await target.note( + issueId, + "_Thread closed on Discord._", + post.closedAt ?? undefined, + ); + } + return; + } + + if (state?.type === "completed") { + await target.note(issueId, "_Thread reopened on Discord._"); + } + + if (post.waiting === "user") { + if (state?.name !== "Blocked") { + await target.setState(issueId, "started", "Blocked"); + } + return; + } + + if (post.waiting === "team") { + if (!backfill && state?.type === "triage") return; + if (state?.name !== "In Progress") { + await target.setState(issueId, "started", "In Progress"); + } + return; + } + + // No waiting signal: send a reopened issue back to Triage. + if (state?.type === "completed") { + await target.setState(issueId, "triage"); + } +} diff --git a/src/bridge/references.ts b/src/bridge/core/references.ts similarity index 59% rename from src/bridge/references.ts rename to src/bridge/core/references.ts index fe17f6a..45a0e26 100644 --- a/src/bridge/references.ts +++ b/src/bridge/core/references.ts @@ -1,24 +1,20 @@ -// Cross-linking of entities mentioned in mirrored content. A bridge extracts -// references from a message, resolves each to a Linear issue (via its -// attachments) and links them. The extractors are source-agnostic so other -// bridges (e.g. a future GitHub Discussions bridge) can reuse them. +// Extracts cross-references from mirrored content: links to other conversations +// or external issues that may already map to a hub issue. The extractors are +// source-agnostic so every connector can reuse them; the orchestrator resolves +// each reference against the hub and rewrites it to a hub issue link. -// A reference to another entity found in mirrored content. `url` is the -// canonical link used to locate a matching Linear issue; `token` is the exact -// substring in the content to rewrite when a match is found. -export interface Reference { - url: string; - token: string; -} +import type { Reference } from "@bridge/core/model.js"; + +export type { Reference }; -// Extracts GitHub issue and pull request references (full URLs). +// GitHub issue and pull request references (full URLs). export function githubReferences(content: string): Reference[] { const re = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g; return [...content.matchAll(re)].map((m) => ({ url: m[0], token: m[0] })); } -// Extracts Discord thread references, both channel mentions (<#id>) and -// message/thread URLs, normalized to the canonical thread URL for the guild. +// Discord thread references: channel mentions (<#id>) and message/thread URLs, +// normalized to the canonical thread URL for the guild. export function discordThreadReferences( content: string, guildId: string, diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts new file mode 100644 index 0000000..ef6c0c1 --- /dev/null +++ b/src/bridge/discord/index.ts @@ -0,0 +1,297 @@ +import { debounce } from "throttle-debounce"; + +import { + ChannelType, + type Client, + Events, + type ThreadChannel, +} from "discord.js"; + +import { config } from "@lib/config.js"; +import { isHelpPost } from "@lib/discord/channels.js"; +import { isHumanMessage, reconcileThread } from "@lib/discord/help.js"; +import { HelpThread } from "@lib/discord/helpThread.js"; + +import type { ExternalRef, Post } from "@bridge/core/model.js"; +import type { Source, Target } from "@bridge/core/connector.js"; +import { Mirror } from "@bridge/core/mirror.js"; +import { withRateLimitRetry } from "@bridge/core/backfill.js"; + +import { isRateLimited } from "@bridge/linear/client.js"; + +import { isStarter, toMessage, toPost, toReaction } from "./map.js"; + +// Discord #help forum as a bridge source: listens for thread/message/reaction +// events, maps them onto the canonical model, and drives the mirror. Also +// enumerates threads for the startup backfill and writes the hub issue link back +// into the thread. +export class DiscordConnector implements Source { + private readonly mirror: Mirror; + + constructor( + private readonly client: Client, + target: Target, + ) { + this.mirror = new Mirror(target, this); + } + + register(): void { + const client = this.client; + + client.on(Events.ThreadCreate, async (thread) => { + if (!(await isHelpPost(thread))) return; + try { + await this.mirror.createPost(await this.postFor(thread)); + } catch (err) { + console.error("Linear bridge: thread create failed:", err); + } + }); + + client.on(Events.ThreadDelete, async (thread) => { + if (!(await isHelpPost(thread))) return; + try { + await this.mirror.deletePost(toPost(new HelpThread(thread), null)); + } catch (err) { + console.error("Linear bridge: thread delete failed:", err); + } + }); + + client.on(Events.MessageCreate, async (message) => { + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message) || isStarter(message)) return; + try { + const post = await this.postFor(message.channel as ThreadChannel); + await this.mirror.addMessage(post, toMessage(message)); + } catch (err) { + console.error("Linear bridge: message create failed:", err); + } + }); + + client.on(Events.MessageUpdate, async (oldMessage, newMessage) => { + try { + const message = newMessage.partial + ? await newMessage.fetch() + : newMessage; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message)) return; + // Ignore edits that changed neither text nor attachments (e.g. an embed + // unfurling or a pin) when the previous state is known. + if ( + !oldMessage.partial && + oldMessage.content === message.content && + oldMessage.attachments.size === message.attachments.size && + oldMessage.attachments.every((_a, id) => message.attachments.has(id)) + ) { + return; + } + const post = toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.editMessage( + post, + toMessage(message), + isStarter(message), + ); + } catch (err) { + console.error("Linear bridge: message update failed:", err); + } + }); + + client.on(Events.MessageDelete, async (message) => { + try { + const channel = message.channel; + if (!channel.isThread() || !(await isHelpPost(channel))) return; + const post = toPost(new HelpThread(channel), null); + const ref: ExternalRef = { + source: "discord", + id: message.id, + url: "", + }; + await this.mirror.deleteMessage(post, ref, message.id === channel.id); + } catch (err) { + console.error("Linear bridge: message delete failed:", err); + } + }); + + client.on(Events.MessageReactionAdd, async (reaction, user) => { + try { + if (user.bot) return; + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // The app aggregates reactions under one identity, so only the first + // Discord reaction of an emoji is mirrored. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved?.count !== 1) return; + const post = toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.addReaction( + post, + this.messageRef(message.id, message.channelId), + toReaction(reaction.emoji), + ); + } catch (err) { + console.error("Linear bridge: reaction add failed:", err); + } + }); + + client.on(Events.MessageReactionRemove, async (reaction) => { + try { + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // Only remove the mirrored reaction once the last Discord user removes it. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved && resolved.count > 0) return; + const post = toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.removeReaction( + post, + this.messageRef(message.id, message.channelId), + toReaction(reaction.emoji), + ); + } catch (err) { + console.error("Linear bridge: reaction remove failed:", err); + } + }); + + // Coalesce bursts of tag edits per thread. syncStatus is idempotent and + // reconciles against the hub state, so no before/after diff is kept. + const flushers = new Map void>(); + client.on(Events.ThreadUpdate, async (_oldThread, newThread) => { + if (!(await isHelpPost(newThread))) return; + let flush = flushers.get(newThread.id); + if (!flush) { + flush = debounce(1000, async (thread: ThreadChannel) => { + flushers.delete(thread.id); + try { + await this.mirror.syncStatus(await this.postFor(thread)); + } catch (err) { + console.error("Linear bridge: thread update failed:", err); + } + }); + flushers.set(newThread.id, flush); + } + flush(newThread); + }); + + console.log("Linear bridge is enabled."); + } + + async announce( + post: Post, + issue: { identifier: string; url: string }, + ): Promise { + const channel = await this.client.channels.fetch(post.ref.id); + if (!channel?.isThread()) return; + await channel.send({ + embeds: [{ description: `[${issue.identifier}](${issue.url})` }], + }); + console.log( + `[bridge] announced ${issue.identifier} in thread ${post.ref.id}`, + ); + } + + // Mirrors #help threads that aren't fully in the hub yet, so threads and + // messages from while the bridge was off still land as issues. With backfillAll + // it imports every thread, paging through all archived threads and waiting out + // rate limits. + async backfill(): Promise { + const { backfillAll, backfillLimit } = config.linearBridge; + if (!backfillAll && backfillLimit <= 0) return; + + const forum = await this.client.channels.fetch(config.helpChannel.id); + if (!forum || forum.type !== ChannelType.GuildForum) return; + + const byId = new Map(); + const active = await forum.threads.fetchActive(); + for (const thread of active.threads.values()) byId.set(thread.id, thread); + + // Pull archived threads too. For a full import, page through every archived + // thread; otherwise a single page bounded by the limit is enough. + let before: Date | undefined; + do { + const page = await forum.threads.fetchArchived({ + limit: backfillAll ? 100 : backfillLimit, + before, + }); + const last = [...page.threads.values()].at(-1); + for (const thread of page.threads.values()) byId.set(thread.id, thread); + before = + backfillAll && page.hasMore + ? (last?.archivedAt ?? undefined) + : undefined; + } while (before); + + const sorted = [...byId.values()].sort((a, b) => + (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), + ); + const threads = backfillAll ? sorted : sorted.slice(0, backfillLimit); + + console.log( + `[bridge] startup backfill: ${threads.length} thread(s)` + + (backfillAll + ? " (full import)" + : ` of ${byId.size} fetched (limit ${backfillLimit})`), + ); + for (const thread of threads) { + try { + await withRateLimitRetry( + () => this.backfillThread(thread), + isRateLimited, + ); + } catch (err) { + console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); + } + } + console.log("[bridge] startup backfill complete"); + } + + // Mirrors a thread: ensures the issue exists, fills in missing messages, then + // reconciles state. Safe to re-run over already-mirrored threads. + private async backfillThread(thread: ThreadChannel): Promise { + console.log(`[bridge] backfilling thread ${thread.id} "${thread.name}"`); + const help = new HelpThread(thread); + + // Older threads may predate the waiting-tag automation. If an open thread + // has no waiting tag, derive one from its last message so the mirrored issue + // gets a meaningful status. + if (help.isOpen && help.waiting === null) { + await reconcileThread(thread); + } + + const starter = await thread.fetchStarterMessage().catch(() => null); + const post = toPost(help, starter); + await this.mirror.createPost(post, false); + + const fetched = await thread.messages.fetch({ limit: 100 }); + const messages = [...fetched.values()] + .reverse() + .filter((m) => isHumanMessage(m) && !isStarter(m)) + .map(toMessage); + await this.mirror.backfillMessages(post, messages); + + await this.mirror.syncStatus(post, true); + } + + private async postFor(thread: ThreadChannel): Promise { + const starter = await thread.fetchStarterMessage().catch(() => null); + return toPost(new HelpThread(thread), starter); + } + + private messageRef(messageId: string, threadId: string): ExternalRef | null { + if (messageId === threadId) return null; + return { source: "discord", id: messageId, url: "" }; + } +} diff --git a/src/bridge/discord/map.ts b/src/bridge/discord/map.ts new file mode 100644 index 0000000..0e93716 --- /dev/null +++ b/src/bridge/discord/map.ts @@ -0,0 +1,168 @@ +import type { Message as DiscordMessage } from "discord.js"; + +import { config } from "@lib/config.js"; +import type { HelpThread } from "@lib/discord/helpThread.js"; + +import type { + Attachment, + Author, + CustomEmoji, + Message, + Post, + Reaction, + SourceAttachment, +} from "@bridge/core/model.js"; +import { + dedupeReferences, + discordThreadReferences, + githubReferences, + type Reference, +} from "@bridge/core/references.js"; + +const SOURCE = "discord" as const; + +// Maps a Discord forum post onto the canonical Post. Attachments are not +// composed into the body: the opening message becomes the issue description as +// text only, matching the hub's existing shape. +export function toPost(help: HelpThread, starter: DiscordMessage | null): Post { + const content = starter?.content ?? ""; + return { + ref: { source: SOURCE, id: help.thread.id, url: help.url }, + title: help.title, + body: starter ? formatText(starter).trim() : "", + author: starter ? authorOf(starter) : undefined, + customEmojis: customEmojisIn(content), + references: referencesOf(content), + labels: help.tags, + lifecycle: help.isClosed ? "closed" : "open", + waiting: help.waiting, + closedAt: help.closedAt, + createdAt: starter?.createdAt, + attachment: attachmentOf(help), + }; +} + +// Maps a Discord message onto the canonical Message (never the starter, which is +// represented by the Post). +export function toMessage(message: DiscordMessage): Message { + const content = message.content ?? ""; + return { + ref: { source: SOURCE, id: message.id, url: message.url }, + author: authorOf(message), + text: formatText(message), + attachments: attachmentsOf(message), + customEmojis: customEmojisIn(content), + references: referencesOf(content), + replyToId: message.reference?.messageId, + createdAt: message.createdAt, + }; +} + +// Whether a message is the forum starter (its id equals the thread id). +export function isStarter(message: DiscordMessage): boolean { + return message.id === message.channelId; +} + +// Maps a Discord emoji onto a hub reaction: a registered discord- shortcode +// for custom emojis (which the hub must register first), or the unicode +// character for standard ones. +export function toReaction(emoji: { + id: string | null; + name: string | null; + animated?: boolean | null; +}): Reaction { + if (emoji.id) { + return { + key: `discord-${emoji.id}`, + custom: { id: emoji.id, animated: emoji.animated ?? false }, + }; + } + return { key: emoji.name ?? "" }; +} + +// Message text with mentions and custom emojis resolved for the hub. +function formatText(message: DiscordMessage): string { + return resolveEmojis(resolveMentions(message)); +} + +// Resolves user and role mentions the hub can't resolve from ids. User mentions +// become a link to the Discord profile; role mentions become @name. Channel +// mentions are left for reference linking. +function resolveMentions(message: DiscordMessage): string { + return (message.content ?? "") + .replace(/<@!?(\d+)>/g, (m, id) => { + const name = + message.mentions.members?.get(id)?.displayName ?? + message.mentions.users.get(id)?.username; + return name ? `[@${name}](https://discord.com/users/${id})` : m; + }) + .replace(/<@&(\d+)>/g, (m, id) => { + const role = message.mentions.roles.get(id); + return role ? `@${role.name}` : m; + }); +} + +// Rewrites custom emojis (<:name:id>, ) as :discord-: shortcodes +// that resolve to the registered hub emojis. +function resolveEmojis(content: string): string { + return content.replace(//g, (_m, id) => `:discord-${id}:`); +} + +function customEmojisIn(content: string): CustomEmoji[] { + const seen = new Set(); + const emojis: CustomEmoji[] = []; + for (const [, animated, id] of content.matchAll(/<(a?):\w+:(\d+)>/g)) { + if (seen.has(id)) continue; + seen.add(id); + emojis.push({ id, animated: animated === "a" }); + } + return emojis; +} + +function attachmentsOf(message: DiscordMessage): Attachment[] { + return [...message.attachments.values()].map((a) => ({ + name: a.name, + url: a.url, + contentType: a.contentType, + isImage: a.contentType?.startsWith("image/") ?? false, + })); +} + +function referencesOf(content: string): Reference[] { + return dedupeReferences([ + ...githubReferences(content), + ...discordThreadReferences(content, config.serverId), + ]); +} + +function authorOf(message: DiscordMessage): Author { + return { + name: message.member?.displayName ?? message.author.username, + iconUrl: + message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), + }; +} + +function attachmentOf(help: HelpThread): SourceAttachment { + return { + title: "Discord thread", + subtitle: subtitle(help), + metadata: { + threadId: help.thread.id, + tagIds: help.tags.map((t) => t.id), + tagNames: help.tags.map((t) => t.name), + status: help.status, + waiting: help.waiting, + }, + }; +} + +function subtitle(help: HelpThread): string { + const parts = ["#help"]; + if (help.isClosed) parts.push("closed"); + if (help.waiting) parts.push(`waiting: ${help.waiting}`); + if (help.tags.length > 0) { + parts.push(`tags: ${help.tags.map((t) => t.name).join(", ")}`); + } + return parts.join(" - "); +} diff --git a/src/bridge/linear/api.ts b/src/bridge/linear/api.ts deleted file mode 100644 index 314bd9a..0000000 --- a/src/bridge/linear/api.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { - LinearClient, - type Attachment, - type Comment, - type Issue, -} from "@linear/sdk"; -import { IssueRelationType } from "@linear/sdk"; - -import { config } from "@lib/config.js"; - -// Validated bridge credentials. Present whenever the bridge is enabled. -function bridgeConfig(): { - appToken: string; - userToken: string; - teamId: string; -} { - const { appToken, userToken, teamId } = config.linearBridge; - if (!appToken || !userToken || !teamId) { - throw new Error( - "linearBridge is enabled but appToken/userToken/teamId are missing", - ); - } - return { appToken, userToken, teamId }; -} - -let appClient: LinearClient | undefined; -let userClient: LinearClient | undefined; - -// App-actor client. Issues, comments and reactions run here so they are -// attributed to the external Discord author (OAuth tokens use accessToken). -function linear(): LinearClient { - if (!appClient) { - appClient = new LinearClient({ accessToken: bridgeConfig().appToken }); - } - return appClient; -} - -// Personal-key client for writes the app actor cannot make: creating custom -// emojis and labels. -function linearUser(): LinearClient { - if (!userClient) { - userClient = new LinearClient({ apiKey: bridgeConfig().userToken }); - } - return userClient; -} - -// Extracts a readable message from a Linear SDK error, whose default string -// form is unhelpful ("[object Object]"). -export function linearError(err: unknown): string { - const e = err as { errors?: { message?: string }[]; message?: string }; - return ( - e?.errors - ?.map((x) => x.message) - .filter(Boolean) - .join("; ") || - e?.message || - String(err) - ); -} - -// Whether an error is a Linear rate-limit rejection, so a bulk import can wait -// and retry rather than abort. -export function isRateLimited(err: unknown): boolean { - const e = err as { - type?: string; - status?: number; - errors?: { extensions?: { type?: string } }[]; - }; - return ( - e?.type === "Ratelimited" || - e?.status === 429 || - (e?.errors?.some((x) => x.extensions?.type === "Ratelimited") ?? false) - ); -} - -// Metadata stored on the Discord attachment of a mirrored issue. -export interface ThreadAttachmentFields { - url: string; - title: string; - subtitle: string; - metadata: Record; -} - -// Returns the first attachment on the URL whose issue lives in the configured -// team. Attachments match across the whole workspace, so scoping to the team -// keeps lookups from touching issues in unrelated Linear teams. -async function attachmentInTeam( - url: string, -): Promise<{ attachment: Attachment; issue: Issue } | null> { - const { teamId } = bridgeConfig(); - const attachments = await linear().attachmentsForURL(url); - for (const attachment of attachments.nodes) { - const issue = await attachment.issue; - if (!issue) continue; - const team = await issue.team; - if (team?.id === teamId) return { attachment, issue }; - } - return null; -} - -// Finds the issue mapped to a thread via its URL attachment, returning the -// issue and attachment ids. -export async function findThreadMapping( - url: string, -): Promise<{ issueId: string; attachmentId: string } | null> { - const match = await attachmentInTeam(url); - if (!match) return null; - return { issueId: match.issue.id, attachmentId: match.attachment.id }; -} - -// Creates an issue in the configured team and returns its id. -export async function createIssue(input: { - title: string; - description: string; - author?: { name: string; iconUrl?: string }; - createdAt?: Date; -}): Promise { - const payload = await linear().createIssue({ - teamId: bridgeConfig().teamId, - projectId: config.linearBridge.projectId, - title: input.title, - description: input.description, - createdAt: input.createdAt, - // Attributes the issue to an external Discord author under app-actor auth; - // ignored fields are safe to omit for personal keys (author is undefined). - createAsUser: input.author?.name, - displayIconUrl: input.author?.iconUrl, - }); - - const issue = await payload.issue; - if (!issue) throw new Error("Linear did not return the created issue"); - console.log(`[bridge] created issue ${issue.identifier} "${input.title}"`); - return issue.id; -} - -// Creates the Discord attachment on a freshly created issue. -export async function createThreadAttachment( - issueId: string, - fields: ThreadAttachmentFields, -): Promise { - await linear().createAttachment({ issueId, ...fields }); - console.log(`[bridge] created attachment on ${issueId} -> ${fields.url}`); -} - -// Updates the issue's Discord attachment in place, or creates it if missing. -export async function upsertThreadAttachment( - issueId: string, - fields: ThreadAttachmentFields, -): Promise { - const mapping = await findThreadMapping(fields.url); - if (!mapping) { - await createThreadAttachment(issueId, fields); - return; - } - - await linear().updateAttachment(mapping.attachmentId, { - title: fields.title, - subtitle: fields.subtitle, - metadata: fields.metadata, - }); - console.log(`[bridge] updated attachment on ${issueId}`); -} - -// Invisible marker (an unused markdown reference-link definition) appended to -// mirrored comments so a later Discord edit/delete can locate the right -// comment. It renders as nothing in Linear but round-trips in the raw body. -const MSG_MARKER = "discord-msg"; - -function withMarker(body: string, messageId: string): string { - return `${body}\n\n[${MSG_MARKER}]: ${messageId}`; -} - -function markerMessageId(body: string): string | null { - return body.match(/^\[discord-msg\]:\s*(\S+)/m)?.[1] ?? null; -} - -export async function addComment( - issueId: string, - body: string, - author?: { name: string; iconUrl?: string }, - messageId?: string, - parentId?: string, - createdAt?: Date, -): Promise { - const input = { - issueId, - body: messageId ? withMarker(body, messageId) : body, - createdAt, - // Attributes the comment to an external Discord author. Requires OAuth - // app-actor auth; Linear rejects these fields for personal API keys, so the - // caller only supplies an author when that mode is configured. - createAsUser: author?.name, - displayIconUrl: author?.iconUrl, - }; - - console.log( - `[bridge] adding comment on ${issueId}` + - `${messageId ? ` for msg ${messageId}` : ""}` + - `${parentId ? ` (reply to ${parentId})` : ""}`, - ); - - try { - await linear().createComment({ ...input, parentId }); - } catch (err) { - // Linear threads are one level deep; if the parent is itself a reply, fall - // back to a top-level comment rather than dropping the message. - if (!parentId) throw err; - await linear().createComment(input); - } -} - -// Returns the Discord message ids already mirrored as comments on the issue, -// read from the invisible markers, so a backfill can skip them. -export async function mirroredMessageIds( - issueId: string, -): Promise> { - const issue = await linear().issue(issueId); - const ids = new Set(); - - let page = await issue.comments({ first: 100 }); - while (true) { - for (const comment of page.nodes) { - const id = markerMessageId(comment.body); - if (id) ids.add(id); - } - if (!page.pageInfo.hasNextPage) break; - page = await issue.comments({ - first: 100, - after: page.pageInfo.endCursor ?? undefined, - }); - } - return ids; -} - -// Updates the mirrored comment for a Discord message. Returns false if the -// message has no mirrored comment. -export async function editComment( - issueId: string, - messageId: string, - body: string, -): Promise { - const commentId = await findCommentByMessage(issueId, messageId); - if (!commentId) return false; - console.log(`[bridge] editing comment for msg ${messageId} on ${issueId}`); - await linear().updateComment(commentId, { - body: withMarker(body, messageId), - }); - return true; -} - -// Deletes the mirrored comment for a Discord message. Returns false if the -// message has no mirrored comment. If the comment has replies, its body is -// blanked instead of deleted, since Linear removes a comment's replies along -// with it. -export async function deleteComment( - issueId: string, - messageId: string, -): Promise { - const node = await findCommentNode(issueId, messageId); - if (!node) return false; - - const children = await node.children(); - if (children.nodes.length > 0) { - console.log( - `[bridge] tombstoning comment for msg ${messageId} on ${issueId} (has replies)`, - ); - await linear().updateComment(node.id, { - body: withMarker("_Message deleted._", messageId), - }); - } else { - console.log(`[bridge] deleting comment for msg ${messageId} on ${issueId}`); - await linear().deleteComment(node.id); - } - return true; -} - -// Finds the mirrored comment node for a Discord message id, or null. -async function findCommentNode( - issueId: string, - messageId: string, -): Promise { - const issue = await linear().issue(issueId); - const { nodes } = await issue.comments(); - for (const comment of nodes) { - if (markerMessageId(comment.body) === messageId) return comment; - } - return null; -} - -// Finds the mirrored comment id for a Discord message id, or null. -export async function findCommentByMessage( - issueId: string, - messageId: string, -): Promise { - return (await findCommentNode(issueId, messageId))?.id ?? null; -} - -// Resolves the comment a Discord reply should attach to: the mirrored comment -// of the referenced message, collapsed to its thread root since Linear threads -// are only one level deep. Returns null when the reference isn't mirrored. -export async function resolveReplyParent( - issueId: string, - messageId: string, -): Promise { - const node = await findCommentNode(issueId, messageId); - if (!node) return null; - const parent = await node.parent; - return parent?.id ?? node.id; -} - -// Replaces an issue's description, used when the opening post is edited. -export async function setIssueDescription( - issueId: string, - description: string, -): Promise { - console.log(`[bridge] updating description on ${issueId}`); - await linear().updateIssue(issueId, { description }); -} - -// Re-hosts a remote file in Linear's storage and returns its permanent asset -// URL, or null if the upload fails (caller falls back to the source URL). -export async function uploadFile( - sourceUrl: string, - filename: string, - contentType: string | null, -): Promise { - try { - const asset = await rehost( - sourceUrl, - filename, - contentType || "application/octet-stream", - ); - console.log( - `[bridge] uploaded file ${filename} -> ${asset ? "ok" : "failed"}`, - ); - return asset; - } catch { - return null; - } -} - -// Fetches a remote file and uploads its bytes to Linear storage, returning the -// permanent asset URL. Linear only accepts asset URLs on its own upload domain, -// so emojis and attachments must be re-hosted here rather than hotlinked. -async function rehost( - sourceUrl: string, - filename: string, - type: string, -): Promise { - const source = await fetch(sourceUrl); - if (!source.ok) return null; - const bytes = await source.arrayBuffer(); - - const upload = (await linear().fileUpload(type, filename, bytes.byteLength)) - .uploadFile; - if (!upload) return null; - - const headers = new Headers({ "Content-Type": type }); - for (const { key, value } of upload.headers) headers.set(key, value); - - const put = await fetch(upload.uploadUrl, { - method: "PUT", - headers, - body: bytes, - }); - return put.ok ? upload.assetUrl : null; -} - -// Trashes an issue (recoverable in Linear). -export async function deleteIssue(issueId: string): Promise { - console.log(`[bridge] trashing issue ${issueId}`); - await linear().deleteIssue(issueId); -} - -// Reconciles the issue's title and project against the thread, when they drift -// (e.g. the thread was renamed, or created before the project was configured). -// One fetch, one update, only when something actually changed. -export async function reconcileIssue( - issueId: string, - title: string, -): Promise { - const { projectId } = config.linearBridge; - const issue = await linear().issue(issueId); - - const update: { title?: string; projectId?: string } = {}; - if (issue.title !== title) update.title = title; - if (projectId && issue.projectId !== projectId) update.projectId = projectId; - if (Object.keys(update).length === 0) return; - - console.log( - `[bridge] reconciling issue ${issueId}: ${Object.keys(update).join(", ")}`, - ); - await linear().updateIssue(issueId, update); -} - -// Returns an issue's identifier and URL, e.g. for linking back from Discord. -export async function getIssueRef( - issueId: string, -): Promise<{ identifier: string; url: string }> { - const issue = await linear().issue(issueId); - return { identifier: issue.identifier, url: issue.url }; -} - -// Returns the workflow state type and name of an issue (e.g. type "started", -// name "In Progress"). -export async function getIssueState( - issueId: string, -): Promise<{ type: string; name: string } | null> { - const issue = await linear().issue(issueId); - const state = await issue.state; - return state ? { type: state.type, name: state.name } : null; -} - -// Moves an issue to a workflow state of the given type in the team. The started -// type has several states (In Progress, Blocked, In Review), so pass the state -// name; without one, the lowest-position state of the type is used. -export async function setIssueState( - issueId: string, - type: "completed" | "triage" | "started", - preferredName?: string, -): Promise { - const stateId = await findStateId(type, preferredName); - if (!stateId) return; - console.log( - `[bridge] setting issue ${issueId} state -> ${preferredName ?? type}`, - ); - await linear().updateIssue(issueId, { stateId }); -} - -const stateIdByType = new Map(); - -// Finds a workflow state of the given type in the team. When preferredName is -// set, a state with that name wins; otherwise the lowest-position state of the -// type is used, since Linear does not order the results. -async function findStateId( - type: string, - preferredName?: string, -): Promise { - const cacheKey = preferredName ? `${type}:${preferredName}` : type; - const cached = stateIdByType.get(cacheKey); - if (cached) return cached; - - const { teamId } = bridgeConfig(); - const states = await linear().workflowStates({ - filter: { team: { id: { eq: teamId } }, type: { eq: type } }, - }); - - const named = - preferredName && - states.nodes.find( - (s) => s.name.toLowerCase() === preferredName.toLowerCase(), - ); - const byPosition = [...states.nodes].sort((a, b) => a.position - b.position); - const id = (named || byPosition[0])?.id ?? null; - if (id) stateIdByType.set(cacheKey, id); - return id; -} - -// --- Emojis & reactions --------------------------------------------------- - -// Names of the workspace's custom emojis, loaded once and updated as we create -// new ones, so we don't recreate existing emojis or spam duplicate errors. -let emojiNames: Set | undefined; - -async function loadEmojiNames(): Promise> { - if (emojiNames) return emojiNames; - const names = new Set(); - let after: string | undefined; - do { - const page = await linearUser().emojis({ first: 250, after }); - for (const e of page.nodes) names.add(e.name); - after = page.pageInfo.hasNextPage - ? (page.pageInfo.endCursor ?? undefined) - : undefined; - } while (after); - emojiNames = names; - return names; -} - -// Registers a Discord custom emoji as a workspace emoji named discord- so -// that :discord-: renders inline. Idempotent: skips emojis that already -// exist and needs the user token, as the app actor cannot create emojis. -export async function ensureEmoji( - id: string, - animated: boolean, -): Promise { - const name = `discord-${id}`; - const names = await loadEmojiNames(); - if (names.has(name)) return; - - const ext = animated ? "gif" : "png"; - // Linear rejects external image URLs, so re-host the Discord emoji first. - const asset = await rehost( - `https://cdn.discordapp.com/emojis/${id}.${ext}`, - `${name}.${ext}`, - animated ? "image/gif" : "image/png", - ).catch(() => null); - if (!asset) { - console.error(`[bridge] ensureEmoji ${name}: upload failed`); - return; - } - - try { - await linearUser().createEmoji({ name, url: asset }); - names.add(name); - console.log(`[bridge] registered emoji ${name}`); - } catch (err) { - console.error(`[bridge] ensureEmoji ${name} failed:`, linearError(err)); - } -} - -export type ReactionTarget = { issueId: string } | { commentId: string }; - -// Reaction ids we created, keyed by target+emoji, so a later removal can delete -// the exact reaction even for unicode emojis whose stored name differs from the -// input we sent. -const reactionIds = new Map(); - -function reactionKey(target: ReactionTarget, emoji: string): string { - const scope = - "issueId" in target ? `i:${target.issueId}` : `c:${target.commentId}`; - return `${scope}|${emoji}`; -} - -export async function addReaction( - target: ReactionTarget, - emoji: string, -): Promise { - try { - const payload = await linear().createReaction({ ...target, emoji }); - const reaction = await payload.reaction; - if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); - console.log(`[bridge] added reaction ${reactionKey(target, emoji)}`); - } catch (err) { - console.error(`[bridge] addReaction ${emoji} failed:`, linearError(err)); - } -} - -export async function removeReaction( - target: ReactionTarget, - emoji: string, -): Promise { - const key = reactionKey(target, emoji); - const id = reactionIds.get(key) ?? (await findReaction(target, emoji)); - if (!id) return; - await linear().deleteReaction(id); - reactionIds.delete(key); - console.log(`[bridge] removed reaction ${key}`); -} - -// Finds a reaction on the target whose stored emoji matches, used as a fallback -// when the created id is not cached (e.g. after a restart). Reliable for custom -// emojis; unicode names are normalized by Linear so may not match. -async function findReaction( - target: ReactionTarget, - emoji: string, -): Promise { - const reactions = - "issueId" in target - ? (await linear().issue(target.issueId)).reactions - : (await linear().comment({ id: target.commentId })).reactions; - return reactions.find((r) => r.emoji === emoji)?.id ?? null; -} - -// --- Labels --------------------------------------------------------------- - -// Flat (ungrouped) labels are used rather than a label group because Linear -// allows only one label per group on an issue, while a help thread can carry -// several tags. Each label is namespaced by name, e.g. "#help > tag". -const labelIdByName = new Map(); - -// Finds or creates a team label with the given name, tagging its description -// with the Discord tag id. Cached by name. Runs on the user token, which owns -// label management. -export async function ensureLabel( - name: string, - tagId: string, -): Promise { - const cached = labelIdByName.get(name); - if (cached) return cached; - - const { teamId } = bridgeConfig(); - const existing = await linearUser().issueLabels({ - filter: { name: { eq: name }, team: { id: { eq: teamId } } }, - }); - - let id = existing.nodes[0]?.id; - if (!id) { - const payload = await linearUser().createIssueLabel({ - name, - description: tagId, - teamId, - }); - const label = await payload.issueLabel; - if (!label) throw new Error("Linear did not return the created label"); - id = label.id; - console.log(`[bridge] created label "${name}"`); - } - - labelIdByName.set(name, id); - return id; -} - -// Reconciles the issue's namespaced labels to exactly match desiredIds, adding -// missing ones and removing only stale labels that share the namespace prefix -// (so unrelated labels are never touched, and labels already absent are never -// "removed"). Runs on the user token that owns the labels. -export async function setNamespacedLabels( - issueId: string, - prefix: string, - desiredIds: string[], -): Promise { - const issue = await linearUser().issue(issueId); - const current = (await issue.labels()).nodes; - const ours = current - .filter((l) => l.name.startsWith(prefix)) - .map((l) => l.id); - - const addedLabelIds = desiredIds.filter((id) => !ours.includes(id)); - const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); - if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; - - console.log( - `[bridge] updating labels on ${issueId}: +${addedLabelIds.length} -${removedLabelIds.length}`, - ); - await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); -} - -// --- Cross-links ---------------------------------------------------------- - -export interface LinkedIssue { - id: string; - identifier: string; - url: string; -} - -// Finds the Linear issue mapped to a URL via its attachments (a mirrored -// Discord thread, or a GitHub issue linked through Linear's integration). -export async function resolveIssueByUrl( - url: string, -): Promise { - const match = await attachmentInTeam(url); - if (!match) return null; - const { issue } = match; - return { id: issue.id, identifier: issue.identifier, url: issue.url }; -} - -// Relation pairs created this session, to avoid duplicate "related" links when -// the same issue is mentioned more than once. -const relatedPairs = new Set(); - -// Marks two issues as related. Idempotent within a session and tolerant of -// Linear rejecting an existing relation. -export async function relateIssues( - issueId: string, - relatedIssueId: string, -): Promise { - const key = [issueId, relatedIssueId].sort().join("|"); - if (relatedPairs.has(key)) return; - relatedPairs.add(key); - try { - await linear().createIssueRelation({ - issueId, - relatedIssueId, - type: IssueRelationType.Related, - }); - console.log(`[bridge] related issues ${key}`); - } catch (err) { - console.error(`[bridge] relateIssues ${key} failed:`, linearError(err)); - } -} diff --git a/src/bridge/linear/assets.ts b/src/bridge/linear/assets.ts new file mode 100644 index 0000000..378c054 --- /dev/null +++ b/src/bridge/linear/assets.ts @@ -0,0 +1,50 @@ +import { linear } from "./client.js"; + +// Fetches a remote file and uploads its bytes to Linear storage, returning the +// permanent asset URL. Linear only accepts asset URLs on its own upload domain, +// so emojis and attachments must be re-hosted here rather than hotlinked. +export async function rehost( + sourceUrl: string, + filename: string, + type: string, +): Promise { + const source = await fetch(sourceUrl); + if (!source.ok) return null; + const bytes = await source.arrayBuffer(); + + const upload = (await linear().fileUpload(type, filename, bytes.byteLength)) + .uploadFile; + if (!upload) return null; + + const headers = new Headers({ "Content-Type": type }); + for (const { key, value } of upload.headers) headers.set(key, value); + + const put = await fetch(upload.uploadUrl, { + method: "PUT", + headers, + body: bytes, + }); + return put.ok ? upload.assetUrl : null; +} + +// Re-hosts a remote file for durable storage, or null if the upload fails (the +// caller falls back to the source URL). +export async function uploadFile( + sourceUrl: string, + filename: string, + contentType: string | null, +): Promise { + try { + const asset = await rehost( + sourceUrl, + filename, + contentType || "application/octet-stream", + ); + console.log( + `[bridge] uploaded file ${filename} -> ${asset ? "ok" : "failed"}`, + ); + return asset; + } catch { + return null; + } +} diff --git a/src/bridge/linear/attachments.ts b/src/bridge/linear/attachments.ts new file mode 100644 index 0000000..ad4f758 --- /dev/null +++ b/src/bridge/linear/attachments.ts @@ -0,0 +1,67 @@ +import type { Attachment, Issue } from "@linear/sdk"; + +import { bridgeConfig, linear } from "./client.js"; + +// Fields stored on the hub issue's attachment that links back to the source +// conversation. Kept byte-compatible with existing mirrored issues. +export interface ThreadAttachmentFields { + url: string; + title: string; + subtitle: string; + metadata: Record; +} + +// Returns the first attachment on the URL whose issue lives in the configured +// team. Attachments match across the whole workspace, so scoping to the team +// keeps lookups from touching issues in unrelated Linear teams. +export async function attachmentInTeam( + url: string, +): Promise<{ attachment: Attachment; issue: Issue } | null> { + const { teamId } = bridgeConfig(); + const attachments = await linear().attachmentsForURL(url); + for (const attachment of attachments.nodes) { + const issue = await attachment.issue; + if (!issue) continue; + const team = await issue.team; + if (team?.id === teamId) return { attachment, issue }; + } + return null; +} + +// Finds the issue mapped to a conversation via its URL attachment, returning the +// issue and attachment ids. +export async function findThreadMapping( + url: string, +): Promise<{ issueId: string; attachmentId: string } | null> { + const match = await attachmentInTeam(url); + if (!match) return null; + return { issueId: match.issue.id, attachmentId: match.attachment.id }; +} + +// Creates the linking attachment on a freshly created issue. +export async function createThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + await linear().createAttachment({ issueId, ...fields }); + console.log(`[bridge] created attachment on ${issueId} -> ${fields.url}`); +} + +// Updates the issue's linking attachment in place, or creates it if missing. +export async function upsertThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + const mapping = await findThreadMapping(fields.url); + if (!mapping) { + await createThreadAttachment(issueId, fields); + return; + } + + await linear().updateAttachment(mapping.attachmentId, { + title: fields.title, + subtitle: fields.subtitle, + metadata: fields.metadata, + }); + console.log(`[bridge] updated attachment on ${issueId}`); +} diff --git a/src/bridge/linear/client.ts b/src/bridge/linear/client.ts new file mode 100644 index 0000000..7b7cd2c --- /dev/null +++ b/src/bridge/linear/client.ts @@ -0,0 +1,68 @@ +import { LinearClient } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +// Validated bridge credentials. Present whenever the bridge is enabled. +export function bridgeConfig(): { + appToken: string; + userToken: string; + teamId: string; +} { + const { appToken, userToken, teamId } = config.linearBridge; + if (!appToken || !userToken || !teamId) { + throw new Error( + "linearBridge is enabled but appToken/userToken/teamId are missing", + ); + } + return { appToken, userToken, teamId }; +} + +let appClient: LinearClient | undefined; +let userClient: LinearClient | undefined; + +// App-actor client. Issues, comments and reactions run here so they are +// attributed to the external author (OAuth tokens use accessToken). +export function linear(): LinearClient { + if (!appClient) { + appClient = new LinearClient({ accessToken: bridgeConfig().appToken }); + } + return appClient; +} + +// Personal-key client for writes the app actor cannot make: creating custom +// emojis and labels. +export function linearUser(): LinearClient { + if (!userClient) { + userClient = new LinearClient({ apiKey: bridgeConfig().userToken }); + } + return userClient; +} + +// Extracts a readable message from a Linear SDK error, whose default string +// form is unhelpful ("[object Object]"). +export function linearError(err: unknown): string { + const e = err as { errors?: { message?: string }[]; message?: string }; + return ( + e?.errors + ?.map((x) => x.message) + .filter(Boolean) + .join("; ") || + e?.message || + String(err) + ); +} + +// Whether an error is a rate-limit rejection, so a bulk import can wait and +// retry rather than abort. +export function isRateLimited(err: unknown): boolean { + const e = err as { + type?: string; + status?: number; + errors?: { extensions?: { type?: string } }[]; + }; + return ( + e?.type === "Ratelimited" || + e?.status === 429 || + (e?.errors?.some((x) => x.extensions?.type === "Ratelimited") ?? false) + ); +} diff --git a/src/bridge/linear/comments.ts b/src/bridge/linear/comments.ts new file mode 100644 index 0000000..c88c925 --- /dev/null +++ b/src/bridge/linear/comments.ts @@ -0,0 +1,159 @@ +import type { Comment } from "@linear/sdk"; + +import type { SourceId } from "@bridge/core/model.js"; + +import { linear } from "./client.js"; + +// Locates the mirrored comment for a source message so a later edit/delete finds +// the right one. `source` namespaces the marker so different platforms don't +// collide; Discord's marker is `discord-msg`, kept byte-compatible with data +// already written to Linear. +export interface Marker { + source: SourceId; + id: string; +} + +// Invisible marker (an unused markdown reference-link definition) appended to +// mirrored comments. It renders as nothing in Linear but round-trips in the raw +// body. +function withMarker(body: string, marker: Marker): string { + return `${body}\n\n[${marker.source}-msg]: ${marker.id}`; +} + +function markerMessageId(body: string): string | null { + return body.match(/^\[[a-z]+-msg\]:\s*(\S+)/m)?.[1] ?? null; +} + +// Adds a comment. When a marker is given the message id is embedded so the +// comment can be found again; a plain note (no marker) is used for system +// messages like "thread closed". +export async function addComment( + issueId: string, + body: string, + author?: { name: string; iconUrl?: string }, + marker?: Marker, + parentId?: string, + createdAt?: Date, +): Promise { + const input = { + issueId, + body: marker ? withMarker(body, marker) : body, + createdAt, + // Attributes the comment to an external author. Requires OAuth app-actor + // auth; Linear rejects these fields for personal API keys, so the caller + // only supplies an author when that mode is configured. + createAsUser: author?.name, + displayIconUrl: author?.iconUrl, + }; + + console.log( + `[bridge] adding comment on ${issueId}` + + `${marker ? ` for msg ${marker.id}` : ""}` + + `${parentId ? ` (reply to ${parentId})` : ""}`, + ); + + try { + await linear().createComment({ ...input, parentId }); + } catch (err) { + // Linear threads are one level deep; if the parent is itself a reply, fall + // back to a top-level comment rather than dropping the message. + if (!parentId) throw err; + await linear().createComment(input); + } +} + +// Returns the source message ids already mirrored as comments on the issue, read +// from the invisible markers, so a backfill can skip them. +export async function mirroredMessageIds( + issueId: string, +): Promise> { + const issue = await linear().issue(issueId); + const ids = new Set(); + + let page = await issue.comments({ first: 100 }); + while (true) { + for (const comment of page.nodes) { + const id = markerMessageId(comment.body); + if (id) ids.add(id); + } + if (!page.pageInfo.hasNextPage) break; + page = await issue.comments({ + first: 100, + after: page.pageInfo.endCursor ?? undefined, + }); + } + return ids; +} + +// Updates the mirrored comment for a message. Returns false if the message has +// no mirrored comment. +export async function editComment( + issueId: string, + marker: Marker, + body: string, +): Promise { + const commentId = await findCommentByMessage(issueId, marker.id); + if (!commentId) return false; + console.log(`[bridge] editing comment for msg ${marker.id} on ${issueId}`); + await linear().updateComment(commentId, { body: withMarker(body, marker) }); + return true; +} + +// Deletes the mirrored comment for a message. Returns false if the message has +// no mirrored comment. If the comment has replies, its body is blanked instead +// of deleted, since Linear removes a comment's replies along with it. +export async function deleteComment( + issueId: string, + marker: Marker, +): Promise { + const node = await findCommentNode(issueId, marker.id); + if (!node) return false; + + const children = await node.children(); + if (children.nodes.length > 0) { + console.log( + `[bridge] tombstoning comment for msg ${marker.id} on ${issueId} (has replies)`, + ); + await linear().updateComment(node.id, { + body: withMarker("_Message deleted._", marker), + }); + } else { + console.log(`[bridge] deleting comment for msg ${marker.id} on ${issueId}`); + await linear().deleteComment(node.id); + } + return true; +} + +// Finds the mirrored comment node for a source message id, or null. +async function findCommentNode( + issueId: string, + messageId: string, +): Promise { + const issue = await linear().issue(issueId); + const { nodes } = await issue.comments(); + for (const comment of nodes) { + if (markerMessageId(comment.body) === messageId) return comment; + } + return null; +} + +// Finds the mirrored comment id for a source message id, or null. +export async function findCommentByMessage( + issueId: string, + messageId: string, +): Promise { + return (await findCommentNode(issueId, messageId))?.id ?? null; +} + +// Resolves the comment a reply should attach to: the mirrored comment of the +// referenced message, collapsed to its thread root since Linear threads are only +// one level deep. Returns null when the reference isn't mirrored. +export async function resolveReplyParent( + issueId: string, + messageId: string, +): Promise { + const node = await findCommentNode(issueId, messageId); + if (!node) return null; + const parent = await node.parent; + return parent?.id ?? node.id; +} diff --git a/src/bridge/linear/emojis.ts b/src/bridge/linear/emojis.ts new file mode 100644 index 0000000..d27658e --- /dev/null +++ b/src/bridge/linear/emojis.ts @@ -0,0 +1,53 @@ +import { linearUser, linearError } from "./client.js"; +import { rehost } from "./assets.js"; + +// Names of the workspace's custom emojis, loaded once and updated as we create +// new ones, so we don't recreate existing emojis or spam duplicate errors. +let emojiNames: Set | undefined; + +async function loadEmojiNames(): Promise> { + if (emojiNames) return emojiNames; + const names = new Set(); + let after: string | undefined; + do { + const page = await linearUser().emojis({ first: 250, after }); + for (const e of page.nodes) names.add(e.name); + after = page.pageInfo.hasNextPage + ? (page.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + emojiNames = names; + return names; +} + +// Registers a custom emoji as a workspace emoji named discord- so that +// :discord-: renders inline. Idempotent: skips emojis that already exist and +// needs the user token, as the app actor cannot create emojis. +export async function ensureEmoji( + id: string, + animated: boolean, +): Promise { + const name = `discord-${id}`; + const names = await loadEmojiNames(); + if (names.has(name)) return; + + const ext = animated ? "gif" : "png"; + // Linear rejects external image URLs, so re-host the source emoji first. + const asset = await rehost( + `https://cdn.discordapp.com/emojis/${id}.${ext}`, + `${name}.${ext}`, + animated ? "image/gif" : "image/png", + ).catch(() => null); + if (!asset) { + console.error(`[bridge] ensureEmoji ${name}: upload failed`); + return; + } + + try { + await linearUser().createEmoji({ name, url: asset }); + names.add(name); + console.log(`[bridge] registered emoji ${name}`); + } catch (err) { + console.error(`[bridge] ensureEmoji ${name} failed:`, linearError(err)); + } +} diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts index e0d4c26..cd67530 100644 --- a/src/bridge/linear/index.ts +++ b/src/bridge/linear/index.ts @@ -1,480 +1,237 @@ import { config } from "@lib/config.js"; -import type { HelpThread } from "@lib/discord/helpThread.js"; -import { - dedupeReferences, - discordThreadReferences, - githubReferences, -} from "@bridge/references.js"; - -import type { Attachment, Emoji, Message } from "discord.js"; - -import * as linear from "./api.js"; +import type { Author, ExternalRef, Message, Post } from "@bridge/core/model.js"; +import { composeBody } from "@bridge/core/model.js"; +import type { + IssueState, + LinkedIssue, + ReactionTarget, + Target, +} from "@bridge/core/connector.js"; -// Caches the create/lookup promise per thread so concurrent listeners resolve -// to a single issue instead of racing to create duplicates. -const issueByThread = new Map>(); - -// Mirrors a single #help thread onto its Linear issue. Wrap a HelpThread and -// call the mirror methods; issue lookup/creation is deduped per thread. -export class LinearMirror { - constructor(private readonly help: HelpThread) {} +import { + addReaction, + removeReaction, + type ReactionTarget as LinearReactionTarget, +} from "./reactions.js"; +import { + createThreadAttachment, + findThreadMapping, + upsertThreadAttachment, + type ThreadAttachmentFields, +} from "./attachments.js"; +import { + createIssue, + deleteIssue, + getIssueRef, + reconcileIssue, + relateIssues, + resolveIssueByUrl, + setIssueDescription, +} from "./issues.js"; +import { + addComment, + deleteComment, + editComment, + findCommentByMessage, + mirroredMessageIds, + resolveReplyParent, +} from "./comments.js"; +import { getIssueState, setIssueState } from "./state.js"; +import { ensureLabel, setNamespacedLabels } from "./labels.js"; +import { ensureEmoji } from "./emojis.js"; +import { uploadFile } from "./assets.js"; + +// Linear as the hub store, adapting the source-agnostic model onto the Linear +// SDK modules. Attachment shape, comment markers and timestamps are kept +// byte-compatible with issues already mirrored into Linear. +export class LinearConnector implements Target { + findIssueId(ref: ExternalRef): Promise { + return findThreadMapping(ref.url).then((m) => m?.issueId ?? null); + } + + async ensureIssue(post: Post): Promise { + const mapping = await findThreadMapping(post.ref.url); + if (mapping) return mapping.issueId; - async ensureIssue(): Promise { - const { id } = this.help.thread; - const cached = issueByThread.get(id); - if (cached) return cached; + const issueId = await createIssue({ + title: post.title, + description: post.body, + author: this.attribution(post.author), + createdAt: post.createdAt, + }); + await createThreadAttachment(issueId, this.attachment(post)); + return issueId; + } - const pending = this.findOrCreateIssue(); - issueByThread.set(id, pending); - try { - return await pending; - } catch (err) { - issueByThread.delete(id); - throw err; - } + async deleteIssue(ref: ExternalRef): Promise { + const mapping = await findThreadMapping(ref.url); + if (mapping) await deleteIssue(mapping.issueId); } - // Mirrors a newly created thread: issue (with the opening post as its body), - // linking attachment, and labels. Announces the issue back in the thread - // unless suppressed (e.g. during startup backfill of old threads). - async create(announce = true): Promise { - console.log( - `[bridge] mirroring thread ${this.help.thread.id} "${this.help.title}"`, - ); - const existed = await this.isMirrored(); - const issueId = await this.ensureIssue(); - await this.syncLabels(issueId); - await this.linkStarterReferences(issueId); - if (announce && !existed) await this.announceIssue(issueId); + async reconcile(issueId: string, post: Post): Promise { + await upsertThreadAttachment(issueId, this.attachment(post)); + await reconcileIssue(issueId, post.title); + await this.syncLabels(issueId, post); } - // Posts a link to the mirrored Linear issue in the Discord thread. - private async announceIssue(issueId: string): Promise { - try { - const ref = await linear.getIssueRef(issueId); - await this.help.thread.send({ - embeds: [{ description: `[${ref.identifier}](${ref.url})` }], - }); - console.log( - `[bridge] announced ${ref.identifier} in thread ${this.help.thread.id}`, - ); - } catch (err) { - console.error("Linear bridge: issue announce failed:", err); + async syncLabels(issueId: string, post: Post): Promise { + if (!config.linearBridge.labels.enabled) return; + const prefix = `${config.linearBridge.labels.namespace} > `; + const desiredIds: string[] = []; + for (const label of post.labels) { + desiredIds.push(await ensureLabel(`${prefix}${label.name}`, label.id)); } + await setNamespacedLabels(issueId, prefix, desiredIds); } - // Mirrors a thread message as an issue comment. - async addMessage(message: Message): Promise { - // The forum starter message is the issue description, not a comment; its id - // equals the thread id for forum posts. - if (message.id === this.help.thread.id) return; + setDescription(issueId: string, text: string): Promise { + return setIssueDescription(issueId, text); + } - const content = message.content ?? ""; - await this.ensureEmojis(content); - const rendered = this.body(message); - if (!rendered) return; + async updateDescription(issueId: string, message: Message): Promise { + await this.ensureEmojis(message); + await setIssueDescription(issueId, await this.renderBody(message)); + } - const issueId = await this.ensureIssue(); - const rewrites = await this.linkReferences(issueId, content); - const parentId = await this.replyParent(issueId, message); - await linear.addComment( + async addComment( + issueId: string, + message: Message, + parentId?: string, + ): Promise { + await this.ensureEmojis(message); + const fast = composeBody(message.text, message.attachments, (a) => a.url); + await addComment( issueId, - this.applyRewrites(rendered, rewrites), - this.author(message), - message.id, + fast, + this.attribution(message.author), + { source: message.ref.source, id: message.ref.id }, parentId, message.createdAt, ); - // Attachments mirror instantly as Discord CDN links (which expire), then the - // comment is edited to swap in permanent Linear-hosted URLs. - if (message.attachments.size > 0) { - await linear.editComment( + // Attachments mirror instantly as CDN links (which expire), then the comment + // is edited to swap in permanent Linear-hosted URLs. + if (message.attachments.length > 0) { + await editComment( issueId, - message.id, - this.applyRewrites(await this.durableBody(message), rewrites), - ); - } - } - - // Reflects a Discord message edit onto its mirrored comment, or the issue - // description for the opening post. No-op if the thread isn't mirrored. - async editMessage(message: Message): Promise { - const mapping = await linear.findThreadMapping(this.help.url); - if (!mapping) return; - - await this.ensureEmojis(message.content ?? ""); - const body = - message.attachments.size > 0 - ? await this.durableBody(message) - : this.body(message); - if (message.id === this.help.thread.id) { - await linear.setIssueDescription(mapping.issueId, body); - } else if (body) { - await linear.editComment(mapping.issueId, message.id, body); - } - } - - // Removes a deleted Discord message from Linear. Regular messages map to - // comments; the opening post maps to the issue description, which is cleared. - async deleteMessage(messageId: string): Promise { - const mapping = await linear.findThreadMapping(this.help.url); - if (!mapping) { - console.log( - `[bridge] deleteMessage: no issue mapping for ${this.help.url}`, + { source: message.ref.source, id: message.ref.id }, + await this.renderBody(message), ); - return; - } - if (messageId === this.help.thread.id) { - await linear.setIssueDescription(mapping.issueId, ""); - return; - } - const ok = await linear.deleteComment(mapping.issueId, messageId); - console.log(`[bridge] deleteMessage msg=${messageId} deleted=${ok}`); - } - - // Refreshes the attachment metadata and labels, then reconciles the issue's - // workflow state. Transitions are decided against the Linear issue state, not - // a Discord old/new diff, so bot-initiated changes (e.g. the /close command) - // are detected reliably. - async syncStatus(backfill = false): Promise { - const issueId = await this.ensureIssue(); - await linear.upsertThreadAttachment(issueId, this.attachment()); - await linear.reconcileIssue(issueId, this.help.title); - await this.syncLabels(issueId); - await this.syncState(issueId, backfill); - } - - // Maps the thread's lifecycle onto the Linear workflow state: closed -> Done, - // waiting on the user -> Blocked, waiting on the team -> In Progress. A new - // thread stays in Triage until the team first engages (moves it out of - // Triage); reopened issues with no waiting signal fall back to Triage. During - // backfill the issue is freshly created in Triage, so the "team engaged" gate - // is skipped and the waiting tag drives the state directly. - private async syncState(issueId: string, backfill = false): Promise { - const state = await linear.getIssueState(issueId); - - if (this.help.isClosed) { - if (state?.type !== "completed") { - await linear.setIssueState(issueId, "completed"); - await linear.addComment( - issueId, - "_Thread closed on Discord._", - undefined, - undefined, - undefined, - this.help.closedAt ?? undefined, - ); - } - return; - } - - if (state?.type === "completed") { - await linear.addComment(issueId, "_Thread reopened on Discord._"); - } - - if (this.help.waiting === "user") { - if (state?.name !== "Blocked") { - await linear.setIssueState(issueId, "started", "Blocked"); - } - return; - } - - if (this.help.waiting === "team") { - // Leave brand-new, un-triaged live threads in Triage until the team - // engages. Backfill applies the waiting tag directly. - if (!backfill && state?.type === "triage") return; - if (state?.name !== "In Progress") { - await linear.setIssueState(issueId, "started", "In Progress"); - } - return; - } - - // No waiting signal: send a reopened issue back to Triage. - if (state?.type === "completed") { - await linear.setIssueState(issueId, "triage"); } } - // Mirrors thread messages that aren't already comments on the issue, in the - // order given. Idempotent: existing messages (matched by marker) are skipped, - // so it is safe to re-run over already-mirrored threads. - async backfillMessages(messages: Message[]): Promise { - const issueId = await this.ensureIssue(); - const mirrored = await linear.mirroredMessageIds(issueId); - console.log( - `[bridge] backfilling ${messages.length} message(s) for issue ${issueId} (${mirrored.size} already mirrored)`, + async editComment(issueId: string, message: Message): Promise { + await this.ensureEmojis(message); + return editComment( + issueId, + { source: message.ref.source, id: message.ref.id }, + await this.renderBody(message), ); - for (const message of messages) { - if (message.id === this.help.thread.id) continue; - if (mirrored.has(message.id)) continue; - await this.addMessage(message); - } } - // Whether this thread already has a mirrored Linear issue. - async isMirrored(): Promise { - return (await linear.findThreadMapping(this.help.url)) !== null; + deleteComment(issueId: string, ref: ExternalRef): Promise { + return deleteComment(issueId, { source: ref.source, id: ref.id }); } - // Trashes the mirrored issue when its Discord thread is deleted. - async delete(): Promise { - const mapping = await linear.findThreadMapping(this.help.url); - if (mapping) await linear.deleteIssue(mapping.issueId); - issueByThread.delete(this.help.thread.id); + mirroredMessageIds(issueId: string): Promise> { + return mirroredMessageIds(issueId); } - // Mirrors a Discord reaction onto the mapped issue (opening post) or comment. - async addReaction(message: Message, emoji: Emoji): Promise { - const target = await this.reactionTarget(message); - if (target) await linear.addReaction(target, await this.emojiKey(emoji)); - } - - // Removes a previously mirrored reaction from its issue or comment. - async removeReaction(message: Message, emoji: Emoji): Promise { - const target = await this.reactionTarget(message); - if (target) await linear.removeReaction(target, await this.emojiKey(emoji)); - } - - // Resolves the Linear reaction target for a Discord message: the issue for the - // opening post, otherwise its mirrored comment. Null when unmapped. - private async reactionTarget( - message: Message, - ): Promise { - const mapping = await linear.findThreadMapping(this.help.url); - if (!mapping) return null; - if (message.id === this.help.thread.id) { - return { issueId: mapping.issueId }; - } - const commentId = await linear.findCommentByMessage( - mapping.issueId, - message.id, - ); - return commentId ? { commentId } : null; - } - - // Maps a Discord emoji to a Linear reaction emoji: a registered discord- - // shortcode for custom emojis, or the unicode character for standard ones. - private async emojiKey(emoji: Emoji): Promise { - if (emoji.id) { - await linear.ensureEmoji(emoji.id, emoji.animated ?? false); - return `discord-${emoji.id}`; - } - return emoji.name ?? ""; - } - - // Finds other threads or GitHub issues mentioned in the content that map to a - // Linear issue, relates them to this issue, and returns token -> markdown link - // rewrites that turn each mention into a link to the mapped issue. - private async linkReferences( + resolveReplyParent( issueId: string, - content: string, - ): Promise> { - const refs = dedupeReferences([ - ...githubReferences(content), - ...discordThreadReferences(content, config.serverId), - ]); - - const rewrites = new Map(); - for (const ref of refs) { - const target = await linear.resolveIssueByUrl(ref.url); - if (!target || target.id === issueId) continue; - await linear.relateIssues(issueId, target.id); - rewrites.set(ref.token, `[${target.identifier}](${target.url})`); - } - return rewrites; + messageId: string, + ): Promise { + return resolveReplyParent(issueId, messageId); } - private applyRewrites(body: string, rewrites: Map): string { - for (const [token, replacement] of rewrites) { - body = body.split(token).join(replacement); - } - return body; + findCommentId(issueId: string, messageId: string): Promise { + return findCommentByMessage(issueId, messageId); } - // Links references found in the opening post and, if any resolved, rewrites - // the issue description to point at the mapped issues. - private async linkStarterReferences(issueId: string): Promise { - const starter = await this.help.thread - .fetchStarterMessage() - .catch(() => null); - const content = starter?.content ?? ""; - const rewrites = await this.linkReferences(issueId, content); - if (rewrites.size === 0) return; - - const description = this.applyRewrites( - this.formatText(starter).trim(), - rewrites, + note(issueId: string, body: string, createdAt?: Date): Promise { + return addComment( + issueId, + body, + undefined, + undefined, + undefined, + createdAt, ); - await linear.setIssueDescription(issueId, description); } - private async findOrCreateIssue(): Promise { - const mapping = await linear.findThreadMapping(this.help.url); - if (mapping) return mapping.issueId; - - // The opening post is the issue body, and its author owns the issue. - const starter = await this.help.thread - .fetchStarterMessage() - .catch(() => null); - - const starterContent = starter?.content ?? ""; - await this.ensureEmojis(starterContent); - const issueId = await linear.createIssue({ - title: this.help.title, - description: this.formatText(starter).trim(), - author: this.author(starter), - createdAt: starter?.createdAt, - }); - await linear.createThreadAttachment(issueId, this.attachment()); - return issueId; + getState(issueId: string): Promise { + return getIssueState(issueId); } - // Reconciles the issue's group labels to match the thread's current tags. - private async syncLabels(issueId: string): Promise { - if (!config.linearBridge.labels.enabled) return; - try { - await this.reconcileLabels(issueId); - } catch (err) { - console.error( - "Linear bridge: label sync failed:", - linear.linearError(err), - ); - } - } - - private async reconcileLabels(issueId: string): Promise { - const prefix = `${config.linearBridge.labels.namespace} > `; - const desiredIds: string[] = []; - for (const tag of this.help.tags) { - desiredIds.push(await linear.ensureLabel(`${prefix}${tag.name}`, tag.id)); - } - await linear.setNamespacedLabels(issueId, prefix, desiredIds); - } - - private attachment(): linear.ThreadAttachmentFields { - return { - url: this.help.url, - title: "Discord thread", - subtitle: this.subtitle(), - metadata: { - threadId: this.help.thread.id, - tagIds: this.help.tags.map((t) => t.id), - tagNames: this.help.tags.map((t) => t.name), - status: this.help.status, - waiting: this.help.waiting, - }, - }; - } - - private subtitle(): string { - const parts = ["#help"]; - if (this.help.isClosed) parts.push("closed"); - if (this.help.waiting) parts.push(`waiting: ${this.help.waiting}`); - if (this.help.tags.length > 0) { - parts.push(`tags: ${this.help.tags.map((t) => t.name).join(", ")}`); - } - return parts.join(" - "); + setState( + issueId: string, + type: "completed" | "triage" | "started", + name?: string, + ): Promise { + return setIssueState(issueId, type, name); } - // Renders a Discord message as markdown: its text plus attachments (images - // inline, other files as links), resolving each attachment URL via urlFor. - private render(message: Message, urlFor: (a: Attachment) => string): string { - const parts: string[] = []; - const text = this.formatText(message).trim(); - if (text) parts.push(text); - for (const attachment of message.attachments.values()) { - const link = `[${attachment.name}](${urlFor(attachment)})`; - const isImage = attachment.contentType?.startsWith("image/") ?? false; - parts.push(isImage ? `!${link}` : link); + async addReaction( + target: ReactionTarget, + reaction: { key: string; custom?: { id: string; animated: boolean } }, + ): Promise { + if (reaction.custom) { + await ensureEmoji(reaction.custom.id, reaction.custom.animated); } - return parts.join("\n\n"); - } - - // Message text with mentions and custom emojis resolved for Linear. - private formatText(message: Message | null): string { - return this.emojis(this.mentions(message)); + await addReaction(target as LinearReactionTarget, reaction.key); } - // Resolves Discord user and role mentions, which Linear can't resolve from - // ids. User mentions become a link to the Discord profile; role mentions - // become @name. Channel mentions are left for reference linking. - private mentions(message: Message | null): string { - if (!message) return ""; - return (message.content ?? "") - .replace(/<@!?(\d+)>/g, (m, id) => { - const name = - message.mentions.members?.get(id)?.displayName ?? - message.mentions.users.get(id)?.username; - return name ? `[@${name}](https://discord.com/users/${id})` : m; - }) - .replace(/<@&(\d+)>/g, (m, id) => { - const role = message.mentions.roles.get(id); - return role ? `@${role.name}` : m; - }); + removeReaction( + target: ReactionTarget, + reaction: { key: string }, + ): Promise { + return removeReaction(target as LinearReactionTarget, reaction.key); } - // Rewrites Discord custom emojis (<:name:id>, ) as :discord-: - // shortcodes that resolve to the registered Linear workspace emojis. - private emojis(content: string): string { - return content.replace( - //g, - (_match, id) => `:discord-${id}:`, - ); + resolveByUrl(url: string): Promise { + return resolveIssueByUrl(url); } - // Registers every Discord custom emoji referenced in the content as a Linear - // workspace emoji so the shortcodes render. - private async ensureEmojis(content: string): Promise { - const seen = new Set(); - for (const [, animated, id] of content.matchAll(/<(a?):\w+:(\d+)>/g)) { - if (seen.has(id)) continue; - seen.add(id); - await linear.ensureEmoji(id, animated === "a"); - } + relate(issueId: string, otherId: string): Promise { + return relateIssues(issueId, otherId); } - // Fast body using Discord CDN URLs, which expire after roughly a day. - private body(message: Message): string { - return this.render(message, (a) => a.url); + issueRef(issueId: string): Promise<{ identifier: string; url: string }> { + return getIssueRef(issueId); } // Body with attachments re-hosted in Linear for permanence, falling back to // the CDN URL for any upload that fails. - private async durableBody(message: Message): Promise { + private async renderBody(message: Message): Promise { const assetByUrl = new Map(); - for (const a of message.attachments.values()) { - const asset = await linear.uploadFile(a.url, a.name, a.contentType); + for (const a of message.attachments) { + const asset = await uploadFile(a.url, a.name, a.contentType); if (asset) assetByUrl.set(a.url, asset); } - return this.render(message, (a) => assetByUrl.get(a.url) ?? a.url); + return composeBody( + message.text, + message.attachments, + (a) => assetByUrl.get(a.url) ?? a.url, + ); } - // Resolves the parent Linear comment for a Discord reply, when the referenced - // message was mirrored as a comment. Returns undefined otherwise (e.g. a - // reply to the opening post, which is the issue description). - private async replyParent( - issueId: string, - message: Message, - ): Promise { - const referencedId = message.reference?.messageId; - if (!referencedId) return undefined; - return ( - (await linear.resolveReplyParent(issueId, referencedId)) ?? undefined - ); + private async ensureEmojis(message: Message): Promise { + for (const e of message.customEmojis) await ensureEmoji(e.id, e.animated); } - // External-author fields for app-actor attribution, or undefined when the - // mode is off or the author is unknown. A personal API key rejects these. - private author( - message: Message | null, - ): { name: string; iconUrl?: string } | undefined { - if (!config.linearBridge.createAsUser || !message) return undefined; + private attachment(post: Post): ThreadAttachmentFields { return { - name: message.member?.displayName ?? message.author.username, - iconUrl: - message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), + url: post.ref.url, + title: post.attachment.title, + subtitle: post.attachment.subtitle, + metadata: post.attachment.metadata, }; } + + // External-author attribution, gated by createAsUser (a personal API key + // rejects these fields, so they're dropped when the mode is off). + private attribution(author?: Author): Author | undefined { + return config.linearBridge.createAsUser ? author : undefined; + } } diff --git a/src/bridge/linear/issues.ts b/src/bridge/linear/issues.ts new file mode 100644 index 0000000..fd2af4f --- /dev/null +++ b/src/bridge/linear/issues.ts @@ -0,0 +1,117 @@ +import { IssueRelationType } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +import { linear, linearError } from "./client.js"; +import { attachmentInTeam } from "./attachments.js"; + +// Creates an issue in the configured team and returns its id. +export async function createIssue(input: { + title: string; + description: string; + author?: { name: string; iconUrl?: string }; + createdAt?: Date; +}): Promise { + const payload = await linear().createIssue({ + teamId: config.linearBridge.teamId, + projectId: config.linearBridge.projectId, + title: input.title, + description: input.description, + createdAt: input.createdAt, + // Attributes the issue to an external author under app-actor auth; ignored + // fields are safe to omit for personal keys (author is undefined). + createAsUser: input.author?.name, + displayIconUrl: input.author?.iconUrl, + }); + + const issue = await payload.issue; + if (!issue) throw new Error("Linear did not return the created issue"); + console.log(`[bridge] created issue ${issue.identifier} "${input.title}"`); + return issue.id; +} + +// Trashes an issue (recoverable in Linear). +export async function deleteIssue(issueId: string): Promise { + console.log(`[bridge] trashing issue ${issueId}`); + await linear().deleteIssue(issueId); +} + +// Replaces an issue's description. +export async function setIssueDescription( + issueId: string, + description: string, +): Promise { + console.log(`[bridge] updating description on ${issueId}`); + await linear().updateIssue(issueId, { description }); +} + +// Reconciles the issue's title and project when they drift (e.g. a renamed +// conversation, or an issue created before the project was configured). One +// fetch, one update, only when something actually changed. +export async function reconcileIssue( + issueId: string, + title: string, +): Promise { + const { projectId } = config.linearBridge; + const issue = await linear().issue(issueId); + + const update: { title?: string; projectId?: string } = {}; + if (issue.title !== title) update.title = title; + if (projectId && issue.projectId !== projectId) update.projectId = projectId; + if (Object.keys(update).length === 0) return; + + console.log( + `[bridge] reconciling issue ${issueId}: ${Object.keys(update).join(", ")}`, + ); + await linear().updateIssue(issueId, update); +} + +// Returns an issue's identifier and URL, e.g. for linking back from a source. +export async function getIssueRef( + issueId: string, +): Promise<{ identifier: string; url: string }> { + const issue = await linear().issue(issueId); + return { identifier: issue.identifier, url: issue.url }; +} + +export interface LinkedIssue { + id: string; + identifier: string; + url: string; +} + +// Finds the issue mapped to a URL via its attachments (a mirrored conversation, +// or a GitHub issue linked through Linear's integration), scoped to the team. +export async function resolveIssueByUrl( + url: string, +): Promise { + const match = await attachmentInTeam(url); + if (!match) return null; + const { issue } = match; + return { id: issue.id, identifier: issue.identifier, url: issue.url }; +} + +// Relation pairs created this session, to avoid duplicate "related" links when +// the same issue is referenced more than once. +const relatedPairs = new Set(); + +// Marks two issues as related. Idempotent within a session and tolerant of +// Linear rejecting an existing relation. +export async function relateIssues( + issueId: string, + relatedIssueId: string, +): Promise { + const key = [issueId, relatedIssueId].sort().join("|"); + if (relatedPairs.has(key)) return; + relatedPairs.add(key); + try { + await linear().createIssueRelation({ + issueId, + relatedIssueId, + type: IssueRelationType.Related, + }); + console.log(`[bridge] related issues ${key}`); + } catch (err) { + console.error(`[bridge] relateIssues ${key} failed:`, linearError(err)); + } +} diff --git a/src/bridge/linear/labels.ts b/src/bridge/linear/labels.ts new file mode 100644 index 0000000..9d5c931 --- /dev/null +++ b/src/bridge/linear/labels.ts @@ -0,0 +1,63 @@ +import { bridgeConfig, linearUser } from "./client.js"; + +// Flat (ungrouped) labels are used rather than a label group because Linear +// allows only one label per group on an issue, while a conversation can carry +// several tags. Each label is namespaced by name, e.g. "#help > tag". +const labelIdByName = new Map(); + +// Finds or creates a team label with the given name, tagging its description +// with the source tag id. Cached by name. Runs on the user token, which owns +// label management. +export async function ensureLabel( + name: string, + tagId: string, +): Promise { + const cached = labelIdByName.get(name); + if (cached) return cached; + + const { teamId } = bridgeConfig(); + const existing = await linearUser().issueLabels({ + filter: { name: { eq: name }, team: { id: { eq: teamId } } }, + }); + + let id = existing.nodes[0]?.id; + if (!id) { + const payload = await linearUser().createIssueLabel({ + name, + description: tagId, + teamId, + }); + const label = await payload.issueLabel; + if (!label) throw new Error("Linear did not return the created label"); + id = label.id; + console.log(`[bridge] created label "${name}"`); + } + + labelIdByName.set(name, id); + return id; +} + +// Reconciles the issue's namespaced labels to exactly match desiredIds, adding +// missing ones and removing only stale labels that share the namespace prefix +// (so unrelated labels are never touched, and labels already absent are never +// "removed"). Runs on the user token that owns the labels. +export async function setNamespacedLabels( + issueId: string, + prefix: string, + desiredIds: string[], +): Promise { + const issue = await linearUser().issue(issueId); + const current = (await issue.labels()).nodes; + const ours = current + .filter((l) => l.name.startsWith(prefix)) + .map((l) => l.id); + + const addedLabelIds = desiredIds.filter((id) => !ours.includes(id)); + const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); + if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; + + console.log( + `[bridge] updating labels on ${issueId}: +${addedLabelIds.length} -${removedLabelIds.length}`, + ); + await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); +} diff --git a/src/bridge/linear/reactions.ts b/src/bridge/linear/reactions.ts new file mode 100644 index 0000000..444244a --- /dev/null +++ b/src/bridge/linear/reactions.ts @@ -0,0 +1,54 @@ +import { linear, linearError } from "./client.js"; + +export type ReactionTarget = { issueId: string } | { commentId: string }; + +// Reaction ids we created, keyed by target+emoji, so a later removal can delete +// the exact reaction even for unicode emojis whose stored name differs from the +// input we sent. +const reactionIds = new Map(); + +function reactionKey(target: ReactionTarget, emoji: string): string { + const scope = + "issueId" in target ? `i:${target.issueId}` : `c:${target.commentId}`; + return `${scope}|${emoji}`; +} + +export async function addReaction( + target: ReactionTarget, + emoji: string, +): Promise { + try { + const payload = await linear().createReaction({ ...target, emoji }); + const reaction = await payload.reaction; + if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); + console.log(`[bridge] added reaction ${reactionKey(target, emoji)}`); + } catch (err) { + console.error(`[bridge] addReaction ${emoji} failed:`, linearError(err)); + } +} + +export async function removeReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const key = reactionKey(target, emoji); + const id = reactionIds.get(key) ?? (await findReaction(target, emoji)); + if (!id) return; + await linear().deleteReaction(id); + reactionIds.delete(key); + console.log(`[bridge] removed reaction ${key}`); +} + +// Finds a reaction on the target whose stored emoji matches, used as a fallback +// when the created id is not cached (e.g. after a restart). Reliable for custom +// emojis; unicode names are normalized by Linear so may not match. +async function findReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const reactions = + "issueId" in target + ? (await linear().issue(target.issueId)).reactions + : (await linear().comment({ id: target.commentId })).reactions; + return reactions.find((r) => r.emoji === emoji)?.id ?? null; +} diff --git a/src/bridge/linear/state.ts b/src/bridge/linear/state.ts new file mode 100644 index 0000000..1b7e536 --- /dev/null +++ b/src/bridge/linear/state.ts @@ -0,0 +1,56 @@ +import { bridgeConfig, linear } from "./client.js"; + +// Returns the workflow state type and name of an issue (e.g. type "started", +// name "In Progress"). +export async function getIssueState( + issueId: string, +): Promise<{ type: string; name: string } | null> { + const issue = await linear().issue(issueId); + const state = await issue.state; + return state ? { type: state.type, name: state.name } : null; +} + +// Moves an issue to a workflow state of the given type in the team. The started +// type has several states (In Progress, Blocked, In Review), so pass the state +// name; without one, the lowest-position state of the type is used. +export async function setIssueState( + issueId: string, + type: "completed" | "triage" | "started", + preferredName?: string, +): Promise { + const stateId = await findStateId(type, preferredName); + if (!stateId) return; + console.log( + `[bridge] setting issue ${issueId} state -> ${preferredName ?? type}`, + ); + await linear().updateIssue(issueId, { stateId }); +} + +const stateIdByType = new Map(); + +// Finds a workflow state of the given type in the team. When preferredName is +// set, a state with that name wins; otherwise the lowest-position state of the +// type is used, since Linear does not order the results. +async function findStateId( + type: string, + preferredName?: string, +): Promise { + const cacheKey = preferredName ? `${type}:${preferredName}` : type; + const cached = stateIdByType.get(cacheKey); + if (cached) return cached; + + const { teamId } = bridgeConfig(); + const states = await linear().workflowStates({ + filter: { team: { id: { eq: teamId } }, type: { eq: type } }, + }); + + const named = + preferredName && + states.nodes.find( + (s) => s.name.toLowerCase() === preferredName.toLowerCase(), + ); + const byPosition = [...states.nodes].sort((a, b) => a.position - b.position); + const id = (named || byPosition[0])?.id ?? null; + if (id) stateIdByType.set(cacheKey, id); + return id; +} diff --git a/src/events/bridge.ts b/src/events/bridge.ts deleted file mode 100644 index d9fc5bc..0000000 --- a/src/events/bridge.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { debounce } from "throttle-debounce"; - -import { - ChannelType, - type Client, - Events, - type ThreadChannel, -} from "discord.js"; - -import { config, validateLinearBridgeConfig } from "@lib/config.js"; -import { isHelpPost } from "@lib/discord/channels.js"; -import { isHumanMessage, reconcileThread } from "@lib/discord/help.js"; -import { HelpThread } from "@lib/discord/helpThread.js"; - -import { LinearMirror } from "@bridge/linear/index.js"; -import { isRateLimited } from "@bridge/linear/api.js"; - -export default function registerEvents(client: Client) { - if (!config.linearBridge.enabled) { - console.log("Linear bridge is disabled."); - return; - } - - validateLinearBridgeConfig(); - - client.on(Events.ThreadCreate, async (thread) => { - if (!(await isHelpPost(thread))) return; - try { - await new LinearMirror(new HelpThread(thread)).create(); - } catch (err) { - console.error("Linear bridge: thread create failed:", err); - } - }); - - client.on(Events.ThreadDelete, async (thread) => { - if (!(await isHelpPost(thread))) return; - try { - await new LinearMirror(new HelpThread(thread)).delete(); - } catch (err) { - console.error("Linear bridge: thread delete failed:", err); - } - }); - - client.on(Events.MessageCreate, async (message) => { - if (!message.inGuild() || !(await isHelpPost(message.channel))) return; - if (!isHumanMessage(message)) return; - try { - const help = new HelpThread(message.channel as ThreadChannel); - await new LinearMirror(help).addMessage(message); - } catch (err) { - console.error("Linear bridge: message create failed:", err); - } - }); - - client.on(Events.MessageUpdate, async (oldMessage, newMessage) => { - try { - const message = newMessage.partial - ? await newMessage.fetch() - : newMessage; - console.log( - `[bridge] MessageUpdate id=${message.id} oldPartial=${oldMessage.partial} attNew=${message.attachments.size}`, - ); - if (!message.inGuild() || !(await isHelpPost(message.channel))) return; - if (!isHumanMessage(message)) return; - // Ignore edits that changed neither text nor attachments (e.g. an embed - // unfurling or a pin) when the previous state is known. - if ( - !oldMessage.partial && - oldMessage.content === message.content && - oldMessage.attachments.size === message.attachments.size && - oldMessage.attachments.every((_a, id) => message.attachments.has(id)) - ) { - return; - } - const help = new HelpThread(message.channel as ThreadChannel); - await new LinearMirror(help).editMessage(message); - } catch (err) { - console.error("Linear bridge: message update failed:", err); - } - }); - - client.on(Events.MessageDelete, async (message) => { - try { - const channel = message.channel; - console.log( - `[bridge] MessageDelete id=${message.id} chanType=${channel?.type} isThread=${channel?.isThread?.()}`, - ); - if (!channel.isThread() || !(await isHelpPost(channel))) return; - await new LinearMirror(new HelpThread(channel)).deleteMessage(message.id); - } catch (err) { - console.error("Linear bridge: message delete failed:", err); - } - }); - - client.on(Events.MessageReactionAdd, async (reaction, user) => { - try { - if (user.bot) return; - const message = reaction.message.partial - ? await reaction.message.fetch() - : reaction.message; - if (!message.inGuild() || !(await isHelpPost(message.channel))) return; - // The app aggregates reactions under one identity, so only the first - // Discord reaction of an emoji is mirrored. - const resolved = message.reactions.resolve( - reaction.emoji.id ?? reaction.emoji.name, - ); - if (resolved?.count !== 1) return; - const help = new HelpThread(message.channel as ThreadChannel); - await new LinearMirror(help).addReaction(message, reaction.emoji); - } catch (err) { - console.error("Linear bridge: reaction add failed:", err); - } - }); - - client.on(Events.MessageReactionRemove, async (reaction) => { - try { - const message = reaction.message.partial - ? await reaction.message.fetch() - : reaction.message; - if (!message.inGuild() || !(await isHelpPost(message.channel))) return; - // Only remove the mirrored reaction once the last Discord user removes it. - const resolved = message.reactions.resolve( - reaction.emoji.id ?? reaction.emoji.name, - ); - if (resolved && resolved.count > 0) return; - const help = new HelpThread(message.channel as ThreadChannel); - await new LinearMirror(help).removeReaction(message, reaction.emoji); - } catch (err) { - console.error("Linear bridge: reaction remove failed:", err); - } - }); - - // Coalesce bursts of tag edits per thread. syncStatus is idempotent and - // reconciles against the Linear issue state, so no before/after diff is kept. - const flushers = new Map void>(); - - client.on(Events.ThreadUpdate, async (_oldThread, newThread) => { - if (!(await isHelpPost(newThread))) return; - - let flush = flushers.get(newThread.id); - if (!flush) { - flush = debounce(1000, async (thread: ThreadChannel) => { - flushers.delete(thread.id); - try { - await new LinearMirror(new HelpThread(thread)).syncStatus(); - } catch (err) { - console.error("Linear bridge: thread update failed:", err); - } - }); - flushers.set(newThread.id, flush); - } - flush(newThread); - }); - - console.log("Linear bridge is enabled."); -} - -// Mirrors help threads that aren't fully in Linear yet, so threads and messages -// from while the bridge was off still land as issues. Runs in the background on -// startup. With backfillAll it imports every thread, paging through all -// archived threads and waiting out Linear rate limits. -export async function backfillHelpThreads(client: Client): Promise { - const { enabled, backfillAll, backfillLimit } = config.linearBridge; - if (!enabled) return; - if (!backfillAll && backfillLimit <= 0) return; - - const forum = await client.channels.fetch(config.helpChannel.id); - if (!forum || forum.type !== ChannelType.GuildForum) return; - - const byId = new Map(); - const active = await forum.threads.fetchActive(); - for (const thread of active.threads.values()) byId.set(thread.id, thread); - - // Pull archived threads too. For a full import, page through every archived - // thread; otherwise a single page bounded by the limit is enough. - let before: Date | undefined; - do { - const page = await forum.threads.fetchArchived({ - limit: backfillAll ? 100 : backfillLimit, - before, - }); - const last = [...page.threads.values()].at(-1); - for (const thread of page.threads.values()) byId.set(thread.id, thread); - before = - backfillAll && page.hasMore ? (last?.archivedAt ?? undefined) : undefined; - } while (before); - - const sorted = [...byId.values()].sort((a, b) => - (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), - ); - const threads = backfillAll ? sorted : sorted.slice(0, backfillLimit); - - console.log( - `[bridge] startup backfill: ${threads.length} thread(s)` + - (backfillAll - ? " (full import)" - : ` of ${byId.size} fetched (limit ${backfillLimit})`), - ); - for (const thread of threads) { - try { - await withRateLimitRetry(() => backfillThread(thread)); - } catch (err) { - console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); - } - } - console.log("[bridge] startup backfill complete"); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// Retries an operation through Linear rate limits. Linear's limits reset on a -// rolling window, so back off and keep waiting rather than dropping work. -async function withRateLimitRetry(fn: () => Promise): Promise { - 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); - } - } -} - -// Mirrors a thread: ensures the issue exists, fills in any messages missing -// from Linear, then reconciles state. Safe to run over already-mirrored -// threads (existing messages are skipped). -async function backfillThread(thread: ThreadChannel): Promise { - console.log(`[bridge] backfilling thread ${thread.id} "${thread.name}"`); - const help = new HelpThread(thread); - - // Older threads may predate the waiting-tag automation. If an open thread - // has no waiting tag, derive one from its last message so the mirrored - // issue gets a meaningful status. - if (help.isOpen && help.waiting === null) { - await reconcileThread(thread); - } - - const mirror = new LinearMirror(help); - await mirror.create(false); - - const messages = await thread.messages.fetch({ limit: 100 }); - const human = [...messages.values()].reverse().filter(isHumanMessage); - await mirror.backfillMessages(human); - - await mirror.syncStatus(true); -} diff --git a/src/index.ts b/src/index.ts index f1cca1b..539a72b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import registerCommandEvents from "./events/commands.js"; import registerWalkthroughEvents from "./events/walkthrough.js"; import registerMessageEvents from "./events/messages.js"; import registerChannelEvents from "./events/channels.js"; -import registerBridgeEvents, { backfillHelpThreads } from "./events/bridge.js"; +import { registerBridge, backfillBridge } from "@bridge/core/bridge.js"; import { Client, @@ -59,7 +59,7 @@ client.once(Events.ClientReady, () => { registerWalkthroughEvents(client); registerMessageEvents(client); registerChannelEvents(client); - registerBridgeEvents(client); + registerBridge(client); shufflePresence(); setInterval(shufflePresence, config.presenceDelay); @@ -68,7 +68,7 @@ client.once(Events.ClientReady, () => { console.error("Failed to catch up on help posts:", err), ); - backfillHelpThreads(client).catch((err) => + backfillBridge(client).catch((err) => console.error("Linear bridge: backfill failed:", err), ); }); From e51802ca540fb2de5c82f2289f571e4982d8ba9d Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:11:22 +0000 Subject: [PATCH 53/62] fix(src/lib/discord): keep waiting on the team when the OP is a team member --- src/lib/discord/help.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts index 58ad4ee..28c8921 100644 --- a/src/lib/discord/help.ts +++ b/src/lib/discord/help.ts @@ -18,13 +18,12 @@ export function isHumanMessage(message: Message): boolean { return !message.author.bot && humanMessageTypes.has(message.type); } -// Picks the waiting tag for a help post based on who sent the last message. -// When the last interaction comes from a community member the team still needs -// to respond, so we apply waitingForTeamTag; when it comes from the Coder team -// we apply waitingForUserTag. Adding one always removes the other. +// Picks the waiting tag for a help post. A post waits on the user once the team +// has the last word, and waits on the team otherwise. Adding one always removes +// the other. export async function applyWaitingTag( thread: ThreadChannel, - lastFromTeam: boolean, + awaitingUser: boolean, ): Promise { const { waitingForUserTag, waitingForTeamTag, closedTag } = config.helpChannel; @@ -32,8 +31,8 @@ export async function applyWaitingTag( // Leave closed posts untouched. if (thread.appliedTags.includes(closedTag)) return; - const desired = lastFromTeam ? waitingForUserTag : waitingForTeamTag; - const opposite = lastFromTeam ? waitingForTeamTag : waitingForUserTag; + const desired = awaitingUser ? waitingForUserTag : waitingForTeamTag; + const opposite = awaitingUser ? waitingForTeamTag : waitingForUserTag; const alreadyCorrect = thread.appliedTags.includes(desired) && @@ -63,12 +62,17 @@ export async function resolveMember( } // Applies the waiting tag for a help post based on who sent the given message. +// A post only waits on the user when the last message is from a team member who +// is not the OP; a team member asking their own question still waits on the +// team, as does any message from the OP or a community member. async function applyWaitingTagFromMessage( thread: ThreadChannel, message: Message, ): Promise { const member = await resolveMember(message); - await applyWaitingTag(thread, member ? isTeamMember(member) : false); + const fromTeam = member ? isTeamMember(member) : false; + const isOp = message.author.id === thread.ownerId; + await applyWaitingTag(thread, fromTeam && !isOp); } // Reconciles a single help post from a freshly received message. From 02025b7a637713f68cd947bfa6037d07351894a7 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:16:50 +0000 Subject: [PATCH 54/62] refactor(src): use scoped comma-separated console args and console.debug --- src/bridge/core/backfill.ts | 2 +- src/bridge/core/bridge.ts | 2 +- src/bridge/core/mirror.ts | 22 ++++++++++------ src/bridge/discord/index.ts | 43 +++++++++++++++++++------------- src/bridge/linear/assets.ts | 7 ++++-- src/bridge/linear/attachments.ts | 4 +-- src/bridge/linear/comments.ts | 23 +++++++++++------ src/bridge/linear/emojis.ts | 6 ++--- src/bridge/linear/issues.ts | 17 +++++++------ src/bridge/linear/labels.ts | 10 +++++--- src/bridge/linear/reactions.ts | 6 ++--- src/bridge/linear/state.ts | 7 ++++-- src/commands/util/close.ts | 2 +- src/deploy-commands.ts | 7 ++++-- src/events/commands.ts | 6 +++-- src/index.ts | 6 ++--- src/lib/config.ts | 4 ++- src/lib/discord/help.ts | 2 +- 18 files changed, 109 insertions(+), 67 deletions(-) diff --git a/src/bridge/core/backfill.ts b/src/bridge/core/backfill.ts index b164daf..cd87fdf 100644 --- a/src/bridge/core/backfill.ts +++ b/src/bridge/core/backfill.ts @@ -14,7 +14,7 @@ export async function withRateLimitRetry( return await fn(); } catch (err) { if (!isRateLimited(err)) throw err; - console.warn(`[bridge] rate limited, waiting ${delayMs / 1000}s`); + console.warn("[bridge]", "rate limited, waiting", `${delayMs / 1000}s`); await sleep(delayMs); delayMs = Math.min(delayMs * 2, 15 * 60_000); } diff --git a/src/bridge/core/bridge.ts b/src/bridge/core/bridge.ts index 20769bd..ddb985d 100644 --- a/src/bridge/core/bridge.ts +++ b/src/bridge/core/bridge.ts @@ -11,7 +11,7 @@ let connector: DiscordConnector | undefined; export function registerBridge(client: Client): void { if (!config.linearBridge.enabled) { - console.log("Linear bridge is disabled."); + console.log("[bridge]", "disabled"); return; } validateLinearBridgeConfig(); diff --git a/src/bridge/core/mirror.ts b/src/bridge/core/mirror.ts index 23177db..129b93b 100644 --- a/src/bridge/core/mirror.ts +++ b/src/bridge/core/mirror.ts @@ -25,7 +25,7 @@ export class Mirror { // attachment, and labels. Announces the issue back to the source unless // suppressed (e.g. during startup backfill of old posts). async createPost(post: Post, announce = true): Promise { - console.log(`[bridge] mirroring post ${post.ref.id} "${post.title}"`); + console.debug("[bridge]", "mirroring post", post.ref.id, post.title); const existed = (await this.target.findIssueId(post.ref)) !== null; const issueId = await this.ensureIssue(post); await this.target.syncLabels(issueId, post); @@ -42,7 +42,7 @@ export class Mirror { try { await this.source.announce(post, await this.target.issueRef(issueId)); } catch (err) { - console.error("Linear bridge: issue announce failed:", err); + console.error("[bridge]", "issue announce failed", err); } } } @@ -89,8 +89,10 @@ export class Mirror { ): Promise { const issueId = await this.target.findIssueId(post.ref); if (!issueId) { - console.log( - `[bridge] deleteMessage: no issue mapping for ${post.ref.url}`, + console.debug( + "[bridge]", + "deleteMessage: no issue mapping", + post.ref.url, ); return; } @@ -99,7 +101,7 @@ export class Mirror { return; } const ok = await this.target.deleteComment(issueId, ref); - console.log(`[bridge] deleteMessage msg=${ref.id} deleted=${ok}`); + console.debug("[bridge]", "deleteMessage", ref.id, "deleted", ok); } // Refreshes attachment metadata, title, project and labels, then reconciles @@ -141,8 +143,14 @@ export class Mirror { async backfillMessages(post: Post, messages: Message[]): Promise { const issueId = await this.ensureIssue(post); const mirrored = await this.target.mirroredMessageIds(issueId); - console.log( - `[bridge] backfilling ${messages.length} message(s) for issue ${issueId} (${mirrored.size} already mirrored)`, + console.debug( + "[bridge]", + "backfilling messages", + messages.length, + "issue", + issueId, + "already", + mirrored.size, ); for (const message of messages) { if (mirrored.has(message.ref.id)) continue; diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts index ef6c0c1..a44c0f9 100644 --- a/src/bridge/discord/index.ts +++ b/src/bridge/discord/index.ts @@ -43,7 +43,7 @@ export class DiscordConnector implements Source { try { await this.mirror.createPost(await this.postFor(thread)); } catch (err) { - console.error("Linear bridge: thread create failed:", err); + console.error("[bridge]", "thread create failed", err); } }); @@ -52,7 +52,7 @@ export class DiscordConnector implements Source { try { await this.mirror.deletePost(toPost(new HelpThread(thread), null)); } catch (err) { - console.error("Linear bridge: thread delete failed:", err); + console.error("[bridge]", "thread delete failed", err); } }); @@ -63,7 +63,7 @@ export class DiscordConnector implements Source { const post = await this.postFor(message.channel as ThreadChannel); await this.mirror.addMessage(post, toMessage(message)); } catch (err) { - console.error("Linear bridge: message create failed:", err); + console.error("[bridge]", "message create failed", err); } }); @@ -94,7 +94,7 @@ export class DiscordConnector implements Source { isStarter(message), ); } catch (err) { - console.error("Linear bridge: message update failed:", err); + console.error("[bridge]", "message update failed", err); } }); @@ -110,7 +110,7 @@ export class DiscordConnector implements Source { }; await this.mirror.deleteMessage(post, ref, message.id === channel.id); } catch (err) { - console.error("Linear bridge: message delete failed:", err); + console.error("[bridge]", "message delete failed", err); } }); @@ -137,7 +137,7 @@ export class DiscordConnector implements Source { toReaction(reaction.emoji), ); } catch (err) { - console.error("Linear bridge: reaction add failed:", err); + console.error("[bridge]", "reaction add failed", err); } }); @@ -162,7 +162,7 @@ export class DiscordConnector implements Source { toReaction(reaction.emoji), ); } catch (err) { - console.error("Linear bridge: reaction remove failed:", err); + console.error("[bridge]", "reaction remove failed", err); } }); @@ -178,7 +178,7 @@ export class DiscordConnector implements Source { try { await this.mirror.syncStatus(await this.postFor(thread)); } catch (err) { - console.error("Linear bridge: thread update failed:", err); + console.error("[bridge]", "thread update failed", err); } }); flushers.set(newThread.id, flush); @@ -186,7 +186,7 @@ export class DiscordConnector implements Source { flush(newThread); }); - console.log("Linear bridge is enabled."); + console.log("[bridge]", "enabled"); } async announce( @@ -198,8 +198,12 @@ export class DiscordConnector implements Source { await channel.send({ embeds: [{ description: `[${issue.identifier}](${issue.url})` }], }); - console.log( - `[bridge] announced ${issue.identifier} in thread ${post.ref.id}`, + console.debug( + "[bridge]", + "announced", + issue.identifier, + "in thread", + post.ref.id, ); } @@ -240,10 +244,13 @@ export class DiscordConnector implements Source { const threads = backfillAll ? sorted : sorted.slice(0, backfillLimit); console.log( - `[bridge] startup backfill: ${threads.length} thread(s)` + - (backfillAll - ? " (full import)" - : ` of ${byId.size} fetched (limit ${backfillLimit})`), + "[bridge]", + "startup backfill:", + threads.length, + "thread(s)", + backfillAll + ? "(full import)" + : `of ${byId.size} fetched (limit ${backfillLimit})`, ); for (const thread of threads) { try { @@ -252,16 +259,16 @@ export class DiscordConnector implements Source { isRateLimited, ); } catch (err) { - console.error(`Linear bridge: backfill failed for ${thread.id}:`, err); + console.error("[bridge]", "backfill failed for thread", thread.id, err); } } - console.log("[bridge] startup backfill complete"); + console.log("[bridge]", "startup backfill complete"); } // Mirrors a thread: ensures the issue exists, fills in missing messages, then // reconciles state. Safe to re-run over already-mirrored threads. private async backfillThread(thread: ThreadChannel): Promise { - console.log(`[bridge] backfilling thread ${thread.id} "${thread.name}"`); + console.log("[bridge]", "backfilling thread", thread.id, thread.name); const help = new HelpThread(thread); // Older threads may predate the waiting-tag automation. If an open thread diff --git a/src/bridge/linear/assets.ts b/src/bridge/linear/assets.ts index 378c054..8b70f1b 100644 --- a/src/bridge/linear/assets.ts +++ b/src/bridge/linear/assets.ts @@ -40,8 +40,11 @@ export async function uploadFile( filename, contentType || "application/octet-stream", ); - console.log( - `[bridge] uploaded file ${filename} -> ${asset ? "ok" : "failed"}`, + console.debug( + "[bridge]", + "uploaded file", + filename, + asset ? "ok" : "failed", ); return asset; } catch { diff --git a/src/bridge/linear/attachments.ts b/src/bridge/linear/attachments.ts index ad4f758..89e072e 100644 --- a/src/bridge/linear/attachments.ts +++ b/src/bridge/linear/attachments.ts @@ -44,7 +44,7 @@ export async function createThreadAttachment( fields: ThreadAttachmentFields, ): Promise { await linear().createAttachment({ issueId, ...fields }); - console.log(`[bridge] created attachment on ${issueId} -> ${fields.url}`); + console.debug("[bridge]", "created attachment", issueId, fields.url); } // Updates the issue's linking attachment in place, or creates it if missing. @@ -63,5 +63,5 @@ export async function upsertThreadAttachment( subtitle: fields.subtitle, metadata: fields.metadata, }); - console.log(`[bridge] updated attachment on ${issueId}`); + console.debug("[bridge]", "updated attachment", issueId); } diff --git a/src/bridge/linear/comments.ts b/src/bridge/linear/comments.ts index c88c925..30d2d8c 100644 --- a/src/bridge/linear/comments.ts +++ b/src/bridge/linear/comments.ts @@ -46,10 +46,14 @@ export async function addComment( displayIconUrl: author?.iconUrl, }; - console.log( - `[bridge] adding comment on ${issueId}` + - `${marker ? ` for msg ${marker.id}` : ""}` + - `${parentId ? ` (reply to ${parentId})` : ""}`, + console.debug( + "[bridge]", + "adding comment", + issueId, + "msg", + marker?.id ?? "-", + "parent", + parentId ?? "-", ); try { @@ -94,7 +98,7 @@ export async function editComment( ): Promise { const commentId = await findCommentByMessage(issueId, marker.id); if (!commentId) return false; - console.log(`[bridge] editing comment for msg ${marker.id} on ${issueId}`); + console.debug("[bridge]", "editing comment", marker.id, issueId); await linear().updateComment(commentId, { body: withMarker(body, marker) }); return true; } @@ -111,14 +115,17 @@ export async function deleteComment( const children = await node.children(); if (children.nodes.length > 0) { - console.log( - `[bridge] tombstoning comment for msg ${marker.id} on ${issueId} (has replies)`, + console.debug( + "[bridge]", + "tombstoning comment (has replies)", + marker.id, + issueId, ); await linear().updateComment(node.id, { body: withMarker("_Message deleted._", marker), }); } else { - console.log(`[bridge] deleting comment for msg ${marker.id} on ${issueId}`); + console.debug("[bridge]", "deleting comment", marker.id, issueId); await linear().deleteComment(node.id); } return true; diff --git a/src/bridge/linear/emojis.ts b/src/bridge/linear/emojis.ts index d27658e..5c2779a 100644 --- a/src/bridge/linear/emojis.ts +++ b/src/bridge/linear/emojis.ts @@ -39,15 +39,15 @@ export async function ensureEmoji( animated ? "image/gif" : "image/png", ).catch(() => null); if (!asset) { - console.error(`[bridge] ensureEmoji ${name}: upload failed`); + console.error("[bridge]", "ensureEmoji upload failed", name); return; } try { await linearUser().createEmoji({ name, url: asset }); names.add(name); - console.log(`[bridge] registered emoji ${name}`); + console.debug("[bridge]", "registered emoji", name); } catch (err) { - console.error(`[bridge] ensureEmoji ${name} failed:`, linearError(err)); + console.error("[bridge]", "ensureEmoji failed", name, linearError(err)); } } diff --git a/src/bridge/linear/issues.ts b/src/bridge/linear/issues.ts index fd2af4f..016d9b4 100644 --- a/src/bridge/linear/issues.ts +++ b/src/bridge/linear/issues.ts @@ -26,13 +26,13 @@ export async function createIssue(input: { const issue = await payload.issue; if (!issue) throw new Error("Linear did not return the created issue"); - console.log(`[bridge] created issue ${issue.identifier} "${input.title}"`); + console.debug("[bridge]", "created issue", issue.identifier, input.title); return issue.id; } // Trashes an issue (recoverable in Linear). export async function deleteIssue(issueId: string): Promise { - console.log(`[bridge] trashing issue ${issueId}`); + console.debug("[bridge]", "trashing issue", issueId); await linear().deleteIssue(issueId); } @@ -41,7 +41,7 @@ export async function setIssueDescription( issueId: string, description: string, ): Promise { - console.log(`[bridge] updating description on ${issueId}`); + console.debug("[bridge]", "updating description", issueId); await linear().updateIssue(issueId, { description }); } @@ -60,8 +60,11 @@ export async function reconcileIssue( if (projectId && issue.projectId !== projectId) update.projectId = projectId; if (Object.keys(update).length === 0) return; - console.log( - `[bridge] reconciling issue ${issueId}: ${Object.keys(update).join(", ")}`, + console.debug( + "[bridge]", + "reconciling issue", + issueId, + Object.keys(update).join(", "), ); await linear().updateIssue(issueId, update); } @@ -110,8 +113,8 @@ export async function relateIssues( relatedIssueId, type: IssueRelationType.Related, }); - console.log(`[bridge] related issues ${key}`); + console.debug("[bridge]", "related issues", key); } catch (err) { - console.error(`[bridge] relateIssues ${key} failed:`, linearError(err)); + console.error("[bridge]", "relateIssues failed", key, linearError(err)); } } diff --git a/src/bridge/linear/labels.ts b/src/bridge/linear/labels.ts index 9d5c931..8d6ddb5 100644 --- a/src/bridge/linear/labels.ts +++ b/src/bridge/linear/labels.ts @@ -30,7 +30,7 @@ export async function ensureLabel( const label = await payload.issueLabel; if (!label) throw new Error("Linear did not return the created label"); id = label.id; - console.log(`[bridge] created label "${name}"`); + console.debug("[bridge]", "created label", name); } labelIdByName.set(name, id); @@ -56,8 +56,12 @@ export async function setNamespacedLabels( const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; - console.log( - `[bridge] updating labels on ${issueId}: +${addedLabelIds.length} -${removedLabelIds.length}`, + console.debug( + "[bridge]", + "updating labels", + issueId, + `+${addedLabelIds.length}`, + `-${removedLabelIds.length}`, ); await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); } diff --git a/src/bridge/linear/reactions.ts b/src/bridge/linear/reactions.ts index 444244a..3a53085 100644 --- a/src/bridge/linear/reactions.ts +++ b/src/bridge/linear/reactions.ts @@ -21,9 +21,9 @@ export async function addReaction( const payload = await linear().createReaction({ ...target, emoji }); const reaction = await payload.reaction; if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); - console.log(`[bridge] added reaction ${reactionKey(target, emoji)}`); + console.debug("[bridge]", "added reaction", reactionKey(target, emoji)); } catch (err) { - console.error(`[bridge] addReaction ${emoji} failed:`, linearError(err)); + console.error("[bridge]", "addReaction failed", emoji, linearError(err)); } } @@ -36,7 +36,7 @@ export async function removeReaction( if (!id) return; await linear().deleteReaction(id); reactionIds.delete(key); - console.log(`[bridge] removed reaction ${key}`); + console.debug("[bridge]", "removed reaction", key); } // Finds a reaction on the target whose stored emoji matches, used as a fallback diff --git a/src/bridge/linear/state.ts b/src/bridge/linear/state.ts index 1b7e536..b5d815d 100644 --- a/src/bridge/linear/state.ts +++ b/src/bridge/linear/state.ts @@ -20,8 +20,11 @@ export async function setIssueState( ): Promise { const stateId = await findStateId(type, preferredName); if (!stateId) return; - console.log( - `[bridge] setting issue ${issueId} state -> ${preferredName ?? type}`, + console.debug( + "[bridge]", + "setting issue state", + issueId, + preferredName ?? type, ); await linear().updateIssue(issueId, { stateId }); } diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts index de90bc1..25b83a9 100644 --- a/src/commands/util/close.ts +++ b/src/commands/util/close.ts @@ -79,7 +79,7 @@ export async function handleIssueState( await threadChannel.setArchived(true); } } catch (err) { - console.error("Error archiving thread:", err); + console.error("[close]", "archiving thread failed", err); } } } catch { diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 937f7f6..225daf6 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -13,7 +13,10 @@ const commandData = Object.values(commands).map((command) => ); console.log( - `Started refreshing ${commandData.length} application (/) commands.`, + "[commands]", + "refreshing", + commandData.length, + "application (/) commands", ); // The put method is used to fully refresh all commands in the guild with the current set @@ -26,4 +29,4 @@ const data: any = await rest.put( { body: commandData }, ); -console.log(`Successfully reloaded ${data.length} application (/) commands.`); +console.log("[commands]", "reloaded", data.length, "application (/) commands"); diff --git a/src/events/commands.ts b/src/events/commands.ts index 719a556..fcca97a 100644 --- a/src/events/commands.ts +++ b/src/events/commands.ts @@ -12,7 +12,9 @@ export default function registerEvents(client: Client) { if (!command) { console.error( - `No command matching "${interaction.commandName}" was found.`, + "[commands]", + "no command matching", + interaction.commandName, ); return; } @@ -20,7 +22,7 @@ export default function registerEvents(client: Client) { try { await command.execute(interaction); } catch (error) { - console.error(error); + console.error("[commands]", "execution failed", error); // TODO: make generic replyOrFollowUp method // TODO: log error if the user is admin diff --git a/src/index.ts b/src/index.ts index 539a72b..d4cefae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,7 @@ function shufflePresence() { } client.once(Events.ClientReady, () => { - console.log(`Logged in as ${client.user?.tag}!`); + console.log("[bot]", "logged in as", client.user?.tag); registerCommandEvents(client); registerWalkthroughEvents(client); @@ -65,11 +65,11 @@ client.once(Events.ClientReady, () => { setInterval(shufflePresence, config.presenceDelay); catchUpHelpPosts(client).catch((err) => - console.error("Failed to catch up on help posts:", err), + console.error("[help]", "catch-up failed", err), ); backfillBridge(client).catch((err) => - console.error("Linear bridge: backfill failed:", err), + console.error("[bridge]", "backfill failed", err), ); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 0d03e72..5ea56fb 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -131,7 +131,9 @@ export function validateLinearBridgeConfig(): void { if (missing.length > 0) { console.error( - `linearBridge.enabled is true but required config is missing: ${missing.join(", ")}`, + "[config]", + "linearBridge.enabled is true but required config is missing:", + missing.join(", "), ); process.exit(1); } diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts index 28c8921..db98518 100644 --- a/src/lib/discord/help.ts +++ b/src/lib/discord/help.ts @@ -109,7 +109,7 @@ export async function catchUpHelpPosts(client: Client): Promise { try { await reconcileThread(thread); } catch (err) { - console.error(`Failed to reconcile help post ${thread.id}:`, err); + console.error("[help]", "failed to reconcile post", thread.id, err); } } } From 546493f1ce366e7a56b8b19bb3ed6f20587e2d56 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:20:02 +0000 Subject: [PATCH 55/62] fix(src/lib/discord): only treat tagless archived threads as closed --- src/bridge/discord/index.ts | 9 +++++---- src/lib/discord/helpThread.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts index a44c0f9..380cf90 100644 --- a/src/bridge/discord/index.ts +++ b/src/bridge/discord/index.ts @@ -271,10 +271,11 @@ export class DiscordConnector implements Source { console.log("[bridge]", "backfilling thread", thread.id, thread.name); const help = new HelpThread(thread); - // Older threads may predate the waiting-tag automation. If an open thread - // has no waiting tag, derive one from its last message so the mirrored issue - // gets a meaningful status. - if (help.isOpen && help.waiting === null) { + // Older threads may predate the waiting-tag automation. If an open, active + // thread has no waiting tag, derive one from its last message so the + // mirrored issue gets a meaningful status. Skip archived threads: writing + // tags would unarchive and bump them. + if (help.isOpen && !thread.archived && help.waiting === null) { await reconcileThread(thread); } diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts index ed50fb0..c345457 100644 --- a/src/lib/discord/helpThread.ts +++ b/src/lib/discord/helpThread.ts @@ -27,12 +27,12 @@ export class HelpThread { } get isClosed(): boolean { - // An archived post is inactive and can't take new messages, so treat it as - // closed alongside an explicit closed tag. - return ( - this.thread.archived === true || - this.thread.appliedTags.includes(config.helpChannel.closedTag) - ); + const { closedTag, openedTag } = config.helpChannel; + const tags = this.thread.appliedTags; + if (tags.includes(closedTag)) return true; + if (tags.includes(openedTag)) return false; + // With no lifecycle tag, an archived post is inactive: treat it as closed. + return this.thread.archived === true; } get isOpen(): boolean { From b1de104815315be98db5e53c4a11c01b74c72e29 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:21:32 +0000 Subject: [PATCH 56/62] fix(src/bridge/discord): never write into archived threads --- src/bridge/discord/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts index 380cf90..c9961b2 100644 --- a/src/bridge/discord/index.ts +++ b/src/bridge/discord/index.ts @@ -194,7 +194,7 @@ export class DiscordConnector implements Source { issue: { identifier: string; url: string }, ): Promise { const channel = await this.client.channels.fetch(post.ref.id); - if (!channel?.isThread()) return; + if (!channel?.isThread() || channel.archived) return; await channel.send({ embeds: [{ description: `[${issue.identifier}](${issue.url})` }], }); From e1cd96385c053d39f4138894651cece4f76208dc Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:28:49 +0000 Subject: [PATCH 57/62] feat(src/bridge/discord): attribute as display name with handle --- src/bridge/discord/map.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bridge/discord/map.ts b/src/bridge/discord/map.ts index 0e93716..46378d2 100644 --- a/src/bridge/discord/map.ts +++ b/src/bridge/discord/map.ts @@ -136,8 +136,11 @@ function referencesOf(content: string): Reference[] { } function authorOf(message: DiscordMessage): Author { + const handle = message.author.username; + const displayName = + message.member?.displayName ?? message.author.displayName ?? handle; return { - name: message.member?.displayName ?? message.author.username, + name: displayName === handle ? handle : `${displayName} (${handle})`, iconUrl: message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), }; From 7c81e1d0ae22473ab901201a1abad1700545a9a3 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:43:48 +0000 Subject: [PATCH 58/62] feat(src/events/channels): post a desktop deep link on new help posts --- src/events/channels.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/events/channels.ts b/src/events/channels.ts index 005864a..dcf8007 100644 --- a/src/events/channels.ts +++ b/src/events/channels.ts @@ -72,6 +72,11 @@ export default function registerEvents(client: Client) { // A new help post is waiting for the Coder team to respond. await applyWaitingTag(thread, false); + + // Deep link so members can jump straight to the post in the desktop app. + await thread.send( + `(open in [Discord Desktop](discord://-/channels/${config.serverId}/${thread.id}))`, + ); }); client.on(Events.ThreadUpdate, async (oldThread, newThread) => { From ae608b3fbd93bdd206179ec8828be544269b8135 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:48:11 +0000 Subject: [PATCH 59/62] fix(src/lib/config): coerce env booleans and numbers so backfillAll=false works --- src/lib/config.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/lib/config.ts b/src/lib/config.ts index 5ea56fb..7e57cd6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -117,6 +117,36 @@ export const { config, layers } = await loadConfig({ ], }); +// configmasher does not coerce types: values from env files or process.env +// arrive as strings, so a boolean like `backfillAll=false` would be the truthy +// string "false". Coerce the env-overridable booleans and numbers to their real +// types after loading. +function bool(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return fallback; +} + +function num(value: unknown, fallback: number): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +config.presenceDelay = num(config.presenceDelay, 10 * 60 * 1000); +config.startupCatchupLimit = num(config.startupCatchupLimit, 20); +config.linearBridge.enabled = bool(config.linearBridge.enabled, false); +config.linearBridge.createAsUser = bool( + config.linearBridge.createAsUser, + false, +); +config.linearBridge.backfillAll = bool(config.linearBridge.backfillAll, false); +config.linearBridge.backfillLimit = num(config.linearBridge.backfillLimit, 50); +config.linearBridge.labels.enabled = bool( + config.linearBridge.labels.enabled, + true, +); + // linearBridge fields are conditionally required: only when the bridge is // enabled. configmasher's `mandatory` list is static, so validate here and exit // the same way a missing mandatory field would. From 637a6b6932a808fc474f1f3acea52df1a7457e63 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:50:23 +0000 Subject: [PATCH 60/62] feat(src/bridge): post the Discord desktop deep link on the Linear issue --- src/bridge/core/mirror.ts | 1 + src/bridge/core/model.ts | 3 +++ src/bridge/discord/map.ts | 1 + src/events/channels.ts | 5 ----- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bridge/core/mirror.ts b/src/bridge/core/mirror.ts index 129b93b..a6b3ed8 100644 --- a/src/bridge/core/mirror.ts +++ b/src/bridge/core/mirror.ts @@ -39,6 +39,7 @@ export class Mirror { } if (announce && !existed) { + if (post.openNote) await this.target.note(issueId, post.openNote); try { await this.source.announce(post, await this.target.issueRef(issueId)); } catch (err) { diff --git a/src/bridge/core/model.ts b/src/bridge/core/model.ts index 8829829..8896c5e 100644 --- a/src/bridge/core/model.ts +++ b/src/bridge/core/model.ts @@ -79,6 +79,9 @@ export interface Post { closedAt: Date | null; createdAt?: Date; attachment: SourceAttachment; + // Optional note posted on the hub issue when it is first mirrored, e.g. a + // deep link back to the source conversation. + openNote?: string; } // A single message within a conversation. The starter message is represented by diff --git a/src/bridge/discord/map.ts b/src/bridge/discord/map.ts index 46378d2..976a7d1 100644 --- a/src/bridge/discord/map.ts +++ b/src/bridge/discord/map.ts @@ -39,6 +39,7 @@ export function toPost(help: HelpThread, starter: DiscordMessage | null): Post { closedAt: help.closedAt, createdAt: starter?.createdAt, attachment: attachmentOf(help), + openNote: `(open in [Discord Desktop](discord://-/channels/${config.serverId}/${help.thread.id}))`, }; } diff --git a/src/events/channels.ts b/src/events/channels.ts index dcf8007..005864a 100644 --- a/src/events/channels.ts +++ b/src/events/channels.ts @@ -72,11 +72,6 @@ export default function registerEvents(client: Client) { // A new help post is waiting for the Coder team to respond. await applyWaitingTag(thread, false); - - // Deep link so members can jump straight to the post in the desktop app. - await thread.send( - `(open in [Discord Desktop](discord://-/channels/${config.serverId}/${thread.id}))`, - ); }); client.on(Events.ThreadUpdate, async (oldThread, newThread) => { From 43998a882539d2d121bf2e81edf3f41a5fdc8395 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:54:35 +0000 Subject: [PATCH 61/62] fix(src/bridge): bound normal backfill by a recency window --- src/bridge/discord/index.ts | 19 ++++++++++++++++--- src/lib/config.ts | 6 ++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts index c9961b2..abbf687 100644 --- a/src/bridge/discord/index.ts +++ b/src/bridge/discord/index.ts @@ -4,6 +4,7 @@ import { ChannelType, type Client, Events, + SnowflakeUtil, type ThreadChannel, } from "discord.js"; @@ -212,7 +213,7 @@ export class DiscordConnector implements Source { // it imports every thread, paging through all archived threads and waiting out // rate limits. async backfill(): Promise { - const { backfillAll, backfillLimit } = config.linearBridge; + const { backfillAll, backfillLimit, backfillDays } = config.linearBridge; if (!backfillAll && backfillLimit <= 0) return; const forum = await this.client.channels.fetch(config.helpChannel.id); @@ -241,7 +242,13 @@ export class DiscordConnector implements Source { const sorted = [...byId.values()].sort((a, b) => (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), ); - const threads = backfillAll ? sorted : sorted.slice(0, backfillLimit); + // A normal backfill is bounded by both a count and a recency window, so it + // can't reach ancient threads in a low-traffic channel. A full import takes + // everything. + const cutoff = Date.now() - backfillDays * 24 * 60 * 60 * 1000; + const threads = backfillAll + ? sorted + : sorted.filter((t) => lastActivity(t) >= cutoff).slice(0, backfillLimit); console.log( "[bridge]", @@ -250,7 +257,7 @@ export class DiscordConnector implements Source { "thread(s)", backfillAll ? "(full import)" - : `of ${byId.size} fetched (limit ${backfillLimit})`, + : `of ${byId.size} fetched (limit ${backfillLimit}, ${backfillDays}d)`, ); for (const thread of threads) { try { @@ -303,3 +310,9 @@ export class DiscordConnector implements Source { return { source: "discord", id: messageId, url: "" }; } } + +// Best-effort last-activity time of a thread, from its last message (or its id +// when empty), decoded from the Discord snowflake. +function lastActivity(thread: ThreadChannel): number { + return SnowflakeUtil.timestampFrom(thread.lastMessageId ?? thread.id); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 7e57cd6..27e32c7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -50,6 +50,10 @@ interface Config { // Number of most recently active help threads to mirror on startup. 0 to // disable. Threads already mirrored are skipped. backfillLimit: number; + // Only mirror threads active within this many days on a normal (non-full) + // startup backfill, so it can't reach ancient threads. Ignored by + // backfillAll. + backfillDays: number; // Mirror every #help thread on startup (all archived pages, ignoring // backfillLimit), retrying through Linear rate limits. Slow; intended for // the initial bulk import. @@ -83,6 +87,7 @@ export const { config, layers } = await loadConfig({ enabled: false, createAsUser: false, backfillLimit: 50, + backfillDays: 14, backfillAll: false, labels: { // Label creation runs on the user token, which can manage the team's @@ -142,6 +147,7 @@ config.linearBridge.createAsUser = bool( ); config.linearBridge.backfillAll = bool(config.linearBridge.backfillAll, false); config.linearBridge.backfillLimit = num(config.linearBridge.backfillLimit, 50); +config.linearBridge.backfillDays = num(config.linearBridge.backfillDays, 14); config.linearBridge.labels.enabled = bool( config.linearBridge.labels.enabled, true, From b410fc867d28dda52fbe4ec676b8b098849a78fb Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:56:32 +0000 Subject: [PATCH 62/62] fix(src/bridge/discord): resolve mentions via cache and fetch fallback --- src/bridge/discord/index.ts | 30 +++++++------ src/bridge/discord/map.ts | 88 +++++++++++++++++++++++++++++-------- 2 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts index abbf687..9dabfc4 100644 --- a/src/bridge/discord/index.ts +++ b/src/bridge/discord/index.ts @@ -51,7 +51,9 @@ export class DiscordConnector implements Source { client.on(Events.ThreadDelete, async (thread) => { if (!(await isHelpPost(thread))) return; try { - await this.mirror.deletePost(toPost(new HelpThread(thread), null)); + await this.mirror.deletePost( + await toPost(new HelpThread(thread), null), + ); } catch (err) { console.error("[bridge]", "thread delete failed", err); } @@ -62,7 +64,7 @@ export class DiscordConnector implements Source { if (!isHumanMessage(message) || isStarter(message)) return; try { const post = await this.postFor(message.channel as ThreadChannel); - await this.mirror.addMessage(post, toMessage(message)); + await this.mirror.addMessage(post, await toMessage(message)); } catch (err) { console.error("[bridge]", "message create failed", err); } @@ -85,13 +87,13 @@ export class DiscordConnector implements Source { ) { return; } - const post = toPost( + const post = await toPost( new HelpThread(message.channel as ThreadChannel), null, ); await this.mirror.editMessage( post, - toMessage(message), + await toMessage(message), isStarter(message), ); } catch (err) { @@ -103,7 +105,7 @@ export class DiscordConnector implements Source { try { const channel = message.channel; if (!channel.isThread() || !(await isHelpPost(channel))) return; - const post = toPost(new HelpThread(channel), null); + const post = await toPost(new HelpThread(channel), null); const ref: ExternalRef = { source: "discord", id: message.id, @@ -128,7 +130,7 @@ export class DiscordConnector implements Source { reaction.emoji.id ?? reaction.emoji.name, ); if (resolved?.count !== 1) return; - const post = toPost( + const post = await toPost( new HelpThread(message.channel as ThreadChannel), null, ); @@ -153,7 +155,7 @@ export class DiscordConnector implements Source { reaction.emoji.id ?? reaction.emoji.name, ); if (resolved && resolved.count > 0) return; - const post = toPost( + const post = await toPost( new HelpThread(message.channel as ThreadChannel), null, ); @@ -287,14 +289,16 @@ export class DiscordConnector implements Source { } const starter = await thread.fetchStarterMessage().catch(() => null); - const post = toPost(help, starter); + const post = await toPost(help, starter); await this.mirror.createPost(post, false); const fetched = await thread.messages.fetch({ limit: 100 }); - const messages = [...fetched.values()] - .reverse() - .filter((m) => isHumanMessage(m) && !isStarter(m)) - .map(toMessage); + const messages = await Promise.all( + [...fetched.values()] + .reverse() + .filter((m) => isHumanMessage(m) && !isStarter(m)) + .map(toMessage), + ); await this.mirror.backfillMessages(post, messages); await this.mirror.syncStatus(post, true); @@ -302,7 +306,7 @@ export class DiscordConnector implements Source { private async postFor(thread: ThreadChannel): Promise { const starter = await thread.fetchStarterMessage().catch(() => null); - return toPost(new HelpThread(thread), starter); + return await toPost(new HelpThread(thread), starter); } private messageRef(messageId: string, threadId: string): ExternalRef | null { diff --git a/src/bridge/discord/map.ts b/src/bridge/discord/map.ts index 976a7d1..aba2473 100644 --- a/src/bridge/discord/map.ts +++ b/src/bridge/discord/map.ts @@ -24,12 +24,15 @@ const SOURCE = "discord" as const; // Maps a Discord forum post onto the canonical Post. Attachments are not // composed into the body: the opening message becomes the issue description as // text only, matching the hub's existing shape. -export function toPost(help: HelpThread, starter: DiscordMessage | null): Post { +export async function toPost( + help: HelpThread, + starter: DiscordMessage | null, +): Promise { const content = starter?.content ?? ""; return { ref: { source: SOURCE, id: help.thread.id, url: help.url }, title: help.title, - body: starter ? formatText(starter).trim() : "", + body: starter ? (await renderText(starter)).trim() : "", author: starter ? authorOf(starter) : undefined, customEmojis: customEmojisIn(content), references: referencesOf(content), @@ -45,12 +48,12 @@ export function toPost(help: HelpThread, starter: DiscordMessage | null): Post { // Maps a Discord message onto the canonical Message (never the starter, which is // represented by the Post). -export function toMessage(message: DiscordMessage): Message { +export async function toMessage(message: DiscordMessage): Promise { const content = message.content ?? ""; return { ref: { source: SOURCE, id: message.id, url: message.url }, author: authorOf(message), - text: formatText(message), + text: await renderText(message), attachments: attachmentsOf(message), customEmojis: customEmojisIn(content), references: referencesOf(content), @@ -82,25 +85,72 @@ export function toReaction(emoji: { } // Message text with mentions and custom emojis resolved for the hub. -function formatText(message: DiscordMessage): string { - return resolveEmojis(resolveMentions(message)); +async function renderText(message: DiscordMessage): Promise { + return resolveEmojis(await resolveMentions(message)); } // Resolves user and role mentions the hub can't resolve from ids. User mentions // become a link to the Discord profile; role mentions become @name. Channel -// mentions are left for reference linking. -function resolveMentions(message: DiscordMessage): string { - return (message.content ?? "") - .replace(/<@!?(\d+)>/g, (m, id) => { - const name = - message.mentions.members?.get(id)?.displayName ?? - message.mentions.users.get(id)?.username; - return name ? `[@${name}](https://discord.com/users/${id})` : m; - }) - .replace(/<@&(\d+)>/g, (m, id) => { - const role = message.mentions.roles.get(id); - return role ? `@${role.name}` : m; - }); +// mentions are left for reference linking. The message's own mention collections +// are unreliable for backfilled/edited messages, so ids are resolved from cache +// and fetched as a fallback. +async function resolveMentions(message: DiscordMessage): Promise { + const content = message.content ?? ""; + + const users = new Map(); + for (const m of content.matchAll(/<@!?(\d+)>/g)) { + const id = m[1]; + if (users.has(id)) continue; + const name = await userName(message, id); + if (name) users.set(id, name); + } + + const roles = new Map(); + for (const m of content.matchAll(/<@&(\d+)>/g)) { + const id = m[1]; + if (roles.has(id)) continue; + const name = await roleName(message, id); + if (name) roles.set(id, name); + } + + return content + .replace(/<@!?(\d+)>/g, (m, id) => + users.has(id) + ? `[@${users.get(id)}](https://discord.com/users/${id})` + : m, + ) + .replace(/<@&(\d+)>/g, (m, id) => + roles.has(id) ? `@${roles.get(id)}` : m, + ); +} + +// Guild display name (nickname) if resolvable, else the global username. +async function userName( + message: DiscordMessage, + id: string, +): Promise { + const member = + message.mentions.members?.get(id) ?? + message.guild?.members.cache.get(id) ?? + (await message.guild?.members.fetch(id).catch(() => null)); + if (member) return member.displayName; + + const user = + message.mentions.users.get(id) ?? + message.client.users.cache.get(id) ?? + (await message.client.users.fetch(id).catch(() => null)); + return user?.username ?? null; +} + +async function roleName( + message: DiscordMessage, + id: string, +): Promise { + const role = + message.mentions.roles.get(id) ?? + message.guild?.roles.cache.get(id) ?? + (await message.guild?.roles.fetch(id).catch(() => null)); + return role?.name ?? null; } // Rewrites custom emojis (<:name:id>, ) as :discord-: shortcodes