Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/discord-bot/docs/agent-turn-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
84 changes: 59 additions & 25 deletions apps/discord-bot/src/features/MentionRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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 }
Expand Down Expand Up @@ -1135,26 +1156,37 @@ 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,
starter: continueStarter,
referencedMessage: input.referencedMessage,
referencedMessageUrl: input.referencedMessageUrl,
jiraIssueKeys: turnJiraIssueKeys,
sentryIssueUrls: turnSentryIssueUrls,
jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl,
guildId: input.guildId,
discordThreadId: input.discordThreadId,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
73 changes: 62 additions & 11 deletions apps/discord-bot/src/features/ThreadInfoPin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?: {
Expand All @@ -66,6 +72,7 @@ export interface ThreadInfoPinMessageRef {
readonly channelId: string;
readonly messageId: string;
readonly jiraIssueKeys: ReadonlyArray<string>;
readonly sentryIssueUrls: ReadonlyArray<string>;
readonly prUrls: ReadonlyArray<string>;
}

Expand Down Expand Up @@ -148,6 +155,7 @@ export function buildThreadInfoRenderInput(input: {
readonly local?: boolean | undefined;
readonly webLink?: string | null | undefined;
readonly extraLines?: ReadonlyArray<string | null | undefined> | undefined;
readonly sentryIssueUrls?: ReadonlyArray<string> | undefined;
readonly jiraIssueKeys?: ReadonlyArray<string> | undefined;
readonly jiraBrowseBaseUrl?: string | undefined;
readonly prUrls?: ReadonlyArray<string> | undefined;
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -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<string>;
/** Sentry short ids that were previously stored as Jira keys and should be dropped. */
readonly dropJiraIssueKeys?: ReadonlyArray<string>;
readonly incomingSentryIssueUrls?: ReadonlyArray<string>;
readonly incomingPrUrls?: ReadonlyArray<string>;
readonly modelSelection?: { readonly instanceId: string; readonly model: string } | null;
readonly worktreePath?: string | null;
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -636,6 +668,7 @@ export const upsertThreadInfoPin = (input: {
channelId: pin.channelId,
messageId: pin.messageId,
jiraIssueKeys,
sentryIssueUrls,
prUrls,
} satisfies ThreadInfoPinMessageRef;
});
Expand Down Expand Up @@ -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) {
Expand All @@ -743,6 +792,8 @@ const backfillOneThreadInfoPin = (link: ThreadLink, botConfig: DiscordBotConfig)
t3ThreadId: link.t3ThreadId,
botConfig,
incomingJiraKeys: [],
dropJiraIssueKeys: sentryMaskedFromHistory,
incomingSentryIssueUrls: [],
incomingPrUrls: [],
modelSelection,
worktreePath,
Expand Down
Loading
Loading