From 8a74326148e0985b9854ff7d213ec26c10c1cd1e Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Mar 2026 17:40:56 +0100 Subject: [PATCH 1/5] docs: add Adding Engines guide and update backends README (#1030) * docs: add Adding Engines guide and update backends README * fix(docs): use ALLOWED_ENV_EXACT from env.ts in Section 7 engine class template Import ALLOWED_ENV_EXACT from ./env.js and return it directly in getAllowedEnvExact(), eliminating the duplication hazard of defining the same set in both env.ts and the engine class. This matches the pattern used by the Claude Code engine class. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.6 --- docs/adding-engines.md | 549 +++++++++++++++++++++++++++++++++++++++++ src/backends/README.md | 34 ++- 2 files changed, 573 insertions(+), 10 deletions(-) create mode 100644 docs/adding-engines.md diff --git a/docs/adding-engines.md b/docs/adding-engines.md new file mode 100644 index 00000000..170cfeca --- /dev/null +++ b/docs/adding-engines.md @@ -0,0 +1,549 @@ +# Adding a New Agent Engine + +This guide walks through every step required to add a new agent engine to CASCADE — from choosing the right archetype to running your first test. Following this guide, adding a new subprocess-based engine (e.g., Gemini CLI, Kilo Code, Continue.dev) should take a few hours, not days. + +--- + +## 1. Choose an Archetype: `native-tool` vs `sdk` + +CASCADE supports two engine archetypes. Choose the one that matches how the tool runs. + +### `native-tool` — Subprocess-based CLI tools + +Use this when your engine: +- Runs as an **external CLI** process (spawned via `child_process.spawn`) +- Has its own built-in file/bash tools (Read, Write, Edit, Bash, Glob, Grep) +- Communicates by receiving a prompt via stdin/HTTP and streaming output +- Examples: **Claude Code**, **Codex**, **OpenCode** + +The `native-tool` archetype provides the `NativeToolEngine` base class (`src/backends/shared/NativeToolEngine.ts`), which handles: +- Shared subprocess environment construction via `buildEngineEnv()` +- `resolveModel()` delegation to your `resolveEngineModel()` implementation +- `supportsAgentType()` returning `true` for all agent types (override if needed) +- `afterExecute()` cleanup for offloaded context files + +### `sdk` — In-process SDK integrations + +Use this when your engine: +- Runs **in-process** as a TypeScript/JavaScript SDK (no subprocess) +- Manages its own LLM API calls directly +- Injects context via synthetic tool calls rather than subprocess environment variables +- Example: **LLMist** (`src/backends/llmist/`) + +For `sdk` engines, implement the `AgentEngine` interface directly (see `src/backends/types.ts`). There is no base class — you are responsible for all lifecycle details. Use the LLMist engine as your reference implementation. + +**When in doubt, use `native-tool`.** It is the more common pattern and has more shared infrastructure. + +--- + +## 2. Create the Engine Directory + +Create a new directory under `src/backends//`. The standard layout for a native-tool engine: + +``` +src/backends/my-engine/ +├── index.ts # Main engine class (extends NativeToolEngine) +├── env.ts # Env-var allowlist (exports ALLOWED_ENV_EXACT) +├── models.ts # Model ID list and default +└── settings.ts # Zod schema + resolver for engine-specific settings (optional) +``` + +--- + +## 3. Define the Engine in `catalog.ts` + +Add an `AgentEngineDefinition` constant to `src/backends/catalog.ts`: + +```typescript +// src/backends/catalog.ts +export const MY_ENGINE_DEFINITION: AgentEngineDefinition = { + id: 'my-engine', // Stable string ID — used in DB and config + label: 'My Engine', // Human-readable label for the dashboard + description: 'Short description of what this engine does.', + archetype: 'native-tool', // or 'sdk' for in-process engines + capabilities: [ + 'inline_prompt_context', + 'offloaded_context_files', + 'native_file_edit_tools', + 'external_cli_tools', + 'streaming_text_events', + 'streaming_tool_events', + 'scoped_env_secrets', + ], + modelSelection: { + type: 'select', // or 'free-text' for open-ended model strings + defaultValueLabel: 'Default (v1.0)', + options: MY_ENGINE_MODELS, // Imported from ./my-engine/models.ts + }, + logLabel: 'My Engine Log', + // Optional: add 'settings' if your engine has configurable fields +}; +``` + +Add it to `DEFAULT_ENGINE_CATALOG` at the bottom of the same file: + +```typescript +export const DEFAULT_ENGINE_CATALOG: AgentEngineDefinition[] = [ + CLAUDE_CODE_ENGINE_DEFINITION, + LLMIST_ENGINE_DEFINITION, + CODEX_ENGINE_DEFINITION, + OPENCODE_ENGINE_DEFINITION, + MY_ENGINE_DEFINITION, // ← add here +]; +``` + +--- + +## 4. Add Env Filtering (`env.ts`) + +Every native-tool engine needs an **allowlist** of environment variables that may be passed to its subprocess. This prevents server-side secrets (`DATABASE_URL`, `REDIS_URL`, `CREDENTIAL_MASTER_KEY`, etc.) from leaking into agent processes. + +Create `src/backends/my-engine/env.ts`: + +```typescript +// src/backends/my-engine/env.ts +import { SHARED_ALLOWED_ENV_EXACT } from '../shared/envFilter.js'; + +/** + * Exact variable names to pass through (shared + My Engine-specific). + * Extend the shared set with auth vars specific to your engine. + */ +export const ALLOWED_ENV_EXACT = new Set([ + ...SHARED_ALLOWED_ENV_EXACT, + + // My Engine auth + 'MY_ENGINE_API_KEY', + + // Squint (pass through so agents can use AST tooling) + 'SQUINT_DB_PATH', +]); +``` + +The shared set (`SHARED_ALLOWED_ENV_EXACT` from `src/backends/shared/envFilter.js`) already includes: +- System vars: `HOME`, `PATH`, `SHELL`, `USER`, `LANG`, `TZ` +- Node vars: `NODE_PATH`, `NODE_EXTRA_CA_CERTS` +- Editor/color: `EDITOR`, `FORCE_COLOR`, `NO_COLOR` +- CASCADE internal: progress comment state, GitHub ack comment ID, session state vars + +The shared prefix allowlist (`SHARED_ALLOWED_ENV_PREFIXES`) passes through `LC_*`, `XDG_*`, `GIT_*`, `SSH_*`, `GPG_*`, and `DOCKER_*` automatically. + +--- + +## 5. Define Models (`models.ts`) + +Create `src/backends/my-engine/models.ts`: + +```typescript +// src/backends/my-engine/models.ts +export const MY_ENGINE_MODELS = [ + { value: 'my-engine-v1', label: 'My Engine v1 (default)' }, + { value: 'my-engine-v2', label: 'My Engine v2 (latest)' }, +] as const; + +export const MY_ENGINE_MODEL_IDS = MY_ENGINE_MODELS.map((m) => m.value); +export const DEFAULT_MY_ENGINE_MODEL = 'my-engine-v1'; +``` + +If your engine accepts arbitrary model strings (e.g., OpenCode which uses `provider/model` format), use `modelSelection: { type: 'free-text' }` in the definition and skip the model list. + +--- + +## 6. Add Settings Schema (Optional) + +If your engine has configurable behaviour (e.g., approval policy, reasoning effort, web search), define a Zod schema for it. + +Create `src/backends/my-engine/settings.ts`: + +```typescript +// src/backends/my-engine/settings.ts +import { z } from 'zod'; +import { type EngineSettings, getEngineSettings } from '../../config/engineSettings.js'; +import type { ProjectConfig } from '../../types/index.js'; + +export const MY_ENGINE_SETTING_DEFAULTS = { + mode: 'balanced' as const, + webSearch: false, +}; + +export const MyEngineSettingsSchema = z.object({ + mode: z.enum(['fast', 'balanced', 'thorough']).optional(), + webSearch: z.boolean().optional(), +}); + +export type MyEngineSettings = z.infer; + +export function resolveMyEngineSettings( + project: ProjectConfig, + engineSettings?: EngineSettings, +): Required { + const effectiveSettings = engineSettings ?? project.engineSettings; + const settings = getEngineSettings(effectiveSettings, 'my-engine', MyEngineSettingsSchema) ?? {}; + return { + mode: settings.mode ?? MY_ENGINE_SETTING_DEFAULTS.mode, + webSearch: settings.webSearch ?? MY_ENGINE_SETTING_DEFAULTS.webSearch, + }; +} +``` + +Then expose these settings in your `AgentEngineDefinition` (in `catalog.ts`): + +```typescript +settings: { + title: 'My Engine Settings', + description: 'Behaviour controls for My Engine runs.', + fields: [ + { + key: 'mode', + label: 'Mode', + type: 'select', + options: [ + { value: 'fast', label: 'Fast' }, + { value: 'balanced', label: 'Balanced' }, + { value: 'thorough', label: 'Thorough' }, + ], + }, + { + key: 'webSearch', + label: 'Web Search', + type: 'boolean', + description: 'Allow web search during runs.', + }, + ], +}, +``` + +--- + +## 7. Implement the Engine Class (`index.ts`) + +Here is the minimal template for a native-tool subprocess engine. This is what you **must** implement: + +```typescript +// src/backends/my-engine/index.ts +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +import { MY_ENGINE_DEFINITION } from '../catalog.js'; +import { NativeToolEngine } from '../shared/NativeToolEngine.js'; +import { buildEngineResult, extractAndBuildPrEvidence } from '../shared/engineResult.js'; +import { buildSystemPrompt, buildTaskPrompt } from '../shared/nativeToolPrompts.js'; +import type { AgentEngineResult, AgentExecutionPlan } from '../types.js'; +import { ALLOWED_ENV_EXACT } from './env.js'; +import { DEFAULT_MY_ENGINE_MODEL, MY_ENGINE_MODEL_IDS } from './models.js'; +import { MyEngineSettingsSchema, resolveMyEngineSettings } from './settings.js'; + +// ─── Model resolution ──────────────────────────────────────────────────────── + +function resolveMyEngineModel(cascadeModel: string): string { + if (MY_ENGINE_MODEL_IDS.includes(cascadeModel)) return cascadeModel; + // Add engine-prefixed model strings if your engine supports them + throw new Error( + `Model "${cascadeModel}" is not compatible with My Engine. ` + + `Configure a supported model (e.g. "${DEFAULT_MY_ENGINE_MODEL}") or switch engines.` + ); +} + +// ─── Engine class ──────────────────────────────────────────────────────────── + +/** + * My Engine backend for CASCADE. + * + * Extends NativeToolEngine to share subprocess env-building, supportsAgentType(), + * resolveModel() delegation, and base afterExecute() context cleanup. + */ +export class MyEngine extends NativeToolEngine { + readonly definition = MY_ENGINE_DEFINITION; + + // ── NativeToolEngine abstract methods ────────────────────────────────────── + + getAllowedEnvExact(): Set { + return ALLOWED_ENV_EXACT; + } + + getExtraEnvVars(): Record { + return { CI: 'true' }; + } + + resolveEngineModel(cascadeModel: string): string { + return resolveMyEngineModel(cascadeModel); + } + + // ── Optional lifecycle hooks ─────────────────────────────────────────────── + + getSettingsSchema() { + return MyEngineSettingsSchema; + } + + async beforeExecute(plan: AgentExecutionPlan): Promise { + // Write auth files, validate prerequisites, etc. + // Called by the adapter before execute(). + } + + async afterExecute(plan: AgentExecutionPlan, result: AgentEngineResult): Promise { + await super.afterExecute(plan, result); // Cleans up offloaded context files + // Additional cleanup (remove temp files, kill sidecars, etc.) + } + + // ── Core execution ───────────────────────────────────────────────────────── + + async execute(input: AgentExecutionPlan): Promise { + const startTime = Date.now(); + + // 1. Build prompts + const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); + const { prompt: taskPrompt, hasOffloadedContext: _hasOffloadedContext } = await buildTaskPrompt( + input.taskPrompt, + input.contextInjections, + input.repoDir, + ); + + // 2. Resolve model — idempotent, safe to call even without the adapter + const model = resolveMyEngineModel(input.model); + + // 3. Resolve settings + const settings = resolveMyEngineSettings(input.project, input.engineSettings); + + // 4. Build subprocess environment + const env = this.buildEnv(input.projectSecrets, input.cliToolsDir, input.nativeToolShimDir); + + input.logWriter('INFO', 'Starting My Engine execution', { + agentType: input.agentType, + model, + repoDir: input.repoDir, + maxIterations: input.maxIterations, + }); + + // 5. Spawn the subprocess and stream output + const rawTextParts: string[] = []; + const stderrChunks: string[] = []; + let iterationCount = 0; + + const exitCode = await new Promise((resolve, reject) => { + const child = spawn('my-engine', [ + '--model', model, + '--mode', settings.mode, + '--json', // Request JSONL/structured output if available + input.repoDir, + ], { + cwd: input.repoDir, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.once('error', (error) => { + reject( + error instanceof Error && 'code' in error && error.code === 'ENOENT' + ? new Error('my-engine CLI not found in PATH. Install it in the worker image.') + : error, + ); + }); + + // Pipe the combined prompt to stdin + child.stdin.write(`${systemPrompt}\n\n${taskPrompt}`); + child.stdin.end(); + + const stdout = createInterface({ input: child.stdout }); + stdout.on('line', (line) => { + rawTextParts.push(line); + input.progressReporter.onText(line); + iterationCount++; + void input.progressReporter.onIteration(iterationCount, input.maxIterations); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + + child.once('close', (code) => resolve(code ?? 1)); + }); + + const finalOutput = rawTextParts.join('\n').trim(); + const { prUrl, prEvidence } = extractAndBuildPrEvidence(finalOutput); + + input.logWriter('INFO', 'My Engine execution completed', { + exitCode, + turns: iterationCount, + durationMs: Date.now() - startTime, + }); + + if (exitCode !== 0) { + return buildEngineResult({ + success: false, + output: finalOutput, + error: stderrChunks.join('').trim() || `my-engine exited with code ${exitCode}`, + prUrl, + prEvidence, + }); + } + + return buildEngineResult({ + success: true, + output: finalOutput, + prUrl, + prEvidence, + }); + } +} + +export { resolveMyEngineModel }; +``` + +### Key helpers used above + +| Helper | Location | Purpose | +|--------|----------|---------| +| `buildSystemPrompt(systemPrompt, availableTools)` | `src/backends/shared/nativeToolPrompts.ts` | Formats the system prompt with tool guidance | +| `buildTaskPrompt(taskPrompt, contextInjections, repoDir)` | `src/backends/shared/nativeToolPrompts.ts` | Offloads large context to files when needed | +| `this.buildEnv(projectSecrets, cliToolsDir, nativeToolShimDir)` | `NativeToolEngine` base class | Builds a sanitised subprocess env | +| `buildEngineResult({ ... })` | `src/backends/shared/engineResult.ts` | Constructs `AgentEngineResult` | +| `extractAndBuildPrEvidence(output)` | `src/backends/shared/engineResult.ts` | Extracts PR URL from output text | + +--- + +## 8. Register in `bootstrap.ts` + +Add your engine to `src/backends/bootstrap.ts` so it is available at runtime: + +```typescript +// src/backends/bootstrap.ts +import { MyEngine } from './my-engine/index.js'; + +export function registerBuiltInEngines(): void { + // ... existing engines ... + if (!getEngine('my-engine')) { + registerEngineWithSettings(new MyEngine()); + } +} +``` + +`registerEngineWithSettings` handles both `registerEngine()` and `registerEngineSettingsSchema()` in one call. If your engine does not implement `getSettingsSchema()`, use `registerEngine()` directly instead. + +--- + +## 9. Update `Dockerfile.worker` + +Install your engine's CLI binary in `Dockerfile.worker`. Follow the pattern used for the existing engines: + +```dockerfile +# Install my-engine CLI +RUN npm install -g @my-org/my-engine@1.0.0 +``` + +Search `Dockerfile.worker` for `@anthropic-ai/claude-code` or `@openai/codex` to find the right place to add your install step. + +--- + +## 10. Test Your Engine + +### A. Engine-contract test (required) + +Create `tests/unit/backends/my-engine.test.ts` to verify the contract. Look at existing engine tests for the pattern: + +``` +tests/unit/backends/ +├── claude-code.test.ts +├── codex.test.ts +└── opencode.test.ts +``` + +At minimum, test: +1. **`definition`** — correct `id`, `archetype`, `capabilities` +2. **`resolveEngineModel()`** — valid model strings pass, invalid ones throw +3. **`getAllowedEnvExact()`** — contains engine auth var; does NOT contain blocked vars +4. **`getExtraEnvVars()`** — returns expected constants (e.g. `CI: 'true'`) +5. **`getSettingsSchema()`** — schema validates expected shape (if implemented) +6. **`execute()`** — with mocked subprocess, returns expected result shape + +### B. Env filter test (required) + +Create `tests/unit/backends/my-engine-env.test.ts` to verify that sensitive variables are blocked: + +```typescript +import { ALLOWED_ENV_EXACT } from '../../../src/backends/my-engine/env.js'; +import { SHARED_BLOCKED_ENV_EXACT } from '../../../src/backends/shared/envFilter.js'; + +it('does not allow any blocked vars', () => { + for (const blocked of SHARED_BLOCKED_ENV_EXACT) { + expect(ALLOWED_ENV_EXACT.has(blocked)).toBe(false); + } +}); + +it('allows engine auth var', () => { + expect(ALLOWED_ENV_EXACT.has('MY_ENGINE_API_KEY')).toBe(true); +}); +``` + +### C. Unit tests for settings (if applicable) + +Test that `resolveMyEngineSettings()` applies defaults correctly and that the schema validates the expected shape. + +### Running tests + +```bash +npm test # All unit tests +npx vitest run tests/unit/backends/ # Just backend tests +``` + +--- + +## 11. Wire Up CLI and Dashboard (Automatic) + +No CLI or dashboard changes are needed. CASCADE reads the engine catalog dynamically: + +- **Dashboard Project Settings** — `getEngineCatalog()` returns all registered engines +- **`cascade projects update --agent-engine my-engine`** — the CLI uses the same dynamic list +- **`cascade agents create --engine my-engine`** — same +- Engine settings fields defined in `AgentEngineDefinition.settings` are rendered automatically in the Agent Configs tab + +--- + +## Summary Checklist + +- [ ] Create `src/backends/my-engine/` directory with `index.ts`, `env.ts`, `models.ts` +- [ ] Add `MY_ENGINE_DEFINITION` to `src/backends/catalog.ts` with correct `archetype` +- [ ] Set `ALLOWED_ENV_EXACT` in `env.ts` — extends shared set, adds engine auth vars +- [ ] Implement `getSettingsSchema()` and `settings.ts` if the engine has configurable options +- [ ] Implement `resolveEngineModel()` — validate and map CASCADE model strings +- [ ] Implement `execute()` — spawn subprocess, stream output, return `AgentEngineResult` +- [ ] Implement `beforeExecute()` / `afterExecute()` hooks if auth files or cleanup are needed +- [ ] Register in `src/backends/bootstrap.ts` via `registerEngineWithSettings()` +- [ ] Add engine CLI install step to `Dockerfile.worker` +- [ ] Write engine-contract tests and env-filter tests +- [ ] Run `npm test` and `npm run typecheck` — all green + +--- + +## Real-World Examples + +Refer to these implementations for patterns and guidance: + +| Engine | Archetype | Location | Notable patterns | +|--------|-----------|----------|-----------------| +| Claude Code | `native-tool` | `src/backends/claude-code/` | SDK-based (not subprocess), `beforeExecute` writes onboarding flag, `afterExecute` cleans up session | +| Codex | `native-tool` | `src/backends/codex/` | Subprocess via `spawn`, JSONL output parsing, subscription auth with token refresh | +| OpenCode | `native-tool` | `src/backends/opencode/` | HTTP server protocol, `runContinuationLoop` for multi-turn, permission policy config | +| LLMist | `sdk` | `src/backends/llmist/` | In-process SDK, synthetic context injection, no `NativeToolEngine` base class | + +--- + +## Architecture Quick-Reference + +``` +src/backends/ +├── types.ts # AgentEngine, AgentEngineDefinition, AgentExecutionPlan interfaces +├── catalog.ts # AgentEngineDefinition constants + DEFAULT_ENGINE_CATALOG +├── registry.ts # Runtime engine registry (registerEngine, getEngine, isNativeToolEngine) +├── bootstrap.ts # Registers all built-in engines + their settings schemas +├── adapter.ts # Shared lifecycle: repo setup, prompts, secrets, post-processing +├── shared/ +│ ├── NativeToolEngine.ts # Abstract base class for native-tool engines +│ ├── envFilter.ts # Shared env-var allowlists and filterProcessEnv() +│ ├── envBuilder.ts # buildEngineEnv() — the single env construction entry point +│ ├── nativeToolPrompts.ts # buildSystemPrompt() and buildTaskPrompt() helpers +│ ├── engineResult.ts # buildEngineResult(), extractAndBuildPrEvidence() +│ └── contextFiles.ts # cleanupContextFiles() for offloaded context +├── claude-code/ # Native-tool (SDK-based) +├── codex/ # Native-tool (subprocess, JSONL) +├── opencode/ # Native-tool (HTTP server protocol) +└── llmist/ # SDK archetype (in-process) +``` diff --git a/src/backends/README.md b/src/backends/README.md index 2b9dec73..86b3f637 100644 --- a/src/backends/README.md +++ b/src/backends/README.md @@ -2,18 +2,32 @@ CASCADE runs coding agents through a shared execution lifecycle and a pluggable engine registry. -Core pieces: +## Core pieces -- `types.ts`: canonical engine contracts -- `registry.ts`: runtime engine registry and catalog source -- `bootstrap.ts`: built-in engine registration +- `types.ts`: canonical engine contracts (`AgentEngine`, `AgentEngineDefinition`, `AgentExecutionPlan`) +- `catalog.ts`: static engine definitions with `archetype` field (`sdk` or `native-tool`) +- `registry.ts`: runtime engine registry (`registerEngine`, `getEngine`, `isNativeToolEngine`) +- `bootstrap.ts`: built-in engine registration (also registers settings schemas) - `adapter.ts`: shared lifecycle around repo setup, prompts, progress, secrets, run tracking, and post-processing -- `llmist/`, `claude-code/`, `codex/`, and `opencode/`: engine-specific adapters +- `shared/NativeToolEngine.ts`: abstract base class for subprocess-based engines (Claude Code, Codex, OpenCode) +- `llmist/`, `claude-code/`, `codex/`, `opencode/`: engine-specific implementations -To add a new engine: +## Archetypes -1. Implement `AgentEngine` with a stable `definition.id`. -2. Register it through the engine registry. -3. Keep orchestration concerns in the shared adapter unless they are truly engine-specific. +Every engine declares an `archetype` in its `AgentEngineDefinition`: -The rest of the product should consume engine metadata dynamically rather than branching on engine names. +- **`native-tool`** — subprocess-based CLI tools (Claude Code, Codex, OpenCode). Extend `NativeToolEngine` from `shared/NativeToolEngine.ts`. The base class provides shared env-building, `supportsAgentType()`, `resolveModel()` delegation, and context file cleanup. +- **`sdk`** — in-process SDK integrations (LLMist). Implement `AgentEngine` directly; no base class is used. + +## To add a new engine + +See [`docs/adding-engines.md`](../../docs/adding-engines.md) for the full step-by-step guide, including archetype selection, env filtering, settings schemas, model resolution, registration, and testing. + +At a high level: + +1. Choose archetype: extend `NativeToolEngine` for subprocess CLIs, implement `AgentEngine` directly for in-process SDKs. +2. Create `src/backends//` with `index.ts`, `env.ts`, `models.ts`, and optionally `settings.ts`. +3. Add an `AgentEngineDefinition` with the `archetype` field to `catalog.ts`. +4. Register the engine (and its settings schema) in `bootstrap.ts`. + +The rest of the product consumes engine metadata dynamically via `getEngineCatalog()` — no branching on engine names required. From 452b5b0d113b20ea0ea58ccadff18a2078834560 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Tue, 24 Mar 2026 16:35:53 +0000 Subject: [PATCH 2/5] fix(router): use trigger result workItemId for GitHub ack run link The GitHub PR ack comment was linking to /work-items/{project}/{prNumber} instead of /work-items/{project}/{trelloCardId}, causing "No runs found" on the dashboard. Use triggerResult.workItemId ?? event.workItemId, mirroring the existing pattern used for PM-focused agent acks (line 320). Co-Authored-By: Claude Sonnet 4.6 --- src/router/adapters/github.ts | 5 ++-- tests/unit/router/adapters/github.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/router/adapters/github.ts b/src/router/adapters/github.ts index 7b8f77c4..da717bd7 100644 --- a/src/router/adapters/github.ts +++ b/src/router/adapters/github.ts @@ -331,7 +331,8 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { // Build the GitHub PR ack message with run link included before posting, // so the actual comment on the PR contains the footer (not just internal metadata). let githubAckMessage: string | undefined; - if (runLinksEnabled && event.workItemId) { + const workItemIdForLink = triggerResult?.workItemId ?? event.workItemId; + if (runLinksEnabled && workItemIdForLink) { const dashboardUrl = getDashboardUrl(); if (dashboardUrl) { const context = extractGitHubContext(payload, event.eventType); @@ -339,7 +340,7 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { const link = buildWorkItemRunsLink({ dashboardUrl, projectId: project.id, - workItemId: event.workItemId, + workItemId: workItemIdForLink, }); githubAckMessage = link ? baseMessage + link : baseMessage; } diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index feecb059..a1495ed6 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -69,6 +69,10 @@ vi.mock('../../../../src/pm/trello/integration.js', () => ({ vi.mock('../../../../src/sentry.js', () => ({ captureException: vi.fn(), })); +vi.mock('../../../../src/utils/runLink.js', () => ({ + buildWorkItemRunsLink: vi.fn().mockReturnValue('\n\n🕵️ [View run](https://example.com)'), + getDashboardUrl: vi.fn().mockReturnValue('https://example.com'), +})); import { isPMFocusedAgent } from '../../../../src/agents/definitions/loader.js'; import { findProjectByRepo } from '../../../../src/config/provider.js'; @@ -86,6 +90,7 @@ import { addEyesReactionToPR } from '../../../../src/router/pre-actions.js'; import type { GitHubJob } from '../../../../src/router/queue.js'; import { sendAcknowledgeReaction } from '../../../../src/router/reactions.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; +import { buildWorkItemRunsLink } from '../../../../src/utils/runLink.js'; const mockProject: RouterProjectConfig = { id: 'p1', @@ -407,6 +412,37 @@ describe('GitHubRouterAdapter', () => { expect(postTrelloAck).toHaveBeenCalledWith('p1', 'trigger-card-id', expect.any(String)); }); + it('uses triggerResult.workItemId over event.workItemId for GitHub PR run link', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', repo: 'owner/repo', runLinksEnabled: true } as never], + }); + vi.mocked(resolveGitHubTokenForAckByAgent).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'p1' }, + } as never); + vi.mocked(postGitHubAck).mockResolvedValue(1); + + await adapter.postAck( + { + projectIdentifier: 'owner/repo', + eventType: 'pull_request', + workItemId: '1030', // PR number — should NOT be used for link + isCommentEvent: false, + // @ts-expect-error extended field + repoFullName: 'owner/repo', + }, + {}, + mockProject, + 'review', + { agentType: 'review', agentInput: {}, workItemId: 'trello-card-abc' }, + ); + + expect(buildWorkItemRunsLink).toHaveBeenCalledWith( + expect.objectContaining({ workItemId: 'trello-card-abc' }), + ); + }); + it('returns undefined for PM-focused agents when no workItemId available', async () => { vi.mocked(isPMFocusedAgent).mockResolvedValue(true); From 510c52349aefc63519eedea58f30c8434415b678 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Mar 2026 17:52:01 +0100 Subject: [PATCH 3/5] feat(dashboard): show thinking blocks fully expanded in LLM Calls tab (#1037) Co-authored-by: Cascade Bot --- tests/unit/web/llm-call-detail.test.ts | 166 ++++++++++++++++++ .../components/llm-calls/llm-call-detail.tsx | 12 +- 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/unit/web/llm-call-detail.test.ts diff --git a/tests/unit/web/llm-call-detail.test.ts b/tests/unit/web/llm-call-detail.test.ts new file mode 100644 index 00000000..b311efd7 --- /dev/null +++ b/tests/unit/web/llm-call-detail.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { parseLlmResponse } from '../../../web/src/lib/llm-response-parser.js'; + +/** + * Tests for LLM call detail display logic, focusing on ThinkingBlock behavior. + * + * Note: React component rendering tests are not possible in the current test + * setup (node environment, no jsdom). These tests cover: + * - The parseLlmResponse function's handling of thinking blocks + * - The char count label logic used by ThinkingBlock + * - Verifying no "collapsing" logic exists at the data layer + */ + +// ─── ThinkingBlock char count label logic ──────────────────────────────────── + +describe('ThinkingBlock char count label', () => { + /** + * Mirrors the label logic from the ThinkingBlock component: + * `💭 Thinking ({text.length.toLocaleString()} chars)` + */ + function buildThinkingLabel(text: string): string { + return `💭 Thinking (${text.length.toLocaleString()} chars)`; + } + + it('shows correct char count for short thinking text', () => { + const text = 'Hello world'; + expect(buildThinkingLabel(text)).toBe('💭 Thinking (11 chars)'); + }); + + it('shows correct char count for empty thinking text', () => { + const text = ''; + expect(buildThinkingLabel(text)).toBe('💭 Thinking (0 chars)'); + }); + + it('uses toLocaleString formatting for large char counts', () => { + // 1000+ chars should format with locale separators (e.g. "1,000" in en-US) + const text = 'a'.repeat(1000); + const label = buildThinkingLabel(text); + // The exact format depends on locale, but it should contain "1" and "000" + expect(label).toContain('💭 Thinking ('); + expect(label).toContain(' chars)'); + // Should include the number 1000 in some locale-formatted form + expect(text.length).toBe(1000); + }); + + it('shows full char count without truncation for very large thinking text', () => { + // Simulates extended thinking mode — thousands of chars + const text = 'x'.repeat(5000); + const label = buildThinkingLabel(text); + expect(label).toContain('💭 Thinking ('); + expect(label).toContain(' chars)'); + expect(text.length).toBe(5000); + }); +}); + +// ─── parseLlmResponse: thinking block parsing ──────────────────────────────── + +describe('parseLlmResponse: thinking blocks are fully preserved', () => { + it('parses a single thinking block from Claude Code format', () => { + const thinkingText = 'I need to think about this carefully.'; + const response = JSON.stringify([{ type: 'thinking', thinking: thinkingText }]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0]).toEqual({ kind: 'thinking', text: thinkingText }); + }); + + it('preserves the full thinking text without truncation', () => { + // Simulate a large thinking block (extended thinking mode) + const thinkingText = 'Deep reasoning step. '.repeat(200); // ~4200 chars + const response = JSON.stringify([{ type: 'thinking', thinking: thinkingText }]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(1); + const block = result.blocks[0]; + expect(block.kind).toBe('thinking'); + if (block.kind === 'thinking') { + // Full text must be preserved — no truncation + expect(block.text).toBe(thinkingText); + expect(block.text.length).toBe(thinkingText.length); + } + }); + + it('parses thinking block alongside text and tool_use blocks', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'Let me consider the approach.' }, + { type: 'text', text: 'Here is my answer.' }, + { type: 'tool_use', name: 'Read', input: { file_path: '/foo/bar.ts' } }, + ]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(3); + expect(result.blocks[0]).toEqual({ + kind: 'thinking', + text: 'Let me consider the approach.', + }); + expect(result.blocks[1]).toEqual({ kind: 'text', text: 'Here is my answer.' }); + expect(result.blocks[2]).toMatchObject({ kind: 'tool_use', name: 'Read' }); + }); + + it('returns thinking block with kind === "thinking" (not collapsed or hidden)', () => { + const response = JSON.stringify([{ type: 'thinking', thinking: 'Some inner thoughts.' }]); + + const result = parseLlmResponse(response); + + // Verify the block kind is "thinking" — the component renders it inline + expect(result.blocks[0].kind).toBe('thinking'); + // Verify there is no intermediate "collapsed" representation + const block = result.blocks[0]; + if (block.kind === 'thinking') { + expect(typeof block.text).toBe('string'); + } + }); + + it('handles multiple thinking blocks in sequence', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'First thought.' }, + { type: 'thinking', thinking: 'Second thought.' }, + ]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(2); + expect(result.blocks[0]).toEqual({ kind: 'thinking', text: 'First thought.' }); + expect(result.blocks[1]).toEqual({ kind: 'thinking', text: 'Second thought.' }); + }); +}); + +// ─── ThinkingBlock renders full content (structural verification) ───────────── + +describe('ThinkingBlock structural guarantees', () => { + /** + * The ThinkingBlock component no longer uses a
/ collapsible + * wrapper. These tests verify the logic that the component relies on ensures + * the full text is available (no truncation at parse layer). + */ + + it('thinking text length equals original text length (no truncation in parser)', () => { + const originalText = 'This is the complete thinking content without any abbreviation.'; + const response = JSON.stringify([{ type: 'thinking', thinking: originalText }]); + + const { blocks } = parseLlmResponse(response); + + const thinkingBlock = blocks.find((b) => b.kind === 'thinking'); + expect(thinkingBlock).toBeDefined(); + if (thinkingBlock && thinkingBlock.kind === 'thinking') { + expect(thinkingBlock.text.length).toBe(originalText.length); + } + }); + + it('toolNames does not include thinking blocks (thinking is not a tool)', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'Some thought.' }, + { type: 'tool_use', name: 'Bash', input: { command: 'ls' } }, + ]); + + const { toolNames } = parseLlmResponse(response); + + // Thinking blocks should not appear in toolNames + expect(toolNames).not.toContain('thinking'); + expect(toolNames).toContain('Bash'); + }); +}); diff --git a/web/src/components/llm-calls/llm-call-detail.tsx b/web/src/components/llm-calls/llm-call-detail.tsx index 45a4db96..c870522f 100644 --- a/web/src/components/llm-calls/llm-call-detail.tsx +++ b/web/src/components/llm-calls/llm-call-detail.tsx @@ -35,14 +35,14 @@ function ToolUseBlock({ name, inputSummary }: { name: string; inputSummary: stri function ThinkingBlock({ text }: { text: string }) { return ( -
- - Thinking ({text.length.toLocaleString()} chars) - +
+
+ 💭 Thinking ({text.length.toLocaleString()} chars) +
 				{text}
 			
