diff --git a/apps/discord-bot/docs/agent-turn-rules.md b/apps/discord-bot/docs/agent-turn-rules.md index bc23795f1179..cb7bb32ffcc1 100644 --- a/apps/discord-bot/docs/agent-turn-rules.md +++ b/apps/discord-bot/docs/agent-turn-rules.md @@ -20,6 +20,7 @@ URL forms only; never bare snowflakes. **t3url:** private GitHub repo → turn `t3 full=…`; public repo → turn `t3 short=…` (host is always just `t3vm`). Prefer short when unsure (don't leak internal hosts on public PRs). **jira:** put turn keys in PR body (prefer primary in title/branch). +**sentry:** turn `sentry:` URLs are Sentry issues — not Jira. Fetch via Sentry MCP. **ref:** referenced msg is primary context when present. diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts index 76dbab697c73..f0053db7aedd 100644 --- a/apps/discord-bot/src/features/MentionRouter.ts +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -113,9 +113,15 @@ import { } from "../presentation/mentions.ts"; import { extractJiraIssueKeysFromDiscordMessage, - mergeJiraIssueKeys, + jiraIssueKeysAfterExcludingSentryFalsePositives, + jiraIssueKeysMaskedBySentryFromMessages, } from "../presentation/jiraLinks.ts"; import { extractPullRequestUrlsFromDiscordMessage } from "../presentation/prLinks.ts"; +import { + extractSentryIssueUrlsFromDiscordMessage, + mergeSentryIssueUrls, +} from "../presentation/sentryLinks.ts"; +import { isThreadInfoPinContent } from "../presentation/threadInfoPin.ts"; import { resolveUniqueT3ThreadIdForWorkItems, serverWorkItemsPathFromStateSqlite, @@ -437,11 +443,26 @@ function jiraKeysFromMessages( const keys: string[] = []; for (const message of messages) { if (message === null || message === undefined) continue; + if (isThreadInfoPinContent(message.content ?? null)) continue; keys.push(...extractJiraIssueKeysFromDiscordMessage(message)); } return keys; } +function sentryUrlsFromMessages( + ...messages: ReadonlyArray< + DiscordMessageLike | null | undefined | { readonly content?: string | null } + > +): ReadonlyArray { + const urls: string[] = []; + for (const message of messages) { + if (message === null || message === undefined) continue; + if (isThreadInfoPinContent(message.content ?? null)) continue; + urls.push(...extractSentryIssueUrlsFromDiscordMessage(message)); + } + return urls; +} + function prUrlsFromMessages( ...messages: ReadonlyArray< DiscordMessageLike | null | undefined | { readonly content?: string | null } @@ -1135,19 +1156,29 @@ const make = (botConfig: DiscordBotConfig) => prompt: input.prompt, attachments: stagedFiles.saved, }); - // Re-inject durable thread Jira keys so later turns (e.g. create PR) still see them. - const turnJiraIssueKeys = mergeJiraIssueKeys( - existing.jiraIssueKeys, - jiraKeysFromMessages(input.mentionMessage, input.referencedMessage, { - content: input.prompt, - }), - ); // Re-load thread starter so co-author trailers stay available mid-thread. const continueStarter = yield* loadThreadStarter({ discordThreadId: input.discordThreadId, parentChannelId: input.parentChannelId, ...(input.mentionMessage === undefined ? {} : { mentionMessage: input.mentionMessage }), }); + const continueWorkItemMessages = [ + continueStarter, + input.mentionMessage, + input.referencedMessage, + { content: input.prompt }, + ]; + // Re-inject durable thread Jira keys so later turns (e.g. create PR) still see them. + // Drop Sentry short ids that were previously stored as Jira (SCANNER-313 etc.). + const turnJiraIssueKeys = jiraIssueKeysAfterExcludingSentryFalsePositives( + existing.jiraIssueKeys, + jiraKeysFromMessages(...continueWorkItemMessages), + jiraIssueKeysMaskedBySentryFromMessages(continueWorkItemMessages), + ); + const turnSentryIssueUrls = mergeSentryIssueUrls( + existing.sentryIssueUrls, + sentryUrlsFromMessages(...continueWorkItemMessages), + ); const prompt = buildDiscordTurnPrompt({ mentionPrompt: promptWithAttachments, requester: input.mentionMessage, @@ -1155,6 +1186,7 @@ const make = (botConfig: DiscordBotConfig) => referencedMessage: input.referencedMessage, referencedMessageUrl: input.referencedMessageUrl, jiraIssueKeys: turnJiraIssueKeys, + sentryIssueUrls: turnSentryIssueUrls, jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl, guildId: input.guildId, discordThreadId: input.discordThreadId, @@ -1396,12 +1428,10 @@ const make = (botConfig: DiscordBotConfig) => discordThreadId: input.discordThreadId, t3ThreadId: existing.t3ThreadId, botConfig, - incomingJiraKeys: jiraKeysFromMessages(input.mentionMessage, input.referencedMessage, { - content: input.prompt, - }), - incomingPrUrls: prUrlsFromMessages(input.mentionMessage, input.referencedMessage, { - content: input.prompt, - }), + incomingJiraKeys: jiraKeysFromMessages(...continueWorkItemMessages), + dropJiraIssueKeys: jiraIssueKeysMaskedBySentryFromMessages(continueWorkItemMessages), + incomingSentryIssueUrls: sentryUrlsFromMessages(...continueWorkItemMessages), + incomingPrUrls: prUrlsFromMessages(...continueWorkItemMessages), modelSelection: continueModelSelection ?? continueShell?.modelSelection ?? null, worktreePath: continueShell?.worktreePath ?? null, local: continueShell?.worktreePath === null, @@ -1456,12 +1486,16 @@ const make = (botConfig: DiscordBotConfig) => mentionPrompt: prompt, referencedMessage: input.referencedMessage, }); - const firstTurnJiraIssueKeys = jiraKeysFromMessages( + const firstTurnWorkItemMessages = [ starter, input.mentionMessage, input.referencedMessage, { content: input.prompt }, - ); + ]; + const firstTurnJiraIssueKeys = jiraKeysFromMessages(...firstTurnWorkItemMessages); + const firstTurnSentryIssueUrls = sentryUrlsFromMessages(...firstTurnWorkItemMessages); + const firstTurnSentryMaskedJira = + jiraIssueKeysMaskedBySentryFromMessages(firstTurnWorkItemMessages); const enrichedPrompt = buildFirstTurnPrompt({ starter, mentionMessage: input.mentionMessage, @@ -1472,6 +1506,7 @@ const make = (botConfig: DiscordBotConfig) => workspaceRoot: resolved.project.workspaceRoot, honeycombTraceUrlTemplate: botConfig.honeycombTraceUrlTemplate, jiraIssueKeys: firstTurnJiraIssueKeys, + sentryIssueUrls: firstTurnSentryIssueUrls, jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl, guildId: input.guildId, discordThreadId: input.discordThreadId, @@ -1516,9 +1551,8 @@ const make = (botConfig: DiscordBotConfig) => createdAt: DateTime.formatIso(DateTime.nowUnsafe()), sentDiscordUserMessageIds: [messageId], jiraIssueKeys: firstTurnJiraIssueKeys, - prUrls: prUrlsFromMessages(starter, input.mentionMessage, input.referencedMessage, { - content: input.prompt, - }), + sentryIssueUrls: firstTurnSentryIssueUrls, + prUrls: prUrlsFromMessages(...firstTurnWorkItemMessages), }); yield* Effect.logInfo("Persisted new Discord↔T3 thread link", { discordThreadId: input.discordThreadId, @@ -1536,9 +1570,9 @@ const make = (botConfig: DiscordBotConfig) => t3ThreadId: threadId, botConfig, incomingJiraKeys: firstTurnJiraIssueKeys, - incomingPrUrls: prUrlsFromMessages(starter, input.mentionMessage, { - content: input.prompt, - }), + dropJiraIssueKeys: firstTurnSentryMaskedJira, + incomingSentryIssueUrls: firstTurnSentryIssueUrls, + incomingPrUrls: prUrlsFromMessages(...firstTurnWorkItemMessages), modelSelection, baseBranchLabel: input.flags.base ?? botConfig.t3DefaultBaseBranch, local: input.flags.local, @@ -1615,9 +1649,9 @@ const make = (botConfig: DiscordBotConfig) => t3ThreadId: threadId, botConfig, incomingJiraKeys: firstTurnJiraIssueKeys, - incomingPrUrls: prUrlsFromMessages(starter, input.mentionMessage, { - content: input.prompt, - }), + dropJiraIssueKeys: firstTurnSentryMaskedJira, + incomingSentryIssueUrls: firstTurnSentryIssueUrls, + incomingPrUrls: prUrlsFromMessages(...firstTurnWorkItemMessages), modelSelection, baseBranchLabel: input.flags.base ?? botConfig.t3DefaultBaseBranch, local: input.flags.local, diff --git a/apps/discord-bot/src/features/ThreadInfoPin.ts b/apps/discord-bot/src/features/ThreadInfoPin.ts index 6cbf8851aff8..5dc8720d458d 100644 --- a/apps/discord-bot/src/features/ThreadInfoPin.ts +++ b/apps/discord-bot/src/features/ThreadInfoPin.ts @@ -8,8 +8,13 @@ import type { DiscordBotConfig } from "../config.ts"; import { resolveGitHubUrlForWorkspace } from "../presentation/githubLinks.ts"; import { extractJiraIssueKeysFromDiscordMessage, - mergeJiraIssueKeys, + jiraIssueKeysAfterExcludingSentryFalsePositives, + jiraIssueKeysMaskedBySentryContext, } from "../presentation/jiraLinks.ts"; +import { + extractSentryIssueUrlsFromDiscordMessage, + mergeSentryIssueUrls, +} from "../presentation/sentryLinks.ts"; import { buildDiscordThreadJumpUrl, buildT3WebThreadUrl, @@ -44,6 +49,7 @@ interface DiscordMessageSummary { readonly url?: string | null; readonly title?: string | null; readonly description?: string | null; + readonly author?: { readonly name?: string | null } | null; readonly footer?: { readonly text?: string | null } | null; }> | null; readonly author?: { @@ -66,6 +72,7 @@ export interface ThreadInfoPinMessageRef { readonly channelId: string; readonly messageId: string; readonly jiraIssueKeys: ReadonlyArray; + readonly sentryIssueUrls: ReadonlyArray; readonly prUrls: ReadonlyArray; } @@ -148,6 +155,7 @@ export function buildThreadInfoRenderInput(input: { readonly local?: boolean | undefined; readonly webLink?: string | null | undefined; readonly extraLines?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; readonly jiraBrowseBaseUrl?: string | undefined; readonly prUrls?: ReadonlyArray | undefined; @@ -178,6 +186,7 @@ export function buildThreadInfoRenderInput(input: { worktreeLine, webLink: input.webLink ?? null, extraLines: [...(input.titleLine ? [input.titleLine] : []), ...(input.extraLines ?? [])], + sentryIssueUrls: input.sentryIssueUrls ?? [], jiraIssueKeys: input.jiraIssueKeys ?? [], jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, prUrls: input.prUrls ?? [], @@ -524,13 +533,16 @@ export const ensureThreadInfoPin = (input: { }); /** - * Persist any newly observed Jira keys / PR URLs, then create/update + pin the thread-info message. + * Persist any newly observed Jira keys / Sentry / PR URLs, then create/update + pin the thread-info message. */ export const upsertThreadInfoPin = (input: { readonly discordThreadId: string; readonly t3ThreadId: string; readonly botConfig: DiscordBotConfig; readonly incomingJiraKeys?: ReadonlyArray; + /** Sentry short ids that were previously stored as Jira keys and should be dropped. */ + readonly dropJiraIssueKeys?: ReadonlyArray; + readonly incomingSentryIssueUrls?: ReadonlyArray; readonly incomingPrUrls?: ReadonlyArray; readonly modelSelection?: { readonly instanceId: string; readonly model: string } | null; readonly worktreePath?: string | null; @@ -545,14 +557,33 @@ export const upsertThreadInfoPin = (input: { const links = yield* ThreadLinkStore; const existing = yield* links.getByDiscordThreadId(input.discordThreadId); - let jiraIssueKeys = mergeJiraIssueKeys(existing?.jiraIssueKeys, input.incomingJiraKeys ?? []); - if ((input.incomingJiraKeys?.length ?? 0) > 0) { - const updated = yield* links.appendJiraIssueKeys( + let jiraIssueKeys = jiraIssueKeysAfterExcludingSentryFalsePositives( + existing?.jiraIssueKeys, + input.incomingJiraKeys ?? [], + input.dropJiraIssueKeys ?? [], + ); + const existingJira = existing?.jiraIssueKeys ?? []; + const jiraUnchanged = + jiraIssueKeys.length === existingJira.length && + jiraIssueKeys.every((key, index) => key === existingJira[index]); + if (!jiraUnchanged) { + const updated = yield* links.setJiraIssueKeys(input.discordThreadId, jiraIssueKeys); + if (updated !== null) { + jiraIssueKeys = updated.jiraIssueKeys ?? jiraIssueKeys; + } + } + + let sentryIssueUrls = mergeSentryIssueUrls( + existing?.sentryIssueUrls, + input.incomingSentryIssueUrls ?? [], + ); + if ((input.incomingSentryIssueUrls?.length ?? 0) > 0) { + const updated = yield* links.appendSentryIssueUrls( input.discordThreadId, - input.incomingJiraKeys ?? [], + input.incomingSentryIssueUrls ?? [], ); if (updated !== null) { - jiraIssueKeys = updated.jiraIssueKeys ?? jiraIssueKeys; + sentryIssueUrls = updated.sentryIssueUrls ?? sentryIssueUrls; } } @@ -613,6 +644,7 @@ export const upsertThreadInfoPin = (input: { webLink: t3WebThreadUrl(input.botConfig.webUiBaseUrl, input.t3ThreadId), extraLines: input.extraLines, titleLine: input.titleLine, + sentryIssueUrls, jiraIssueKeys, jiraBrowseBaseUrl: input.botConfig.jiraBrowseBaseUrl, prUrls, @@ -636,6 +668,7 @@ export const upsertThreadInfoPin = (input: { channelId: pin.channelId, messageId: pin.messageId, jiraIssueKeys, + sentryIssueUrls, prUrls, } satisfies ThreadInfoPinMessageRef; }); @@ -713,21 +746,37 @@ const backfillOneThreadInfoPin = (link: ThreadLink, botConfig: DiscordBotConfig) const history = yield* fetchChannelMessagesOldestFirst(link.discordThreadId); const keysFromHistory: string[] = []; + const sentryMaskedFromHistory: string[] = []; + const sentryUrlsFromHistory: string[] = []; const prUrlsFromHistory: string[] = []; let discoveredInfoMessageId: string | null = link.infoDiscordMessageId ?? null; for (const message of history) { + if (isThreadInfoPinContent(message.content)) { + if (discoveredInfoMessageId === null) discoveredInfoMessageId = message.id; + // Do not re-parse our own pin — it can echo previously misclassified Jira keys. + continue; + } const keys = extractJiraIssueKeysFromDiscordMessage(message); for (const key of keys) keysFromHistory.push(key); + for (const key of jiraIssueKeysMaskedBySentryContext(message)) { + sentryMaskedFromHistory.push(key); + } + for (const url of extractSentryIssueUrlsFromDiscordMessage(message)) { + sentryUrlsFromHistory.push(url); + } const prUrls = extractPullRequestUrlsFromDiscordMessage(message); for (const url of prUrls) prUrlsFromHistory.push(url); - if (discoveredInfoMessageId === null && isThreadInfoPinContent(message.content)) { - discoveredInfoMessageId = message.id; - } } - const mergedKeys = mergeJiraIssueKeys(link.jiraIssueKeys, keysFromHistory); + const mergedKeys = jiraIssueKeysAfterExcludingSentryFalsePositives( + link.jiraIssueKeys, + keysFromHistory, + sentryMaskedFromHistory, + ); yield* links.setJiraIssueKeys(link.discordThreadId, mergedKeys); + const mergedSentryUrls = mergeSentryIssueUrls(link.sentryIssueUrls, sentryUrlsFromHistory); + yield* links.setSentryIssueUrls(link.discordThreadId, mergedSentryUrls); const mergedPrUrls = mergePullRequestUrls(link.prUrls, prUrlsFromHistory); yield* links.setPrUrls(link.discordThreadId, mergedPrUrls); if (discoveredInfoMessageId !== null && discoveredInfoMessageId !== link.infoDiscordMessageId) { @@ -743,6 +792,8 @@ const backfillOneThreadInfoPin = (link: ThreadLink, botConfig: DiscordBotConfig) t3ThreadId: link.t3ThreadId, botConfig, incomingJiraKeys: [], + dropJiraIssueKeys: sentryMaskedFromHistory, + incomingSentryIssueUrls: [], incomingPrUrls: [], modelSelection, worktreePath, diff --git a/apps/discord-bot/src/presentation/jiraLinks.test.ts b/apps/discord-bot/src/presentation/jiraLinks.test.ts index 3762bd4d58f6..513e7aa24c11 100644 --- a/apps/discord-bot/src/presentation/jiraLinks.test.ts +++ b/apps/discord-bot/src/presentation/jiraLinks.test.ts @@ -5,6 +5,8 @@ import { extractJiraIssueKeysFromDiscordMessage, formatJiraLinksForDiscord, jiraBrowseUrl, + jiraIssueKeysAfterExcludingSentryFalsePositives, + jiraIssueKeysMaskedBySentryContext, mergeJiraIssueKeys, normalizeJiraIssueKey, resolveJiraBrowseBaseUrl, @@ -71,6 +73,73 @@ describe("extractJiraIssueKeys", () => { }), ).toEqual(["PROJ-50"]); }); + + it("does not treat sentry.io short ids as Jira keys", () => { + expect(extractJiraIssueKeys("see https://macs-scanner.sentry.io/issues/SCANNER-313/")).toEqual( + [], + ); + expect( + extractJiraIssueKeysFromDiscordMessage({ + author: { username: "Sentry", bot: true }, + content: "", + embeds: [ + { + title: "T3 did not become ready after a server restart", + url: "https://macs-scanner.sentry.io/issues/7506163172/", + footer: { text: "SCANNER-313" }, + }, + ], + }), + ).toEqual([]); + }); + + it("still extracts Atlassian keys next to a Sentry embed", () => { + expect( + extractJiraIssueKeysFromDiscordMessage({ + author: { username: "Sentry", bot: true }, + content: "also https://example.atlassian.net/browse/PROJ-9", + embeds: [ + { + url: "https://macs-scanner.sentry.io/issues/7506163172/", + footer: { text: "SCANNER-313" }, + }, + ], + }), + ).toEqual(["PROJ-9"]); + }); +}); + +describe("sentry-masked Jira false positives", () => { + it("returns the Sentry short id so stored Jira keys can be dropped", () => { + expect( + jiraIssueKeysMaskedBySentryContext({ + author: { username: "Sentry", bot: true }, + embeds: [ + { + url: "https://macs-scanner.sentry.io/issues/7506163172/", + footer: { text: "SCANNER-313" }, + }, + ], + }), + ).toEqual(["SCANNER-313"]); + }); + + it("drops stored Sentry short ids unless they were also extracted as real Jira", () => { + expect( + jiraIssueKeysAfterExcludingSentryFalsePositives( + ["SCANNER-313", "PROJ-1"], + ["PROJ-1"], + ["SCANNER-313"], + ), + ).toEqual(["PROJ-1"]); + expect( + jiraIssueKeysAfterExcludingSentryFalsePositives( + ["SCANNER-313"], + ["SCANNER-313"], + ["SCANNER-313"], + ), + ).toEqual(["SCANNER-313"]); + }); }); describe("mergeJiraIssueKeys", () => { diff --git a/apps/discord-bot/src/presentation/jiraLinks.ts b/apps/discord-bot/src/presentation/jiraLinks.ts index 628bd080595d..59384fc3156b 100644 --- a/apps/discord-bot/src/presentation/jiraLinks.ts +++ b/apps/discord-bot/src/presentation/jiraLinks.ts @@ -3,8 +3,17 @@ * * Keys are stored in first-seen order. Duplicates are ignored (case-insensitive match * with canonical uppercase key form). + * + * Sentry short ids (`SCANNER-313`) match the Jira key shape. Tokens inside sentry.io + * URLs, Sentry Discord embeds, or Sentry-bot messages are not treated as Jira. */ +import { + discordMessageLooksLikeSentry, + sentryIssueUrlRanges, + type SentryDiscordMessageInput, +} from "./sentryLinks.ts"; + /** Classic Jira issue key: PROJ-123 (project 2–10 alnum chars, numeric id). */ const JIRA_ISSUE_KEY_PATTERN = /\b([A-Z][A-Z0-9]{1,9}-\d{1,7})\b/g; @@ -59,61 +68,77 @@ export function jiraBrowseUrl(baseUrl: string | undefined, key: string): string return `${base}/browse/${normalized}`; } -/** - * Extract issue keys from free text (message content, embed fields, etc.) - * in left-to-right first-seen order without duplicates. - */ -export function extractJiraIssueKeys(text: string | null | undefined): ReadonlyArray { - if (text === null || text === undefined || text.length === 0) return []; - - const found: string[] = []; - const seen = new Set(); - - const push = (raw: string) => { - const key = normalizeJiraIssueKey(raw); - if (key === null || seen.has(key)) return; - seen.add(key); - found.push(key); - }; - - // Prefer URL-sourced keys first so they appear in URL order when mixed with bare keys - // in the same string — still overall left-to-right via a single scan of positions. - type Hit = { readonly index: number; readonly key: string }; - const hits: Hit[] = []; - - for (const pattern of [ - JIRA_BROWSE_URL_PATTERN, - JIRA_SELECTED_ISSUE_PATTERN, - JIRA_ISSUE_KEY_PATTERN, - ]) { +export type ExtractJiraIssueKeysOptions = { + /** + * When false, only keys from Atlassian browse / selectedIssue URLs are returned. + * Default true, unless the text already contains a sentry.io URL (bare keys in + * that blob are Sentry short ids, not Jira). + */ + readonly includeBareKeys?: boolean; + /** Skip tokens whose match index sits inside a sentry.io URL. Default true. */ + readonly skipSentryUrls?: boolean; +}; + +type JiraKeyHit = { readonly index: number; readonly key: string }; + +function collectJiraKeyHits( + text: string, + patterns: ReadonlyArray, + sentryRanges: ReadonlyArray<{ readonly start: number; readonly end: number }>, + skipSentryUrls: boolean, +): JiraKeyHit[] { + const hits: JiraKeyHit[] = []; + for (const pattern of patterns) { pattern.lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { + const matchIndex = match.index; + if ( + skipSentryUrls && + sentryRanges.some((range) => matchIndex >= range.start && matchIndex < range.end) + ) { + continue; + } const key = normalizeJiraIssueKey(match[1] ?? ""); if (key === null) continue; - hits.push({ index: match.index, key }); + hits.push({ index: matchIndex, key }); } } - hits.sort((a, b) => a.index - b.index || a.key.localeCompare(b.key)); - for (const hit of hits) { - push(hit.key); + return hits; +} + +const ATLASSIAN_KEY_PATTERNS = [JIRA_BROWSE_URL_PATTERN, JIRA_SELECTED_ISSUE_PATTERN]; +const ALL_KEY_PATTERNS = [...ATLASSIAN_KEY_PATTERNS, JIRA_ISSUE_KEY_PATTERN]; + +/** + * Extract issue keys from free text (message content, embed fields, etc.) + * in left-to-right first-seen order without duplicates. + */ +export function extractJiraIssueKeys( + text: string | null | undefined, + options?: ExtractJiraIssueKeysOptions, +): ReadonlyArray { + if (text === null || text === undefined || text.length === 0) return []; + + const skipSentryUrls = options?.skipSentryUrls ?? true; + const sentryRanges = + skipSentryUrls || options?.includeBareKeys === false ? sentryIssueUrlRanges(text) : []; + const includeBareKeys = + options?.includeBareKeys ?? (skipSentryUrls ? sentryRanges.length === 0 : true); + + const found: string[] = []; + const seen = new Set(); + const patterns = includeBareKeys ? ALL_KEY_PATTERNS : ATLASSIAN_KEY_PATTERNS; + for (const hit of collectJiraKeyHits(text, patterns, sentryRanges, skipSentryUrls)) { + if (seen.has(hit.key)) continue; + seen.add(hit.key); + found.push(hit.key); } return found; } -export function extractJiraIssueKeysFromDiscordMessage(input: { - readonly content?: string | null | undefined; - readonly embeds?: - | ReadonlyArray<{ - readonly url?: string | null | undefined; - readonly title?: string | null | undefined; - readonly description?: string | null | undefined; - readonly footer?: { readonly text?: string | null | undefined } | null | undefined; - }> - | null - | undefined; -}): ReadonlyArray { +function joinDiscordMessageText(input: SentryDiscordMessageInput): string { const parts: string[] = []; if (input.content) parts.push(input.content); for (const embed of input.embeds ?? []) { @@ -122,7 +147,73 @@ export function extractJiraIssueKeysFromDiscordMessage(input: { if (embed.description) parts.push(embed.description); if (embed.footer?.text) parts.push(embed.footer.text); } - return extractJiraIssueKeys(parts.join("\n")); + return parts.join("\n"); +} + +export function extractJiraIssueKeysFromDiscordMessage( + input: SentryDiscordMessageInput, +): ReadonlyArray { + const text = joinDiscordMessageText(input); + const sentryContext = discordMessageLooksLikeSentry(input); + return extractJiraIssueKeys(text, { + includeBareKeys: !sentryContext, + skipSentryUrls: true, + }); +} + +/** + * Jira-shaped tokens in a Sentry alert / sentry.io URL that must not be stored as Jira keys. + * Atlassian browse URLs in the same message are kept (not returned here). + */ +export function jiraIssueKeysMaskedBySentryContext( + input: SentryDiscordMessageInput, +): ReadonlyArray { + const text = joinDiscordMessageText(input); + const sentryContext = discordMessageLooksLikeSentry(input); + const sentryRanges = sentryIssueUrlRanges(text); + if (!sentryContext && sentryRanges.length === 0) return []; + + const atlassianOnly = new Set( + extractJiraIssueKeys(text, { includeBareKeys: false, skipSentryUrls: true }), + ); + return extractJiraIssueKeys(text, { includeBareKeys: true, skipSentryUrls: false }).filter( + (key) => !atlassianOnly.has(key), + ); +} + +export function jiraIssueKeysMaskedBySentryFromMessages( + messages: ReadonlyArray, +): ReadonlyArray { + const keys: string[] = []; + for (const message of messages) { + if (message === null || message === undefined) continue; + keys.push(...jiraIssueKeysMaskedBySentryContext(message)); + } + return mergeJiraIssueKeys([], keys); +} + +/** Drop `omit` from `keys` (canonical uppercase), preserving first-seen order. */ +export function omitJiraIssueKeys( + keys: ReadonlyArray | null | undefined, + omit: ReadonlyArray | null | undefined, +): ReadonlyArray { + const dropped = new Set(mergeJiraIssueKeys([], omit)); + if (dropped.size === 0) return mergeJiraIssueKeys([], keys); + return mergeJiraIssueKeys([], keys).filter((key) => !dropped.has(key)); +} + +/** + * Merge stored + newly extracted Jira keys, then drop Sentry short ids that were + * never independently extracted from a non-Sentry message / Atlassian URL. + */ +export function jiraIssueKeysAfterExcludingSentryFalsePositives( + existing: ReadonlyArray | null | undefined, + extracted: ReadonlyArray | null | undefined, + sentryMasked: ReadonlyArray | null | undefined, +): ReadonlyArray { + const keptExtracted = mergeJiraIssueKeys([], extracted); + const drop = omitJiraIssueKeys(sentryMasked, keptExtracted); + return omitJiraIssueKeys(mergeJiraIssueKeys(existing, keptExtracted), drop); } /** Append newly seen keys preserving first-seen order; never duplicates. */ diff --git a/apps/discord-bot/src/presentation/sentryLinks.test.ts b/apps/discord-bot/src/presentation/sentryLinks.test.ts new file mode 100644 index 000000000000..25ae92f6e783 --- /dev/null +++ b/apps/discord-bot/src/presentation/sentryLinks.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applySentryShortIdToIssueUrl, + discordMessageLooksLikeSentry, + extractSentryIssueUrls, + extractSentryIssueUrlsFromDiscordMessage, + formatLinkedSentryWorkItemsBlock, + formatSentryLinksForDiscord, + mergeSentryIssueUrls, + normalizeSentryIssueUrl, + sentryIssueLabelFromUrl, + sentryShortIdFromText, +} from "./sentryLinks.ts"; + +const SCANNER_SENTRY_URL = "https://macs-scanner.sentry.io/issues/SCANNER-313"; +const NUMERIC_SENTRY_URL = "https://macs-scanner.sentry.io/issues/7506163172"; + +describe("normalizeSentryIssueUrl", () => { + it("canonicalizes org subdomain issue URLs", () => { + expect(normalizeSentryIssueUrl(`${SCANNER_SENTRY_URL}/?project=1#events`)).toBe( + SCANNER_SENTRY_URL, + ); + expect(normalizeSentryIssueUrl("http://De.Sentry.io/issues/ABC-1/")).toBe( + "https://de.sentry.io/issues/ABC-1", + ); + }); + + it("rejects non-sentry hosts", () => { + expect(normalizeSentryIssueUrl("https://example.atlassian.net/browse/SCANNER-313")).toBeNull(); + expect(normalizeSentryIssueUrl("https://example.com/sentry.io/issues/1")).toBeNull(); + }); +}); + +describe("extractSentryIssueUrls", () => { + it("finds sentry.io links including short ids in the path", () => { + expect( + extractSentryIssueUrls(`see ${SCANNER_SENTRY_URL}/ and also ${NUMERIC_SENTRY_URL}/`), + ).toEqual([SCANNER_SENTRY_URL, NUMERIC_SENTRY_URL]); + }); +}); + +describe("discordMessageLooksLikeSentry / extract from Discord", () => { + it("detects Sentry bot embeds with a sentry.io url and short-id footer", () => { + const message = { + author: { username: "Sentry", bot: true }, + content: "", + embeds: [ + { + title: "T3 did not become ready after a server restart", + url: `${NUMERIC_SENTRY_URL}/`, + footer: { text: "SCANNER-313" }, + }, + ], + }; + expect(discordMessageLooksLikeSentry(message)).toBe(true); + expect(extractSentryIssueUrlsFromDiscordMessage(message)).toEqual([SCANNER_SENTRY_URL]); + }); + + it("detects a pasted sentry.io link from a human", () => { + expect( + discordMessageLooksLikeSentry({ + author: { username: "patroza" }, + content: `what happened here? ${SCANNER_SENTRY_URL}`, + }), + ).toBe(true); + }); + + it("does not treat ordinary messages as Sentry", () => { + expect( + discordMessageLooksLikeSentry({ + author: { username: "patroza" }, + content: "please look at PROJ-367", + }), + ).toBe(false); + }); +}); + +describe("formatSentryLinksForDiscord", () => { + it("renders markdown links labelled with the issue id", () => { + expect(sentryIssueLabelFromUrl(SCANNER_SENTRY_URL)).toBe("SCANNER-313"); + expect(formatSentryLinksForDiscord([`${NUMERIC_SENTRY_URL}/`, SCANNER_SENTRY_URL])).toBe( + [ + "**Sentry**", + `• [7506163172](${NUMERIC_SENTRY_URL})`, + `• [SCANNER-313](${SCANNER_SENTRY_URL})`, + ].join("\n"), + ); + }); + + it("returns null for empty lists", () => { + expect(formatSentryLinksForDiscord([])).toBeNull(); + expect(mergeSentryIssueUrls(["x"], [SCANNER_SENTRY_URL])).toEqual([SCANNER_SENTRY_URL]); + }); +}); + +describe("sentry short ids on numeric issue URLs", () => { + it("reads the qualified short id from a Sentry footer", () => { + expect(sentryShortIdFromText("SCANNER-313")).toBe("SCANNER-313"); + expect(sentryShortIdFromText("SCANNER-313 via Scanner API • Today at 12:55 PM")).toBe( + "SCANNER-313", + ); + expect(sentryShortIdFromText("please look at PROJ-367")).toBeNull(); + }); + + it("rewrites numeric /issues/N paths", () => { + expect(applySentryShortIdToIssueUrl(NUMERIC_SENTRY_URL, "SCANNER-313")).toBe( + SCANNER_SENTRY_URL, + ); + expect(applySentryShortIdToIssueUrl(SCANNER_SENTRY_URL, "OTHER-1")).toBe(SCANNER_SENTRY_URL); + }); + + it("formats durable agent-turn URLs", () => { + expect(formatLinkedSentryWorkItemsBlock([`${NUMERIC_SENTRY_URL}/`, SCANNER_SENTRY_URL])).toBe( + `sentry: ${NUMERIC_SENTRY_URL} ${SCANNER_SENTRY_URL}`, + ); + expect(formatLinkedSentryWorkItemsBlock([])).toBeNull(); + }); +}); diff --git a/apps/discord-bot/src/presentation/sentryLinks.ts b/apps/discord-bot/src/presentation/sentryLinks.ts new file mode 100644 index 000000000000..382be5f3396f --- /dev/null +++ b/apps/discord-bot/src/presentation/sentryLinks.ts @@ -0,0 +1,252 @@ +/** + * Detect Sentry issue URLs / Discord alert embeds so they are not treated as Jira. + * + * Sentry short ids (`SCANNER-313`) have the same shape as Jira keys (`PROJ-123`). + * Classification must use the surrounding URL / bot author, not the token alone. + */ + +/** Host is sentry.io or a subdomain (org.sentry.io, de.sentry.io, …). */ +const SENTRY_HOST = /(?:^|\.)sentry\.io$/iu; + +/** + * Absolute sentry.io issue/share URL. Trailing punctuation is left on the match + * and stripped during normalize so markdown `(url)` / `url.` still parse. + */ +const SENTRY_URL_PATTERN = /https?:\/\/(?:[a-z0-9-]+\.)*sentry\.io\/[^\s<>"'\])]*/gi; + +export type SentryDiscordMessageInput = { + readonly content?: string | null | undefined; + readonly author?: + | { + readonly username?: string | null | undefined; + readonly displayName?: string | null | undefined; + readonly global_name?: string | null | undefined; + readonly bot?: boolean | undefined; + } + | null + | undefined; + readonly embeds?: + | ReadonlyArray<{ + readonly url?: string | null | undefined; + readonly title?: string | null | undefined; + readonly description?: string | null | undefined; + readonly author?: { readonly name?: string | null | undefined } | null | undefined; + readonly footer?: { readonly text?: string | null | undefined } | null | undefined; + readonly fields?: + | ReadonlyArray<{ + readonly name?: string | null | undefined; + readonly value?: string | null | undefined; + }> + | null + | undefined; + }> + | null + | undefined; +}; + +export function isSentryHostname(hostname: string): boolean { + return SENTRY_HOST.test(hostname.trim()); +} + +export function sentryIssueUrlRanges( + text: string, +): ReadonlyArray<{ readonly start: number; readonly end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + SENTRY_URL_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = SENTRY_URL_PATTERN.exec(text)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length }); + } + return ranges; +} + +export function textHasSentryIssueUrl(text: string | null | undefined): boolean { + if (text === null || text === undefined || text.length === 0) return false; + return sentryIssueUrlRanges(text).length > 0; +} + +function authorLooksLikeSentryBot(name: string | null | undefined): boolean { + if (name === null || name === undefined) return false; + const trimmed = name.trim(); + if (trimmed.length === 0) return false; + return /^sentry(?:[\s._-]?bot)?$/iu.test(trimmed); +} + +/** True when this Discord message is a Sentry alert, unfurl, or pasted sentry.io link. */ +export function discordMessageLooksLikeSentry(input: SentryDiscordMessageInput): boolean { + if (authorLooksLikeSentryBot(input.author?.username)) return true; + if (authorLooksLikeSentryBot(input.author?.displayName)) return true; + if (authorLooksLikeSentryBot(input.author?.global_name)) return true; + if (textHasSentryIssueUrl(input.content)) return true; + for (const embed of input.embeds ?? []) { + if (authorLooksLikeSentryBot(embed.author?.name)) return true; + if (typeof embed.url === "string" && textHasSentryIssueUrl(embed.url)) return true; + if (typeof embed.title === "string" && textHasSentryIssueUrl(embed.title)) return true; + if (typeof embed.description === "string" && textHasSentryIssueUrl(embed.description)) { + return true; + } + if (typeof embed.footer?.text === "string" && textHasSentryIssueUrl(embed.footer.text)) { + return true; + } + for (const field of embed.fields ?? []) { + if (typeof field.value === "string" && textHasSentryIssueUrl(field.value)) return true; + } + } + return false; +} + +/** + * Canonical browse URL: https host, no query/hash, no trailing slash on the path. + * Returns null when the string is not a sentry.io http(s) URL. + */ +export function normalizeSentryIssueUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + + const match = /https?:\/\/(?:[a-z0-9-]+\.)*sentry\.io\/[^\s<>"'\])]*/iu.exec(trimmed); + if (match === null) return null; + + let candidate = match[0] ?? ""; + candidate = candidate.replace(/[),.;]+$/u, ""); + try { + const url = new URL(candidate); + if (!isSentryHostname(url.hostname)) return null; + url.hash = ""; + url.search = ""; + url.protocol = "https:"; + if (url.pathname.length > 1) { + url.pathname = url.pathname.replace(/\/+$/u, ""); + } + return url.toString(); + } catch { + return null; + } +} + +export function mergeSentryIssueUrls( + existing: ReadonlyArray | null | undefined, + incoming: ReadonlyArray | null | undefined, +): ReadonlyArray { + const result: string[] = []; + const seen = new Set(); + for (const raw of [...(existing ?? []), ...(incoming ?? [])]) { + const url = normalizeSentryIssueUrl(raw); + if (url === null || seen.has(url)) continue; + seen.add(url); + result.push(url); + } + return result; +} + +export function extractSentryIssueUrls(text: string | null | undefined): ReadonlyArray { + if (text === null || text === undefined || text.length === 0) return []; + const found: string[] = []; + const seen = new Set(); + for (const range of sentryIssueUrlRanges(text)) { + const url = normalizeSentryIssueUrl(text.slice(range.start, range.end)); + if (url === null || seen.has(url)) continue; + seen.add(url); + found.push(url); + } + return found; +} + +/** + * Sentry Discord footers start with the qualified short id (`SCANNER-313`, + * `EXAMPLE-PROJECT-API-JW`). Used to label numeric `/issues/123` URLs. + */ +export function sentryShortIdFromText(text: string | null | undefined): string | null { + if (text === null || text === undefined) return null; + const trimmed = text.trim(); + if (trimmed.length === 0) return null; + const match = /^([A-Z][A-Z0-9]+(?:-[A-Z0-9]+)+)\b/u.exec(trimmed); + const id = match?.[1]; + if (id === undefined || id.length < 3) return null; + return id; +} + +/** Replace a numeric `/issues/123` path with a Sentry short id when we have one. */ +export function applySentryShortIdToIssueUrl(url: string, shortId: string): string { + const normalized = normalizeSentryIssueUrl(url); + if (normalized === null) return url; + try { + const parsed = new URL(normalized); + if (/\/issues\/\d+$/u.test(parsed.pathname)) { + parsed.pathname = parsed.pathname.replace(/\/issues\/\d+$/u, `/issues/${shortId}`); + return parsed.toString(); + } + } catch { + return normalized; + } + return normalized; +} + +function joinSentryMessageText(input: SentryDiscordMessageInput): string { + const parts: string[] = []; + if (input.content) parts.push(input.content); + for (const embed of input.embeds ?? []) { + if (embed.url) parts.push(embed.url); + if (embed.title) parts.push(embed.title); + if (embed.description) parts.push(embed.description); + if (embed.footer?.text) parts.push(embed.footer.text); + for (const field of embed.fields ?? []) { + if (field.value) parts.push(field.value); + } + } + return parts.join("\n"); +} + +export function extractSentryIssueUrlsFromDiscordMessage( + input: SentryDiscordMessageInput, +): ReadonlyArray { + const urls = extractSentryIssueUrls(joinSentryMessageText(input)); + const shortId = + sentryShortIdFromText(input.embeds?.find((embed) => embed.footer?.text)?.footer?.text) ?? + sentryShortIdFromText(input.content); + if (shortId === null) return urls; + return mergeSentryIssueUrls( + [], + urls.map((url) => applySentryShortIdToIssueUrl(url, shortId)), + ); +} + +/** + * Durable per-thread Sentry issue URLs for agent turns (one line). + * Omitted when none are known so ordinary prompts stay compact. + */ +export function formatLinkedSentryWorkItemsBlock( + urls: ReadonlyArray | null | undefined, +): string | null { + const ordered = mergeSentryIssueUrls([], urls); + if (ordered.length === 0) return null; + return `sentry: ${ordered.join(" ")}`; +} + +/** Compact pin/prompt label: issue path id (`SCANNER-313` or numeric id). */ +export function sentryIssueLabelFromUrl(url: string): string { + const normalized = normalizeSentryIssueUrl(url) ?? url.trim(); + try { + const parsed = new URL(normalized); + const issueMatch = parsed.pathname.match(/\/issues\/([^/]+)/iu); + if (issueMatch?.[1] !== undefined && issueMatch[1].length > 0) { + return decodeURIComponent(issueMatch[1]); + } + const shareMatch = parsed.pathname.match(/\/share\/([^/]+)/iu); + if (shareMatch?.[1] !== undefined && shareMatch[1].length > 0) { + return decodeURIComponent(shareMatch[1]); + } + } catch { + // fall through + } + return normalized; +} + +export function formatSentryLinksForDiscord(urls: ReadonlyArray): string | null { + const ordered = mergeSentryIssueUrls([], urls); + if (ordered.length === 0) return null; + const lines = ordered.map((url) => { + const label = sentryIssueLabelFromUrl(url); + return `• [${label}](${url})`; + }); + return ["**Sentry**", ...lines].join("\n"); +} diff --git a/apps/discord-bot/src/presentation/threadContext.test.ts b/apps/discord-bot/src/presentation/threadContext.test.ts index 1c81c40ba508..53e61c14fb8c 100644 --- a/apps/discord-bot/src/presentation/threadContext.test.ts +++ b/apps/discord-bot/src/presentation/threadContext.test.ts @@ -175,6 +175,7 @@ describe("resolveAgentTurnRulesPath", () => { expect(body).toContain("PR footer"); expect(body).toContain("Style:"); expect(body).toContain("client overlay"); + expect(body).toContain("**sentry:**"); }); }); @@ -277,6 +278,17 @@ describe("buildDiscordTurnPrompt", () => { jiraBrowseBaseUrl: "https://example.atlassian.net", }); expect(prompt).not.toContain("jira:"); + expect(prompt).not.toContain("sentry:"); + }); + + it("injects sentry issue URLs (not as Jira keys)", () => { + const prompt = buildDiscordTurnPrompt({ + mentionPrompt: "what happened here?", + sentryIssueUrls: ["https://macs-scanner.sentry.io/issues/SCANNER-313"], + }); + expect(prompt).toContain("sentry: https://macs-scanner.sentry.io/issues/SCANNER-313"); + expect(prompt).not.toContain("jira:"); + expect(prompt).toContain("what happened here?"); }); it("leaves identity attribution to the server", () => { diff --git a/apps/discord-bot/src/presentation/threadContext.ts b/apps/discord-bot/src/presentation/threadContext.ts index 10417d3c9831..63728371d33e 100644 --- a/apps/discord-bot/src/presentation/threadContext.ts +++ b/apps/discord-bot/src/presentation/threadContext.ts @@ -18,6 +18,7 @@ import { toT3PublicShortThreadUrl, } from "./discordPrAttribution.ts"; import { mergeJiraIssueKeys } from "./jiraLinks.ts"; +import { formatLinkedSentryWorkItemsBlock } from "./sentryLinks.ts"; function discordBotPackageRoot(): string { // presentation/ → src/ → package root @@ -81,6 +82,12 @@ export interface ThreadBootstrapContext { * Re-injected every turn so later PR/work turns still see earlier ticket links. */ readonly jiraIssueKeys?: ReadonlyArray | undefined; + /** + * Durable Sentry issue URLs for this Discord thread (first-seen order). + * Re-injected every turn so later investigation turns still see them as Sentry, + * not Jira. + */ + readonly sentryIssueUrls?: ReadonlyArray | undefined; /** Browse base for turning keys into links (e.g. https://org.atlassian.net). */ readonly jiraBrowseBaseUrl?: string | undefined; /** Guild snowflake — required to build a real Discord thread jump URL for PR footers. */ @@ -217,6 +224,7 @@ export function buildDiscordTurnPrompt(input: { readonly referencedMessage?: DiscordMessageLike | null | undefined; readonly referencedMessageUrl?: string | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly jiraBrowseBaseUrl?: string | undefined; readonly guildId?: string | null | undefined; readonly discordThreadId?: string | null | undefined; @@ -236,6 +244,9 @@ export function buildDiscordTurnPrompt(input: { })}` : ""; + const sentryBlock = formatLinkedSentryWorkItemsBlock(input.sentryIssueUrls); + const sentrySection = sentryBlock !== null ? `\n${sentryBlock}` : ""; + const jiraBlock = formatLinkedJiraWorkItemsBlock({ jiraIssueKeys: input.jiraIssueKeys, jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, @@ -262,7 +273,7 @@ export function buildDiscordTurnPrompt(input: { // Discord overlay path is surface-specific static policy for this turn. return `## Discord conversation context rules: ${overlayRulesPath} -req: ${formatRequesterLine(input.requester)}${jiraSection}${prFooterSection}${t3Section} +req: ${formatRequesterLine(input.requester)}${sentrySection}${jiraSection}${prFooterSection}${t3Section} ## User request ${input.mentionPrompt.trim()}${referencedBlock}`; @@ -379,6 +390,7 @@ export function buildFirstTurnPrompt(input: ThreadBootstrapContext): string { referencedMessage: input.referencedMessage, referencedMessageUrl: input.referencedMessageUrl, jiraIssueKeys: input.jiraIssueKeys, + sentryIssueUrls: input.sentryIssueUrls, jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, guildId: input.guildId, discordThreadId: input.discordThreadId, @@ -470,6 +482,7 @@ ${buildDiscordTurnPrompt({ starter: input.starter, // Referenced / starter bodies are rendered in dedicated bootstrap sections below. jiraIssueKeys: input.jiraIssueKeys, + sentryIssueUrls: input.sentryIssueUrls, jiraBrowseBaseUrl: input.jiraBrowseBaseUrl, guildId: input.guildId, discordThreadId: input.discordThreadId, diff --git a/apps/discord-bot/src/presentation/threadInfoPin.test.ts b/apps/discord-bot/src/presentation/threadInfoPin.test.ts index 46d752ed9a7a..b7cd69108fef 100644 --- a/apps/discord-bot/src/presentation/threadInfoPin.test.ts +++ b/apps/discord-bot/src/presentation/threadInfoPin.test.ts @@ -15,6 +15,7 @@ describe("renderThreadInfoPin", () => { modelLine: "grok/grok-4.5", worktreeLine: "Worktree off `main`", webLink: "http://198.18.83.2:3773/?thread=abc", + sentryIssueUrls: ["https://macs-scanner.sentry.io/issues/SCANNER-313"], jiraIssueKeys: ["PROJ-367", "PROJ-400"], jiraBrowseBaseUrl: "https://example.atlassian.net", channelGithubRepoSlug: "example-org/scanner", @@ -28,6 +29,8 @@ describe("renderThreadInfoPin", () => { expect(rendered).toContain("Model: `grok/grok-4.5`"); expect(rendered).toContain("Worktree off `main`"); expect(rendered).toContain("Open in Omegent: http://198.18.83.2:3773/?thread=abc"); + expect(rendered).toContain("**Sentry**"); + expect(rendered).toContain("[SCANNER-313](https://macs-scanner.sentry.io/issues/SCANNER-313)"); expect(rendered).toContain("**Jira**"); expect(rendered).toContain("[PROJ-367](https://example.atlassian.net/browse/PROJ-367)"); expect(rendered).toContain("[PROJ-400](https://example.atlassian.net/browse/PROJ-400)"); @@ -37,6 +40,7 @@ describe("renderThreadInfoPin", () => { expect(rendered).toContain( "[example-org/configurator PR #123](https://github.com/example-org/configurator/pull/123)", ); + expect(rendered.indexOf("**Sentry**")).toBeLessThan(rendered.indexOf("**Jira**")); expect(rendered.indexOf("**Jira**")).toBeLessThan(rendered.indexOf("**PRs**")); }); @@ -48,6 +52,7 @@ describe("renderThreadInfoPin", () => { jiraIssueKeys: [], prUrls: [], }); + expect(rendered).not.toContain("**Sentry**"); expect(rendered).not.toContain("**Jira**"); expect(rendered).not.toContain("**PRs**"); expect(rendered).toContain("Mode: local (no worktree)"); diff --git a/apps/discord-bot/src/presentation/threadInfoPin.ts b/apps/discord-bot/src/presentation/threadInfoPin.ts index 947473788a24..8e033ed545fc 100644 --- a/apps/discord-bot/src/presentation/threadInfoPin.ts +++ b/apps/discord-bot/src/presentation/threadInfoPin.ts @@ -1,6 +1,7 @@ // @effect-diagnostics globalDate:off import { formatJiraLinksForDiscord } from "./jiraLinks.ts"; import { formatPullRequestLinksForDiscord } from "./prLinks.ts"; +import { formatSentryLinksForDiscord } from "./sentryLinks.ts"; /** Stable marker so we can find/update the pinned thread-info message after restarts. */ export const THREAD_INFO_PIN_MARKER = "Omegent Info"; @@ -16,6 +17,7 @@ export type ThreadInfoPinRenderInput = { readonly worktreeLine: string | null; readonly webLink: string | null; readonly extraLines?: ReadonlyArray; + readonly sentryIssueUrls?: ReadonlyArray; readonly jiraIssueKeys?: ReadonlyArray; readonly jiraBrowseBaseUrl?: string | undefined; readonly prUrls?: ReadonlyArray; @@ -169,6 +171,12 @@ export function renderThreadInfoPin(input: ThreadInfoPinRenderInput): string { lines.push(alreadyLabelled ? input.webLink : `Open in Omegent: ${input.webLink}`); } + const sentrySection = formatSentryLinksForDiscord(input.sentryIssueUrls ?? []); + if (sentrySection !== null) { + lines.push(""); + lines.push(sentrySection); + } + const jiraSection = formatJiraLinksForDiscord(input.jiraIssueKeys ?? [], input.jiraBrowseBaseUrl); if (jiraSection !== null) { lines.push(""); diff --git a/apps/discord-bot/src/store/ThreadLinkStore.test.ts b/apps/discord-bot/src/store/ThreadLinkStore.test.ts index 404e1ec7c7e5..fe6b5878ce22 100644 --- a/apps/discord-bot/src/store/ThreadLinkStore.test.ts +++ b/apps/discord-bot/src/store/ThreadLinkStore.test.ts @@ -80,6 +80,7 @@ describe("migrateV1Link / parseLinksDocument", () => { streamDiscordMessageIds: undefined, sentDiscordUserMessageIds: undefined, jiraIssueKeys: undefined, + sentryIssueUrls: undefined, prUrls: undefined, infoDiscordMessageId: undefined, initialModelLine: undefined, @@ -292,6 +293,45 @@ it.effect("persists jira keys in first-seen order and info message id", () => }), ); +it.effect("persists sentry issue urls in first-seen order across reloads and minimal puts", () => + Effect.gen(function* () { + const dataDir = yield* makeTempDir; + const store = yield* makeThreadLinkStore(dataDir); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + yield* store.appendSentryIssueUrls("discord-thread-1", [ + "https://macs-scanner.sentry.io/issues/SCANNER-313/?project=1", + "https://macs-scanner.sentry.io/issues/7506163172/", + ]); + yield* store.appendSentryIssueUrls("discord-thread-1", [ + "https://macs-scanner.sentry.io/issues/SCANNER-313", + ]); + + yield* store.put({ + discordThreadId: "discord-thread-1", + t3ThreadId: ThreadIdBrand.make("thread-1"), + projectId: ProjectIdBrand.make("project-1"), + channelId: "channel-1", + guildId: "guild-1", + createdAt: "2026-07-18T00:00:00.000Z", + }); + + const reloaded = yield* makeThreadLinkStore(dataDir); + const link = yield* reloaded.getByDiscordThreadId("discord-thread-1"); + assert.deepStrictEqual(link?.sentryIssueUrls, [ + "https://macs-scanner.sentry.io/issues/SCANNER-313", + "https://macs-scanner.sentry.io/issues/7506163172", + ]); + }), +); + it.effect("persists PR urls in first-seen order across reloads and minimal puts", () => Effect.gen(function* () { const dataDir = yield* makeTempDir; diff --git a/apps/discord-bot/src/store/ThreadLinkStore.ts b/apps/discord-bot/src/store/ThreadLinkStore.ts index 49fa06284282..b750fd3d3fdc 100644 --- a/apps/discord-bot/src/store/ThreadLinkStore.ts +++ b/apps/discord-bot/src/store/ThreadLinkStore.ts @@ -10,6 +10,8 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { mergeSentryIssueUrls } from "../presentation/sentryLinks.ts"; + export const LINKS_DOCUMENT_VERSION = 2 as const; export const ThreadLinkStatus = Schema.Literals(["active", "tombstone"]); @@ -66,6 +68,13 @@ export const ThreadLink = Schema.Struct({ * Surfaced on the pinned thread-info message. */ jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), + /** + * Sentry issue URLs observed for this Discord thread, in first-seen order + * (canonical https://*.sentry.io/issues/…, no duplicates). + * Surfaced on the pinned thread-info message next to Jira — Sentry short ids + * look like Jira keys and must not be stored as jiraIssueKeys. + */ + sentryIssueUrls: Schema.optional(Schema.Array(Schema.String)), /** * GitHub pull request URLs observed for this Discord thread, in first-seen order * (canonical https://github.com/owner/repo/pull/N, no duplicates). @@ -105,6 +114,7 @@ export type ThreadLink = { readonly streamDiscordMessageIds?: ReadonlyArray | undefined; readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly prUrls?: ReadonlyArray | undefined; readonly infoDiscordMessageId?: string | undefined; readonly initialModelLine?: string | undefined; @@ -134,6 +144,7 @@ export type ThreadLinkInput = { readonly streamDiscordMessageIds?: ReadonlyArray | undefined; readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly prUrls?: ReadonlyArray | undefined; readonly infoDiscordMessageId?: string | undefined; readonly initialModelLine?: string | undefined; @@ -177,6 +188,7 @@ const ThreadLinkV1 = Schema.Struct({ streamDiscordMessageIds: Schema.optional(Schema.Array(Schema.String)), sentDiscordUserMessageIds: Schema.optional(Schema.Array(Schema.String)), jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), + sentryIssueUrls: Schema.optional(Schema.Array(Schema.String)), prUrls: Schema.optional(Schema.Array(Schema.String)), infoDiscordMessageId: Schema.optional(Schema.String), }); @@ -257,6 +269,7 @@ function asThreadLink(link: { readonly streamDiscordMessageIds?: ReadonlyArray | undefined; readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly prUrls?: ReadonlyArray | undefined; readonly infoDiscordMessageId?: string | undefined; readonly initialModelLine?: string | undefined; @@ -284,6 +297,7 @@ function asThreadLink(link: { streamDiscordMessageIds: link.streamDiscordMessageIds, sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, jiraIssueKeys: link.jiraIssueKeys, + sentryIssueUrls: link.sentryIssueUrls, prUrls: link.prUrls, infoDiscordMessageId: link.infoDiscordMessageId, initialModelLine: link.initialModelLine, @@ -305,6 +319,7 @@ export function migrateV1Link(link: { readonly streamDiscordMessageIds?: ReadonlyArray | undefined; readonly sentDiscordUserMessageIds?: ReadonlyArray | undefined; readonly jiraIssueKeys?: ReadonlyArray | undefined; + readonly sentryIssueUrls?: ReadonlyArray | undefined; readonly prUrls?: ReadonlyArray | undefined; readonly infoDiscordMessageId?: string | undefined; readonly initialModelLine?: string | undefined; @@ -330,6 +345,7 @@ export function migrateV1Link(link: { streamDiscordMessageIds: link.streamDiscordMessageIds, sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, jiraIssueKeys: link.jiraIssueKeys, + sentryIssueUrls: link.sentryIssueUrls, prUrls: link.prUrls, infoDiscordMessageId: link.infoDiscordMessageId, initialModelLine: link.initialModelLine, @@ -361,6 +377,7 @@ export function normalizeThreadLinkInput(link: ThreadLinkInput): ThreadLink { streamDiscordMessageIds: link.streamDiscordMessageIds, sentDiscordUserMessageIds: link.sentDiscordUserMessageIds, jiraIssueKeys: link.jiraIssueKeys, + sentryIssueUrls: link.sentryIssueUrls, prUrls: link.prUrls, infoDiscordMessageId: link.infoDiscordMessageId, initialModelLine: link.initialModelLine, @@ -467,6 +484,15 @@ export interface ThreadLinkStoreService { discordThreadId: string, jiraIssueKeys: ReadonlyArray, ) => Effect.Effect; + /** Merge newly observed Sentry issue URLs in first-seen order (no duplicates). */ + readonly appendSentryIssueUrls: ( + discordThreadId: string, + sentryIssueUrls: ReadonlyArray, + ) => Effect.Effect; + readonly setSentryIssueUrls: ( + discordThreadId: string, + sentryIssueUrls: ReadonlyArray, + ) => Effect.Effect; /** Merge newly observed GitHub PR URLs in first-seen order (no duplicates). */ readonly appendPrUrls: ( discordThreadId: string, @@ -651,6 +677,10 @@ export const makeThreadLinkStore = (dataDirRaw: string) => link.jiraIssueKeys !== undefined ? normalized.jiraIssueKeys : existing.jiraIssueKeys, + sentryIssueUrls: + link.sentryIssueUrls !== undefined + ? normalized.sentryIssueUrls + : existing.sentryIssueUrls, prUrls: link.prUrls !== undefined ? normalized.prUrls : existing.prUrls, infoDiscordMessageId: link.infoDiscordMessageId !== undefined @@ -789,6 +819,32 @@ export const makeThreadLinkStore = (dataDirRaw: string) => }; }), + appendSentryIssueUrls: (discordThreadId, sentryIssueUrls) => + updateLink(discordThreadId, (existing) => { + const merged = mergeSentryIssueUrls(existing.sentryIssueUrls, sentryIssueUrls); + if ( + merged.length === (existing.sentryIssueUrls?.length ?? 0) && + merged.every((url, index) => url === existing.sentryIssueUrls?.[index]) + ) { + return existing; + } + return { + ...existing, + sentryIssueUrls: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + + setSentryIssueUrls: (discordThreadId, sentryIssueUrls) => + updateLink(discordThreadId, (existing) => { + const merged = mergeSentryIssueUrls([], sentryIssueUrls); + return { + ...existing, + sentryIssueUrls: merged.length > 0 ? merged : undefined, + updatedAt: nowIso(), + }; + }), + appendPrUrls: (discordThreadId, prUrls) => updateLink(discordThreadId, (existing) => { const merged = mergePrUrlsOrdered(existing.prUrls, prUrls);