diff --git a/docs/architecture/02-webhook-pipeline.md b/docs/architecture/02-webhook-pipeline.md index fda4e4ef3..214fd2070 100644 --- a/docs/architecture/02-webhook-pipeline.md +++ b/docs/architecture/02-webhook-pipeline.md @@ -85,6 +85,8 @@ interface ParsedWebhookEvent { | `LinearRouterAdapter` | `src/router/adapters/linear.ts` | Linear team ID | | `SentryRouterAdapter` | `src/router/adapters/sentry.ts` | CASCADE `projectId` (from URL) | +Sentry keeps the route shape `/sentry/webhook/:projectId`. The project ID selects the Cascade project, whose Sentry config includes a paired `organizationSlug`/`projectSlug`. The adapter filters payloads by matching Sentry project identifiers against the configured `projectSlug`; organization-level deliveries whose payload project does not match are acknowledged but do not dispatch agents. + ## The 12-Step Pipeline `src/router/webhook-processor.ts` — `processRouterWebhook()` diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index c8812535b..9820e89b0 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -136,8 +136,9 @@ function registerBuiltInTriggers(registry: TriggerRegistry): void { | Handler | Event | Agent | |---------|-------|-------| -| `AlertingIssueTrigger` | Sentry issue alert | `alerting` | -| `AlertingMetricTrigger` | Sentry metric alert | `alerting` | +| `AlertingIssueTrigger` | Sentry `event_alert` resource | `alerting` | +| `AlertingMetricTrigger` | Sentry `metric_alert` resource | `alerting` | +| `SentryIssueLifecycleTrigger` | Sentry `issue` lifecycle resource | `alerting` | ## Trigger Configuration @@ -147,7 +148,7 @@ Triggers use category-prefixed events from `src/triggers/shared/events.ts`. `TRI - PM: `pm:status-changed`, `pm:label-added`, `pm:comment-mention` - SCM: `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted`, `scm:review-requested`, `scm:pr-opened`, `scm:pr-comment-mention`, `scm:pr-merged`, `scm:pr-ready-to-merge`, `scm:pr-conflict-detected` -- Alerting: `alerting:issue-alert`, `alerting:metric-alert` +- Alerting: `alerting:issue-alert`, `alerting:metric-alert`, `alerting:issue-lifecycle` - Internal: `internal:auto-chain` New handlers should import `TRIGGER_EVENTS` instead of adding raw string literals. The static guard in `tests/unit/triggers/trigger-event-consistency.test.ts` fails when a handler gates on one event string and emits a different `agentInput.triggerEvent`. @@ -203,7 +204,7 @@ Each trigger in a YAML agent definition can declare a `contextPipeline` — an o | `prepopulateTodos` | Pre-populate todo list from work item checklists | | `prContext` | Fetch PR details, GitHub changed-file metadata, locally verified compact per-file diffs from `origin/...HEAD`, and CI checks; emit a `SKIPPED FILES` injection when files are omitted (over budget, deleted, binary/no patch, or local diff unavailable) | | `prConversation` | Fetch PR comments and review threads | -| `pipelineSnapshot` | Fetch CI pipeline status | +| `pipelineSnapshot` | Fetch PM workflow/pipeline state and emit the single authoritative `PipelineSnapshotSummary` JSON context for backlog-manager | | `alertingIssue` | Fetch Sentry issue and event details | ## Shared Agent Execution diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md index b2b82ac4f..6b22a5c0b 100644 --- a/docs/architecture/04-agent-system.md +++ b/docs/architecture/04-agent-system.md @@ -212,6 +212,14 @@ Pipeline prompts receive separate PM identifiers for selection and creation: For Trello, BACKLOG is a list, so `backlogStatusId` and `workItemCreateContainerId` are both the backlog list ID. For JIRA, `backlogStatusId` is `jira.statuses.backlog` and creation uses `jira.projectKey`. For Linear, `backlogStatusId` is `linear.statuses.backlog` and creation uses `linear.teamId`; backlog-manager must not use the Linear team ID to discover candidate backlog issues. +### Backlog-manager pipeline context + +The `backlog-manager` agent requires the `pipelineSnapshot` context step on every run. That internal step key now emits exactly one context injection named `PipelineSnapshotSummary`; its `result` is structured JSON, not markdown. The JSON is the authoritative contract for active pipeline capacity, backlog ordering, per-status counts, `itemsById`, comments, checklists, labels, descriptions, attachments/media references, dependency signals, and provider or item-read errors. + +The previous human-readable markdown `PipelineSnapshot` context was intentionally removed as a clean contract break. Prompt policy should read `PipelineSnapshotSummary.statuses..itemIds` and `PipelineSnapshotSummary.itemsById` directly instead of parsing formatted work-item text. + +When capacity is available but every backlog item is blocked, backlog-manager posts the `Backlog Blocked` comment exactly once on the first item in `PipelineSnapshotSummary.statuses.backlog.itemIds` provider order. If BACKLOG is empty, it exits silently without posting that comment. Selected items are still moved only from BACKLOG to TODO with `MoveWorkItem.expectedSourceState` set to the configured backlog source. + ### Alert task prompt context Alerting task prompts can reference scalar alert fields passed through `AgentInput`: diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index d4ef73a73..45a805421 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -148,8 +148,8 @@ Each provider declares its credential roles — the mapping from logical role na - `SentryAlertingIntegration` implements `AlertingIntegration` - `sentryClient` — REST API client with Bearer token auth -- Supports issue alerts, metric alerts, and issue lifecycle webhooks -- Config: `organizationSlug` stored in `project_integrations.config` JSONB +- Supports Sentry webhook resources `event_alert`, `metric_alert`, and `issue` lifecycle +- Config: `organizationSlug` and `projectSlug` stored in `project_integrations.config` JSONB ## PM Abstraction diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 3812c2f9c..6c376c1aa 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -77,6 +77,16 @@ Backlog selection and work-item creation use different native concepts: For Linear, `teamId` is not a backlog state. It is the container used when creating or searching issues, and unfiltered team listing may include unmapped workflow states such as Ideas. Pipeline snapshot and backlog-manager paths use status-aware listing (`ListWorkItems --status backlog`) so only the configured `linear.statuses.backlog` state is eligible for selection. +Backlog-manager receives pipeline state through the required `pipelineSnapshot` +context step, which emits one structured `PipelineSnapshotSummary` JSON +injection. The JSON includes provider-source status counts, active pipeline +count, backlog item order, `itemsById`, comments, checklists, labels, +descriptions, attachments/media references, dependency signals, and provider +errors. The legacy markdown `PipelineSnapshot` context is intentionally not +emitted. If all backlog items appear blocked, backlog-manager posts the blocked +backlog comment only on the first BACKLOG item in `statuses.backlog.itemIds` +provider order; when BACKLOG is empty it exits without posting. + `maxInFlightItems` is enforced at two points: (a) the `backlog-manager` chain gates (won't auto-pull from BACKLOG when at capacity) and (b) the PM `status-changed` triggers (won't fire `implementation` when a card is moved diff --git a/docs/getting-started.md b/docs/getting-started.md index 6be0c1076..b0ef84090 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -307,7 +307,7 @@ node bin/cascade.js webhooks create my-project \ --callback-url https://your-tunnel.ngrok.io ``` -This creates webhooks on GitHub, Trello, and Jira when those integrations are configured, reusing existing hooks when the canonical callback URL already exists. Linear and Sentry are informational/manual setup paths: the dashboard and API show the correct callback URL and whether a signing secret is stored, but you create the webhook in the provider UI. +This creates webhooks on GitHub, Trello, and Jira when those integrations are configured, reusing existing hooks when the canonical callback URL already exists. Linear and Sentry are informational/manual setup paths: the dashboard and API show the correct callback URL and whether a signing secret is stored, but you create the webhook in the provider UI. For Sentry, the URL remains `https://your-router-host/sentry/webhook/:projectId`; organization-level deliveries may reach that URL, but Cascade dispatches only payloads whose Sentry project matches the configured `projectSlug`. | Provider | Setup behavior | Callback URL | |----------|----------------|--------------| @@ -315,7 +315,7 @@ This creates webhooks on GitHub, Trello, and Jira when those integrations are co | Trello | Programmatic create/list/delete | `https://your-router-host/trello/webhook` | | Jira | Programmatic create/list/delete plus label ensure | `https://your-router-host/jira/webhook` | | Linear | Manual setup with optional `LINEAR_WEBHOOK_SECRET` | `https://your-router-host/linear/webhook` | -| Sentry | Manual setup with optional Sentry webhook secret | `https://your-router-host/sentry/webhook/my-project` | +| Sentry | Manual setup with optional Sentry webhook secret; paired with configured `organizationSlug`/`projectSlug` and filtered by payload project matching `projectSlug` | `https://your-router-host/sentry/webhook/my-project` | --- diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 53cf66569..26f7692ca 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -58,7 +58,7 @@ triggers: # Required context — runs for EVERY invocation regardless of trigger source. # Manual `cascade runs trigger --agent-type backlog-manager` would otherwise # skip the per-trigger contextPipeline (triggerEvent is undefined), and the -# agent would freelance with no snapshot. Empty pipelineSnapshot result +# agent would freelance with no structured PipelineSnapshotSummary JSON. Empty pipelineSnapshot result # aborts the run with a structured error + Sentry capture under tag # `context_pipeline_required_step_failed` (closes the 2026-04-29 incident # where MNG-422 was pulled from SPLITTING into TODO). @@ -70,10 +70,11 @@ prompts: # The system prompt (.eta file) contains full instructions. # This taskPrompt provides the per-run context and directive. taskPrompt: | - A Pipeline Snapshot has been pre-loaded into your context with the current state of all pipeline lists (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE, MERGED). + A PipelineSnapshotSummary JSON object has been pre-loaded into your context with the current state of all pipeline statuses (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE, MERGED). - 1. Review the pre-loaded Pipeline Snapshot and count items currently in the active pipeline (TODO + IN PROGRESS + IN REVIEW). - 2. If the count is below the capacity limit (see system prompt): use the pre-loaded BACKLOG data from the snapshot to select ALL eligible unblocked items to fill remaining capacity completely — always move the maximum number possible. + 1. Parse PipelineSnapshotSummary. If activeCapacityReliable is false, abort immediately — capacity count is unreliable due to a fetch error; do not move any items. Otherwise use activePipelineCount for items currently in the active pipeline (TODO + IN PROGRESS + IN REVIEW). + 2. If the count is below the capacity limit (see system prompt): use PipelineSnapshotSummary.statuses.backlog.itemIds in provider order plus itemsById details to select ALL eligible unblocked items to fill remaining capacity completely — always move the maximum number possible. 3. If already at or above capacity: exit immediately without taking action. + 4. If every BACKLOG item is blocked, post one Backlog Blocked comment on the first BACKLOG item in PipelineSnapshotSummary.statuses.backlog.itemIds order. If BACKLOG is empty, exit silently. hint: Only act if pipeline has capacity (items in TODO + IN PROGRESS + IN REVIEW < maxInFlightItems). When acting, ALWAYS fill ALL remaining capacity. diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index 5f43beb8d..4d2021f19 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -7,7 +7,11 @@ import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js'; import { ListDirectory } from '../../gadgets/ListDirectory.js'; -import { readWorkItem, readWorkItemWithMedia } from '../../gadgets/pm/core/readWorkItem.js'; +import { + readStructuredWorkItemDetails, + readWorkItemWithMedia, + type StructuredWorkItemDetails, +} from '../../gadgets/pm/core/readWorkItem.js'; import { formatSentryEvent } from '../../gadgets/sentry/core/format.js'; import type { Todo } from '../../gadgets/todo/storage.js'; import { @@ -18,6 +22,13 @@ import { } from '../../gadgets/todo/storage.js'; import { githubClient } from '../../github/client.js'; import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js'; +import type { + Attachment, + Checklist, + MediaReference, + WorkItem, + WorkItemLabel, +} from '../../pm/index.js'; import { getPMProviderOrNull } from '../../pm/index.js'; import { getSentryClient } from '../../sentry/client.js'; import type { AgentInput, ProjectConfig } from '../../types/index.js'; @@ -367,8 +378,69 @@ interface PipelineListResult { error: string | null; } +type PipelineStatusKey = PipelineList['statusKey']; + +interface PipelineCommentSummary { + id: string; + authorName: string; + date: string; + text: string; +} + +interface PipelineDependencySignal { + sourceType: 'description' | 'comment' | 'checklist' | 'attachment'; + sourceId?: string; + text: string; + matches: string[]; +} + +interface PipelineStatusSummary { + statusKey: PipelineStatusKey; + statusName: string; + itemIds: string[]; + count: number; + error?: string; +} + +interface PipelineItemSummary { + id: string; + title: string; + url: string; + statusKey: PipelineStatusKey; + statusName: string; + providerStatus?: string; + providerStatusId?: string; + description?: string; + labels: WorkItemLabel[]; + checklists: Checklist[]; + comments: PipelineCommentSummary[]; + attachments: Attachment[]; + mediaReferences: MediaReference[]; + dependencySignals: PipelineDependencySignal[]; + error?: string; +} + +interface PipelineSnapshotSummary { + schemaVersion: 1; + provider: string; + statuses: Partial>; + activePipelineCount: number; + /** + * `true` when all active-status list fetches (todo, inProgress, inReview) succeeded. + * `false` when any active-status fetch failed — the count is a lower bound, not authoritative. + * When false, the backlog-manager MUST abort without moving items. + */ + activeCapacityReliable: boolean; + activeStatusKeys: PipelineStatusKey[]; + itemsById: Record; + errors: Array<{ statusKey?: PipelineStatusKey; itemId?: string; message: string }>; +} + const PIPELINE_DETAIL_LISTS = new Set(['BACKLOG', 'TODO', 'IN_PROGRESS', 'IN_REVIEW']); const PIPELINE_DETAIL_CONCURRENCY = 5; +const ACTIVE_PIPELINE_STATUS_KEYS: PipelineStatusKey[] = ['todo', 'inProgress', 'inReview']; +const DEPENDENCY_SIGNAL_REGEX = + /\b(?:blocked by|depends on|waiting for|after|requires)\b|[A-Z][A-Z0-9]+-\d+|https?:\/\/\S+/gi; function buildPipelineLists(project: ProjectConfig): PipelineList[] { const trelloConfig = getTrelloConfig(project); @@ -437,15 +509,15 @@ function collectItemsNeedingFullDetails(listResults: PipelineListResult[]): Arra async function fetchFullPipelineDetails( items: Array<{ id: string }>, logWriter: LogWriter, -): Promise> { - const fullDetails = new Map(); +): Promise> { + const fullDetails = new Map(); for (let i = 0; i < items.length; i += PIPELINE_DETAIL_CONCURRENCY) { const batch = items.slice(i, i + PIPELINE_DETAIL_CONCURRENCY); await Promise.all( batch.map(async ({ id }) => { try { - const details = await readWorkItem(id, true); + const details = await readStructuredWorkItemDetails(id, true); fullDetails.set(id, details); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -453,7 +525,7 @@ async function fetchFullPipelineDetails( workItemId: id, error: message, }); - fullDetails.set(id, `Error reading details: ${message}`); + fullDetails.set(id, { error: message }); } }), ); @@ -462,59 +534,190 @@ async function fetchFullPipelineDetails( return fullDetails; } -function appendPipelineSection( - sections: string[], - listResult: PipelineListResult, - fullDetails: Map, -): void { - const { list, items, error } = listResult; +function collectDependencySignalsFromText( + sourceType: PipelineDependencySignal['sourceType'], + text: string | undefined, + sourceId?: string, +): PipelineDependencySignal[] { + const matches = Array.from(new Set(text?.match(DEPENDENCY_SIGNAL_REGEX) ?? [])); + if (!text || matches.length === 0) return []; + return [{ sourceType, sourceId, text, matches }]; +} - sections.push(`## ${list.name} (status: ${list.statusKey})`); - sections.push(''); +function collectDependencySignals(details: { + item: WorkItem; + checklists: Checklist[]; + attachments: Attachment[]; + comments: PipelineCommentSummary[]; +}): PipelineDependencySignal[] { + const signals: PipelineDependencySignal[] = [ + ...collectDependencySignalsFromText('description', details.item.description), + ]; - if (error) { - sections.push(`_Failed to fetch: ${error}_`); - sections.push(''); - return; + for (const checklist of details.checklists) { + for (const item of checklist.items) { + signals.push(...collectDependencySignalsFromText('checklist', item.name, item.id)); + } } - if (!items || items.length === 0) { - sections.push('_Empty — no items_'); - sections.push(''); - return; + for (const comment of details.comments) { + signals.push(...collectDependencySignalsFromText('comment', comment.text, comment.id)); } - sections.push(`${items.length} item(s):`); - sections.push(''); + for (const attachment of details.attachments) { + signals.push(...collectDependencySignalsFromText('attachment', attachment.url, attachment.id)); + } - if (!PIPELINE_DETAIL_LISTS.has(list.name)) { - for (const item of items) { - sections.push(`- [${item.id}] ${item.title}${item.url ? ` (${item.url})` : ''}`); - } - sections.push(''); - return; + return signals; +} + +function summarizeComments(details: StructuredWorkItemDetails): PipelineCommentSummary[] { + return details.comments.map((comment) => ({ + id: comment.id, + authorName: comment.author.name, + date: comment.date, + text: comment.text, + })); +} + +function buildItemSummary( + list: PipelineList, + listItem: WorkItem, + fullDetails: Map, +): PipelineItemSummary { + const detail = fullDetails.get(listItem.id); + if (detail && 'error' in detail) { + return { + id: listItem.id, + title: listItem.title, + url: listItem.url, + statusKey: list.statusKey, + statusName: list.name, + providerStatus: listItem.status, + providerStatusId: listItem.statusId, + description: listItem.description, + labels: listItem.labels, + checklists: [], + comments: [], + attachments: [], + mediaReferences: [], + dependencySignals: collectDependencySignalsFromText('description', listItem.description), + error: detail.error, + }; } - for (const item of items) { - const details = fullDetails.get(item.id); - if (details) { - sections.push(`### [${item.id}] ${item.title}`); - sections.push(''); - sections.push(details); - sections.push(''); + if (detail) { + const comments = summarizeComments(detail); + const item = detail.item; + return { + id: item.id, + title: item.title, + url: item.url, + statusKey: list.statusKey, + statusName: list.name, + providerStatus: item.status, + providerStatusId: item.statusId, + description: item.description, + labels: item.labels, + checklists: detail.checklists, + comments, + attachments: detail.attachments, + mediaReferences: detail.media, + dependencySignals: collectDependencySignals({ + item, + checklists: detail.checklists, + attachments: detail.attachments, + comments, + }), + }; + } + + const compactDetails = { + item: listItem, + checklists: [] as Checklist[], + attachments: [] as Attachment[], + comments: [] as PipelineCommentSummary[], + }; + return { + id: listItem.id, + title: listItem.title, + url: listItem.url, + statusKey: list.statusKey, + statusName: list.name, + providerStatus: listItem.status, + providerStatusId: listItem.statusId, + description: listItem.description, + labels: listItem.labels, + checklists: [], + comments: [], + attachments: [], + mediaReferences: listItem.inlineMedia ?? [], + dependencySignals: collectDependencySignals(compactDetails), + }; +} + +function buildPipelineSnapshotSummary( + listResults: PipelineListResult[], + fullDetails: Map, + provider: NonNullable>, +): PipelineSnapshotSummary { + const statuses: PipelineSnapshotSummary['statuses'] = {}; + const itemsById: Record = {}; + const errors: PipelineSnapshotSummary['errors'] = []; + + for (const { list, items, error } of listResults) { + const itemIds = items?.map((item) => item.id) ?? []; + statuses[list.statusKey] = { + statusKey: list.statusKey, + statusName: list.name, + itemIds, + count: itemIds.length, + ...(error ? { error } : {}), + }; + + if (error) { + errors.push({ statusKey: list.statusKey, message: error }); continue; } - sections.push(`- [${item.id}] ${item.title} _(details unavailable)_`); + for (const item of items ?? []) { + const summary = buildItemSummary(list, item, fullDetails); + itemsById[item.id] = summary; + if (summary.error) { + errors.push({ statusKey: list.statusKey, itemId: item.id, message: summary.error }); + } + } } + + const activePipelineCount = ACTIVE_PIPELINE_STATUS_KEYS.reduce( + (total, statusKey) => total + (statuses[statusKey]?.count ?? 0), + 0, + ); + + // If any active-status list fetch failed, the count is a lower bound, not authoritative. + // Callers must treat capacity as unknown and abort moves when this is false. + const activeCapacityReliable = ACTIVE_PIPELINE_STATUS_KEYS.every( + (statusKey) => !statuses[statusKey]?.error, + ); + + return { + schemaVersion: 1, + provider: provider.type, + statuses, + activePipelineCount, + activeCapacityReliable, + activeStatusKeys: ACTIVE_PIPELINE_STATUS_KEYS, + itemsById, + errors, + }; } /** - * Fetch full contents of all pipeline lists (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE, MERGED) - * and inject them as a structured snapshot into agent context. + * Fetch pipeline state (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE, MERGED) + * and inject it as the structured PipelineSnapshotSummary JSON contract. * - * This allows the backlog-manager agent to make decisions without making additional - * ListWorkItems or ReadWorkItem calls — the full pipeline state is pre-loaded. + * This allows the backlog-manager agent to make decisions without parsing the + * runtime ReadWorkItem markdown format. */ export async function fetchPipelineSnapshotStep( params: FetchContextParams, @@ -540,22 +743,14 @@ export async function fetchPipelineSnapshotStep( const listResults = await fetchPipelineLists(lists, provider, params.logWriter); const itemsNeedingFullDetails = collectItemsNeedingFullDetails(listResults); const fullDetails = await fetchFullPipelineDetails(itemsNeedingFullDetails, params.logWriter); - - // Format the snapshot - const sections: string[] = ['# Pipeline Snapshot', '']; - - for (const listResult of listResults) { - appendPipelineSection(sections, listResult, fullDetails); - } - - const result = sections.join('\n'); + const summary = buildPipelineSnapshotSummary(listResults, fullDetails, provider); return [ { - toolName: 'PipelineSnapshot', - params: { comment: 'Pre-fetched full pipeline snapshot across all lists' }, - result, - description: `Pre-fetched pipeline snapshot (${lists.length} lists, ${itemsNeedingFullDetails.length} items with full details)`, + toolName: 'PipelineSnapshotSummary', + params: { comment: 'Pre-fetched structured pipeline snapshot across all statuses' }, + result: JSON.stringify(summary, null, 2), + description: `Pre-fetched structured pipeline snapshot (${lists.length} statuses, ${itemsNeedingFullDetails.length} items with full details)`, }, ]; } diff --git a/src/agents/prompts/templates/backlog-manager.eta b/src/agents/prompts/templates/backlog-manager.eta index eb8e4303d..7fc2b7496 100644 --- a/src/agents/prompts/templates/backlog-manager.eta +++ b/src/agents/prompts/templates/backlog-manager.eta @@ -13,7 +13,7 @@ Use these EXACT IDs when calling `MoveWorkItem`. BACKLOG is a workflow state/lis CRITICAL: 1. **CHECK PIPELINE FIRST** - Count items in the active pipeline (TODO + IN PROGRESS + IN REVIEW) and compare to the capacity limit (<%= it.maxInFlightItems ?? 1 %>). 2. **CAPACITY LIMIT** - <%= it.maxInFlightItems == null || it.maxInFlightItems === 1 ? 'Move exactly one ' + (it.workItemNoun || 'card') + ' per run. Never move multiple.' : 'You MUST fill ALL remaining capacity. Move up to ' + it.maxInFlightItems + ' ' + (it.workItemNounPlural || 'cards') + ' per run — always move as many eligible items as there are open slots.' %> -3. **READ BEFORE SELECTING** - Read <%= it.workItemNoun || 'card' %> contents, descriptions, and checklists to make an informed decision. +3. **USE STRUCTURED SNAPSHOT DATA** - `PipelineSnapshotSummary` JSON contains the provider-source contents, descriptions, comments, checklists, labels, and dependency signals needed for selection. 4. DO NOT MANAGE LABELS - Labels are handled automatically by the system. ## Your Purpose @@ -22,12 +22,14 @@ You maintain flow by ensuring there's always work ready when the pipeline has ca ## Pipeline Status Check (MANDATORY FIRST STEP) -A **Pipeline Snapshot** has been pre-loaded into your context containing the current state of all pipeline lists. Use this pre-loaded data instead of calling `ListWorkItems`: +A `PipelineSnapshotSummary` JSON object has been pre-loaded into your context containing the current state of all pipeline lists/statuses. This JSON is the authoritative pipeline context. -1. **Check the pre-loaded snapshot** and count <%= it.workItemNounPlural || 'cards' %> in these active pipeline stages: - - TODO - - IN PROGRESS - - IN REVIEW +1. **Parse `PipelineSnapshotSummary` JSON** and use `activePipelineCount` as the active pipeline count. It is the count of <%= it.workItemNounPlural || 'cards' %> in these active pipeline stages: + - `todo` + - `inProgress` + - `inReview` + + **If `activeCapacityReliable` is `false`, abort immediately** — one or more active-status list fetches failed and the pipeline count is a lower bound, not authoritative. Do NOT move any items when capacity is unknown. Exit without posting comments. 2. **Capacity check**: If the count of active <%= it.workItemNounPlural || 'cards' %> (TODO + IN PROGRESS + IN REVIEW) is **>= <%= it.maxInFlightItems ?? 1 %>** (the capacity limit): - Exit immediately - the pipeline is at capacity @@ -42,17 +44,19 @@ Note: DONE and MERGED <%= it.workItemNounPlural || 'cards' %> are completed work When the active pipeline has capacity: -1. **Use pre-loaded BACKLOG data** from the Pipeline Snapshot — full details (title, description, checklists, comments) are already available. No need to call `ListWorkItems` or `ReadWorkItem` for BACKLOG <%= it.workItemNounPlural || 'cards' %>. If you must verify the backlog list, call `ListWorkItems` with `status: "backlog"`, never with a provider container ID. -2. **Review each <%= it.workItemNoun || 'card' %> from the snapshot** to understand: +1. **Use pre-loaded BACKLOG data** from `PipelineSnapshotSummary.statuses.backlog.itemIds` and `PipelineSnapshotSummary.itemsById` — full provider details (title, description, labels, checklists, comments, attachments, media references, and dependency signals) are already available. No need to call `ListWorkItems` or `ReadWorkItem` for BACKLOG <%= it.workItemNounPlural || 'cards' %>. If you must verify the backlog list, call `ListWorkItems` with `status: "backlog"`, never with a provider container ID. +2. **Review each <%= it.workItemNoun || 'card' %> from the JSON snapshot in `statuses.backlog.itemIds` order** to understand: - Title and description + - Labels - Checklists and acceptance criteria - Comments (may contain context or dependencies) + - `dependencySignals` evidence surfaced from provider descriptions, comments, checklists, attachments, issue IDs, URLs, and dependency keywords 3. **Identify blocked <%= it.workItemNounPlural || 'cards' %>** - look for: - References to other <%= it.workItemNounPlural || 'cards' %>: "blocked by", "depends on", "waiting for", "after" - Cross-references to <%= it.workItemNoun || 'card' %> IDs, URLs, or titles - Comments indicating external dependencies - **Stale annotations**: Text like "(not yet merged)" in a description was written when the <%= it.workItemNoun || 'card' %> was created and is **always stale**. Do NOT use it as evidence of blocked status — only the MERGED list itself is authoritative. - - **IMPORTANT — MERGED check**: Before declaring a <%= it.workItemNoun || 'card' %> blocked, scan the MERGED section of the Pipeline Snapshot. Use **substring matching**: if the dependency name (e.g., "SCMIntegration", "OpenCodeEngine", "integrationRoles") appears anywhere within a MERGED title, that dependency is **resolved** and does NOT block. Each MERGED entry also shows its URL in parentheses — if the description references a <%= it.pmName || 'PM' %> link, match it against the URL too. A module or class name found anywhere in a title counts as a match. + - **IMPORTANT — MERGED check**: Before declaring a <%= it.workItemNoun || 'card' %> blocked, scan `PipelineSnapshotSummary.statuses.merged.itemIds` and the matching `itemsById` entries. Use **substring matching**: if the dependency name (e.g., "SCMIntegration", "OpenCodeEngine", "integrationRoles") appears anywhere within a MERGED title, that dependency is **resolved** and does NOT block. If the description references a <%= it.pmName || 'PM' %> link, match it against the MERGED item URL too. A module or class name found anywhere in a title counts as a match. 4. **Select ALL eligible unblocked <%= it.workItemNounPlural || 'cards' %> up to remaining capacity** considering: - Smaller, self-contained <%= it.workItemNounPlural || 'cards' %> are preferred - <%= it.workItemNounPluralCap || 'Cards' %> with clear acceptance criteria @@ -75,6 +79,7 @@ The pipeline is now ready for implementation. ``` When all <%= it.workItemNounPlural || 'cards' %> are blocked: +Post this comment exactly once on the first BACKLOG item in `PipelineSnapshotSummary.statuses.backlog.itemIds` order. If BACKLOG is empty, exit silently without posting this comment. Do not post it on a DONE/MERGED trigger item and do not post it on every blocked backlog item. ``` **Backlog Blocked** @@ -96,7 +101,7 @@ Manual intervention may be needed to unblock the backlog. ## Rules -- **HARD CONSTRAINT — NEVER MOVE A <%= it.workItemNounCap || 'CARD' %> NOT IN BACKLOG.** The only valid source state/list is BACKLOG, and the only valid destination is TODO. Never move <%= it.workItemNounPlural || 'cards' %> from SPLITTING, PLANNING, TODO, IN_PROGRESS, IN_REVIEW, DONE, or MERGED — those <%= it.workItemNounPlural || 'cards' %> are already mid-pipeline and another agent or human owns their state. If you don't see a <%= it.workItemNoun || 'card' %> in the BACKLOG section of the Pipeline Snapshot, you CANNOT select it. NEVER call `ListWorkItems` against provider containers to discover candidates — the snapshot's BACKLOG section is the only valid source. If the snapshot is missing and you must verify, use `ListWorkItems` with `status: "backlog"` only. If the BACKLOG section is empty, ABORT — do not improvise by listing other lists. +- **HARD CONSTRAINT — NEVER MOVE A <%= it.workItemNounCap || 'CARD' %> NOT IN BACKLOG.** The only valid source state/list is BACKLOG, and the only valid destination is TODO. Never move <%= it.workItemNounPlural || 'cards' %> from SPLITTING, PLANNING, TODO, IN_PROGRESS, IN_REVIEW, DONE, or MERGED — those <%= it.workItemNounPlural || 'cards' %> are already mid-pipeline and another agent or human owns their state. If the <%= it.workItemNoun || 'card' %> ID is not in `PipelineSnapshotSummary.statuses.backlog.itemIds`, you CANNOT select it. NEVER call `ListWorkItems` against provider containers to discover candidates — the snapshot's BACKLOG item IDs are the only valid source. If the snapshot is missing and you must verify, use `ListWorkItems` with `status: "backlog"` only. If BACKLOG is empty, ABORT silently — do not improvise by listing other lists and do not post a blocked-backlog comment. - ALWAYS check pipeline status FIRST before scanning the backlog - NEVER move <%= it.workItemNounPlural || 'cards' %> if the active pipeline is at capacity (<%= it.maxInFlightItems ?? 1 %> item(s)) - EXIT SILENTLY if pipeline is at capacity - do not post comments @@ -105,6 +110,7 @@ Manual intervention may be needed to unblock the backlog. <% if ((it.maxInFlightItems ?? 1) > 1) { %>- MAXIMIZE THROUGHPUT — if remaining capacity is <%= it.maxInFlightItems %> and <%= it.maxInFlightItems %>+ unblocked items exist, you MUST move <%= it.maxInFlightItems %> items, not fewer. <% } %> - ALWAYS post a comment BEFORE moving the <%= it.workItemNoun || 'card' %> — comment first, then move to TODO +- If all backlog items are blocked, post the `Backlog Blocked` comment exactly once on the first BACKLOG item from `PipelineSnapshotSummary.statuses.backlog.itemIds`; if BACKLOG is empty, exit silently. - CONSERVATIVE on detecting dependencies — when unsure if text implies a dependency, treat it as one. But GENEROUS on MERGED resolution — use substring matching and prefer resolved over blocked for ambiguous matches. - LOOK FOR dependency keywords: "blocked by", "depends on", "waiting for", "after", "requires" - IGNORE "(not yet merged)" and similar stale annotations in descriptions — they are written at card creation and never updated. The MERGED list is the only source of truth. diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 62b474627..20d02e330 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -295,11 +295,21 @@ export const integrationsDiscoveryRouter = router({ * The token is never stored by this endpoint. */ verifySentry: protectedProcedure - .input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) })) + .input( + z.object({ + apiToken: z.string().min(1), + organizationSlug: z.string().trim().min(1), + projectSlug: z.string().trim().min(1).optional(), + }), + ) .mutation(async ({ ctx, input }) => { logger.debug('integrationsDiscovery.verifySentry called', { orgId: ctx.effectiveOrgId }); return wrapIntegrationCall('Failed to verify Sentry credentials', async () => { - const url = `https://sentry.io/api/0/organizations/${encodeURIComponent(input.organizationSlug)}/`; + const organizationSlug = input.organizationSlug; + const projectSlug = input.projectSlug; + const url = projectSlug + ? `https://sentry.io/api/0/projects/${encodeURIComponent(organizationSlug)}/${encodeURIComponent(projectSlug)}/` + : `https://sentry.io/api/0/organizations/${encodeURIComponent(organizationSlug)}/`; const response = await fetch(url, { headers: { Authorization: `Bearer ${input.apiToken}` }, }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 013812ac8..43f6fcd3e 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -42,6 +42,44 @@ async function verifyProjectOwnership(projectId: string, orgId: string) { } } +function normalizeIntegrationConfig(input: { + category: 'pm' | 'scm' | 'alerting'; + provider: string; + config: Record; +}): Record { + if (input.category !== 'alerting' || input.provider !== 'sentry') { + return input.config; + } + + const organizationSlug = + typeof input.config.organizationSlug === 'string' ? input.config.organizationSlug.trim() : ''; + const projectSlug = + typeof input.config.projectSlug === 'string' ? input.config.projectSlug.trim() : ''; + const resultsContainerId = + typeof input.config.resultsContainerId === 'string' + ? input.config.resultsContainerId.trim() + : ''; + + if (!organizationSlug) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Sentry organization slug is required', + }); + } + if (!projectSlug) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Sentry project slug is required', + }); + } + + return { + organizationSlug, + projectSlug, + ...(resultsContainerId ? { resultsContainerId } : {}), + }; +} + function serializeProject( project: T, ): Omit & { engineSettings: T['agentEngineSettings'] | null } { @@ -188,11 +226,12 @@ export const projectsRouter = router({ ) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); + const config = normalizeIntegrationConfig(input); return upsertProjectIntegration( input.projectId, input.category, input.provider, - input.config, + config, input.triggers, ); }), diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 950f44a03..60ee805cc 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -100,11 +100,15 @@ function buildSentryDisplayInfo( projectId: string, baseUrl: string, ): SentryWebhookInfo | undefined { - if (!pctx.sentryConfigured) return undefined; + if (!pctx.sentryConfigured || !pctx.sentryOrganizationSlug || !pctx.sentryProjectSlug) { + return undefined; + } return { url: `${baseUrl}/sentry/webhook/${projectId}`, webhookSecretSet: pctx.sentryWebhookSecretSet ?? false, - note: 'Configure this URL manually in your Sentry Internal Integration webhook settings.', + organizationSlug: pctx.sentryOrganizationSlug, + projectSlug: pctx.sentryProjectSlug, + note: `Configure this URL manually in your Sentry Internal Integration webhook settings for ${pctx.sentryOrganizationSlug}/${pctx.sentryProjectSlug}. Cascade dispatches only payloads whose Sentry project matches the configured project slug "${pctx.sentryProjectSlug}".`, }; } @@ -139,16 +143,13 @@ export const webhooksRouter = router({ jiraListWebhooks(pctx), ]); - // Sentry — informational only (webhooks must be configured in Sentry UI) - let sentry: SentryWebhookInfo | null = null; - if (input.callbackBaseUrl && pctx.sentryConfigured) { - const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - sentry = { - url: `${baseUrl}/sentry/webhook/${input.projectId}`, - webhookSecretSet: pctx.sentryWebhookSecretSet ?? false, - note: 'Configure this URL in your Sentry Internal Integration webhook settings.', - }; - } + const sentry = input.callbackBaseUrl + ? (buildSentryDisplayInfo( + pctx, + input.projectId, + input.callbackBaseUrl.replace(/\/$/, ''), + ) ?? null) + : null; // Linear — informational only (webhooks must be configured in Linear team settings) let linear: LinearWebhookInfo | null = null; diff --git a/src/api/routers/webhooks/context.ts b/src/api/routers/webhooks/context.ts index b4acc193e..61c69a8df 100644 --- a/src/api/routers/webhooks/context.ts +++ b/src/api/routers/webhooks/context.ts @@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getAllProjectCredentials } from '../../../config/provider.js'; import { findProjectByIdFromDb } from '../../../db/repositories/configRepository.js'; -import { getIntegrationByProjectAndCategory } from '../../../db/repositories/integrationsRepository.js'; import { getJiraConfig, getTrelloConfig } from '../../../pm/config.js'; +import { getSentryIntegrationConfig } from '../../../sentry/integration.js'; import { verifyProjectOrgAccess } from '../_shared/projectAccess.js'; import type { ProjectContext } from './types.js'; @@ -35,9 +35,8 @@ export async function resolveProjectContext( ] : undefined; - // Check if Sentry alerting integration is configured - const alertingIntegration = await getIntegrationByProjectAndCategory(projectId, 'alerting'); - const sentryConfigured = alertingIntegration?.provider === 'sentry' && !!creds.SENTRY_API_TOKEN; + const sentryConfig = await getSentryIntegrationConfig(projectId); + const sentryConfigured = !!creds.SENTRY_API_TOKEN && sentryConfig !== null; return { projectId, @@ -55,6 +54,8 @@ export async function resolveProjectContext( jiraApiToken: creds.JIRA_API_TOKEN ?? '', webhookSecret: creds.GITHUB_WEBHOOK_SECRET ?? undefined, sentryConfigured, + sentryOrganizationSlug: sentryConfig?.organizationSlug, + sentryProjectSlug: sentryConfig?.projectSlug, sentryWebhookSecretSet: !!creds.SENTRY_WEBHOOK_SECRET, linearApiKey: creds.LINEAR_API_KEY ?? undefined, linearWebhookSecretSet: !!creds.LINEAR_WEBHOOK_SECRET, diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index b3da303a3..ea81e3c02 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -29,6 +29,8 @@ export interface JiraWebhookInfo { export interface SentryWebhookInfo { url: string; webhookSecretSet: boolean; + organizationSlug: string; + projectSlug: string; note: string; } @@ -54,6 +56,8 @@ export interface ProjectContext { jiraApiToken?: string; webhookSecret?: string; sentryConfigured?: boolean; + sentryOrganizationSlug?: string; + sentryProjectSlug?: string; sentryWebhookSecretSet?: boolean; linearApiKey?: string; linearWebhookSecretSet?: boolean; diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index e990aa3ba..b381407cb 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -83,12 +83,18 @@ export default class WebhooksCreate extends DashboardCommand { this.log(''); this.log('Sentry (manual setup required):'); this.log(` Webhook URL: ${result.sentry.url}`); + this.log( + ` Paired project: ${result.sentry.organizationSlug}/${result.sentry.projectSlug}`, + ); this.log(` Webhook secret: ${result.sentry.webhookSecretSet ? 'configured' : 'not set'}`); + this.log(` Delivery filter: payload project must match "${result.sentry.projectSlug}".`); this.log(' Steps:'); this.log(' 1. Go to Sentry > Settings > Developer Settings > Internal Integrations'); this.log(' 2. Create or edit an Internal Integration'); this.log(' 3. Set the Webhook URL to the URL above'); - this.log(' 4. Enable "issue" and/or "event_alert" webhook subscriptions'); + this.log( + ' 4. Enable "event_alert", "metric_alert", and/or "issue" webhook subscriptions', + ); if (!result.sentry.webhookSecretSet) { this.log(' 5. Copy the Client Secret and save it as SENTRY_WEBHOOK_SECRET credential'); } diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts index 683c846ee..55a73e7db 100644 --- a/src/cli/dashboard/webhooks/list.ts +++ b/src/cli/dashboard/webhooks/list.ts @@ -87,7 +87,11 @@ export default class WebhooksList extends DashboardCommand { this.log('Sentry webhook:'); if (result.sentry) { this.log(` URL: ${result.sentry.url}`); + this.log( + ` Paired project: ${result.sentry.organizationSlug}/${result.sentry.projectSlug}`, + ); this.log(` Webhook secret: ${result.sentry.webhookSecretSet ? 'configured' : 'not set'}`); + this.log(` Delivery filter: payload project must match "${result.sentry.projectSlug}".`); this.log(` ${result.sentry.note}`); } else { this.log(' (not configured)'); diff --git a/src/gadgets/pm/core/readWorkItem.ts b/src/gadgets/pm/core/readWorkItem.ts index 968e3946a..12243f0fd 100644 --- a/src/gadgets/pm/core/readWorkItem.ts +++ b/src/gadgets/pm/core/readWorkItem.ts @@ -1,31 +1,14 @@ -import type { Attachment, MediaReference } from '../../../pm/index.js'; +import type { + Attachment, + Checklist, + MediaReference, + WorkItem, + WorkItemComment, + WorkItemLabel, +} from '../../../pm/index.js'; import { filterImageMedia, getPMProvider, getPMProviderOrNull } from '../../../pm/index.js'; import { logger } from '../../../utils/logging.js'; -interface Label { - name: string; - color?: string; -} - -interface ChecklistItem { - id: string; - name: string; - complete: boolean; -} - -interface Checklist { - id: string; - name: string; - items: ChecklistItem[]; -} - -interface Comment { - author: { name: string }; - date: string; - text: string; - inlineMedia?: MediaReference[]; -} - /** * Result returned by readWorkItemWithMedia(). */ @@ -38,7 +21,16 @@ export interface WorkItemWithMedia { urlsDetected: number; } -function formatLabels(labels: Label[]): string { +export interface StructuredWorkItemDetails { + item: WorkItem; + checklists: Checklist[]; + attachments: Attachment[]; + comments: WorkItemComment[]; + media: MediaReference[]; + urlsDetected: number; +} + +function formatLabels(labels: WorkItemLabel[]): string { if (labels.length === 0) return ''; const items = labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n'); return `## Labels\n\n${items}\n\n`; @@ -71,7 +63,7 @@ function formatAttachments(attachments: Attachment[]): string { return `${result}\n`; } -function formatComments(comments: Comment[]): string { +function formatComments(comments: WorkItemComment[]): string { if (comments.length === 0) return '## Comments\n\n(No comments)\n\n'; let result = `## Comments (${comments.length})\n\n`; for (const comment of comments.slice().reverse()) { @@ -111,6 +103,28 @@ export async function readWorkItemWithMedia( workItemId: string, includeComments = true, ): Promise { + const details = await readStructuredWorkItemDetails(workItemId, includeComments); + const { item, checklists, attachments, comments, media, urlsDetected } = details; + + let text = `# ${item.title}\n\n**URL:** ${item.url}\n\n## Description\n\n${item.description || '(No description)'}\n\n`; + text += formatLabels(item.labels); + text += formatChecklists(checklists); + text += formatAttachments(attachments); + + if (includeComments) { + text += formatComments(comments); + } + + // Append pre-fetched images section listing discovered images + text += formatPreFetchedImages(media); + + return { text, media, urlsDetected }; +} + +export async function readStructuredWorkItemDetails( + workItemId: string, + includeComments = true, +): Promise { const provider = getPMProvider(); const [item, checklists, attachments] = await Promise.all([ provider.getWorkItem(workItemId), @@ -140,20 +154,15 @@ export async function readWorkItemWithMedia( ), ); - let text = `# ${item.title}\n\n**URL:** ${item.url}\n\n## Description\n\n${item.description || '(No description)'}\n\n`; - text += formatLabels(item.labels); - text += formatChecklists(checklists); - text += formatAttachments(attachments); - + let comments: WorkItemComment[] = []; if (includeComments) { - const comments = await provider.getWorkItemComments(workItemId); + comments = await provider.getWorkItemComments(workItemId); for (const comment of comments) { if (comment.inlineMedia && comment.inlineMedia.length > 0) { urlsDetected += comment.inlineMedia.length; allMedia.push(...filterImageMedia(comment.inlineMedia)); } } - text += formatComments(comments); } // Deduplicate by URL — JIRA description images are always backed by an attachment, @@ -166,10 +175,7 @@ export async function readWorkItemWithMedia( return true; }); - // Append pre-fetched images section listing discovered images - text += formatPreFetchedImages(dedupedMedia); - - return { text, media: dedupedMedia, urlsDetected }; + return { item, checklists, attachments, comments, media: dedupedMedia, urlsDetected }; } /** diff --git a/src/router/adapters/sentry.ts b/src/router/adapters/sentry.ts index 28b2de594..36dbe0a19 100644 --- a/src/router/adapters/sentry.ts +++ b/src/router/adapters/sentry.ts @@ -9,9 +9,15 @@ import { withJiraCredentials } from '../../jira/client.js'; import { withLinearCredentials } from '../../linear/client.js'; +import { getSentryIntegrationConfig } from '../../sentry/integration.js'; +import { + formatSentryProjectMatchFailure, + matchSentryPayloadProject, +} from '../../sentry/project-filter.js'; import type { SentryAugmentedPayload } from '../../sentry/types.js'; import { withTrelloCredentials } from '../../trello/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; +import { buildSkipResult } from '../../triggers/shared/result-builders.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { loadProjectConfig, type RouterProjectConfig } from '../config.js'; @@ -112,6 +118,22 @@ export class SentryRouterAdapter implements RouterPlatformAdapter { return null; } + const sentryConfig = await getSentryIntegrationConfig(fullProject.id); + const projectMatch = matchSentryPayloadProject( + payload as SentryAugmentedPayload, + sentryConfig?.projectSlug, + ); + if (!projectMatch.allowed) { + const message = formatSentryProjectMatchFailure(projectMatch); + logger.info('SentryRouterAdapter: payload project filtered before dispatch', { + projectId: fullProject.id, + reason: projectMatch.reason, + configuredProjectSlug: projectMatch.configuredProjectSlug, + payloadProjects: projectMatch.payloadProjects, + }); + return buildSkipResult('sentry-project-filter', message); + } + const ctx: TriggerContext = { project: fullProject, source: 'sentry', payload }; // Establish PM credential scope so that materializeAlertWorkItem can call diff --git a/src/sentry/integration.ts b/src/sentry/integration.ts index 712751bb1..2d453a4a6 100644 --- a/src/sentry/integration.ts +++ b/src/sentry/integration.ts @@ -14,6 +14,8 @@ import { getIntegrationByProjectAndCategory } from '../db/repositories/integrati export interface SentryIntegrationConfig { /** Sentry organization slug (e.g. "my-company") */ organizationSlug: string; + /** Sentry project slug (e.g. "api") */ + projectSlug: string; /** * PM container ID where the alerting agent creates investigation work items. * Maps to the prompt creation container when no PM backlog is configured. @@ -36,12 +38,22 @@ export async function getSentryIntegrationConfig( if (!row || row.provider !== 'sentry') return null; const config = row.config as Record | null; - if (!config?.organizationSlug || typeof config.organizationSlug !== 'string') return null; + const organizationSlug = normalizeRequiredString(config?.organizationSlug); + const projectSlug = normalizeRequiredString(config?.projectSlug); + if (!organizationSlug || !projectSlug) return null; + + const resultsContainerId = normalizeRequiredString(config?.resultsContainerId); return { - organizationSlug: config.organizationSlug, - ...(typeof config.resultsContainerId === 'string' - ? { resultsContainerId: config.resultsContainerId } - : {}), + organizationSlug, + projectSlug, + ...(resultsContainerId ? { resultsContainerId } : {}), }; } + +function normalizeRequiredString(value: unknown): string | null { + if (typeof value !== 'string') return null; + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/sentry/project-filter.ts b/src/sentry/project-filter.ts new file mode 100644 index 000000000..75ab01193 --- /dev/null +++ b/src/sentry/project-filter.ts @@ -0,0 +1,237 @@ +import type { + SentryAugmentedPayload, + SentryHookResource, + SentryIssueAlertPayload, + SentryIssuePayload, + SentryMetricAlertPayload, + SentryWebhookPayload, +} from './types.js'; + +export interface SentryProjectCandidate { + id?: string; + slug?: string; + name?: string; + source: string; +} + +export type SentryProjectMatchReason = + | 'matched' + | 'missing_configured_project' + | 'missing_payload_project' + | 'project_mismatch'; + +export interface SentryProjectMatchResult { + allowed: boolean; + reason: SentryProjectMatchReason; + configuredProjectSlug: string | null; + payloadProjects: SentryProjectCandidate[]; +} + +export function formatSentryProjectMatchFailure(result: SentryProjectMatchResult): string { + const configuredProject = result.configuredProjectSlug ?? '(missing)'; + const payloadProjects = + result.payloadProjects + .map((project) => { + const identifiers = [ + project.id ? `id=${project.id}` : null, + project.slug ? `slug=${project.slug}` : null, + project.name ? `name=${project.name}` : null, + ] + .filter(Boolean) + .join(','); + return `${project.source}{${identifiers}}`; + }) + .join('; ') || '(missing)'; + + return `${result.reason}: configuredProjectSlug=${configuredProject}; payloadProjects=${payloadProjects}`; +} + +type ProjectLike = { + project?: unknown; + id?: unknown; + slug?: unknown; + name?: unknown; + project_slug?: unknown; + projectSlug?: unknown; +}; + +export function extractSentryPayloadProjects( + input: SentryAugmentedPayload | SentryWebhookPayload, + resourceOverride?: SentryHookResource, +): SentryProjectCandidate[] { + const { resource, payload } = unwrapPayload(input, resourceOverride); + if (!payload) return []; + + switch (resource) { + case 'event_alert': + return extractEventAlertProjects(payload as SentryIssueAlertPayload); + case 'metric_alert': + return extractMetricAlertProjects(payload as SentryMetricAlertPayload); + case 'issue': + return extractIssueProjects(payload as SentryIssuePayload); + default: + return []; + } +} + +export function matchSentryPayloadProject( + input: SentryAugmentedPayload | SentryWebhookPayload, + configuredProjectSlug: string | null | undefined, + resourceOverride?: SentryHookResource, +): SentryProjectMatchResult { + const normalizedConfiguredProjectSlug = normalizeComparable(configuredProjectSlug); + const payloadProjects = extractSentryPayloadProjects(input, resourceOverride); + + if (!normalizedConfiguredProjectSlug) { + return { + allowed: false, + reason: 'missing_configured_project', + configuredProjectSlug: null, + payloadProjects, + }; + } + + const trimmedConfiguredProjectSlug = configuredProjectSlug?.trim() ?? ''; + if (payloadProjects.length === 0) { + return { + allowed: false, + reason: 'missing_payload_project', + configuredProjectSlug: trimmedConfiguredProjectSlug, + payloadProjects, + }; + } + + const allowed = payloadProjects.some((project) => + projectMatches(project, trimmedConfiguredProjectSlug, normalizedConfiguredProjectSlug), + ); + + return { + allowed, + reason: allowed ? 'matched' : 'project_mismatch', + configuredProjectSlug: trimmedConfiguredProjectSlug, + payloadProjects, + }; +} + +function unwrapPayload( + input: SentryAugmentedPayload | SentryWebhookPayload, + resourceOverride?: SentryHookResource, +): { resource?: SentryHookResource; payload?: SentryWebhookPayload } { + if (isRecord(input) && 'payload' in input && 'resource' in input) { + const resource = typeof input.resource === 'string' ? input.resource : resourceOverride; + return { + resource: resource as SentryHookResource | undefined, + payload: input.payload as SentryWebhookPayload | undefined, + }; + } + + return { + resource: resourceOverride ?? inferResource(input as SentryWebhookPayload), + payload: input as SentryWebhookPayload, + }; +} + +function inferResource(payload: SentryWebhookPayload): SentryHookResource | undefined { + if (!isRecord(payload)) return undefined; + if (isRecord(payload.data) && 'event' in payload.data) return 'event_alert'; + if (isRecord(payload.data) && 'metric_alert' in payload.data) return 'metric_alert'; + if (isRecord(payload.data) && 'issue' in payload.data) return 'issue'; + return undefined; +} + +function extractEventAlertProjects(payload: SentryIssueAlertPayload): SentryProjectCandidate[] { + const event = payload.data?.event as ProjectLike | undefined; + if (!isRecord(event)) return []; + + return compactCandidates([ + candidateFromProjectValue(event.project, 'data.event.project'), + candidateFromString(event.project_slug, 'data.event.project_slug', 'slug'), + candidateFromString(event.projectSlug, 'data.event.projectSlug', 'slug'), + ]); +} + +function extractMetricAlertProjects(payload: SentryMetricAlertPayload): SentryProjectCandidate[] { + const projects = payload.data?.metric_alert?.projects; + if (!Array.isArray(projects)) return []; + + return compactCandidates( + projects.map((project, index) => + candidateFromProjectValue(project, `data.metric_alert.projects[${index}]`), + ), + ); +} + +function extractIssueProjects(payload: SentryIssuePayload): SentryProjectCandidate[] { + const project = payload.data?.issue?.project; + return compactCandidates([candidateFromProjectValue(project, 'data.issue.project')]); +} + +function candidateFromProjectValue(value: unknown, source: string): SentryProjectCandidate | null { + if (typeof value === 'string') { + return candidateFromString(value, source, 'slug'); + } + + if (!isRecord(value)) return null; + + const candidate: SentryProjectCandidate = { source }; + const id = normalizeDisplayString(value.id); + const slug = normalizeDisplayString(value.slug ?? value.project_slug ?? value.projectSlug); + const name = normalizeDisplayString(value.name); + + if (id) candidate.id = id; + if (slug) candidate.slug = slug; + if (name) candidate.name = name; + + return hasProjectIdentifier(candidate) ? candidate : null; +} + +function candidateFromString( + value: unknown, + source: string, + field: 'slug' | 'name', +): SentryProjectCandidate | null { + const normalized = normalizeDisplayString(value); + if (!normalized) return null; + + return { + [field]: normalized, + source, + }; +} + +function compactCandidates( + candidates: Array, +): SentryProjectCandidate[] { + return candidates.filter((candidate): candidate is SentryProjectCandidate => candidate !== null); +} + +function projectMatches( + project: SentryProjectCandidate, + trimmedConfiguredProjectSlug: string, + normalizedConfiguredProjectSlug: string, +): boolean { + if (project.id === trimmedConfiguredProjectSlug) return true; + + return ( + normalizeComparable(project.slug) === normalizedConfiguredProjectSlug || + normalizeComparable(project.name) === normalizedConfiguredProjectSlug + ); +} + +function hasProjectIdentifier(candidate: SentryProjectCandidate): boolean { + return Boolean(candidate.id || candidate.slug || candidate.name); +} + +function normalizeDisplayString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeComparable(value: unknown): string | null { + return normalizeDisplayString(value)?.toLocaleLowerCase() ?? null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/sentry/types.ts b/src/sentry/types.ts index 16066335c..9249244f4 100644 --- a/src/sentry/types.ts +++ b/src/sentry/types.ts @@ -113,7 +113,11 @@ export interface SentryEvent { web_url?: string; issue_id?: string; issue_url?: string; - project?: string; + project?: + | string + | { id?: string; slug?: string; name?: string; project_slug?: string; projectSlug?: string }; + project_slug?: string; + projectSlug?: string; release?: string | Record; environment?: string; platform?: string; @@ -212,7 +216,10 @@ export interface SentryMetricAlertPayload { triggers?: unknown[]; }; status?: string; - projects?: string[]; + projects?: Array< + | string + | { id?: string; slug?: string; name?: string; project_slug?: string; projectSlug?: string } + >; date_created?: string; date_detected?: string; date_started?: string; diff --git a/src/triggers/sentry/webhook-handler.ts b/src/triggers/sentry/webhook-handler.ts index 31d32ad64..5c49763b2 100644 --- a/src/triggers/sentry/webhook-handler.ts +++ b/src/triggers/sentry/webhook-handler.ts @@ -20,6 +20,11 @@ import { AlertSlotMissingError } from '../../integrations/alerting/_shared/types.js'; import { pmRegistry } from '../../pm/registry.js'; +import { getSentryIntegrationConfig } from '../../sentry/integration.js'; +import { + formatSentryProjectMatchFailure, + matchSentryPayloadProject, +} from '../../sentry/project-filter.js'; import type { SentryAugmentedPayload } from '../../sentry/types.js'; import type { ProjectConfig, TriggerResult } from '../../types/index.js'; import { startWatchdog } from '../../utils/lifecycle.js'; @@ -123,6 +128,22 @@ export async function processSentryWebhook( return; } + const sentryConfig = await getSentryIntegrationConfig(projectId); + const projectMatch = matchSentryPayloadProject( + payload as SentryAugmentedPayload, + sentryConfig?.projectSlug, + ); + if (!projectMatch.allowed) { + logger.info('processSentryWebhook: payload project filtered before trigger resolution', { + projectId, + reason: projectMatch.reason, + message: formatSentryProjectMatchFailure(projectMatch), + configuredProjectSlug: projectMatch.configuredProjectSlug, + payloadProjects: projectMatch.payloadProjects, + }); + return; + } + const ctx = { project: pc.project, source: 'sentry' as const, diff --git a/tests/unit/agents/definitions/pipelineSnapshot.test.ts b/tests/unit/agents/definitions/pipelineSnapshot.test.ts index bc866f84d..91f43364d 100644 --- a/tests/unit/agents/definitions/pipelineSnapshot.test.ts +++ b/tests/unit/agents/definitions/pipelineSnapshot.test.ts @@ -1,21 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../../src/pm/index.js', () => ({ - getPMProviderOrNull: vi.fn(), -})); - -vi.mock('../../../../src/gadgets/pm/core/readWorkItem.js', () => ({ - readWorkItem: vi.fn(), -})); +vi.mock('../../../../src/pm/index.js', async () => { + const actual = await vi.importActual( + '../../../../src/pm/index.js', + ); + return { + ...actual, + getPMProviderOrNull: vi.fn(), + getPMProvider: vi.fn(), + filterImageMedia: vi.fn((refs) => refs.filter((ref) => ref.mimeType.startsWith('image/'))), + }; +}); import type { FetchContextParams } from '../../../../src/agents/definitions/contextSteps.js'; import { fetchPipelineSnapshotStep } from '../../../../src/agents/definitions/contextSteps.js'; -import { readWorkItem } from '../../../../src/gadgets/pm/core/readWorkItem.js'; -import { getPMProviderOrNull } from '../../../../src/pm/index.js'; +import { getPMProvider, getPMProviderOrNull } from '../../../../src/pm/index.js'; import type { AgentInput, ProjectConfig } from '../../../../src/types/index.js'; +import { createMockPMProvider } from '../../../helpers/mockPMProvider.js'; const mockGetPMProviderOrNull = vi.mocked(getPMProviderOrNull); -const mockReadWorkItem = vi.mocked(readWorkItem); +const mockGetPMProvider = vi.mocked(getPMProvider); +const mockProvider = createMockPMProvider(); function makeProject(overrides: Partial = {}): ProjectConfig { return { @@ -55,11 +60,28 @@ function makeParams( }; } -const mockProvider = { - listWorkItems: vi.fn(), -}; +function parseSummary(result: Awaited>) { + return JSON.parse(result[0].result as string); +} describe('fetchPipelineSnapshotStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPMProviderOrNull.mockReturnValue(mockProvider); + mockGetPMProvider.mockReturnValue(mockProvider); + mockProvider.listWorkItems.mockResolvedValue([]); + mockProvider.getWorkItem.mockImplementation(async (id: string) => ({ + id, + title: `Detailed ${id}`, + url: `https://pm.test/${id}`, + description: '', + labels: [], + })); + mockProvider.getChecklists.mockResolvedValue([]); + mockProvider.getAttachments.mockResolvedValue([]); + mockProvider.getWorkItemComments.mockResolvedValue([]); + }); + it('returns empty array when no PM provider', async () => { mockGetPMProviderOrNull.mockReturnValue(null); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); @@ -67,14 +89,11 @@ describe('fetchPipelineSnapshotStep', () => { }); it('returns empty array when no project config', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - const params = makeParams(); // no project - const result = await fetchPipelineSnapshotStep(params); + const result = await fetchPipelineSnapshotStep(makeParams()); expect(result).toEqual([]); }); it('returns empty array when no lists configured', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const project = { id: 'test-project', orgId: 'test-org', @@ -84,15 +103,25 @@ describe('fetchPipelineSnapshotStep', () => { pm: { type: 'trello' }, trello: { boardId: 'board-1', lists: {}, labels: {} }, } as unknown as ProjectConfig; + const result = await fetchPipelineSnapshotStep(makeParams({}, project)); + expect(result).toEqual([]); }); - it('uses unified provider.listWorkItems(undefined, { status }) for Linear projects', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); - mockReadWorkItem.mockResolvedValue('# details'); + it('emits exactly one PipelineSnapshotSummary JSON injection', async () => { + const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + expect(result).toHaveLength(1); + expect(result[0].toolName).toBe('PipelineSnapshotSummary'); + expect(result[0].toolName).not.toBe('PipelineSnapshot'); + expect(() => JSON.parse(result[0].result as string)).not.toThrow(); + expect(result[0].params).toEqual({ + comment: 'Pre-fetched structured pipeline snapshot across all statuses', + }); + }); + + it('uses unified provider.listWorkItems(undefined, { status }) for Linear projects', async () => { const linearProject = { id: 'test-project', orgId: 'test-org', @@ -114,198 +143,338 @@ describe('fetchPipelineSnapshotStep', () => { }, } as unknown as ProjectConfig; - const result = await fetchPipelineSnapshotStep(makeParams({}, linearProject)); + await fetchPipelineSnapshotStep(makeParams({}, linearProject)); - expect(result).toHaveLength(1); - // After the listWorkItems unification fix: the loader passes - // (undefined, { status: cascadeKey }) — NOT the raw state UUID as - // containerId. Each provider self-resolves the scope from its config. for (const status of ['backlog', 'todo', 'inProgress', 'inReview', 'done', 'merged']) { expect(mockProvider.listWorkItems).toHaveBeenCalledWith(undefined, { status }); } }); - it('returns a single ContextInjection with toolName PipelineSnapshot', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); - mockReadWorkItem.mockResolvedValue('# Card Details\n\nSome content'); - - const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - expect(result).toHaveLength(1); - expect(result[0].toolName).toBe('PipelineSnapshot'); - expect(result[0].params).toEqual({ - comment: 'Pre-fetched full pipeline snapshot across all lists', + it('summarizes status counts, active count, and provider ordering', async () => { + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'backlog') { + return [ + { id: 'MNG-1', title: 'First', url: 'https://pm.test/1', description: '', labels: [] }, + { id: 'MNG-2', title: 'Second', url: 'https://pm.test/2', description: '', labels: [] }, + ]; + } + if (filter?.status === 'todo') { + return [ + { id: 'MNG-3', title: 'Todo', url: 'https://pm.test/3', description: '', labels: [] }, + ]; + } + if (filter?.status === 'inReview') { + return [ + { + id: 'MNG-4', + title: 'Review', + url: 'https://pm.test/4', + description: '', + labels: [], + }, + ]; + } + return []; }); - }); - - it('includes all configured list sections in output', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); - - const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - const output = result[0].result as string; - expect(output).toContain('## BACKLOG'); - expect(output).toContain('## TODO'); - expect(output).toContain('## IN_PROGRESS'); - expect(output).toContain('## IN_REVIEW'); - expect(output).toContain('## DONE'); - expect(output).toContain('## MERGED'); - }); - - it('marks empty lists as empty in output', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - const output = result[0].result as string; - expect(output).toContain('_Empty — no items_'); + const summary = parseSummary(result); + + expect(summary.schemaVersion).toBe(1); + expect(summary.provider).toBe('trello'); + expect(summary.activeStatusKeys).toEqual(['todo', 'inProgress', 'inReview']); + expect(summary.activePipelineCount).toBe(2); + expect(summary.statuses.backlog).toMatchObject({ + statusKey: 'backlog', + statusName: 'BACKLOG', + count: 2, + itemIds: ['MNG-1', 'MNG-2'], + }); + expect(summary.statuses.todo.count).toBe(1); + expect(summary.statuses.inReview.count).toBe(1); }); - it('fetches full details for BACKLOG, TODO, IN_PROGRESS, IN_REVIEW items', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - - const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; + it('populates structured item details from provider calls without formatted markdown parsing', async () => { mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { - if (filter?.status === 'backlog') return [card]; - if (filter?.status === 'todo') return [card]; - return []; + if (filter?.status !== 'backlog') return []; + return [ + { + id: 'MNG-10', + title: 'List title', + url: 'https://pm.test/list', + description: 'List description', + labels: [{ id: 'l-list', name: 'List Label' }], + }, + ]; + }); + mockProvider.getWorkItem.mockResolvedValue({ + id: 'MNG-10', + title: 'Detailed title', + url: 'https://pm.test/detail', + status: 'Backlog', + statusId: 'state-backlog', + description: 'Detailed description depends on MNG-123', + labels: [{ id: 'l1', name: 'Feature', color: 'blue' }], + inlineMedia: [ + { url: 'https://pm.test/image.png', mimeType: 'image/png', source: 'description' }, + ], }); - mockReadWorkItem.mockResolvedValue('# Test Card\n\nFull details here'); + mockProvider.getChecklists.mockResolvedValue([ + { + id: 'cl1', + name: 'Acceptance', + workItemId: 'MNG-10', + items: [{ id: 'ci1', name: 'Requires API contract', complete: false }], + }, + ]); + mockProvider.getAttachments.mockResolvedValue([ + { + id: 'a1', + name: 'spec.md', + url: 'https://docs.test/spec', + mimeType: 'text/markdown', + bytes: 100, + date: '2026-05-12T00:00:00.000Z', + }, + ]); + mockProvider.getWorkItemComments.mockResolvedValue([ + { + id: 'c1', + author: { id: 'u1', name: 'Alice', username: 'alice' }, + date: '2026-05-12T00:00:00.000Z', + text: 'Waiting for https://linear.app/issue/MNG-123', + }, + ]); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - expect(mockReadWorkItem).toHaveBeenCalledWith('card-1', true); - const output = result[0].result as string; - expect(output).toContain('Full details here'); + const summary = parseSummary(result); + const item = summary.itemsById['MNG-10']; + + expect(item).toMatchObject({ + id: 'MNG-10', + title: 'Detailed title', + url: 'https://pm.test/detail', + statusKey: 'backlog', + statusName: 'BACKLOG', + providerStatus: 'Backlog', + providerStatusId: 'state-backlog', + description: 'Detailed description depends on MNG-123', + labels: [{ id: 'l1', name: 'Feature', color: 'blue' }], + checklists: [ + { + id: 'cl1', + name: 'Acceptance', + items: [{ id: 'ci1', name: 'Requires API contract', complete: false }], + }, + ], + comments: [ + { + id: 'c1', + authorName: 'Alice', + text: 'Waiting for https://linear.app/issue/MNG-123', + }, + ], + attachments: [{ id: 'a1', name: 'spec.md', url: 'https://docs.test/spec' }], + mediaReferences: [ + { url: 'https://pm.test/image.png', mimeType: 'image/png', source: 'description' }, + ], + }); + expect(JSON.stringify(item)).not.toContain('# Detailed title'); }); - it('uses title-and-url format for DONE and MERGED lists', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - - const card = { id: 'card-done', title: 'Done Card', url: 'http://trello.com/c/2', labels: [] }; + it('keeps DONE and MERGED compact without full detail fetches', async () => { mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { - if (filter?.status === 'done' || filter?.status === 'merged') return [card]; + if (filter?.status === 'done' || filter?.status === 'merged') { + return [ + { + id: `MNG-${filter.status}`, + title: `${filter.status} title`, + url: `https://pm.test/${filter.status}`, + description: `${filter.status} list description`, + labels: [{ id: 'l1', name: 'done-label' }], + }, + ]; + } return []; }); - mockReadWorkItem.mockResolvedValue('# Done Card\n\nFull details'); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - // readWorkItem should NOT be called for DONE/MERGED items - expect(mockReadWorkItem).not.toHaveBeenCalledWith('card-done', true); - - const output = result[0].result as string; - // Title + URL format - expect(output).toContain('[card-done] Done Card'); - expect(output).toContain('http://trello.com/c/2'); + const summary = parseSummary(result); + + expect(mockProvider.getWorkItem).not.toHaveBeenCalled(); + expect(summary.itemsById['MNG-done']).toMatchObject({ + id: 'MNG-done', + title: 'done title', + statusKey: 'done', + statusName: 'DONE', + checklists: [], + comments: [], + }); + expect(summary.itemsById['MNG-merged']).toMatchObject({ + id: 'MNG-merged', + title: 'merged title', + statusKey: 'merged', + statusName: 'MERGED', + }); }); - it('omits URL parentheses for DONE/MERGED items when url is empty', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - - const card = { id: 'card-done', title: 'Done Card', url: '', labels: [] }; + it('represents provider list errors and item read errors in JSON', async () => { mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { - if (filter?.status === 'done') return [card]; + if (filter?.status === 'todo') throw new Error('List network error'); + if (filter?.status === 'backlog') { + return [ + { id: 'MNG-20', title: 'Broken', url: 'https://pm.test/20', description: '', labels: [] }, + ]; + } return []; }); - - const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); - - const output = result[0].result as string; - expect(output).toContain('[card-done] Done Card'); - expect(output).not.toContain('()'); - }); - - it('handles list fetch errors gracefully', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockRejectedValue(new Error('Network error')); + mockProvider.getWorkItem.mockRejectedValue(new Error('Item read error')); const params = makeParams({}, makeProject()); const result = await fetchPipelineSnapshotStep(params); - - expect(result).toHaveLength(1); - const output = result[0].result as string; - expect(output).toContain('Failed to fetch'); + const summary = parseSummary(result); + + expect(summary.statuses.todo.error).toBe('List network error'); + expect(summary.itemsById['MNG-20'].error).toBe('Item read error'); + expect(summary.errors).toEqual( + expect.arrayContaining([ + { statusKey: 'todo', message: 'List network error' }, + { statusKey: 'backlog', itemId: 'MNG-20', message: 'Item read error' }, + ]), + ); expect(params.logWriter).toHaveBeenCalledWith( 'WARN', expect.stringContaining('Failed to fetch list'), - expect.objectContaining({ error: 'Network error' }), + expect.objectContaining({ error: 'List network error' }), ); }); - it('handles card read errors gracefully', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - - const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; + it('sets activeCapacityReliable false when an active status fetch fails', async () => { mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { - if (filter?.status === 'backlog') return [card]; + if (filter?.status === 'todo') throw new Error('TODO fetch failed'); return []; }); - mockReadWorkItem.mockRejectedValue(new Error('Card read error')); - const params = makeParams({}, makeProject()); - const result = await fetchPipelineSnapshotStep(params); + const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const summary = parseSummary(result); - // Should still return a result even if card read fails - expect(result).toHaveLength(1); + expect(summary.activeCapacityReliable).toBe(false); + expect(summary.activePipelineCount).toBe(0); + expect(summary.statuses.todo.error).toBe('TODO fetch failed'); }); - it('description includes count of lists and full-detail items', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); + it('sets activeCapacityReliable false when any active status (inProgress or inReview) fails', async () => { + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'inReview') throw new Error('inReview fetch failed'); + return []; + }); + + const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const summary = parseSummary(result); - const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; + expect(summary.activeCapacityReliable).toBe(false); + expect(summary.statuses.inReview.error).toBe('inReview fetch failed'); + }); + + it('sets activeCapacityReliable true when all active status fetches succeed (non-active error does not affect it)', async () => { mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { - if (filter?.status === 'backlog') return [card]; + // done/merged failing should not affect active capacity reliability + if (filter?.status === 'done') throw new Error('done fetch failed'); return []; }); - mockReadWorkItem.mockResolvedValue('# Test Card'); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const summary = parseSummary(result); - expect(result[0].description).toContain('6 lists'); - expect(result[0].description).toContain('1 items with full details'); + expect(summary.activeCapacityReliable).toBe(true); + expect(summary.statuses.done.error).toBe('done fetch failed'); }); - it('works with JIRA project config', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); + it('sets activeCapacityReliable true when no fetch errors occur', async () => { + const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const summary = parseSummary(result); - const jiraProject = { - id: 'jira-project', - orgId: 'test-org', - name: 'JIRA Project', - repo: 'owner/repo', - baseBranch: 'main', - pm: { type: 'jira' }, - jira: { - projectKey: 'PROJ', - baseUrl: 'https://example.atlassian.net', - statuses: { - backlog: 'Backlog', - todo: 'To Do', - inProgress: 'In Progress', - inReview: 'In Review', - done: 'Done', - merged: 'Merged', + expect(summary.activeCapacityReliable).toBe(true); + }); + + it('extracts dependency signals from descriptions, comments, checklists, issue IDs, URLs, and keywords', async () => { + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status !== 'backlog') return []; + return [ + { + id: 'MNG-30', + title: 'Blocked', + url: 'https://pm.test/30', + description: '', + labels: [], }, + ]; + }); + mockProvider.getWorkItem.mockResolvedValue({ + id: 'MNG-30', + title: 'Blocked', + url: 'https://pm.test/30', + description: 'Blocked by MNG-123 after https://linear.app/issue/MNG-456', + labels: [], + }); + mockProvider.getChecklists.mockResolvedValue([ + { + id: 'cl1', + name: 'Tasks', + workItemId: 'MNG-30', + items: [{ id: 'ci1', name: 'Requires migration', complete: false }], }, - } as unknown as ProjectConfig; - - const result = await fetchPipelineSnapshotStep(makeParams({}, jiraProject)); + ]); + mockProvider.getWorkItemComments.mockResolvedValue([ + { + id: 'c1', + author: { id: 'u1', name: 'Bob', username: 'bob' }, + date: '2026-05-12T00:00:00.000Z', + text: 'Waiting for auth rollout', + }, + { + id: 'c2', + author: { id: 'u2', name: 'Eve', username: 'eve' }, + date: '2026-05-12T01:00:00.000Z', + text: 'Depends on MNG-789', + }, + ]); - expect(result).toHaveLength(1); - expect(result[0].toolName).toBe('PipelineSnapshot'); - // All 6 lists should be fetched - expect(mockProvider.listWorkItems).toHaveBeenCalledTimes(6); + const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const summary = parseSummary(result); + const signals = summary.itemsById['MNG-30'].dependencySignals; + + expect(signals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceType: 'description', + matches: expect.arrayContaining([ + 'Blocked by', + 'MNG-123', + 'after', + 'https://linear.app/issue/MNG-456', + ]), + }), + expect.objectContaining({ + sourceType: 'checklist', + sourceId: 'ci1', + matches: expect.arrayContaining(['Requires']), + }), + expect.objectContaining({ + sourceType: 'comment', + sourceId: 'c1', + matches: expect.arrayContaining(['Waiting for']), + }), + expect.objectContaining({ + sourceType: 'comment', + sourceId: 'c2', + matches: expect.arrayContaining(['Depends on', 'MNG-789']), + }), + ]), + ); }); - it('handles partially configured lists (some missing)', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); - + it('handles partially configured lists', async () => { const partialProject = { id: 'test-project', orgId: 'test-org', @@ -318,31 +487,45 @@ describe('fetchPipelineSnapshotStep', () => { lists: { backlog: 'list-backlog', todo: 'list-todo', - // inProgress, inReview, done, merged NOT configured }, labels: {}, }, } as unknown as ProjectConfig; const result = await fetchPipelineSnapshotStep(makeParams({}, partialProject)); + const summary = parseSummary(result); - expect(result).toHaveLength(1); - // Only 2 lists configured expect(mockProvider.listWorkItems).toHaveBeenCalledTimes(2); - expect(result[0].description).toContain('2 lists'); + expect(Object.keys(summary.statuses)).toEqual(['backlog', 'todo']); + expect(result[0].description).toContain('2 statuses'); }); - it('includes CASCADE status keys in section headers', async () => { - mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); - mockProvider.listWorkItems.mockResolvedValue([]); + it('works with JIRA project config', async () => { + const jiraProject = { + id: 'jira-project', + orgId: 'test-org', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: { + backlog: 'Backlog', + todo: 'To Do', + inProgress: 'In Progress', + inReview: 'In Review', + done: 'Done', + merged: 'Merged', + }, + }, + } as unknown as ProjectConfig; - const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); + const result = await fetchPipelineSnapshotStep(makeParams({}, jiraProject)); - const output = result[0].result as string; - // Headers expose the CASCADE status key (what move-work-item expects), - // not the provider-native ID — that's a Linear UUID for Linear projects - // and useless to the agent. - expect(output).toContain('status: backlog'); - expect(output).toContain('status: todo'); + expect(result).toHaveLength(1); + expect(result[0].toolName).toBe('PipelineSnapshotSummary'); + expect(mockProvider.listWorkItems).toHaveBeenCalledTimes(6); }); }); diff --git a/tests/unit/agents/definitions/profiles.test.ts b/tests/unit/agents/definitions/profiles.test.ts index 6688a45f8..3cced97f8 100644 --- a/tests/unit/agents/definitions/profiles.test.ts +++ b/tests/unit/agents/definitions/profiles.test.ts @@ -255,7 +255,7 @@ describe('getAgentProfile', () => { // trigger --agent-type backlog-manager` ran with NO pipelineSnapshot // because resolveContextPipeline returned [] for undefined triggerEvent. mockPipelineSnapshotStep.mockResolvedValue([ - { toolName: 'PipelineSnapshot', params: {}, result: 'ok', description: 'snapshot' }, + { toolName: 'PipelineSnapshotSummary', params: {}, result: 'ok', description: 'snapshot' }, ]); mockResolveAgentDefinition.mockResolvedValue( makeDefinition({ requiredContext: ['pipelineSnapshot'] }), @@ -268,7 +268,7 @@ describe('getAgentProfile', () => { expect(mockPipelineSnapshotStep).toHaveBeenCalledOnce(); expect(result).toHaveLength(1); - expect(result[0].toolName).toBe('PipelineSnapshot'); + expect(result[0].toolName).toBe('PipelineSnapshotSummary'); }); it('aborts with a structured error when a requiredContext step returns 0 injections', async () => { @@ -309,7 +309,7 @@ describe('getAgentProfile', () => { // scm:pr-merged) lists pipelineSnapshot in its contextPipeline AND // the agent declares it as requiredContext. mockPipelineSnapshotStep.mockResolvedValue([ - { toolName: 'PipelineSnapshot', params: {}, result: 'ok', description: 'snapshot' }, + { toolName: 'PipelineSnapshotSummary', params: {}, result: 'ok', description: 'snapshot' }, ]); mockResolveAgentDefinition.mockResolvedValue( makeDefinition({ @@ -340,7 +340,7 @@ describe('getAgentProfile', () => { order.push('pipelineSnapshot'); return [ { - toolName: 'PipelineSnapshot', + toolName: 'PipelineSnapshotSummary', params: {}, result: 'ok', description: 'snapshot', diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 9c2ccb07f..1db4f58eb 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -206,6 +206,40 @@ describe('system prompts content', () => { expect(prompt).toContain('MANDATORY FIRST STEP'); }); + it('backlog-manager prompt uses PipelineSnapshotSummary JSON as the only pipeline contract', () => { + const prompt = getSystemPrompt('backlog-manager'); + expect(prompt).toContain('PipelineSnapshotSummary'); + expect(prompt).toContain('activePipelineCount'); + expect(prompt).toContain('itemsById'); + expect(prompt).toContain('dependencySignals'); + expect(prompt).not.toContain('legacy markdown Pipeline Snapshot'); + }); + + it('backlog-manager prompt aborts moves when activeCapacityReliable is false', () => { + const prompt = getSystemPrompt('backlog-manager'); + expect(prompt).toContain('activeCapacityReliable'); + expect(prompt).toContain('abort immediately'); + // The abort instruction must appear before backlog selection guidance + const abortIdx = prompt.indexOf('activeCapacityReliable'); + const selectionIdx = prompt.indexOf('Backlog Selection Process'); + expect(abortIdx).toBeLessThan(selectionIdx); + }); + + it('backlog-manager prompt targets all-blocked comment to first BACKLOG item only', () => { + const prompt = getSystemPrompt('backlog-manager'); + expect(prompt).toContain( + 'Post this comment exactly once on the first BACKLOG item in `PipelineSnapshotSummary.statuses.backlog.itemIds` order', + ); + expect(prompt).toContain('Do not post it on a DONE/MERGED trigger item'); + expect(prompt).toContain('do not post it on every blocked backlog item'); + }); + + it('backlog-manager prompt exits silently when backlog is empty', () => { + const prompt = getSystemPrompt('backlog-manager'); + expect(prompt).toContain('If BACKLOG is empty, exit silently'); + expect(prompt).toContain('do not post a blocked-backlog comment'); + }); + it('backlog-manager prompt checks only active pipeline stages (not DONE)', () => { const prompt = getSystemPrompt('backlog-manager'); expect(prompt).toContain('TODO'); @@ -241,6 +275,7 @@ describe('system prompts content', () => { }); expect(prompt).toContain('BACKLOG_STATUS_ID: `state-backlog`'); expect(prompt).toContain('status: "backlog"'); + expect(prompt).toContain('expectedSourceState: state-backlog'); expect(prompt).not.toContain('Use these EXACT IDs when calling `ListWorkItems`'); }); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index b5b49b3d3..240ff2f41 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -689,6 +689,28 @@ describe('integrationsDiscoveryRouter', () => { ); }); + it('returns project id, name, and slug when projectSlug is provided', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 'proj-123', name: 'API', slug: 'api' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + projectSlug: 'api', + }); + + expect(result).toEqual({ id: 'proj-123', name: 'API', slug: 'api' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/projects/my-org/api/', + expect.objectContaining({ + headers: { Authorization: 'Bearer sntrys_abc' }, + }), + ); + }); + it('returns empty strings when Sentry response fields are missing', async () => { mockFetch.mockResolvedValue({ ok: true, @@ -717,6 +739,23 @@ describe('integrationsDiscoveryRouter', () => { ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); + it('wraps unknown project response in BAD_REQUEST', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + projectSlug: 'missing-api', + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + it('wraps network failure in BAD_REQUEST', async () => { mockFetch.mockRejectedValue(new Error('Network error')); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 732185e3a..8bffd60d9 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -480,18 +480,77 @@ describe('projectsRouter', () => { projectId: 'p1', category: 'alerting', provider: 'sentry', - config: { organizationSlug: 'my-org' }, + config: { organizationSlug: 'my-org', projectSlug: 'api' }, }); expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( 'p1', 'alerting', 'sentry', - { organizationSlug: 'my-org' }, + { organizationSlug: 'my-org', projectSlug: 'api' }, undefined, ); }); + it('trims and upserts Sentry project slug config', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpsertProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.upsert({ + projectId: 'p1', + category: 'alerting', + provider: 'sentry', + config: { + organizationSlug: ' my-org ', + projectSlug: ' api ', + resultsContainerId: ' Alerts ', + }, + }); + + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( + 'p1', + 'alerting', + 'sentry', + { organizationSlug: 'my-org', projectSlug: 'api', resultsContainerId: 'Alerts' }, + undefined, + ); + }); + + it('rejects Sentry alerting integration without organization slug', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.integrations.upsert({ + projectId: 'p1', + category: 'alerting', + provider: 'sentry', + config: { organizationSlug: ' ', projectSlug: 'api' }, + }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Sentry organization slug is required', + }); + }); + + it('rejects Sentry alerting integration without project slug', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.integrations.upsert({ + projectId: 'p1', + category: 'alerting', + provider: 'sentry', + config: { organizationSlug: 'my-org', projectSlug: '' }, + }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Sentry project slug is required', + }); + }); + it('rejects unknown category', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect( diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index c377d15da..ea1172c64 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -113,6 +113,17 @@ const mockLinearProject = { }, }; +const mockSentryProject = { + id: 'sentry-project', + orgId: 'org-1', + repo: 'owner/sentry-repo', + pm: { type: 'linear' }, + linear: { + teamId: 'TEAM-123', + statuses: { todo: 'Todo' }, + }, +}; + function setupJiraProjectContext() { mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); @@ -168,6 +179,34 @@ function setupProjectContext(opts?: { mockGetAllProjectCredentials.mockResolvedValue(creds); } +function setupSentryProjectContext(opts?: { + noSentryApiToken?: boolean; + webhookSecret?: boolean; + config?: Record | null; + provider?: string; +}) { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectByIdFromDb.mockResolvedValue(mockSentryProject); + mockGetIntegrationByProjectAndCategory.mockResolvedValue( + opts?.config === null + ? null + : { + provider: opts?.provider ?? 'sentry', + config: opts?.config ?? { organizationSlug: 'my-org', projectSlug: 'api' }, + }, + ); + const creds: Record = {}; + if (!opts?.noSentryApiToken) { + creds.SENTRY_API_TOKEN = 'sntrys_test123'; + } + if (opts?.webhookSecret) { + creds.SENTRY_WEBHOOK_SECRET = 'sentry-secret-abc'; + } + mockGetAllProjectCredentials.mockResolvedValue(creds); +} + describe('webhooksRouter', () => { describe('list', () => { it('returns trello and github webhooks', async () => { @@ -296,6 +335,43 @@ describe('webhooksRouter', () => { expect(result.github).toEqual([]); expect(mockListWebhooks).not.toHaveBeenCalled(); }); + + it('returns Sentry project pairing when alerting config and API token are complete', async () => { + setupSentryProjectContext({ webhookSecret: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'sentry-project', + callbackBaseUrl: 'http://example.com/', + }); + + expect(result.sentry).toEqual({ + url: 'http://example.com/sentry/webhook/sentry-project', + webhookSecretSet: true, + organizationSlug: 'my-org', + projectSlug: 'api', + note: expect.stringContaining('my-org/api'), + }); + expect(result.sentry?.note).toContain('project matches the configured project slug "api"'); + }); + + it('does not return Sentry info without project slug or API token', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + setupSentryProjectContext({ config: { organizationSlug: 'my-org' } }); + const missingProjectSlug = await caller.list({ + projectId: 'sentry-project', + callbackBaseUrl: 'http://example.com', + }); + expect(missingProjectSlug.sentry).toBeNull(); + + setupSentryProjectContext({ noSentryApiToken: true }); + const missingApiToken = await caller.list({ + projectId: 'sentry-project', + callbackBaseUrl: 'http://example.com', + }); + expect(missingApiToken.sentry).toBeNull(); + }); }); describe('create', () => { @@ -594,6 +670,24 @@ describe('webhooksRouter', () => { expect(result.jira).toMatchObject({ id: 101 }); expect(result.labelsEnsured).toEqual([]); }); + + it('returns Sentry manual setup info with paired project context', async () => { + setupSentryProjectContext(); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'sentry-project', + callbackBaseUrl: 'http://example.com/', + }); + + expect(result.sentry).toEqual({ + url: 'http://example.com/sentry/webhook/sentry-project', + webhookSecretSet: false, + organizationSlug: 'my-org', + projectSlug: 'api', + note: expect.stringContaining('project matches the configured project slug "api"'), + }); + }); }); describe('delete', () => { diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts index 10a762515..5f40e1953 100644 --- a/tests/unit/backends/secretOrchestrator.test.ts +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -139,6 +139,7 @@ describe('buildExecutionPlan', () => { it('fetches sentry config for alerting agent', async () => { mockGetSentryIntegrationConfig.mockResolvedValueOnce({ organizationSlug: 'org', + projectSlug: 'api', resultsContainerId: 'sentry-container-123', }); diff --git a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts index 4879cf7fd..cdb86e716 100644 --- a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts +++ b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts @@ -138,6 +138,39 @@ describe('WebhooksList (webhooks list)', () => { expect(client.webhooks.list.query).toHaveBeenCalled(); }); + it('displays Sentry paired project and filtering guidance', async () => { + const client = makeClient({ + webhooks: { + list: { + query: vi.fn().mockResolvedValue({ + trello: [], + github: [], + jira: [], + sentry: { + url: 'http://localhost:3001/sentry/webhook/my-project', + webhookSecretSet: true, + organizationSlug: 'my-org', + projectSlug: 'api', + note: 'Cascade dispatches only payloads whose Sentry project matches the configured project slug "api".', + }, + linear: null, + errors: {}, + }), + }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WebhooksList(['my-project'], oclifConfig as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).toContain('Paired project: my-org/api'); + expect(output).toContain('Delivery filter: payload project must match "api".'); + expect(output).toContain('configured project slug "api"'); + }); + it('requires project ID argument', async () => { mockCreateDashboardClient.mockReturnValue(makeClient()); @@ -217,6 +250,34 @@ describe('WebhooksCreate (webhooks create)', () => { ); }); + it('displays Sentry paired project and manual filtering guidance', async () => { + const client = makeClient({ + webhooks: { + create: { + mutate: vi.fn().mockResolvedValue({ + sentry: { + url: 'http://localhost:3001/sentry/webhook/my-project', + webhookSecretSet: false, + organizationSlug: 'my-org', + projectSlug: 'api', + note: 'Cascade dispatches only payloads whose Sentry project matches the configured project slug "api".', + }, + }), + }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WebhooksCreate(['my-project'], oclifConfig as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).toContain('Paired project: my-org/api'); + expect(output).toContain('Delivery filter: payload project must match "api".'); + expect(output).toContain('"event_alert", "metric_alert", and/or "issue"'); + }); + it('requires project ID argument', async () => { mockCreateDashboardClient.mockReturnValue(makeClient()); diff --git a/tests/unit/gadgets/pm/core/readWorkItem.test.ts b/tests/unit/gadgets/pm/core/readWorkItem.test.ts index d04e3c0fb..8bda60f02 100644 --- a/tests/unit/gadgets/pm/core/readWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/readWorkItem.test.ts @@ -28,6 +28,7 @@ vi.mock('../../../../../src/utils/logging.js', () => ({ })); import { + readStructuredWorkItemDetails, readWorkItem, readWorkItemWithMedia, } from '../../../../../src/gadgets/pm/core/readWorkItem.js'; @@ -635,3 +636,69 @@ describe('readWorkItemWithMedia', () => { expect(result.text.match(/\[Image: diagram\]/g)).toHaveLength(1); }); }); + +describe('readStructuredWorkItemDetails', () => { + it('returns raw provider fields and filtered media without formatting markdown', async () => { + mockProvider.getWorkItem.mockResolvedValue({ + id: 'item1', + title: 'Structured Work Item', + url: 'https://trello.com/c/item1', + description: 'Depends on MNG-123', + labels: [{ id: 'l1', name: 'Bug', color: 'red' }], + inlineMedia: [ + { url: 'https://example.com/desc.png', mimeType: 'image/png', source: 'description' }, + { + url: 'https://example.com/desc.pdf', + mimeType: 'application/pdf', + source: 'description', + }, + ], + }); + mockProvider.getChecklists.mockResolvedValue([ + { + id: 'cl1', + name: 'Tasks', + workItemId: 'item1', + items: [{ id: 'ci1', name: 'Item 1', complete: false }], + }, + ]); + mockProvider.getAttachments.mockResolvedValue([ + { + id: 'a1', + name: 'screenshot.png', + url: 'https://example.com/screenshot.png', + mimeType: 'image/png', + bytes: 100, + date: '2026-05-12T00:00:00.000Z', + }, + ]); + mockProvider.getWorkItemComments.mockResolvedValue([ + { + id: 'c1', + author: { id: 'u1', name: 'Alice', username: 'alice' }, + date: '2026-05-12T00:00:00.000Z', + text: 'Waiting for review', + inlineMedia: [ + { + url: 'https://example.com/comment.jpg', + mimeType: 'image/jpeg', + source: 'comment' as const, + }, + ], + }, + ]); + + const result = await readStructuredWorkItemDetails('item1', true); + + expect(result.item.title).toBe('Structured Work Item'); + expect(result.checklists[0].items[0].id).toBe('ci1'); + expect(result.attachments[0].name).toBe('screenshot.png'); + expect(result.comments[0].text).toBe('Waiting for review'); + expect(result.media.map((ref) => ref.url)).toEqual([ + 'https://example.com/desc.png', + 'https://example.com/screenshot.png', + 'https://example.com/comment.jpg', + ]); + expect(JSON.stringify(result)).not.toContain('# Structured Work Item'); + }); +}); diff --git a/tests/unit/router/adapters/sentry.test.ts b/tests/unit/router/adapters/sentry.test.ts index 3883b0cb2..43fbb919d 100644 --- a/tests/unit/router/adapters/sentry.test.ts +++ b/tests/unit/router/adapters/sentry.test.ts @@ -7,10 +7,15 @@ vi.mock('../../../../src/router/config.js', () => ({ loadProjectConfig: vi.fn(), })); +vi.mock('../../../../src/sentry/integration.js', () => ({ + getSentryIntegrationConfig: vi.fn(), +})); + import { SentryRouterAdapter } from '../../../../src/router/adapters/sentry.js'; import type { RouterProjectConfig } from '../../../../src/router/config.js'; import { loadProjectConfig } from '../../../../src/router/config.js'; import type { SentryJob } from '../../../../src/router/queue.js'; +import { getSentryIntegrationConfig } from '../../../../src/sentry/integration.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; // ============================================================================ @@ -35,13 +40,13 @@ const mockTriggerRegistry = { const validEventAlertPayload = { resource: 'event_alert', - payload: { action: 'triggered', data: { event: {} } }, + payload: { action: 'triggered', data: { event: { project: 'api' } } }, cascadeProjectId: 'p1', }; const validMetricAlertPayload = { resource: 'metric_alert', - payload: { action: 'critical', data: {} }, + payload: { action: 'critical', data: { metric_alert: { projects: [{ slug: 'api' }] } } }, cascadeProjectId: 'p1', }; @@ -51,6 +56,10 @@ beforeEach(() => { projects: [mockProject], fullProjects: [mockFullProject as never], }); + vi.mocked(getSentryIntegrationConfig).mockResolvedValue({ + organizationSlug: 'mongrel', + projectSlug: 'api', + }); }); describe('SentryRouterAdapter', () => { @@ -294,6 +303,63 @@ describe('SentryRouterAdapter', () => { expect(result).toEqual(mockTriggerResult); }); + it('returns structured skip and does not dispatch when payload project mismatches configured project', async () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + const payload = { + resource: 'event_alert', + payload: { action: 'triggered', data: { event: { project: 'mobile' } } }, + cascadeProjectId: 'p1', + }; + + const result = await adapter.dispatchWithCredentials( + event, + payload, + mockProject, + mockTriggerRegistry, + ); + + expect(getSentryIntegrationConfig).toHaveBeenCalledWith('p1'); + expect(mockTriggerRegistry.dispatch).not.toHaveBeenCalled(); + expect(result).toEqual({ + agentType: null, + agentInput: {}, + skipReason: { + handler: 'sentry-project-filter', + message: expect.stringContaining('project_mismatch'), + }, + }); + }); + + it('returns structured skip and does not dispatch when Sentry config is missing', async () => { + vi.mocked(getSentryIntegrationConfig).mockResolvedValueOnce(null); + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + const result = await adapter.dispatchWithCredentials( + event, + validEventAlertPayload, + mockProject, + mockTriggerRegistry, + ); + + expect(mockTriggerRegistry.dispatch).not.toHaveBeenCalled(); + expect(result).toEqual({ + agentType: null, + agentInput: {}, + skipReason: { + handler: 'sentry-project-filter', + message: expect.stringContaining('missing_configured_project'), + }, + }); + }); + it('returns null when full project is not found', async () => { vi.mocked(loadProjectConfig).mockResolvedValueOnce({ projects: [mockProject], diff --git a/tests/unit/sentry/alerting-integration.test.ts b/tests/unit/sentry/alerting-integration.test.ts index 07ad67a6f..42b6393b1 100644 --- a/tests/unit/sentry/alerting-integration.test.ts +++ b/tests/unit/sentry/alerting-integration.test.ts @@ -48,7 +48,10 @@ describe('SentryAlertingIntegration', () => { // ========================================================================= describe('hasIntegration', () => { it('returns true when sentry integration config is non-null', async () => { - mockGetSentryIntegrationConfig.mockResolvedValue({ organizationSlug: 'my-org' }); + mockGetSentryIntegrationConfig.mockResolvedValue({ + organizationSlug: 'my-org', + projectSlug: 'api', + }); const result = await integration.hasIntegration('proj-1'); @@ -79,12 +82,12 @@ describe('SentryAlertingIntegration', () => { // ========================================================================= describe('getConfig', () => { it('returns SentryIntegrationConfig when sentry integration is configured', async () => { - const config = { organizationSlug: 'my-company' }; + const config = { organizationSlug: 'my-company', projectSlug: 'web' }; mockGetSentryIntegrationConfig.mockResolvedValue(config); const result = await integration.getConfig('proj-1'); - expect(result).toEqual({ organizationSlug: 'my-company' }); + expect(result).toEqual({ organizationSlug: 'my-company', projectSlug: 'web' }); expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('proj-1'); }); diff --git a/tests/unit/sentry/integration.test.ts b/tests/unit/sentry/integration.test.ts index aba67428e..2eafdd445 100644 --- a/tests/unit/sentry/integration.test.ts +++ b/tests/unit/sentry/integration.test.ts @@ -28,7 +28,7 @@ describe('sentry/integration', () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-1', provider: 'pagerduty', - config: { organizationSlug: 'my-org' }, + config: { organizationSlug: 'my-org', projectSlug: 'api' }, }); const result = await getSentryIntegrationConfig('proj-1'); @@ -52,7 +52,43 @@ describe('sentry/integration', () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-1', provider: 'sentry', - config: { organizationSlug: 12345 }, + config: { organizationSlug: 12345, projectSlug: 'api' }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when config is missing projectSlug', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 'my-org' }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when projectSlug is not a string', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 'my-org', projectSlug: 12345 }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when required slugs are blank strings', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: ' ', projectSlug: '\t' }, }); const result = await getSentryIntegrationConfig('proj-1'); @@ -76,37 +112,42 @@ describe('sentry/integration', () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-1', provider: 'sentry', - config: { organizationSlug: 'my-company' }, + config: { organizationSlug: 'my-company', projectSlug: 'api' }, }); const result = await getSentryIntegrationConfig('proj-1'); - expect(result).toEqual({ organizationSlug: 'my-company' }); + expect(result).toEqual({ organizationSlug: 'my-company', projectSlug: 'api' }); }); - it('returns correct organizationSlug from config', async () => { + it('returns normalized slugs from config', async () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-2', provider: 'sentry', - config: { organizationSlug: 'acme-corp', extraField: 'ignored' }, + config: { organizationSlug: ' acme-corp ', projectSlug: ' web ', extraField: 'ignored' }, }); const result = await getSentryIntegrationConfig('proj-2'); - expect(result).toEqual({ organizationSlug: 'acme-corp' }); + expect(result).toEqual({ organizationSlug: 'acme-corp', projectSlug: 'web' }); }); it('returns resultsContainerId when present in config', async () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-3', provider: 'sentry', - config: { organizationSlug: 'my-org', resultsContainerId: 'list-backlog-123' }, + config: { + organizationSlug: 'my-org', + projectSlug: 'api', + resultsContainerId: 'list-backlog-123', + }, }); const result = await getSentryIntegrationConfig('proj-3'); expect(result).toEqual({ organizationSlug: 'my-org', + projectSlug: 'api', resultsContainerId: 'list-backlog-123', }); }); @@ -115,12 +156,12 @@ describe('sentry/integration', () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-4', provider: 'sentry', - config: { organizationSlug: 'my-org' }, + config: { organizationSlug: 'my-org', projectSlug: 'api' }, }); const result = await getSentryIntegrationConfig('proj-4'); - expect(result).toEqual({ organizationSlug: 'my-org' }); + expect(result).toEqual({ organizationSlug: 'my-org', projectSlug: 'api' }); expect(result?.resultsContainerId).toBeUndefined(); }); @@ -128,7 +169,7 @@ describe('sentry/integration', () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ id: 'int-5', provider: 'sentry', - config: { organizationSlug: 'my-org', resultsContainerId: 42 }, + config: { organizationSlug: 'my-org', projectSlug: 'api', resultsContainerId: 42 }, }); const result = await getSentryIntegrationConfig('proj-5'); @@ -136,6 +177,19 @@ describe('sentry/integration', () => { expect(result?.resultsContainerId).toBeUndefined(); }); + it('omits resultsContainerId when it is blank', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-6', + provider: 'sentry', + config: { organizationSlug: 'my-org', projectSlug: 'api', resultsContainerId: ' ' }, + }); + + const result = await getSentryIntegrationConfig('proj-6'); + + expect(result).toEqual({ organizationSlug: 'my-org', projectSlug: 'api' }); + expect(result?.resultsContainerId).toBeUndefined(); + }); + it('queries using projectId and alerting category', async () => { mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); diff --git a/tests/unit/sentry/project-filter.test.ts b/tests/unit/sentry/project-filter.test.ts new file mode 100644 index 000000000..e44f1ee8c --- /dev/null +++ b/tests/unit/sentry/project-filter.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { + extractSentryPayloadProjects, + matchSentryPayloadProject, +} from '../../../src/sentry/project-filter.js'; +import type { + SentryAugmentedPayload, + SentryIssueAlertPayload, + SentryIssuePayload, + SentryMetricAlertPayload, +} from '../../../src/sentry/types.js'; + +function makeEventAlertPayload( + eventOverrides: Partial = {}, +): SentryAugmentedPayload { + return { + resource: 'event_alert', + cascadeProjectId: 'cascade-project', + payload: { + action: 'triggered', + data: { + event: { + id: 'event-1', + issue_id: 'issue-1', + project: 'api', + ...eventOverrides, + }, + }, + }, + }; +} + +function makeMetricAlertPayload( + projects: SentryMetricAlertPayload['data']['metric_alert']['projects'] = ['api'], +): SentryAugmentedPayload { + return { + resource: 'metric_alert', + cascadeProjectId: 'cascade-project', + payload: { + action: 'critical', + data: { + metric_alert: { + projects, + }, + }, + }, + }; +} + +function makeIssuePayload( + project?: SentryIssuePayload['data']['issue']['project'], +): SentryAugmentedPayload { + const issueProject = project ?? { + id: '4501', + slug: 'api', + name: 'API', + }; + + return { + resource: 'issue', + cascadeProjectId: 'cascade-project', + payload: { + action: 'created', + data: { + issue: { + id: 'issue-1', + title: 'Sentry Issue', + project: issueProject, + }, + }, + }, + }; +} + +function makeIssuePayloadWithoutProject(): SentryAugmentedPayload { + return { + resource: 'issue', + cascadeProjectId: 'cascade-project', + payload: { + action: 'created', + data: { + issue: { + id: 'issue-1', + title: 'Sentry Issue', + }, + }, + }, + }; +} + +describe('sentry/project-filter', () => { + describe('extractSentryPayloadProjects', () => { + it('extracts event_alert projects from event.project and slug variants', () => { + const result = extractSentryPayloadProjects( + makeEventAlertPayload({ + project: { id: '4501', slug: ' API ', name: 'Backend API' }, + project_slug: 'api-worker', + }), + ); + + expect(result).toEqual([ + { + id: '4501', + slug: 'API', + name: 'Backend API', + source: 'data.event.project', + }, + { + slug: 'api-worker', + source: 'data.event.project_slug', + }, + ]); + }); + + it('extracts metric_alert projects from every metric_alert.projects entry', () => { + const result = extractSentryPayloadProjects( + makeMetricAlertPayload([ + 'web', + { id: '4501', slug: ' api ', name: 'Backend API' }, + { project_slug: 'worker' }, + ]), + ); + + expect(result).toEqual([ + { slug: 'web', source: 'data.metric_alert.projects[0]' }, + { + id: '4501', + slug: 'api', + name: 'Backend API', + source: 'data.metric_alert.projects[1]', + }, + { slug: 'worker', source: 'data.metric_alert.projects[2]' }, + ]); + }); + + it('extracts issue lifecycle project slug, id, and name', () => { + const result = extractSentryPayloadProjects( + makeIssuePayload({ id: '4501', slug: ' api ', name: 'Backend API' }), + ); + + expect(result).toEqual([ + { + id: '4501', + slug: 'api', + name: 'Backend API', + source: 'data.issue.project', + }, + ]); + }); + }); + + describe.each([ + { + resource: 'event_alert', + makePayload: () => + makeEventAlertPayload({ + project: { id: '4501', slug: ' API ', name: 'Backend API' }, + }), + makeMissingPayloadProject: () => + makeEventAlertPayload({ project: undefined, project_slug: undefined }), + }, + { + resource: 'metric_alert', + makePayload: () => + makeMetricAlertPayload(['web', { id: '4501', slug: ' API ', name: 'Backend API' }]), + makeMissingPayloadProject: () => makeMetricAlertPayload([]), + }, + { + resource: 'issue', + makePayload: () => makeIssuePayload({ id: '4501', slug: ' API ', name: 'Backend API' }), + makeMissingPayloadProject: () => makeIssuePayloadWithoutProject(), + }, + ])('matchSentryPayloadProject for $resource', ({ makePayload, makeMissingPayloadProject }) => { + it('allows a matching configured project slug case-insensitively', () => { + const result = matchSentryPayloadProject(makePayload(), 'api'); + + expect(result).toMatchObject({ + allowed: true, + reason: 'matched', + configuredProjectSlug: 'api', + }); + expect(result.payloadProjects.length).toBeGreaterThan(0); + }); + + it('allows a matching configured project name case-insensitively', () => { + const result = matchSentryPayloadProject(makePayload(), 'backend api'); + + expect(result).toMatchObject({ + allowed: true, + reason: 'matched', + configuredProjectSlug: 'backend api', + }); + }); + + it('allows an exact project ID match when the payload includes an ID', () => { + const result = matchSentryPayloadProject(makePayload(), '4501'); + + expect(result).toMatchObject({ + allowed: true, + reason: 'matched', + configuredProjectSlug: '4501', + }); + }); + + it('returns a structured mismatch result when payload projects do not match', () => { + const result = matchSentryPayloadProject(makePayload(), 'mobile'); + + expect(result).toMatchObject({ + allowed: false, + reason: 'project_mismatch', + configuredProjectSlug: 'mobile', + }); + expect(result.payloadProjects.length).toBeGreaterThan(0); + }); + + it('returns a structured result when configured project is missing', () => { + const result = matchSentryPayloadProject(makePayload(), ' '); + + expect(result).toMatchObject({ + allowed: false, + reason: 'missing_configured_project', + configuredProjectSlug: null, + }); + expect(result.payloadProjects.length).toBeGreaterThan(0); + }); + + it('returns a structured result when payload project is missing', () => { + const result = matchSentryPayloadProject(makeMissingPayloadProject(), 'api'); + + expect(result).toEqual({ + allowed: false, + reason: 'missing_payload_project', + configuredProjectSlug: 'api', + payloadProjects: [], + }); + }); + }); +}); diff --git a/tests/unit/triggers/sentry-alerting.test.ts b/tests/unit/triggers/sentry-alerting.test.ts index 7e81dffe7..ef53bdd09 100644 --- a/tests/unit/triggers/sentry-alerting.test.ts +++ b/tests/unit/triggers/sentry-alerting.test.ts @@ -36,7 +36,7 @@ const mockProject = createMockProject({ }, }); -const sentryConfig = { organizationSlug: 'my-org' }; +const sentryConfig = { organizationSlug: 'my-org', projectSlug: 'api' }; function makeSentryIssueAlertCtx( overrides: { diff --git a/tests/unit/triggers/sentry-webhook-handler.test.ts b/tests/unit/triggers/sentry-webhook-handler.test.ts index d066598b1..b38c728e2 100644 --- a/tests/unit/triggers/sentry-webhook-handler.test.ts +++ b/tests/unit/triggers/sentry-webhook-handler.test.ts @@ -7,6 +7,10 @@ vi.mock('../../../src/config/provider.js', () => ({ loadProjectConfigById: vi.fn(), })); +vi.mock('../../../src/sentry/integration.js', () => ({ + getSentryIntegrationConfig: vi.fn(), +})); + vi.mock('../../../src/utils/lifecycle.js', () => ({ startWatchdog: vi.fn(), })); @@ -62,6 +66,7 @@ import { } from '../../../src/integrations/alerting/_shared/format.js'; import { materializeAlertWorkItem } from '../../../src/integrations/alerting/_shared/materialize.js'; import { AlertSlotMissingError } from '../../../src/integrations/alerting/_shared/types.js'; +import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js'; import { processSentryWebhook } from '../../../src/triggers/sentry/webhook-handler.js'; import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; import { withAgentTypeConcurrency } from '../../../src/triggers/shared/concurrency.js'; @@ -71,6 +76,30 @@ import { createMockProject } from '../../helpers/factories.js'; const mockProject = createMockProject({ id: 'proj-sentry' }); +function makeEventAlertPayload(project = 'api') { + return { + resource: 'event_alert', + cascadeProjectId: 'proj-sentry', + payload: { data: { event: { project } } }, + }; +} + +function makeMetricAlertPayload(project = 'api') { + return { + resource: 'metric_alert', + cascadeProjectId: 'proj-sentry', + payload: { data: { metric_alert: { projects: [{ slug: project }] } } }, + }; +} + +function makeIssuePayload(project = 'api') { + return { + resource: 'issue', + cascadeProjectId: 'proj-sentry', + payload: { data: { issue: { project } } }, + }; +} + describe('processSentryWebhook', () => { let mockRegistry: { dispatch: ReturnType }; @@ -81,6 +110,10 @@ describe('processSentryWebhook', () => { project: mockProject, config: { projects: [mockProject] } as never, }); + vi.mocked(getSentryIntegrationConfig).mockResolvedValue({ + organizationSlug: 'mongrel', + projectSlug: 'api', + }); vi.mocked(runAgentExecutionPipeline).mockResolvedValue(undefined); // Re-apply pass-through implementations after resetAllMocks clears them vi.mocked(withAgentTypeConcurrency).mockImplementation((_projectId, _agentType, fn) => @@ -105,7 +138,7 @@ describe('processSentryWebhook', () => { }); it('loads project config by projectId and calls resolveTriggerResult with sentry source', async () => { - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, undefined); @@ -123,7 +156,7 @@ describe('processSentryWebhook', () => { }); it('creates a TriggerContext with source sentry and the given payload', async () => { - const payload = { resource: 'metric_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeMetricAlertPayload(); await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never); @@ -148,7 +181,7 @@ describe('processSentryWebhook', () => { }); it('passes triggerResult to resolveTriggerResult when provided', async () => { - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {} } as never; await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, triggerResult); @@ -162,7 +195,7 @@ describe('processSentryWebhook', () => { }); it('logs info message when triggerResult is provided (via resolveTriggerResult)', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {} } as never; vi.mocked(resolveTriggerResult).mockResolvedValue(triggerResult); @@ -176,7 +209,7 @@ describe('processSentryWebhook', () => { }); it('runs the agent execution pipeline when triggerResult has an agentType', async () => { - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {} } as never; vi.mocked(resolveTriggerResult).mockResolvedValue(triggerResult); @@ -191,7 +224,7 @@ describe('processSentryWebhook', () => { }); it('does not run the agent when resolveTriggerResult returns null', async () => { - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); vi.mocked(resolveTriggerResult).mockResolvedValue(null); await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never); @@ -199,8 +232,23 @@ describe('processSentryWebhook', () => { expect(runAgentExecutionPipeline).not.toHaveBeenCalled(); }); + it('returns before trigger resolution and PM materialization when payload project mismatches configured project', async () => { + const payload = makeEventAlertPayload('mobile'); + const triggerResult = { + agentType: 'alerting', + agentInput: { alertIssueId: 'sentry-issue-42' }, + } as never; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, triggerResult); + + expect(getSentryIntegrationConfig).toHaveBeenCalledWith('proj-sentry'); + expect(resolveTriggerResult).not.toHaveBeenCalled(); + expect(materializeAlertWorkItem).not.toHaveBeenCalled(); + expect(runAgentExecutionPipeline).not.toHaveBeenCalled(); + }); + it('applies agent-type concurrency when running the agent', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {} } as never; vi.mocked(resolveTriggerResult).mockResolvedValue(triggerResult); @@ -216,7 +264,7 @@ describe('processSentryWebhook', () => { }); it('skips execution when concurrency is blocked', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {} } as never; vi.mocked(resolveTriggerResult).mockResolvedValue(triggerResult); vi.mocked(withAgentTypeConcurrency).mockResolvedValue(false); @@ -229,7 +277,7 @@ describe('processSentryWebhook', () => { // ── PM card materialisation (spec 019) ────────────────────────────────── it('materialises a PM work item when alertIssueId is set and workItemId is absent', async () => { - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { @@ -268,7 +316,7 @@ describe('processSentryWebhook', () => { }); it('skips materialisation and runs agent directly when workItemId is already set', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', workItemId: 'wi-already-set', @@ -288,7 +336,7 @@ describe('processSentryWebhook', () => { }); it('skips materialisation when alertIssueId is not a string', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: {}, @@ -302,7 +350,7 @@ describe('processSentryWebhook', () => { }); it('logs a warning and skips agent when materialisation throws AlertSlotMissingError', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { alertIssueId: 'sentry-issue-42' }, @@ -322,7 +370,7 @@ describe('processSentryWebhook', () => { }); it('re-throws transient PM errors so BullMQ can retry the job', async () => { - const payload = { resource: 'event_alert' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { alertIssueId: 'sentry-issue-42' }, @@ -341,7 +389,7 @@ describe('processSentryWebhook', () => { // ── Metric alert PM card materialisation (spec 019 review feedback) ────── it('materialises a PM work item for metric alerts when alertMetricKey is set', async () => { - const payload = { resource: 'metric_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeMetricAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { @@ -381,7 +429,7 @@ describe('processSentryWebhook', () => { }); it('skips metric alert materialisation when workItemId is already set', async () => { - const payload = { resource: 'metric_alert' }; + const payload = makeMetricAlertPayload(); const triggerResult = { agentType: 'alerting', workItemId: 'metric-card-already', @@ -401,7 +449,7 @@ describe('processSentryWebhook', () => { }); it('skips agent and warns when metric alert materialisation throws AlertSlotMissingError', async () => { - const payload = { resource: 'metric_alert' }; + const payload = makeMetricAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { alertMetricKey: 'my-org:Error Rate High' }, @@ -421,7 +469,7 @@ describe('processSentryWebhook', () => { }); it('re-throws transient PM errors for metric alerts so BullMQ can retry', async () => { - const payload = { resource: 'metric_alert' }; + const payload = makeMetricAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { alertMetricKey: 'my-org:Error Rate High' }, @@ -444,7 +492,7 @@ describe('processSentryWebhook', () => { // to pick the lifecycle format helper + 'sentry-issue' AlertSource. it('materialises a PM work item for issue-lifecycle when triggerEvent is alerting:issue-lifecycle', async () => { - const payload = { resource: 'issue', cascadeProjectId: 'proj-sentry' }; + const payload = makeIssuePayload(); const triggerResult = { agentType: 'alerting', agentInput: { @@ -490,7 +538,7 @@ describe('processSentryWebhook', () => { // Regression net: the existing event_alert flow must keep using // `formatSentryCardBody` and `'sentry'` AlertSource even though both // surfaces pass `alertIssueId`. - const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const payload = makeEventAlertPayload(); const triggerResult = { agentType: 'alerting', agentInput: { @@ -514,7 +562,7 @@ describe('processSentryWebhook', () => { }); it('skips issue-lifecycle materialisation when workItemId is already set', async () => { - const payload = { resource: 'issue' }; + const payload = makeIssuePayload(); const triggerResult = { agentType: 'alerting', workItemId: 'issue-card-already', @@ -538,7 +586,7 @@ describe('processSentryWebhook', () => { }); it('skips agent and warns when issue-lifecycle materialisation throws AlertSlotMissingError', async () => { - const payload = { resource: 'issue' }; + const payload = makeIssuePayload(); const triggerResult = { agentType: 'alerting', agentInput: { @@ -561,7 +609,7 @@ describe('processSentryWebhook', () => { }); it('re-throws transient PM errors for issue-lifecycle so BullMQ can retry', async () => { - const payload = { resource: 'issue' }; + const payload = makeIssuePayload(); const triggerResult = { agentType: 'alerting', agentInput: { diff --git a/tests/unit/triggers/sentry/alerting-issue-materializer.test.ts b/tests/unit/triggers/sentry/alerting-issue-materializer.test.ts index 93789b53b..f5484b261 100644 --- a/tests/unit/triggers/sentry/alerting-issue-materializer.test.ts +++ b/tests/unit/triggers/sentry/alerting-issue-materializer.test.ts @@ -29,7 +29,7 @@ import { checkTriggerEnabledWithParams } from '../../../../src/triggers/shared/t import type { TriggerContext } from '../../../../src/types/index.js'; import { createMockProject } from '../../../helpers/factories.js'; -const sentryConfig = { organizationSlug: 'my-org' }; +const sentryConfig = { organizationSlug: 'my-org', projectSlug: 'api' }; // Project with alerts slot configured — required for dispatch const mockProjectWithAlerts = createMockProject({ diff --git a/tests/unit/triggers/sentry/issue-lifecycle.test.ts b/tests/unit/triggers/sentry/issue-lifecycle.test.ts index 592c1475d..0547b4d25 100644 --- a/tests/unit/triggers/sentry/issue-lifecycle.test.ts +++ b/tests/unit/triggers/sentry/issue-lifecycle.test.ts @@ -42,7 +42,7 @@ const mockProject = createMockProject({ }, }); -const sentryConfig = { organizationSlug: 'mongrel' }; +const sentryConfig = { organizationSlug: 'mongrel', projectSlug: 'cascade' }; function makeIssueLifecycleCtx( overrides: { diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx index 4869ae789..6f4e9e6ec 100644 --- a/web/src/components/projects/integration-alerting-tab.tsx +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -219,6 +219,7 @@ export function AlertingTab({ const [organizationSlug, setOrganizationSlug] = useState( (existingConfig.organizationSlug as string) ?? '', ); + const [projectSlug, setProjectSlug] = useState((existingConfig.projectSlug as string) ?? ''); const [resultsContainerId, setResultsContainerId] = useState( (existingConfig.resultsContainerId as string) ?? '', ); @@ -245,11 +246,13 @@ export function AlertingTab({ const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); const handleVerify = async (rawToken: string) => { + const trimmedOrganizationSlug = organizationSlug.trim(); + const trimmedProjectSlug = projectSlug.trim(); if (!rawToken) { setVerifyError('Enter the API token value to verify it'); return; } - if (!organizationSlug) { + if (!trimmedOrganizationSlug) { setVerifyError('Enter the organization slug to verify it'); return; } @@ -259,7 +262,8 @@ export function AlertingTab({ try { const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ apiToken: rawToken, - organizationSlug, + organizationSlug: trimmedOrganizationSlug, + ...(trimmedProjectSlug ? { projectSlug: trimmedProjectSlug } : {}), }); setVerifyResult(result); } catch (err) { @@ -271,13 +275,23 @@ export function AlertingTab({ const saveMutation = useMutation({ mutationFn: async () => { + const trimmedOrganizationSlug = organizationSlug.trim(); + const trimmedProjectSlug = projectSlug.trim(); + const trimmedResultsContainerId = resultsContainerId.trim(); + if (!trimmedOrganizationSlug) { + throw new Error('Sentry organization slug is required'); + } + if (!trimmedProjectSlug) { + throw new Error('Sentry project slug is required'); + } return trpcClient.projects.integrations.upsert.mutate({ projectId, category: 'alerting', provider: 'sentry', config: { - organizationSlug, - ...(resultsContainerId ? { resultsContainerId } : {}), + organizationSlug: trimmedOrganizationSlug, + projectSlug: trimmedProjectSlug, + ...(trimmedResultsContainerId ? { resultsContainerId: trimmedResultsContainerId } : {}), }, }); }, @@ -330,6 +344,20 @@ export function AlertingTab({ /> + {/* Project Slug */} +
+ +

+ Your Sentry project slug (found in project URLs after the organization slug). +

+ setProjectSlug(e.target.value)} + placeholder="my-project" + /> +
+
{/* Investigation Results List */}