-
+ ); } @@ -101,6 +101,8 @@ export function LlmCallDetail({ runId, callNumber }: LlmCallDetailProps) { {showRaw ? ( + // Raw view keeps max-h-96 as a compact scrollable JSON representation. + // The structured view intentionally shows all content fully expanded (no truncation).
 					{formatRawContent(call.response)}
 				
From 2bf2c68fa88b7b1a79567faef638a18484c1472b Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Tue, 24 Mar 2026 16:57:08 +0000 Subject: [PATCH 4/5] test(router/adapters/github): fix false-positive run link coverage test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test for triggerResult.workItemId run link was a false positive: isPMFocusedAgent leaked 'true' from the preceding test (clearMocks only clears call counts, not implementations), so the function took the PM ack path and called buildWorkItemRunsLink via withRunLink — never exercising the changed lines 334-347. Fix: explicitly reset isPMFocusedAgent to false and mock extractPRNumber so postGitHubPRAck doesn't bail early. Lines 336-347 now confirmed covered. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/router/adapters/github.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index a1495ed6..029c2aa0 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -413,6 +413,8 @@ describe('GitHubRouterAdapter', () => { }); it('uses triggerResult.workItemId over event.workItemId for GitHub PR run link', async () => { + // Ensure isPMFocusedAgent returns false so we take the GitHub PR ack path, not PM path + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], fullProjects: [{ id: 'p1', repo: 'owner/repo', runLinksEnabled: true } as never], @@ -421,6 +423,7 @@ describe('GitHubRouterAdapter', () => { token: 'ghp_test', project: { id: 'p1' }, } as never); + vi.mocked(extractPRNumber).mockReturnValue(42); vi.mocked(postGitHubAck).mockResolvedValue(1); await adapter.postAck( From 47f5a60db9a9e3e7df5218c4fd5d59b0864620a7 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 24 Mar 2026 18:05:11 +0100 Subject: [PATCH 5/5] feat(alerting): add Sentry integration configuration to dashboard (#1038) Co-authored-by: Cascade Bot --- src/api/routers/integrationsDiscovery.ts | 31 +++ src/api/routers/projects.ts | 6 +- .../api/routers/integrationsDiscovery.test.ts | 105 +++++++++ tests/unit/api/routers/projects.test.ts | 44 ++++ .../components/projects/integration-form.tsx | 199 +++++++++++++++++- 5 files changed, 381 insertions(+), 4 deletions(-) diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 7d976a98..cdd3bdfb 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -398,4 +398,35 @@ export const integrationsDiscoveryRouter = router({ }); } }), + + /** + * Verify a Sentry API token and organization slug. + * Used by the Integrations tab Alerting credential inputs. + * Accepts plaintext credentials from the form and calls the Sentry API to verify. + * The token is never stored by this endpoint. + */ + verifySentry: protectedProcedure + .input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) })) + .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 response = await fetch(url, { + headers: { Authorization: `Bearer ${input.apiToken}` }, + }); + if (!response.ok) { + throw new Error(`Sentry API returned ${response.status}: ${response.statusText}`); + } + const data = (await response.json()) as { + id?: string; + name?: string; + slug?: string; + }; + return { + id: data.id ?? '', + name: data.name ?? '', + slug: data.slug ?? '', + }; + }); + }), }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 77f62db0..6a2f28f3 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -176,7 +176,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), provider: z.string().min(1), config: z.record(z.unknown()), triggers: z.record(z.boolean()).optional(), @@ -197,7 +197,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])), }), ) @@ -207,7 +207,7 @@ export const projectsRouter = router({ }), delete: protectedProcedure - .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'alerting']) })) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); await deleteProjectIntegration(input.projectId, input.category); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 6c8b1d02..6eab94b8 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -19,6 +19,7 @@ const { mockVerifyProjectOrgAccess, mockGetIntegrationCredentialOrNull, mockGetIntegrationByProjectAndCategory, + mockFetch, } = vi.hoisted(() => ({ mockTrelloGetMe: vi.fn(), mockTrelloGetBoards: vi.fn(), @@ -36,6 +37,7 @@ const { mockVerifyProjectOrgAccess: vi.fn(), mockGetIntegrationCredentialOrNull: vi.fn(), mockGetIntegrationByProjectAndCategory: vi.fn(), + mockFetch: vi.fn(), })); vi.mock('../../../../src/trello/client.js', () => ({ @@ -106,10 +108,14 @@ const jiraCredsInput = { baseUrl: 'https://myorg.atlassian.net', }; +// Assign global fetch mock +vi.stubGlobal('fetch', mockFetch); + describe('integrationsDiscoveryRouter', () => { beforeEach(() => { // Default: org access check passes mockVerifyProjectOrgAccess.mockResolvedValue(undefined); + mockFetch.mockReset(); }); // ── Auth ───────────────────────────────────────────────────────────── @@ -953,4 +959,103 @@ describe('integrationsDiscoveryRouter', () => { await expect(caller.verifyGithubToken({ token: '' })).rejects.toThrow(); }); }); + + // ── verifySentry ───────────────────────────────────────────────────── + + describe('verifySentry', () => { + it('returns org id, name, and slug on success', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 'org-123', name: 'My Org', slug: 'my-org' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: 'org-123', name: 'My Org', slug: 'my-org' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/my-org/', + expect.objectContaining({ + headers: { Authorization: 'Bearer sntrys_abc' }, + }), + ); + }); + + it('returns empty strings when Sentry response fields are missing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: '', name: '', slug: '' }); + }); + + it('wraps non-ok response in BAD_REQUEST', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'bad-token', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('wraps network failure in BAD_REQUEST', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('URL-encodes the organization slug', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: '1', name: 'Org', slug: 'org-with-slash' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifySentry({ apiToken: 'tok', organizationSlug: 'org/with/slash' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/org%2Fwith%2Fslash/', + expect.any(Object), + ); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.verifySentry({ apiToken: 'tok', organizationSlug: 'my-org' }), + 'UNAUTHORIZED', + ); + }); + + it('rejects empty apiToken', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: '', organizationSlug: 'my-org' }), + ).rejects.toThrow(); + }); + + it('rejects empty organizationSlug', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: '' }), + ).rejects.toThrow(); + }); + }); }); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index aca393dd..fde2b880 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -387,6 +387,40 @@ describe('projectsRouter', () => { undefined, ); }); + + it('upserts alerting integration with category alerting', 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' }, + }); + + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( + 'p1', + 'alerting', + 'sentry', + { organizationSlug: 'my-org' }, + undefined, + ); + }); + + it('rejects unknown category', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.integrations.upsert({ + projectId: 'p1', + // @ts-expect-error testing invalid category + category: 'unknown', + provider: 'sentry', + config: {}, + }), + ).rejects.toThrow(); + }); }); describe('delete', () => { @@ -399,6 +433,16 @@ describe('projectsRouter', () => { expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm'); }); + + it('deletes alerting integration', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.delete({ projectId: 'p1', category: 'alerting' }); + + expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'alerting'); + }); }); }); diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 44d77551..8e520258 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -18,7 +18,7 @@ import { useEffect, useState } from 'react'; import { PMWizard } from './pm-wizard.js'; import { ProjectSecretField } from './project-secret-field.js'; -type IntegrationCategory = 'pm' | 'scm'; +type IntegrationCategory = 'pm' | 'scm' | 'alerting'; // ============================================================================ // GitHub Credential Slots (replaces the old CredentialSelector dropdowns) @@ -433,6 +433,192 @@ function SCMTab({ ); } +// ============================================================================ +// Alerting Tab (Sentry) +// ============================================================================ + +interface AlertingTabProps { + projectId: string; + alertingIntegration?: Record; +} + +function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { + const queryClient = useQueryClient(); + + const existingConfig = (alertingIntegration?.config as Record) ?? {}; + const [organizationSlug, setOrganizationSlug] = useState( + (existingConfig.organizationSlug as string) ?? '', + ); + + const [verifyResult, setVerifyResult] = useState<{ + id: string; + name: string; + slug: string; + } | null>(null); + const [verifyError, setVerifyError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const sentryWebhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/sentry/webhook/${projectId}` + : `/sentry/webhook/${projectId}`; + + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + const credentials = credentialsQuery.data ?? []; + const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); + const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); + + const handleVerify = async (rawToken: string) => { + if (!rawToken) { + setVerifyError('Enter the API token value to verify it'); + return; + } + if (!organizationSlug) { + setVerifyError('Enter the organization slug to verify it'); + return; + } + setIsVerifying(true); + setVerifyError(null); + setVerifyResult(null); + try { + const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ + apiToken: rawToken, + organizationSlug, + }); + setVerifyResult(result); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : String(err)); + } finally { + setIsVerifying(false); + } + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'alerting', + provider: 'sentry', + config: { organizationSlug }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.delete.mutate({ + projectId, + category: 'alerting', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+ {/* Organization Slug */} +
+ +

+ Your Sentry organization slug (found in your Sentry URL:{' '} + sentry.io/organizations/<slug>/). +

+ setOrganizationSlug(e.target.value)} + placeholder="my-organization" + /> +
+ +
+ + {/* Credentials */} +
+ + + +
+ +
+ + {/* Sentry Webhook URL */} +
+ +

+ Configure this URL in your Sentry project's webhook settings to receive alerts. +

+
+ {sentryWebhookUrl} + +
+
+ +
+ + {/* Save / Delete */} +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} + {alertingIntegration && ( + + )} + {deleteMutation.isError && ( + {deleteMutation.error.message} + )} +
+
+ ); +} + // ============================================================================ // Helpers // ============================================================================ @@ -489,6 +675,7 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const integrations = integrationsQuery.data ?? []; const pmIntegration = findIntegrationByCategory(integrations, 'pm'); const pmProvider = (pmIntegration?.provider as string) ?? 'trello'; + const alertingIntegration = findIntegrationByCategory(integrations, 'alerting'); return (
@@ -505,6 +692,12 @@ export function IntegrationForm({ projectId }: { projectId: string }) { activeTab={activeTab} onClick={() => setActiveTab('scm')} /> + setActiveTab('alerting')} + />
{activeTab === 'pm' && ( @@ -516,6 +709,10 @@ export function IntegrationForm({ projectId }: { projectId: string }) { )} {activeTab === 'scm' && } + + {activeTab === 'alerting' && ( + + )} ); }