diff --git a/package-lock.json b/package-lock.json index d696a6d1..7fa4b7e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,9 +156,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -172,9 +169,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -188,9 +182,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -204,9 +195,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 90ff853d..511d42b7 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -189,12 +189,12 @@ export interface TaskPromptInput { workItemId?: string; prNumber?: number; prBranch?: string; - // PM comment trigger fields - triggerCommentText?: string; - triggerCommentAuthor?: string; - // PR comment trigger fields + // Comment trigger fields (unified for PM and SCM comment-mention triggers) triggerCommentBody?: string; triggerCommentPath?: string; + triggerCommentAuthor?: string; + /** @deprecated Use triggerCommentBody. Kept as backward-compatible alias. */ + triggerCommentText?: string; // Allow extra fields for future extensibility [key: string]: unknown; } @@ -210,9 +210,9 @@ export function buildTaskPromptContext(input: TaskPromptInput): TaskPromptContex workItemId: input.workItemId, prNumber: input.prNumber, prBranch: input.prBranch, - commentText: input.triggerCommentText, + commentText: input.triggerCommentBody ?? input.triggerCommentText, commentAuthor: input.triggerCommentAuthor, - commentBody: input.triggerCommentBody, + commentBody: input.triggerCommentBody ?? input.triggerCommentText, commentPath: input.triggerCommentPath, }; } diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 42a81df3..1e35719b 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -119,12 +119,15 @@ export async function deleteLinearAck( // for backward compatibility with pm/ integrations and router/trello.ts. // --------------------------------------------------------------------------- +export type { JiraBotIdentity, TrelloBotIdentity } from './bot-identity-resolvers.js'; export { _resetJiraBotCache, _resetLinearBotCache, _resetTrelloBotCache, resolveJiraBotAccountId, + resolveJiraBotIdentity, resolveLinearBotUserId, + resolveTrelloBotIdentity, resolveTrelloBotMemberId, } from './bot-identity-resolvers.js'; diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts index b1461c34..89f5e310 100644 --- a/src/router/bot-identity-resolvers.ts +++ b/src/router/bot-identity-resolvers.ts @@ -19,13 +19,18 @@ import { // JIRA bot identity // --------------------------------------------------------------------------- -const jiraBotIdentityCache = new BotIdentityCache('accountId'); +export interface JiraBotIdentity { + accountId: string; + displayName: string; +} + +const jiraBotIdentityCache = new BotIdentityCache('identity'); /** - * Resolve the JIRA account ID for the bot credentials linked to a project. + * Resolve the JIRA bot identity (accountId + displayName) for a project. * Cached per-project with 60s TTL. Returns null on any failure. */ -export async function resolveJiraBotAccountId(projectId: string): Promise { +export async function resolveJiraBotIdentity(projectId: string): Promise { return jiraBotIdentityCache.resolve(projectId, async () => { const creds = await resolveJiraCredentials(projectId); if (!creds) return null; @@ -35,11 +40,21 @@ export async function resolveJiraBotAccountId(projectId: string): Promise { + const identity = await resolveJiraBotIdentity(projectId); + return identity?.accountId ?? null; +} + /** @internal Visible for testing only */ export function _resetJiraBotCache(): void { jiraBotIdentityCache._reset(); @@ -49,13 +64,20 @@ export function _resetJiraBotCache(): void { // Trello bot identity // --------------------------------------------------------------------------- -const trelloBotIdentityCache = new BotIdentityCache('memberId'); +export interface TrelloBotIdentity { + id: string; + username: string; +} + +const trelloBotIdentityCache = new BotIdentityCache('identity'); /** - * Resolve the Trello member ID for the bot credentials linked to a project. + * Resolve the Trello bot identity (id + username) for a project. * Cached per-project with 60s TTL. Returns null on any failure. */ -export async function resolveTrelloBotMemberId(projectId: string): Promise { +export async function resolveTrelloBotIdentity( + projectId: string, +): Promise { return trelloBotIdentityCache.resolve(projectId, async () => { const creds = await resolveTrelloCredentials(projectId); if (!creds) return null; @@ -66,11 +88,21 @@ export async function resolveTrelloBotMemberId(projectId: string): Promise { + const identity = await resolveTrelloBotIdentity(projectId); + return identity?.id ?? null; +} + /** @internal Visible for testing only */ export function _resetTrelloBotCache(): void { trelloBotIdentityCache._reset(); diff --git a/src/triggers/github/check-suite-failure.ts b/src/triggers/github/check-suite-failure.ts index ec5c62ca..5d073e82 100644 --- a/src/triggers/github/check-suite-failure.ts +++ b/src/triggers/github/check-suite-failure.ts @@ -4,7 +4,7 @@ import { logger } from '../../utils/logging.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js'; -import { parsePrNumberFromRef, resolveWorkItemId } from './utils.js'; +import { parsePrNumberFromRef, resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; /** * Resolve a PR number from a check_suite payload. @@ -121,6 +121,7 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { // Resolve work item from DB const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); + const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); // Get ALL check runs for this commit to verify they're all complete const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); @@ -205,6 +206,8 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { prUrl: prDetails.htmlUrl, prTitle: prDetails.title, workItemId, + workItemUrl, + workItemTitle, }; } } diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index a77e8ffb..b35c734c 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -9,7 +9,12 @@ import { releaseReviewDispatch, } from './review-dispatch-dedup.js'; import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js'; -import { evaluateAuthorMode, parsePrNumberFromRef, resolveWorkItemId } from './utils.js'; +import { + evaluateAuthorMode, + parsePrNumberFromRef, + resolveWorkItemDisplayData, + resolveWorkItemId, +} from './utils.js'; const MAX_RETRIES = 12; const RETRY_DELAY_MS = 10_000; @@ -156,6 +161,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { // Resolve work item from DB const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); + const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); // Skip if the reviewer persona's latest review already covers the current HEAD SHA const reviews = await githubClient.getPRReviews(owner, repo, prNumber); @@ -224,6 +230,8 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { prUrl: prDetails.htmlUrl, prTitle: prDetails.title, workItemId, + workItemUrl, + workItemTitle, waitForChecks: true, onBlocked: () => releaseReviewDispatch(dedupKey), }; diff --git a/src/triggers/github/pr-comment-mention.ts b/src/triggers/github/pr-comment-mention.ts index a97c6103..14642a43 100644 --- a/src/triggers/github/pr-comment-mention.ts +++ b/src/triggers/github/pr-comment-mention.ts @@ -5,7 +5,7 @@ import { logger } from '../../utils/logging.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import { isGitHubIssueCommentPayload, isGitHubPRReviewCommentPayload } from './types.js'; -import { resolveWorkItemId } from './utils.js'; +import { resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; /** * Trigger that fires when someone @mentions the reviewer bot in a PR comment. @@ -118,6 +118,7 @@ export class PRCommentMentionTrigger implements TriggerHandler { // Resolve work item from DB const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); + const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); logger.info('PR comment @mention detected, triggering respond-to-pr-comment agent', { prNumber, @@ -144,6 +145,8 @@ export class PRCommentMentionTrigger implements TriggerHandler { prUrl, prTitle, workItemId, + workItemUrl, + workItemTitle, }; } } diff --git a/src/triggers/github/pr-review-submitted.ts b/src/triggers/github/pr-review-submitted.ts index 89ce36c3..cf8e5fe8 100644 --- a/src/triggers/github/pr-review-submitted.ts +++ b/src/triggers/github/pr-review-submitted.ts @@ -3,7 +3,7 @@ import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/ import { logger } from '../../utils/logging.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import { type GitHubPullRequestReviewPayload, isGitHubPullRequestReviewPayload } from './types.js'; -import { resolveWorkItemId } from './utils.js'; +import { resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; export class PRReviewSubmittedTrigger implements TriggerHandler { name = 'pr-review-submitted'; @@ -59,6 +59,7 @@ export class PRReviewSubmittedTrigger implements TriggerHandler { // Resolve work item from DB const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); + const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); logger.info('PR review submitted, triggering review agent', { prNumber, @@ -83,6 +84,8 @@ export class PRReviewSubmittedTrigger implements TriggerHandler { prUrl: reviewPayload.pull_request.html_url, prTitle: reviewPayload.pull_request.title, workItemId, + workItemUrl, + workItemTitle, }; } } diff --git a/src/triggers/github/utils.ts b/src/triggers/github/utils.ts index f5492bef..17235dff 100644 --- a/src/triggers/github/utils.ts +++ b/src/triggers/github/utils.ts @@ -1,5 +1,6 @@ import { lookupWorkItemForPR } from '../../db/repositories/prWorkItemsRepository.js'; import type { PersonaIdentities } from '../../github/personas.js'; +import { getPMProviderOrNull } from '../../pm/context.js'; import type { ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -129,3 +130,33 @@ export async function resolveWorkItemId( return undefined; } + +/** + * Fetch work item display data (URL and title) from the active PM provider. + * + * Best-effort: returns an empty object on any error so callers can safely + * spread the result without checking for failure. Requires a PM provider + * to be in scope (set up by `withPMScope`). + * + * @param workItemId - The work item ID to look up (Trello card ID, JIRA issue key, etc.) + */ +export async function resolveWorkItemDisplayData( + workItemId: string | undefined, +): Promise<{ workItemUrl?: string; workItemTitle?: string }> { + if (!workItemId) return {}; + try { + const provider = getPMProviderOrNull(); + if (!provider) return {}; + const workItem = await provider.getWorkItem(workItemId); + return { + workItemUrl: workItem.url ?? undefined, + workItemTitle: workItem.title ?? undefined, + }; + } catch (err) { + logger.debug('Could not resolve work item display data (best-effort)', { + workItemId, + error: String(err), + }); + return {}; + } +} diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts index dfc3dfbe..88983f83 100644 --- a/src/triggers/jira/comment-mention.ts +++ b/src/triggers/jira/comment-mention.ts @@ -1,36 +1,17 @@ /** * JIRA comment @mention trigger. * - * Fires when someone @mentions the CASCADE bot user in a JIRA issue comment. - * Runs the respond-to-planning-comment agent. + * Fires when someone @mentions the CASCADE bot user in a JIRA issue comment + * on an issue in the PLANNING status. Runs the respond-to-planning-comment agent. */ -import { jiraClient } from '../../jira/client.js'; import { getJiraConfig } from '../../pm/config.js'; +import { resolveJiraBotIdentity } from '../../router/bot-identity-resolvers.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { JiraWebhookPayload } from './types.js'; -// Cache authenticated user info to avoid repeated API calls -let cachedUserInfo: { accountId: string; displayName: string } | null = null; - -async function getAuthenticatedUserInfo(): Promise<{ accountId: string; displayName: string }> { - if (cachedUserInfo) { - return cachedUserInfo; - } - const me = await jiraClient.getMyself(); - cachedUserInfo = { - accountId: me.accountId ?? '', - displayName: me.displayName ?? '', - }; - logger.info('Cached authenticated JIRA user info', { - accountId: cachedUserInfo.accountId, - displayName: cachedUserInfo.displayName, - }); - return cachedUserInfo; -} - /** * Extract plain text from a comment body. * Handles both ADF objects (recursive extraction) and wiki markup strings. @@ -87,6 +68,35 @@ function hasMention(body: unknown, accountId: string, depth = 0): boolean { return false; } +/** + * Check if the issue is in the configured PLANNING status. + * Returns false (and logs) when the project has no planning status configured + * or the issue's current status doesn't match. + */ +function isInPlanningStatus( + project: TriggerContext['project'], + issueKey: string, + currentStatusName: string | undefined, +): boolean { + const planningStatusName = getJiraConfig(project)?.statuses.planning; + if (!planningStatusName) { + logger.debug( + 'Planning status not configured for JIRA project, skipping comment mention trigger', + { projectId: project.id }, + ); + return false; + } + if (currentStatusName?.toLowerCase() !== planningStatusName.toLowerCase()) { + logger.debug('JIRA issue not in planning status, skipping comment mention trigger', { + issueKey, + currentStatus: currentStatusName, + planningStatus: planningStatusName, + }); + return false; + } + return true; +} + export class JiraCommentMentionTrigger implements TriggerHandler { name = 'jira-comment-mention'; description = @@ -132,8 +142,14 @@ export class JiraCommentMentionTrigger implements TriggerHandler { return null; } - // Resolve our JIRA identity - const userInfo = await getAuthenticatedUserInfo(); + // Resolve our JIRA identity using the shared per-project cached resolver + const userInfo = await resolveJiraBotIdentity(ctx.project.id); + if (!userInfo) { + logger.warn('JIRA comment trigger: could not resolve bot user identity, skipping', { + projectId: ctx.project.id, + }); + return null; + } logger.info('JIRA bot identity resolved', { botAccountId: userInfo.accountId, botDisplayName: userInfo.displayName, @@ -161,17 +177,23 @@ export class JiraCommentMentionTrigger implements TriggerHandler { return null; } + // Gate on PLANNING status — only respond to comments on PLANNING issues + const currentStatusName = payload.issue?.fields?.status?.name; + if (!isInPlanningStatus(ctx.project, issueKey, currentStatusName)) { + return null; + } + const jiraConfig = getJiraConfig(ctx.project); + const commentText = extractText(commentBody); const authorName = commentAuthor?.displayName || 'unknown'; // Capture work item display data from the issue payload and Jira config - const jiraConfig = getJiraConfig(ctx.project); const workItemUrl = jiraConfig?.baseUrl ? `${jiraConfig.baseUrl}/browse/${issueKey}` : undefined; const workItemTitle = payload.issue?.fields?.summary ?? undefined; - logger.info('JIRA comment @mention detected, triggering agent', { + logger.info('JIRA comment @mention detected on PLANNING issue, triggering agent', { issueKey, commentAuthor: authorName, botAccountId: userInfo.accountId, @@ -181,7 +203,8 @@ export class JiraCommentMentionTrigger implements TriggerHandler { agentType: 'respond-to-planning-comment', agentInput: { workItemId: issueKey, - triggerCommentText: commentText, + triggerCommentBody: commentText, + triggerCommentText: commentText, // @deprecated — use triggerCommentBody triggerCommentAuthor: authorName, workItemUrl, workItemTitle, diff --git a/src/triggers/linear/comment-mention.ts b/src/triggers/linear/comment-mention.ts index 91779d82..49f498c7 100644 --- a/src/triggers/linear/comment-mention.ts +++ b/src/triggers/linear/comment-mention.ts @@ -1,8 +1,8 @@ /** * Linear comment @mention trigger. * - * Fires when someone @mentions the CASCADE bot user in a Linear issue comment. - * Runs the respond-to-planning-comment agent. + * Fires when someone @mentions the CASCADE bot user in a Linear issue comment + * on an issue in the PLANNING state. Runs the respond-to-planning-comment agent. * * Linear webhook structure for comment creation: * action: 'create', type: 'Comment' @@ -12,6 +12,7 @@ * data.issue.identifier: the issue identifier (e.g. TEAM-123) */ +import { getLinearConfig } from '../../pm/config.js'; import { resolveLinearBotIdentity } from '../../router/bot-identity-resolvers.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -130,9 +131,29 @@ export class LinearCommentMentionTrigger implements TriggerHandler { return null; } + // Gate on PLANNING state — only respond to comments on PLANNING issues + const linearConfig = getLinearConfig(ctx.project); + const planningStateId = linearConfig?.statuses.planning; + if (!planningStateId) { + logger.debug( + 'Planning state not configured for Linear project, skipping comment mention trigger', + { projectId: ctx.project.id }, + ); + return null; + } + const currentStateId = issue?.stateId; + if (currentStateId !== planningStateId) { + logger.debug('Linear issue not in planning state, skipping comment mention trigger', { + issueIdentifier, + currentStateId, + planningStateId, + }); + return null; + } + const issueUrl = issue?.url; - logger.info('Linear comment @mention detected, triggering agent', { + logger.info('Linear comment @mention detected on PLANNING issue, triggering agent', { issueIdentifier, commentAuthorId, botUserId, @@ -142,7 +163,8 @@ export class LinearCommentMentionTrigger implements TriggerHandler { agentType: 'respond-to-planning-comment', agentInput: { workItemId: issueIdentifier, - triggerCommentText: commentBody, + triggerCommentBody: commentBody, + triggerCommentText: commentBody, // @deprecated — use triggerCommentBody triggerCommentAuthor: commentAuthorId, workItemUrl: issueUrl, workItemTitle: undefined, diff --git a/src/triggers/shared/agent-pm-poster.ts b/src/triggers/shared/agent-pm-poster.ts index cdecca9f..78fd2cab 100644 --- a/src/triggers/shared/agent-pm-poster.ts +++ b/src/triggers/shared/agent-pm-poster.ts @@ -27,6 +27,7 @@ const TRUNCATION_NOTICE = '\n\n_[Review body truncated — view full review on G const AGENT_OUTPUT_CONFIG: Record = { 'respond-to-ci': { emoji: '🔧', header: 'CI Fix Summary' }, 'respond-to-review': { emoji: '💬', header: 'Review Response Summary' }, + 'respond-to-pr-comment': { emoji: '📝', header: 'PR Comment Response' }, 'resolve-conflicts': { emoji: '🔀', header: 'Conflict Resolution Summary' }, }; diff --git a/src/triggers/shared/pm-ack.ts b/src/triggers/shared/pm-ack.ts index 72b8c3ad..34db80e1 100644 --- a/src/triggers/shared/pm-ack.ts +++ b/src/triggers/shared/pm-ack.ts @@ -2,7 +2,7 @@ * Shared PM acknowledgment posting utility for webhook handlers. * * Centralises the logic for posting acknowledgment comments to PM tools - * (Trello/JIRA) for PM-focused agents triggered from GitHub or other + * (Trello/JIRA/Linear) for PM-focused agents triggered from GitHub or other * non-PM sources. * * Used by: @@ -12,18 +12,18 @@ * and does not use this shared utility. */ -import { postJiraAck, postTrelloAck } from '../../router/acknowledgments.js'; +import { postJiraAck, postLinearAck, postTrelloAck } from '../../router/acknowledgments.js'; import { logger } from '../../utils/logging.js'; /** - * Post a PM acknowledgment comment to Trello or JIRA. + * Post a PM acknowledgment comment to Trello, JIRA, or Linear. * * Returns the comment ID if successfully posted, or null if the PM type * is not supported or posting failed. * * @param projectId The project ID for credential resolution. * @param workItemId The work item ID to post the comment on (card ID / issue key). - * @param pmType The PM provider type ('trello' or 'jira'). + * @param pmType The PM provider type ('trello', 'jira', or 'linear'). * @param message The acknowledgment message to post. * @param agentType Used only for warning log context when pmType is unknown. */ @@ -42,6 +42,10 @@ export async function postPMAckComment( return postJiraAck(projectId, workItemId, message); } + if (pmType === 'linear') { + return postLinearAck(projectId, workItemId, message); + } + logger.warn('Unknown PM type for PM-focused agent ack, skipping', { agentType, pmType, diff --git a/src/triggers/trello/comment-mention.ts b/src/triggers/trello/comment-mention.ts index 91698c44..45995ba1 100644 --- a/src/triggers/trello/comment-mention.ts +++ b/src/triggers/trello/comment-mention.ts @@ -1,4 +1,5 @@ import { getTrelloConfig } from '../../pm/config.js'; +import { resolveTrelloBotIdentity } from '../../router/bot-identity-resolvers.js'; import { trelloClient } from '../../trello/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -6,22 +7,6 @@ import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { TrelloWebhookPayload } from '../types.js'; import { isTrelloWebhookPayload } from '../types.js'; -// Cache authenticated member info to avoid repeated API calls -let cachedMemberInfo: { id: string; username: string } | null = null; - -async function getAuthenticatedMemberInfo(): Promise<{ id: string; username: string }> { - if (cachedMemberInfo) { - return cachedMemberInfo; - } - const me = await trelloClient.getMe(); - cachedMemberInfo = { id: me.id, username: me.username }; - logger.info('Cached authenticated member info', { - memberId: cachedMemberInfo.id, - username: cachedMemberInfo.username, - }); - return cachedMemberInfo; -} - /** * Trigger that fires when someone @mentions the CASCADE bot in a Trello card comment * on a card in the PLANNING list. Runs the respond-to-planning-comment agent. @@ -61,8 +46,14 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { return null; } - // Resolve our Trello identity - const memberInfo = await getAuthenticatedMemberInfo(); + // Resolve our Trello identity using the shared per-project cached resolver + const memberInfo = await resolveTrelloBotIdentity(ctx.project.id); + if (!memberInfo) { + logger.warn('Trello comment trigger: could not resolve bot member identity, skipping', { + projectId: ctx.project.id, + }); + return null; + } // Check for @mention (case-insensitive) const mentionPattern = new RegExp(`@${memberInfo.username}\\b`, 'i'); @@ -116,7 +107,8 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { agentType: 'respond-to-planning-comment', agentInput: { workItemId: cardId, - triggerCommentText: commentText, + triggerCommentBody: commentText, + triggerCommentText: commentText, // @deprecated — use triggerCommentBody triggerCommentAuthor: commentAuthor, workItemUrl, workItemTitle, diff --git a/src/types/index.ts b/src/types/index.ts index aa255774..2f4333d5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,13 +38,18 @@ export interface AgentInput { originalWorkItemUrl?: string; detectedAgentType?: string; - // Trello comment trigger fields - triggerCommentText?: string; - triggerCommentAuthor?: string; - - // PR comment trigger fields (for respond-to-pr-comment and similar agents) + // Unified comment trigger fields — both PM (Trello/JIRA/Linear) and SCM (GitHub) triggers use these + /** The body text of the triggering comment. Canonical field for all comment-mention triggers. */ triggerCommentBody?: string; triggerCommentPath?: string; + triggerCommentAuthor?: string; + + /** + * @deprecated Use `triggerCommentBody` instead. + * Retained for one release as a backward-compatible alias. PM comment-mention + * triggers populate both fields with the same value. + */ + triggerCommentText?: string; // Interactive mode (local development) interactive?: boolean; diff --git a/tests/unit/triggers/jira-comment-mention.test.ts b/tests/unit/triggers/jira-comment-mention.test.ts index 5b3afa59..e5b8c492 100644 --- a/tests/unit/triggers/jira-comment-mention.test.ts +++ b/tests/unit/triggers/jira-comment-mention.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Hoist mocks before any imports -const { mockJiraClientGetMyself, mockCheckTriggerEnabled, mockLogger } = vi.hoisted(() => ({ - mockJiraClientGetMyself: vi.fn(), +const { mockResolveJiraBotIdentity, mockCheckTriggerEnabled, mockLogger } = vi.hoisted(() => ({ + mockResolveJiraBotIdentity: vi.fn(), mockCheckTriggerEnabled: vi.fn().mockResolvedValue(true), mockLogger: { info: vi.fn(), @@ -12,10 +12,8 @@ const { mockJiraClientGetMyself, mockCheckTriggerEnabled, mockLogger } = vi.hois }, })); -vi.mock('../../../src/jira/client.js', () => ({ - jiraClient: { - getMyself: mockJiraClientGetMyself, - }, +vi.mock('../../../src/router/bot-identity-resolvers.js', () => ({ + resolveJiraBotIdentity: (...args: unknown[]) => mockResolveJiraBotIdentity(...args), })); vi.mock('../../../src/triggers/shared/trigger-check.js', () => ({ @@ -33,6 +31,7 @@ const BOT_ACCOUNT_ID = 'bot-account-001'; const BOT_DISPLAY_NAME = 'CascadeBot'; const OTHER_ACCOUNT_ID = 'user-account-456'; const ISSUE_KEY = 'PROJ-123'; +const PLANNING_STATUS = 'Planning'; function makeProject() { return { @@ -40,7 +39,10 @@ function makeProject() { name: 'Test Project', repo: 'owner/repo', baseBranch: 'main', - jira: { projectKey: 'PROJ' }, + jira: { + projectKey: 'PROJ', + statuses: { planning: PLANNING_STATUS }, + }, } as TriggerContext['project']; } @@ -49,6 +51,7 @@ function makeCtx( source?: TriggerContext['source']; webhookEvent?: string; issueKey?: string; + issueStatusName?: string; commentBody?: unknown; commentAuthorAccountId?: string; commentAuthorDisplayName?: string; @@ -56,7 +59,13 @@ function makeCtx( ): TriggerContext { const payload = { webhookEvent: overrides.webhookEvent ?? 'comment_created', - issue: { key: overrides.issueKey ?? ISSUE_KEY }, + issue: { + key: overrides.issueKey ?? ISSUE_KEY, + fields: { + status: { name: overrides.issueStatusName ?? PLANNING_STATUS }, + summary: 'Test Issue Summary', + }, + }, comment: { body: overrides.commentBody ?? `[~accountid:${BOT_ACCOUNT_ID}] please help`, author: { @@ -97,7 +106,7 @@ describe('JiraCommentMentionTrigger', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(mockCheckTriggerEnabled).mockResolvedValue(true); - mockJiraClientGetMyself.mockResolvedValue({ + mockResolveJiraBotIdentity.mockResolvedValue({ accountId: BOT_ACCOUNT_ID, displayName: BOT_DISPLAY_NAME, }); @@ -205,12 +214,32 @@ describe('JiraCommentMentionTrigger', () => { expect(result).toBeNull(); }); - it('includes triggerCommentText in agentInput (wiki markup)', async () => { + it('returns null when issue is not in PLANNING status', async () => { + const result = await trigger.handle(makeCtx({ issueStatusName: 'In Progress' })); + + expect(result).toBeNull(); + }); + + it('returns null when planning status is not configured in project', async () => { + const ctx = makeCtx(); + // Override project to remove planning status config + (ctx as Record).project = { + ...makeProject(), + jira: { projectKey: 'PROJ', statuses: {} }, + }; + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + + it('includes triggerCommentText and triggerCommentBody in agentInput (wiki markup)', async () => { const result = await trigger.handle( makeCtx({ commentBody: `[~accountid:${BOT_ACCOUNT_ID}] please do this thing` }), ); expect(result?.agentInput.triggerCommentText).toContain('please do this thing'); + expect(result?.agentInput.triggerCommentBody).toContain('please do this thing'); }); it('includes comment author display name in agentInput', async () => { @@ -229,7 +258,7 @@ describe('JiraCommentMentionTrigger', () => { expect(result?.agentInput.triggerCommentAuthor).toBe('unknown'); }); - it('handles multiple calls correctly (caches user info)', async () => { + it('handles multiple calls correctly (calls resolveJiraBotIdentity each time)', async () => { // First call const result1 = await trigger.handle(makeCtx()); // Second call @@ -237,8 +266,8 @@ describe('JiraCommentMentionTrigger', () => { expect(result1).not.toBeNull(); expect(result2).not.toBeNull(); - // getMyself should be called at most once per trigger instance - expect(mockJiraClientGetMyself.mock.calls.length).toBeLessThanOrEqual(2); + // resolveJiraBotIdentity is called per handle() invocation + expect(mockResolveJiraBotIdentity.mock.calls.length).toBe(2); }); }); }); diff --git a/tests/unit/triggers/linear-comment-mention.test.ts b/tests/unit/triggers/linear-comment-mention.test.ts index a822982f..40a5efdf 100644 --- a/tests/unit/triggers/linear-comment-mention.test.ts +++ b/tests/unit/triggers/linear-comment-mention.test.ts @@ -37,6 +37,10 @@ const mockProject = { baseBranch: 'main', branchPrefix: 'feature/', pm: { type: 'linear' as const }, + linear: { + teamId: 'team-abc', + statuses: { planning: 'state-todo' }, + }, } as TriggerContext['project']; function buildCtx( @@ -238,6 +242,7 @@ describe('LinearCommentMentionTrigger', () => { id: 'fallback-issue-id', // no identifier url: 'https://linear.app/org/issue/fallback', + stateId: 'state-todo', // must be in planning state }; const result = await trigger.handle(ctx); expect(result?.workItemId).toBe('fallback-issue-id'); @@ -259,5 +264,37 @@ describe('LinearCommentMentionTrigger', () => { const result = await trigger.handle(ctx); expect(result?.agentInput.linearIssueId).toBe('issue-uuid-99'); }); + + it('returns null when issue is not in PLANNING state', async () => { + const ctx = buildCtx(); + const data = ctx.payload as Record; + (data.data as Record).issue = { + id: ISSUE_ID, + identifier: ISSUE_IDENTIFIER, + title: 'Test issue', + teamId: 'team-abc', + url: 'https://linear.app/org/issue/TEAM-99', + stateId: 'state-in-progress', // not planning + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when planning state is not configured in project', async () => { + const ctx = buildCtx(); + (ctx as Record).project = { + ...mockProject, + linear: { teamId: 'team-abc', statuses: {} }, // no planning state + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('includes triggerCommentBody (canonical) in agentInput', async () => { + const body = `@[Bot](${BOT_USER_ID}) please implement feature X`; + const result = await trigger.handle(buildCtx({ commentBody: body })); + + expect(result?.agentInput.triggerCommentBody).toBe(body); + }); }); }); diff --git a/tests/unit/triggers/trello-comment-mention.test.ts b/tests/unit/triggers/trello-comment-mention.test.ts index 985fe708..dbd366b7 100644 --- a/tests/unit/triggers/trello-comment-mention.test.ts +++ b/tests/unit/triggers/trello-comment-mention.test.ts @@ -1,18 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Hoist mocks before imports -const { mockGetMe, mockGetCard } = vi.hoisted(() => ({ - mockGetMe: vi.fn(), +const { mockResolveTrelloBotIdentity, mockGetCard } = vi.hoisted(() => ({ + mockResolveTrelloBotIdentity: vi.fn(), mockGetCard: vi.fn(), })); vi.mock('../../../src/trello/client.js', () => ({ trelloClient: { - getMe: mockGetMe, getCard: mockGetCard, }, })); +vi.mock('../../../src/router/bot-identity-resolvers.js', () => ({ + resolveTrelloBotIdentity: (...args: unknown[]) => mockResolveTrelloBotIdentity(...args), +})); + import { mockConfigResolverModule, mockLogger, @@ -24,10 +27,6 @@ vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); -// We need to reset the module-level cache between tests. -// The module uses a module-level variable `cachedMemberInfo`. -// We can reset it by re-importing with vi.resetModules() or by calling the exported functions. - import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; import { TrelloCommentMentionTrigger } from '../../../src/triggers/trello/comment-mention.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; @@ -104,9 +103,8 @@ describe('TrelloCommentMentionTrigger', () => { vi.resetAllMocks(); vi.mocked(checkTriggerEnabled).mockResolvedValue(true); trigger = new TrelloCommentMentionTrigger(); - // Reset the module-level member info cache by re-importing. - // The cache is a module-level variable, so we set up getMe to always respond. - mockGetMe.mockResolvedValue({ id: BOT_MEMBER_ID, username: BOT_USERNAME }); + // Set up the bot identity resolver to return a valid identity for each test. + mockResolveTrelloBotIdentity.mockResolvedValue({ id: BOT_MEMBER_ID, username: BOT_USERNAME }); mockGetCard.mockResolvedValue({ id: 'card-1', idList: PLANNING_LIST_ID, @@ -235,8 +233,8 @@ describe('TrelloCommentMentionTrigger', () => { expect(result1?.agentType).toBe('respond-to-planning-comment'); expect(result2?.agentType).toBe('respond-to-planning-comment'); - // getMe should have been called AT MOST once (cached after first call or cached from prior test) - expect(mockGetMe.mock.calls.length).toBeLessThanOrEqual(1); + // resolveTrelloBotIdentity should be called for each handle call (no module-level caching) + expect(mockResolveTrelloBotIdentity.mock.calls.length).toBe(2); }); }); });