diff --git a/src/agents/definitions/alerting.yaml b/src/agents/definitions/alerting.yaml index 7cc638aa..9277e7f9 100644 --- a/src/agents/definitions/alerting.yaml +++ b/src/agents/definitions/alerting.yaml @@ -58,9 +58,12 @@ prompts: 2. Read the relevant source files to understand context and confirm the root cause 3. Summarize: what failed, why, and which code path is responsible <% if (it.backlogListId) { %> - 4. Create a bug fix work item in the backlog (list/status ID: <%= it.backlogListId %>) with: + 4. Create a bug fix investigation work item in the backlog (list/status ID: <%= it.backlogListId %>) with: - Title: short, actionable description (e.g. "Fix: NullPointerException in PaymentService.charge") - Description: root cause, error details, affected file/function, link to the alert issue + <% } else if (it.workItemId) { %> + 4. Post a comment on the current work item (<%= it.workItemId %>) summarising the investigation: + root cause, affected file/function, and recommended fix approach <% } %> 5. Call Finish when done @@ -69,3 +72,4 @@ hint: | Focus on frames from application code (not third-party library frames). Check the event timeline/breadcrumbs for the action or request that preceded the failure. Keep the bug work item description concise and actionable. + If no backlog list is configured, post investigation findings as a comment on the triggering work item. diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index c27d7a79..72412f97 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -51,6 +51,10 @@ function getPromptTerminology(pmType: string | undefined) { * Shared by the llmist agent lifecycle (agents/base.ts) and the adapter * (backends/adapter.ts) so both backends use consistent prompt context * building logic including PM-type normalization and work item noun i18n. + * + * @param alertingResultsContainerId - Optional PM container ID from Sentry integration config. + * Used as a fallback `backlogListId` when no PM backlog is configured on the project. + * Populated by `secretOrchestrator` for alerting agent runs. */ export function buildPromptContext( workItemId: string | undefined, @@ -64,17 +68,22 @@ export function buildPromptContext( originalWorkItemUrl: string; detectedAgentType: string; }, + alertingResultsContainerId?: string, ): PromptContext { const pmProvider = getPMProviderOrNull(); const listIds = getListIds(project); const terminology = getPromptTerminology(pmProvider?.type); + // Fall back to the Sentry-configured results container when no PM backlog is set. + const backlogListId = listIds.backlogListId ?? alertingResultsContainerId; + return { workItemId, workItemUrl: workItemId && pmProvider ? pmProvider.getWorkItemUrl(workItemId) : undefined, projectId: project.id, baseBranch: project.baseBranch, ...listIds, + backlogListId, pmType: pmProvider?.type, ...terminology, maxInFlightItems: project.maxInFlightItems ?? 1, diff --git a/src/backends/secretOrchestrator.ts b/src/backends/secretOrchestrator.ts index 405a010d..c2f39442 100644 --- a/src/backends/secretOrchestrator.ts +++ b/src/backends/secretOrchestrator.ts @@ -9,6 +9,7 @@ import type { createAgentLogger } from '../agents/utils/logging.js'; import { mergeEngineSettings } from '../config/engineSettings.js'; import { loadPartials } from '../db/repositories/partialsRepository.js'; import { withGitHubToken } from '../github/client.js'; +import { getSentryIntegrationConfig } from '../sentry/integration.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js'; import { getDashboardUrl } from '../utils/runLink.js'; import { createNativeToolRuntimeArtifacts } from './nativeToolRuntime.js'; @@ -59,11 +60,25 @@ export async function buildExecutionPlan( } : undefined; + // For alerting agents, look up Sentry `resultsContainerId` as a fallback + // backlogListId when no PM backlog status/list is configured on the project. + let alertingResultsContainerId: string | undefined; + if (agentType === 'alerting') { + try { + const sentryConfig = await getSentryIntegrationConfig(project.id); + alertingResultsContainerId = sentryConfig?.resultsContainerId; + } catch { + // Non-fatal — proceed without the fallback container + } + } + const promptContext: PromptContext = buildPromptContext( workItemId, project, input.triggerType, prContext, + undefined, + alertingResultsContainerId, ); // Load DB partials for template include resolution diff --git a/src/sentry/integration.ts b/src/sentry/integration.ts index 54acdb18..ddbc1459 100644 --- a/src/sentry/integration.ts +++ b/src/sentry/integration.ts @@ -14,6 +14,11 @@ import { getIntegrationByProjectAndCategory } from '../db/repositories/integrati export interface SentryIntegrationConfig { /** Sentry organization slug (e.g. "my-company") */ organizationSlug: string; + /** + * PM container ID where the alerting agent creates investigation work items. + * Maps to `backlogListId` in the prompt context when no PM backlog is configured. + */ + resultsContainerId?: string; } // ============================================================================ @@ -35,5 +40,8 @@ export async function getSentryIntegrationConfig( return { organizationSlug: config.organizationSlug, + ...(typeof config.resultsContainerId === 'string' + ? { resultsContainerId: config.resultsContainerId } + : {}), }; } diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts index a7346125..5016b4fc 100644 --- a/tests/unit/agents/shared/promptContext.test.ts +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -467,6 +467,87 @@ describe('buildPromptContext', () => { }); }); + describe('with alertingResultsContainerId fallback', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.type = 'trello'; + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('uses alertingResultsContainerId as backlogListId when PM backlog is not set', () => { + const projectWithoutBacklog = makeProject({ + trello: { + boardId: 'board1', + lists: { + splitting: 'list1', + planning: 'list2', + todo: 'list3', + // no backlog + inProgress: 'list-in-progress', + inReview: 'list-in-review', + merged: 'list-merged', + }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }); + const ctx = buildPromptContext( + 'card123', + projectWithoutBacklog as never, + undefined, + undefined, + undefined, + 'sentry-container-id', + ); + expect(ctx.backlogListId).toBe('sentry-container-id'); + }); + + it('PM backlogListId takes precedence over alertingResultsContainerId', () => { + const ctx = buildPromptContext( + 'card123', + makeProject() as never, + undefined, + undefined, + undefined, + 'sentry-container-id', + ); + // makeProject has trello.lists.backlog = 'list-backlog' + expect(ctx.backlogListId).toBe('list-backlog'); + }); + + it('alertingResultsContainerId is ignored when it is undefined', () => { + const ctx = buildPromptContext( + 'card123', + makeProject() as never, + undefined, + undefined, + undefined, + undefined, + ); + // still gets backlog from PM config + expect(ctx.backlogListId).toBe('list-backlog'); + }); + + it('backlogListId remains undefined when neither PM backlog nor alertingResultsContainerId is set', () => { + const projectWithoutBacklog = makeProject({ + trello: { + boardId: 'board1', + lists: { + splitting: 'list1', + planning: 'list2', + todo: 'list3', + inProgress: 'list-in-progress', + inReview: 'list-in-review', + merged: 'list-merged', + }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }); + const ctx = buildPromptContext('card123', projectWithoutBacklog as never); + expect(ctx.backlogListId).toBeUndefined(); + }); + }); + describe('without PM provider (no PM context — e.g. debug agent from dashboard)', () => { beforeEach(() => { mockGetPMProvider.mockReturnValue(null); diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts index 923fca17..74e76f2f 100644 --- a/tests/unit/backends/secretOrchestrator.test.ts +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -1,14 +1,75 @@ +/* biome-ignore lint/suspicious/noExplicitAny: test mocks */ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../src/utils/runLink.js', () => ({ getDashboardUrl: vi.fn(), })); -import { injectRunLinkSecrets } from '../../../src/backends/secretOrchestrator.js'; +// Mock everything that buildExecutionPlan might call +vi.mock('../../../src/agents/shared/modelResolution.js', () => ({ + resolveModelConfig: vi.fn().mockResolvedValue({ + systemPrompt: 'system', + taskPrompt: 'task', + model: 'claude', + maxIterations: 10, + }), +})); + +vi.mock('../../../src/agents/shared/promptContext.js', () => ({ + buildPromptContext: vi.fn().mockReturnValue({}), +})); + +vi.mock('../../../src/db/repositories/partialsRepository.js', () => ({ + loadPartials: vi.fn().mockResolvedValue(new Map()), +})); + +vi.mock('../../../src/sentry/integration.js', () => ({ + getSentryIntegrationConfig: vi.fn(), +})); + +vi.mock('../../../src/agents/definitions/profiles.js', () => ({ + getAgentProfile: vi.fn().mockReturnValue({ + fetchContext: vi.fn().mockResolvedValue({}), + finishHooks: {}, + filterTools: vi.fn().mockReturnValue([]), + }), +})); + +vi.mock('../../../src/agents/definitions/toolManifests.js', () => ({ + getToolManifests: vi.fn().mockReturnValue([]), +})); + +vi.mock('../../../src/backends/registry.js', () => ({ + isNativeToolEngineDefinition: vi.fn().mockReturnValue(false), +})); + +vi.mock('../../../src/agents/definitions/index.js', () => ({ + needsGitStateStopHooks: vi.fn().mockReturnValue(false), +})); + +vi.mock('../../../src/backends/secretBuilder.js', () => ({ + augmentProjectSecrets: vi.fn().mockResolvedValue({}), + resolveGitHubToken: vi.fn(), + injectGitHubAckCommentId: vi.fn(), + injectProgressCommentId: vi.fn(), +})); + +vi.mock('../../../src/backends/sidecarManager.js', () => ({ + createCompletionArtifacts: vi.fn().mockReturnValue({}), +})); + +import { buildPromptContext } from '../../../src/agents/shared/promptContext.js'; +import { + buildExecutionPlan, + injectRunLinkSecrets, +} from '../../../src/backends/secretOrchestrator.js'; +import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js'; import type { ProjectConfig } from '../../../src/types/index.js'; import { getDashboardUrl } from '../../../src/utils/runLink.js'; const mockGetDashboardUrl = vi.mocked(getDashboardUrl); +const mockGetSentryIntegrationConfig = vi.mocked(getSentryIntegrationConfig); +const mockBuildPromptContext = vi.mocked(buildPromptContext); function makeProject(overrides?: Partial): ProjectConfig { return { @@ -24,6 +85,91 @@ function makeProject(overrides?: Partial): ProjectConfig { beforeEach(() => { mockGetDashboardUrl.mockReturnValue(undefined); + vi.clearAllMocks(); +}); + +describe('buildExecutionPlan', () => { + it('fetches sentry config for alerting agent', async () => { + mockGetSentryIntegrationConfig.mockResolvedValueOnce({ + organizationSlug: 'org', + resultsContainerId: 'sentry-container-123', + }); + + const project = makeProject(); + await buildExecutionPlan( + 'alerting', + { project, config: {}, triggerType: 'sentry:issue-created' } as unknown as any, + '/repo', + {} as unknown as any, + {} as unknown as any, + 'token', + false, + 'claude-code', + {} as unknown as any, + ); + + expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('test-project'); + expect(mockBuildPromptContext).toHaveBeenCalledWith( + undefined, + project, + 'sentry:issue-created', + undefined, + undefined, + 'sentry-container-123', + ); + }); + + it('does not fetch sentry config for non-alerting agent', async () => { + const project = makeProject(); + await buildExecutionPlan( + 'implementation', + { project, config: {}, triggerType: 'manual' } as unknown as any, + '/repo', + {} as unknown as any, + {} as unknown as any, + 'token', + false, + 'claude-code', + {} as unknown as any, + ); + + expect(mockGetSentryIntegrationConfig).not.toHaveBeenCalled(); + expect(mockBuildPromptContext).toHaveBeenCalledWith( + undefined, + project, + 'manual', + undefined, + undefined, + undefined, + ); + }); + + it('handles sentry config failure gracefully', async () => { + mockGetSentryIntegrationConfig.mockRejectedValueOnce(new Error('DB failure')); + + const project = makeProject(); + await buildExecutionPlan( + 'alerting', + { project, config: {}, triggerType: 'sentry:issue-created' } as unknown as any, + '/repo', + {} as unknown as any, + {} as unknown as any, + 'token', + false, + 'claude-code', + {} as unknown as any, + ); + + expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('test-project'); + expect(mockBuildPromptContext).toHaveBeenCalledWith( + undefined, + project, + 'sentry:issue-created', + undefined, + undefined, + undefined, + ); + }); }); describe('injectRunLinkSecrets', () => { diff --git a/tests/unit/sentry/integration.test.ts b/tests/unit/sentry/integration.test.ts index 08264b05..aba67428 100644 --- a/tests/unit/sentry/integration.test.ts +++ b/tests/unit/sentry/integration.test.ts @@ -96,6 +96,46 @@ describe('sentry/integration', () => { expect(result).toEqual({ organizationSlug: 'acme-corp' }); }); + it('returns resultsContainerId when present in config', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-3', + provider: 'sentry', + config: { organizationSlug: 'my-org', resultsContainerId: 'list-backlog-123' }, + }); + + const result = await getSentryIntegrationConfig('proj-3'); + + expect(result).toEqual({ + organizationSlug: 'my-org', + resultsContainerId: 'list-backlog-123', + }); + }); + + it('omits resultsContainerId when absent from config', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-4', + provider: 'sentry', + config: { organizationSlug: 'my-org' }, + }); + + const result = await getSentryIntegrationConfig('proj-4'); + + expect(result).toEqual({ organizationSlug: 'my-org' }); + expect(result?.resultsContainerId).toBeUndefined(); + }); + + it('omits resultsContainerId when it is not a string', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-5', + provider: 'sentry', + config: { organizationSlug: 'my-org', resultsContainerId: 42 }, + }); + + const result = await getSentryIntegrationConfig('proj-5'); + + expect(result?.resultsContainerId).toBeUndefined(); + }); + it('queries using projectId and alerting category', async () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx index 3363f497..71308309 100644 --- a/web/src/components/projects/integration-alerting-tab.tsx +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -2,7 +2,7 @@ * Alerting (Sentry) integration tab component. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Trash2 } from 'lucide-react'; +import { Info, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { CopyButton } from '@/components/ui/copy-button.js'; import { Input } from '@/components/ui/input.js'; @@ -11,6 +11,189 @@ import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { ProjectSecretField } from './project-secret-field.js'; +// ============================================================================ +// PM Container Picker +// ============================================================================ + +interface ContainerPickerProps { + projectId: string; + pmProvider: string; + /** The project's existing PM integration config (used to derive discovery args). */ + pmConfig: Record; + value: string; + onChange: (id: string) => void; +} + +interface ProviderPickerConfig { + /** Discovery capability to call for fetching the right items. */ + capability: 'states' | 'teams'; + /** Build the `args` object for the discover call from the project's PM config. */ + getArgs: (pmConfig: Record) => Record; + /** + * Which property of the returned discovery item to save as `resultsContainerId`. + * - JIRA `states` → `name` (status name — the adapter matches transitions by name). + * - Linear `teams` → `id` (team UUID used directly as `backlogListId`). + */ + getOptionValue: (item: { id: string; name: string }) => string; +} + +/** + * Returns discovery config for a PM provider, or `undefined` when no + * dropdown picker is supported for that provider. + * + * - JIRA: `states` capability, scoped to the configured project key. + * `backlogListId` for JIRA = a status name (e.g. "Backlog"), so the picker + * saves `item.name` not the numeric state ID. + * - Linear: `teams` capability; team ID is correct for `backlogListId`. + * - Trello: no `lists` discovery capability exists in the manifest + * (`boards` capability returns boards, not lists within a board). + * Trello users must enter the list ID manually. + */ +function providerPickerConfig(provider: string): ProviderPickerConfig | undefined { + switch (provider) { + case 'jira': + return { + capability: 'states', + // `containerId` must be the JIRA project key (e.g. "PROJ") so + // the states endpoint fetches statuses for the configured project. + getArgs: (pmConfig) => ({ containerId: (pmConfig.projectKey as string) ?? '' }), + // JIRA's adapter matches transitions by status NAME, not numeric ID. + getOptionValue: (item) => item.name, + }; + case 'linear': + return { + capability: 'teams', + getArgs: () => ({}), + getOptionValue: (item) => item.id, + }; + default: + // Trello (and any unknown provider): no lists-level capability. + return undefined; + } +} + +function PMContainerPicker({ + projectId, + pmProvider, + pmConfig, + value, + onChange, +}: ContainerPickerProps) { + const pickerConfig = providerPickerConfig(pmProvider); + + const containersMutation = useMutation({ + mutationFn: async () => { + if (!pickerConfig) { + throw new Error(`No container discovery capability for provider "${pmProvider}"`); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: pmProvider, + capability: pickerConfig.capability, + args: pickerConfig.getArgs(pmConfig), + projectId, + })) as Array<{ id: string; name: string }>; + }, + }); + + return ( +
+
+ + +
+ {containersMutation.isError && ( +

{containersMutation.error.message}

+ )} +

+ Or enter the ID manually:{' '} + onChange(e.target.value)} + placeholder="list-id or status-name" + className="ml-1 inline-block h-6 rounded border border-input bg-background px-2 text-xs" + /> +

+
+ ); +} + +/** + * Renders the Investigation Results container input. + * Shows a `PMContainerPicker` when the provider supports dropdown discovery, + * or falls back to a plain text `Input` for Trello and unconfigured projects. + * Extracted to keep `AlertingTab`'s cognitive complexity below the project limit. + */ +function ContainerInput({ + projectId, + pmProvider, + pmConfig, + value, + onChange, +}: { + projectId: string; + pmProvider: string | undefined; + pmConfig: Record | undefined; + value: string; + onChange: (v: string) => void; +}) { + if (pmProvider && providerPickerConfig(pmProvider)) { + return ( + + ); + } + const placeholder = pmProvider + ? `Enter list ID manually (no picker available for ${pmProvider})` + : 'List ID or status name (configure PM integration to use a picker)'; + return ( + onChange(e.target.value)} + placeholder={placeholder} + /> + ); +} + // ============================================================================ // Alerting Tab (Sentry) // ============================================================================ @@ -18,15 +201,27 @@ import { ProjectSecretField } from './project-secret-field.js'; interface AlertingTabProps { projectId: string; alertingIntegration?: Record; + /** PM provider slug (e.g. "trello", "jira", "linear") when a PM integration is configured. */ + pmProvider?: string; + /** The project's existing PM integration config, used to drive the container picker. */ + pmConfig?: Record; } -export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { +export function AlertingTab({ + projectId, + alertingIntegration, + pmProvider, + pmConfig, +}: AlertingTabProps) { const queryClient = useQueryClient(); const existingConfig = (alertingIntegration?.config as Record) ?? {}; const [organizationSlug, setOrganizationSlug] = useState( (existingConfig.organizationSlug as string) ?? '', ); + const [resultsContainerId, setResultsContainerId] = useState( + (existingConfig.resultsContainerId as string) ?? '', + ); const [verifyResult, setVerifyResult] = useState<{ id: string; @@ -80,7 +275,10 @@ export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps projectId, category: 'alerting', provider: 'sentry', - config: { organizationSlug }, + config: { + organizationSlug, + ...(resultsContainerId ? { resultsContainerId } : {}), + }, }); }, onSuccess: () => { @@ -106,6 +304,17 @@ export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps return (
+ {/* Agent enablement info box */} +
+ +
+ Enable the Alerting Agent — After saving this + integration, go to the Agents tab and enable the{' '} + alerting agent type so Sentry alerts trigger + investigation runs automatically. +
+
+ {/* Organization Slug */}
@@ -123,6 +332,24 @@ export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps
+ {/* Investigation Results List */} +
+ +

+ The PM list or status where the alerting agent creates investigation work items. Used as + the target container when the agent creates bug fix cards. +

+ +
+ +
+ {/* Credentials */}
diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 328bed69..12bab445 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -99,7 +99,12 @@ export function IntegrationForm({ projectId }: { projectId: string }) { {activeTab === 'scm' && } {activeTab === 'alerting' && ( - + ) : undefined} + /> )}
);