diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..6df39a38 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,109 @@ +name: Build and Deploy (Dev) + +on: + push: + branches: + - dev + workflow_dispatch: + +env: + REGISTRY: ghcr.io + ROUTER_IMAGE: ghcr.io/zbigniewsobiecki/cascade-router + WORKER_IMAGE: ghcr.io/zbigniewsobiecki/cascade-worker + DASHBOARD_IMAGE: ghcr.io/zbigniewsobiecki/cascade-dashboard + +jobs: + build-and-deploy: + name: Build and Deploy (Dev) + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + run: | + echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build and push router image + run: | + docker build -f Dockerfile.router -t ${{ env.ROUTER_IMAGE }}:dev -t ${{ env.ROUTER_IMAGE }}:dev-${{ github.sha }} . + docker push ${{ env.ROUTER_IMAGE }}:dev + docker push ${{ env.ROUTER_IMAGE }}:dev-${{ github.sha }} + + - name: Build and push worker image + run: | + docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:dev -t ${{ env.WORKER_IMAGE }}:dev-${{ github.sha }} . + docker push ${{ env.WORKER_IMAGE }}:dev + docker push ${{ env.WORKER_IMAGE }}:dev-${{ github.sha }} + + - name: Build and push dashboard image + run: | + docker build -f Dockerfile.dashboard -t ${{ env.DASHBOARD_IMAGE }}:dev -t ${{ env.DASHBOARD_IMAGE }}:dev-${{ github.sha }} . + docker push ${{ env.DASHBOARD_IMAGE }}:dev + docker push ${{ env.DASHBOARD_IMAGE }}:dev-${{ github.sha }} + + - name: Build and deploy frontend to Cloudflare Pages (dev) + run: | + docker build -f Dockerfile.frontend \ + --build-arg VITE_API_URL=https://dev.api.ca.sca.de.com \ + -t cascade-frontend-dev:build . + # Ensure the Cloudflare Pages project exists (idempotent) + docker run --rm \ + -e CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -e CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" \ + cascade-frontend-dev:build \ + wrangler pages project create cascade-dashboard-dev --production-branch=main || true + docker run --rm \ + -e CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -e CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" \ + cascade-frontend-dev:build \ + wrangler pages deploy dist/web --project-name=cascade-dashboard-dev --branch=main + + - name: Pull and restart cascade-router-dev + run: | + cd /opt/services + docker compose pull cascade-router-dev + docker compose up -d --force-recreate cascade-router-dev + + - name: Verify cascade-router-dev is healthy + run: | + echo "Waiting for cascade-router-dev to start..." + for i in $(seq 1 30); do + if docker inspect cascade-router-dev --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + echo "cascade-router-dev is healthy" + exit 0 + fi + if docker inspect cascade-router-dev --format '{{.State.Status}}' 2>/dev/null | grep -q restarting; then + echo "ERROR: cascade-router-dev is crashlooping!" + docker logs cascade-router-dev --tail 20 + exit 1 + fi + sleep 5 + done + echo "ERROR: cascade-router-dev did not become healthy within 150s" + docker logs cascade-router-dev --tail 20 + exit 1 + + - name: Pull and restart cascade-dashboard-dev + run: | + cd /opt/services + docker compose pull cascade-dashboard-dev + docker compose up -d --force-recreate cascade-dashboard-dev + + - name: Verify cascade-dashboard-dev is healthy + run: | + echo "Waiting for cascade-dashboard-dev to start..." + for i in $(seq 1 30); do + if docker inspect cascade-dashboard-dev --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + echo "cascade-dashboard-dev is healthy" + exit 0 + fi + if docker inspect cascade-dashboard-dev --format '{{.State.Status}}' 2>/dev/null | grep -q restarting; then + echo "ERROR: cascade-dashboard-dev is crashlooping!" + docker logs cascade-dashboard-dev --tail 20 + exit 1 + fi + sleep 5 + done + echo "ERROR: cascade-dashboard-dev did not become healthy within 150s" + docker logs cascade-dashboard-dev --tail 20 + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 6f037dd6..467aa3fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Optional (infrastructure): - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code backend (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. -**Project credentials** (`GITHUB_TOKEN_IMPLEMENTER`, `GITHUB_TOKEN_REVIEWER`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `credentials` table (org-scoped, encrypted at rest when `CREDENTIAL_MASTER_KEY` is set) with optional per-project overrides via `project_credential_overrides`. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. +**Project credentials** (`GITHUB_TOKEN_IMPLEMENTER`, `GITHUB_TOKEN_REVIEWER`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `credentials` table (org-scoped, encrypted at rest when `CREDENTIAL_MASTER_KEY` is set). Integration-specific credentials (GitHub tokens, Trello keys, JIRA tokens) are linked to integrations via the `integration_credentials` join table with provider-defined roles. Non-integration credentials (LLM API keys) remain org-scoped defaults. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. ## Database Configuration @@ -89,10 +89,10 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p - `organizations` - Organization definitions (multi-tenant support) - `cascade_defaults` - Global defaults per org (model, iterations, timeouts, budget) - `projects` - Per-project config (repo, base branch, budget, backend) -- `project_integrations` - Integration configs per project (Trello boards/lists/labels as JSONB) +- `project_integrations` - Integration configs per project with `category` (pm/scm), `provider` (trello/jira/github), `config` JSONB, and `triggers` JSONB. One PM + one SCM per project (enforced by unique constraint) +- `integration_credentials` - Links integration roles to org-scoped credential rows (e.g., `api_key` → credential #5). Roles are provider-specific: trello has `api_key`/`token`, jira has `email`/`api_token`, github has `implementer_token`/`reviewer_token` - `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped globally, per-org, or per-project - `credentials` - Org-scoped credentials (API keys, tokens) -- `project_credential_overrides` - Per-project credential overrides (optional, falls back to org defaults) - `users` - Dashboard users (email, bcrypt password hash, org-scoped) - `sessions` - Session tokens for cookie-based auth (30-day expiry) @@ -117,15 +117,13 @@ Migrations are hand-written SQL files in `src/db/migrations/` tracked by drizzle For databases initially set up with `drizzle-kit push` (no migration journal), run `npm run db:bootstrap-journal` once to register existing migrations in the `drizzle.__drizzle_migrations` tracking table. -### Per-Project Secrets +### Credentials -Credentials are stored in the `credentials` table (org-scoped) with optional per-project overrides via `project_credential_overrides`. +Org-scoped credentials are stored in the `credentials` table. Integration-specific credentials are linked via the `integration_credentials` join table with provider-defined roles. ```bash npx tsx tools/manage-secrets.ts create [--name "..."] [--default] npx tsx tools/manage-secrets.ts list -npx tsx tools/manage-secrets.ts set-override -npx tsx tools/manage-secrets.ts remove-override npx tsx tools/manage-secrets.ts resolve ``` @@ -158,11 +156,13 @@ CASCADE uses two dedicated GitHub bot accounts per project to prevent feedback l - **Reviewer** (`GITHUB_TOKEN_REVIEWER`) — reviews PRs, can approve or request changes - Agents: `review` -Both tokens are **required** for each project. Configure via the dashboard (Project Settings > Integrations > GitHub tab) or CLI: +Both tokens are **required** for each project. Create org-scoped credentials, then link them to the project's SCM integration via the dashboard (Project Settings > Integrations > Source Control tab) or CLI: ```bash cascade credentials create --name "Implementer Bot" --key GITHUB_TOKEN_IMPLEMENTER --value ghp_aaa... --default cascade credentials create --name "Reviewer Bot" --key GITHUB_TOKEN_REVIEWER --value ghp_bbb... --default +cascade projects integration-credential-set --category scm --role implementer_token --credential-id 5 +cascade projects integration-credential-set --category scm --role reviewer_token --credential-id 7 ``` **Bot detection**: Both persona usernames are resolved at first use and cached. Trigger handlers use `isCascadeBot(login)` to check if an event came from either persona, preventing self-triggered loops. @@ -172,17 +172,22 @@ cascade credentials create --name "Reviewer Bot" --key GITHUB_TOKEN_REVIEWER --v - `respond-to-pr-comment` skips @mentions from **any** known persona - `check-suite-success` checks reviews from the **reviewer** persona specifically -### Per-Agent Credential Overrides +### Integration Credential Resolution -Override any credential for a specific agent type. The dual-persona tokens are the primary use case: +Integration credentials are resolved by `(projectId, category, role)`: -```bash -# Per-project overrides (auto-configured by the GitHub integration tab) -cascade projects override-set --key GITHUB_TOKEN_IMPLEMENTER --credential-id 5 -cascade projects override-set --key GITHUB_TOKEN_REVIEWER --credential-id 7 +```typescript +// Get a specific integration credential +const trelloKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + +// Get all integration credentials + org defaults as flat env-var-key map (for worker environments) +const allCreds = await getAllProjectCredentials(projectId); + +// Non-integration org-scoped credentials (LLM API keys) +const openrouterKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); ``` -Resolution order: agent+project override → project override → org default → null. +Role definitions and env-var-key mappings are in `src/config/integrationRoles.ts`. ## Claude Code Backend @@ -329,11 +334,9 @@ cascade projects create --id my-project --name "My Project" --repo owner/repo cascade projects update --model claude-sonnet-4-5-20250929 cascade projects delete --yes cascade projects integrations -cascade projects integration-set --type trello --config '{"boardId":"..."}' -cascade projects overrides -cascade projects override-set --key GITHUB_TOKEN_IMPLEMENTER --credential-id 5 -cascade projects override-set --key GITHUB_TOKEN_REVIEWER --credential-id 7 -cascade projects override-rm --key GITHUB_TOKEN_IMPLEMENTER +cascade projects integration-set --category pm --provider trello --config '{"boardId":"..."}' +cascade projects integration-credential-set --category scm --role implementer_token --credential-id 5 +cascade projects integration-credential-rm --category scm --role implementer_token # Credentials cascade credentials list @@ -378,7 +381,7 @@ src/cli/dashboard/ ├── logout.ts ├── whoami.ts ├── runs/ # 6 commands -├── projects/ # 10 commands +├── projects/ # 8 commands ├── credentials/ # 4 commands ├── defaults/ # 2 commands ├── org/ # 2 commands diff --git a/Dockerfile.router b/Dockerfile.router index 465329cc..ecc48975 100644 --- a/Dockerfile.router +++ b/Dockerfile.router @@ -28,6 +28,7 @@ COPY --from=builder /app/dist/config ./dist/config COPY --from=builder /app/dist/types ./dist/types COPY --from=builder /app/dist/db ./dist/db COPY --from=builder /app/dist/utils ./dist/utils +COPY --from=builder /app/dist/trello ./dist/trello # Copy config COPY config ./config diff --git a/src/agents/base.ts b/src/agents/base.ts index 259cee41..0c38e123 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -1,31 +1,9 @@ import type { ModelSpec, createLogger } from 'llmist'; -import { WriteFile } from '../gadgets/WriteFile.js'; import { type ProgressMonitor, createProgressMonitor } from '../backends/progress.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; import { loadPartials } from '../db/repositories/partialsRepository.js'; -import { AstGrep } from '../gadgets/AstGrep.js'; -import { FileMultiEdit } from '../gadgets/FileMultiEdit.js'; -import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; -import { Finish } from '../gadgets/Finish.js'; -import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { ReadFile } from '../gadgets/ReadFile.js'; -import { RipGrep } from '../gadgets/RipGrep.js'; -import { Sleep } from '../gadgets/Sleep.js'; -import { VerifyChanges } from '../gadgets/VerifyChanges.js'; -import { CreatePR } from '../gadgets/github/index.js'; -import { - AddChecklist, - CreateWorkItem, - ListWorkItems, - PMUpdateChecklistItem, - PostComment, - ReadWorkItem, - UpdateWorkItem, - formatWorkItemData, -} from '../gadgets/pm/index.js'; -import { Tmux } from '../gadgets/tmux.js'; -import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; +import { formatWorkItemData } from '../gadgets/pm/index.js'; import { type Todo, formatTodoList, initTodoSession, saveTodos } from '../gadgets/todo/storage.js'; import { getPMProvider } from '../pm/index.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; @@ -33,6 +11,7 @@ import { logger } from '../utils/logging.js'; import { extractPRUrl } from '../utils/prUrl.js'; import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; import { getAgentCapabilities } from './shared/capabilities.js'; +import { buildWorkItemGadgets } from './shared/gadgets.js'; import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js'; import { resolveModelConfig } from './shared/modelResolution.js'; import { buildPromptContext } from './shared/promptContext.js'; @@ -43,6 +22,12 @@ import { injectSquintContext, injectSyntheticCall, } from './shared/syntheticCalls.js'; +import { + buildCheckFailurePrompt, + buildCommentResponsePrompt, + buildDebugPrompt, + buildWorkItemPrompt, +} from './shared/taskPrompts.js'; import type { AccumulatedLlmCall } from './utils/hooks.js'; import type { AgentLogger } from './utils/logging.js'; import type { TrackingContext } from './utils/tracking.js'; @@ -111,7 +96,7 @@ function selectPrompt( } if (prContext) return buildCheckFailurePrompt(prContext); if (debugContext) return buildDebugPrompt(debugContext); - return buildPrompt(cardId ?? ''); + return buildWorkItemPrompt(cardId ?? ''); } async function buildAgentContext( @@ -178,124 +163,12 @@ async function buildAgentContext( }; } -function buildCommentResponsePrompt( - cardId: string, - commentText: string, - commentAuthor: string, -): string { - return `A user (@${commentAuthor}) mentioned you in a comment on work item ${cardId}. - -Their comment: ---- -${commentText} ---- - -The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. -Read the user's comment carefully and respond accordingly. Default to surgical, targeted updates unless they clearly ask for a full rewrite.`; -} - -function buildPrompt(cardId: string): string { - return `Analyze and process the work item with ID: ${cardId}. The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. Review it and proceed with your task.`; -} - -function buildCheckFailurePrompt(prContext: { - prNumber: number; - prBranch: string; - repoFullName: string; - headSha: string; -}): string { - const [owner, repo] = prContext.repoFullName.split('/'); - - return `You are on branch \`${prContext.prBranch}\` for PR #${prContext.prNumber}. - -Your task is to fix the failing checks and push your changes. - -## Instructions - -1. **Investigate failures**: Use Tmux to run: - \`gh run list --branch ${prContext.prBranch} --limit 5 --json databaseId,conclusion,status,workflowName\` - -2. **Get failure details**: Find failed run ID and run: - \`gh run view --log-failed\` - -3. **Analyze error types**: - - Lint errors: Run \`npm run lint\` or \`pnpm run lint\` - - Type errors: Run \`npm run typecheck\` - - Test failures: Run \`npm test\` - - Build errors: Run \`npm run build\` - -4. **Fix issues**: Make targeted fixes following existing codebase patterns - -5. **Verify locally**: Run the same checks that failed in CI before pushing - -6. **Commit and push**: - \`\`\`bash - git add . - git commit -m "fix: address failing checks" - git push - \`\`\` - -The push will re-trigger checks automatically. - -## GitHub Context -Owner: ${owner} -Repo: ${repo} -PR: #${prContext.prNumber} -Branch: ${prContext.prBranch}`; -} - -function buildDebugPrompt(debugContext: { - logDir: string; - originalCardName: string; - originalCardUrl: string; - detectedAgentType: string; -}): string { - return `Analyze the ${debugContext.detectedAgentType} agent session logs in directory: ${debugContext.logDir} - -Original card: "${debugContext.originalCardName}" -Link: ${debugContext.originalCardUrl} - -Start by listing the contents of the log directory, then read and analyze the logs to identify issues.`; -} - // ============================================================================ // Agent Builder Creation // ============================================================================ function getBaseAgentGadgets(agentType: string) { - const caps = getAgentCapabilities(agentType); - - return [ - // Filesystem gadgets (read-only when canEditFiles is false) - new ListDirectory(), - new ReadFile(), - new RipGrep(), - new AstGrep(), - ...(caps.canEditFiles - ? [new FileSearchAndReplace(), new FileMultiEdit(), new WriteFile(), new VerifyChanges()] - : []), - // Shell commands via tmux (no timeout issues) - new Tmux(), - new Sleep(), - // Task tracking gadgets - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - // GitHub gadgets (PR creation gated by capability) - ...(caps.canCreatePR ? [new CreatePR()] : []), - // PM gadgets (work items, comments, checklists — PM-agnostic) - new ReadWorkItem(), - new PostComment(), - new UpdateWorkItem(), - new CreateWorkItem(), - new ListWorkItems(), - new AddChecklist(), - // UpdateChecklistItem gated by capability — prevents planning from marking items complete - // prematurely, while respond-to-planning-comment CAN update them - ...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem()] : []), - // Session control - new Finish(), - ]; + return buildWorkItemGadgets(getAgentCapabilities(agentType)); } function createAgentBuilderWithGadgets( diff --git a/src/agents/shared/gadgets.ts b/src/agents/shared/gadgets.ts index f07b4039..ea601668 100644 --- a/src/agents/shared/gadgets.ts +++ b/src/agents/shared/gadgets.ts @@ -1,12 +1,17 @@ import { AstGrep } from '../../gadgets/AstGrep.js'; +import { FileMultiEdit } from '../../gadgets/FileMultiEdit.js'; import { FileSearchAndReplace } from '../../gadgets/FileSearchAndReplace.js'; import { Finish } from '../../gadgets/Finish.js'; import { ListDirectory } from '../../gadgets/ListDirectory.js'; import { ReadFile } from '../../gadgets/ReadFile.js'; import { RipGrep } from '../../gadgets/RipGrep.js'; import { Sleep } from '../../gadgets/Sleep.js'; +import { VerifyChanges } from '../../gadgets/VerifyChanges.js'; import { WriteFile } from '../../gadgets/WriteFile.js'; import { + CreatePR, + CreatePRReview, + GetPRChecks, GetPRComments, GetPRDetails, GetPRDiff, @@ -14,20 +19,112 @@ import { ReplyToReviewComment, UpdatePRComment, } from '../../gadgets/github/index.js'; +import { + AddChecklist, + CreateWorkItem, + ListWorkItems, + PMUpdateChecklistItem, + PostComment, + ReadWorkItem, + UpdateWorkItem, +} from '../../gadgets/pm/index.js'; import { Tmux } from '../../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../../gadgets/todo/index.js'; import type { CreateBuilderOptions } from './builderFactory.js'; +import type { AgentCapabilities } from './capabilities.js'; -export function createPRAgentGadgets(options?: { +/** + * Build the standard set of gadgets for work-item-based agents. + * + * Used by both the llmist backend (agents/base.ts) and the Claude Code backend + * (backends/agent-profiles.ts) to ensure identical gadget sets for matching + * agent types regardless of which backend runs the agent. + * + * Applies capabilities to gate optional gadgets: + * - canEditFiles: file writing tools (FileSearchAndReplace, FileMultiEdit, WriteFile, VerifyChanges) + * - canCreatePR: CreatePR + * - canUpdateChecklists: PMUpdateChecklistItem + */ +export function buildWorkItemGadgets(caps: AgentCapabilities): CreateBuilderOptions['gadgets'] { + return [ + // Filesystem gadgets (read-only when canEditFiles is false) + new ListDirectory(), + new ReadFile(), + new RipGrep(), + new AstGrep(), + ...(caps.canEditFiles + ? [new FileSearchAndReplace(), new FileMultiEdit(), new WriteFile(), new VerifyChanges()] + : []), + // Shell commands via tmux (no timeout issues) + new Tmux(), + new Sleep(), + // Task tracking gadgets + new TodoUpsert(), + new TodoUpdateStatus(), + new TodoDelete(), + // GitHub gadgets (PR creation gated by capability) + ...(caps.canCreatePR ? [new CreatePR()] : []), + // PM gadgets (work items, comments, checklists — PM-agnostic) + new ReadWorkItem(), + new PostComment(), + new UpdateWorkItem(), + new CreateWorkItem(), + new ListWorkItems(), + new AddChecklist(), + // UpdateChecklistItem gated by capability — prevents planning from marking items complete + // prematurely, while respond-to-planning-comment CAN update them + ...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem()] : []), + // Session control + new Finish(), + ]; +} + +/** + * Build gadgets for the review agent (read-only, PR-focused). + * + * Used by both backends — review agent sees PR details, diff, checks, and + * can submit a review, but cannot modify files or create new PRs. + */ +export function buildReviewGadgets(): CreateBuilderOptions['gadgets'] { + return [ + new ListDirectory(), + new ReadFile(), + new Tmux(), + new Sleep(), + new TodoUpsert(), + new TodoUpdateStatus(), + new TodoDelete(), + new GetPRDetails(), + new GetPRDiff(), + new GetPRChecks(), + new CreatePRReview(), + new UpdatePRComment(), + new Finish(), + ]; +} + +/** + * Build gadgets for PR-modifying agents (respond-to-review, respond-to-ci, + * respond-to-pr-comment). + * + * Includes file editing + GitHub tools but NOT CreatePR (agents push to + * existing branches). Pass includeReviewComments=true for agents that need + * GetPRComments and ReplyToReviewComment. + * + * Used by both backends to ensure identical tool sets. + */ +export function buildPRAgentGadgets(options?: { includeReviewComments?: boolean; }): CreateBuilderOptions['gadgets'] { const gadgets: CreateBuilderOptions['gadgets'] = [ new ListDirectory(), new ReadFile(), new FileSearchAndReplace(), + new FileMultiEdit(), new WriteFile(), - new RipGrep(), + new VerifyChanges(), new AstGrep(), + new RipGrep(), new Tmux(), new Sleep(), new TodoUpsert(), @@ -35,6 +132,7 @@ export function createPRAgentGadgets(options?: { new TodoDelete(), new GetPRDetails(), new GetPRDiff(), + new GetPRChecks(), new PostPRComment(), new UpdatePRComment(), new Finish(), @@ -46,3 +144,13 @@ export function createPRAgentGadgets(options?: { return gadgets; } + +/** + * @deprecated Use buildPRAgentGadgets() instead. + * Kept for backwards compatibility with existing callers. + */ +export function createPRAgentGadgets(options?: { + includeReviewComments?: boolean; +}): CreateBuilderOptions['gadgets'] { + return buildPRAgentGadgets(options); +} diff --git a/src/agents/shared/taskPrompts.ts b/src/agents/shared/taskPrompts.ts new file mode 100644 index 00000000..4d535b15 --- /dev/null +++ b/src/agents/shared/taskPrompts.ts @@ -0,0 +1,152 @@ +/** + * Shared task prompt builders used by both backends. + * + * The llmist backend (agents/base.ts) and the Claude Code backend + * (backends/agent-profiles.ts) both need task-level prompts for each agent type. + * This module is the single source of truth so the two backends produce + * identical instructions for each agent type. + */ + +// ============================================================================ +// Work-item agents +// ============================================================================ + +/** + * Standard prompt for agents whose primary task is processing a work item + * (briefing, planning, implementation, debug). + */ +export function buildWorkItemPrompt(cardId: string): string { + return `Analyze and process the work item with ID: ${cardId}. The work item data has been pre-loaded.`; +} + +/** + * Prompt for agents responding to a PM comment mentioning them. + */ +export function buildCommentResponsePrompt( + cardId: string, + commentText: string, + commentAuthor: string, +): string { + return `A user (@${commentAuthor}) mentioned you in a comment on work item ${cardId}. + +Their comment: +--- +${commentText} +--- + +The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. +Read the user's comment carefully and respond accordingly. Default to surgical, targeted updates unless they clearly ask for a full rewrite.`; +} + +// ============================================================================ +// PR agents +// ============================================================================ + +/** + * Prompt for the review agent. + */ +export function buildReviewPrompt(prNumber: number): string { + return `Review PR #${prNumber}. + +Examine the code changes carefully and submit your review using CreatePRReview.`; +} + +/** + * Prompt for the respond-to-ci agent. + */ +export function buildCIResponsePrompt(prBranch: string, prNumber: number): string { + return `You are on the branch \`${prBranch}\` for PR #${prNumber}. + +CI checks have failed. Analyze the failures and fix them.`; +} + +/** + * Prompt for PR-comment-response agents (respond-to-review, respond-to-pr-comment). + */ +export function buildPRCommentResponsePrompt( + prBranch: string, + prNumber: number, + commentBody: string, + commentPath?: string, +): string { + const pathContext = commentPath ? `\nFile: ${commentPath}` : ''; + + return `You are on the branch \`${prBranch}\` for PR #${prNumber}. + +A user commented on this PR and mentioned you. Respond to their comment. +${pathContext} + +Their comment: +--- +${commentBody} +--- + +Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader.`; +} + +/** + * Prompt for the respond-to-ci agent (llmist backend format — includes GitHub context). + * Used by agents/base.ts when the trigger type is 'check-failure'. + */ +export function buildCheckFailurePrompt(prContext: { + prNumber: number; + prBranch: string; + repoFullName: string; + headSha: string; +}): string { + const [owner, repo] = prContext.repoFullName.split('/'); + + return `You are on branch \`${prContext.prBranch}\` for PR #${prContext.prNumber}. + +Your task is to fix the failing checks and push your changes. + +## Instructions + +1. **Investigate failures**: Use Tmux to run: + \`gh run list --branch ${prContext.prBranch} --limit 5 --json databaseId,conclusion,status,workflowName\` + +2. **Get failure details**: Find failed run ID and run: + \`gh run view --log-failed\` + +3. **Analyze error types**: + - Lint errors: Run \`npm run lint\` or \`pnpm run lint\` + - Type errors: Run \`npm run typecheck\` + - Test failures: Run \`npm test\` + - Build errors: Run \`npm run build\` + +4. **Fix issues**: Make targeted fixes following existing codebase patterns + +5. **Verify locally**: Run the same checks that failed in CI before pushing + +6. **Commit and push**: + \`\`\`bash + git add . + git commit -m "fix: address failing checks" + git push + \`\`\` + +The push will re-trigger checks automatically. + +## GitHub Context +Owner: ${owner} +Repo: ${repo} +PR: #${prContext.prNumber} +Branch: ${prContext.prBranch}`; +} + +/** + * Prompt for the debug agent analyzing session logs. + */ +export function buildDebugPrompt(debugContext: { + logDir: string; + originalCardName: string; + originalCardUrl: string; + detectedAgentType: string; +}): string { + return `Analyze the ${debugContext.detectedAgentType} agent session logs in directory: ${debugContext.logDir} + +Original card: "${debugContext.originalCardName}" +Link: ${debugContext.originalCardUrl} + +Start by listing the contents of the log directory, then read and analyze the logs to identify issues.`; +} diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index de85b9ac..92c36f30 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -2,21 +2,18 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getDb } from '../../db/client.js'; -import { - listProjectOverrides, - removeAgentCredentialOverride, - removeProjectCredentialOverride, - setAgentCredentialOverride, - setProjectCredentialOverride, -} from '../../db/repositories/credentialsRepository.js'; import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; import { createProject, deleteProject, deleteProjectIntegration, + getIntegrationByProjectAndCategory, getProjectFull, + listIntegrationCredentials, listProjectIntegrations, listProjectsFull, + removeIntegrationCredential, + setIntegrationCredential, updateProject, upsertProjectIntegration, } from '../../db/repositories/settingsRepository.js'; @@ -123,84 +120,91 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - type: z.string().min(1), + category: z.enum(['pm', 'scm']), + provider: z.string().min(1), config: z.record(z.unknown()), + triggers: z.record(z.boolean()).optional(), }), ) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - await upsertProjectIntegration(input.projectId, input.type, input.config); + return upsertProjectIntegration( + input.projectId, + input.category, + input.provider, + input.config, + input.triggers, + ); }), delete: protectedProcedure - .input(z.object({ projectId: z.string(), type: z.string() })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - await deleteProjectIntegration(input.projectId, input.type); + await deleteProjectIntegration(input.projectId, input.category); }), }), - // Credential Overrides - credentialOverrides: router({ + // Integration Credentials + integrationCredentials: router({ list: protectedProcedure - .input(z.object({ projectId: z.string() })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) .query(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - return listProjectOverrides(input.projectId); + const integration = await getIntegrationByProjectAndCategory( + input.projectId, + input.category, + ); + if (!integration) return []; + return listIntegrationCredentials(integration.id); }), set: protectedProcedure .input( z.object({ projectId: z.string(), - envVarKey: z.string(), + category: z.enum(['pm', 'scm']), + role: z.string().min(1), credentialId: z.number(), }), ) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); await verifyCredentialOwnership(input.credentialId, ctx.effectiveOrgId); - await setProjectCredentialOverride(input.projectId, input.envVarKey, input.credentialId); + const integration = await getIntegrationByProjectAndCategory( + input.projectId, + input.category, + ); + if (!integration) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `No ${input.category} integration found for project`, + }); + } + await setIntegrationCredential(integration.id, input.role, input.credentialId); }), remove: protectedProcedure - .input(z.object({ projectId: z.string(), envVarKey: z.string() })) - .mutation(async ({ ctx, input }) => { - await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - await removeProjectCredentialOverride(input.projectId, input.envVarKey); - }), - - setAgent: protectedProcedure .input( z.object({ projectId: z.string(), - envVarKey: z.string(), - agentType: z.string(), - credentialId: z.number(), + category: z.enum(['pm', 'scm']), + role: z.string().min(1), }), ) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - await verifyCredentialOwnership(input.credentialId, ctx.effectiveOrgId); - await setAgentCredentialOverride( + const integration = await getIntegrationByProjectAndCategory( input.projectId, - input.envVarKey, - input.agentType, - input.credentialId, + input.category, ); - }), - - removeAgent: protectedProcedure - .input( - z.object({ - projectId: z.string(), - envVarKey: z.string(), - agentType: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); - await removeAgentCredentialOverride(input.projectId, input.envVarKey, input.agentType); + if (!integration) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `No ${input.category} integration found for project`, + }); + } + await removeIntegrationCredential(integration.id, input.role); }), }), }); diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index b9eb87d6..94dd2844 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -2,9 +2,14 @@ import { Octokit } from '@octokit/rest'; import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { PROVIDER_CREDENTIAL_ROLES } from '../../config/integrationRoles.js'; +import type { IntegrationProvider } from '../../config/integrationRoles.js'; import { getDb } from '../../db/client.js'; import { findProjectByIdFromDb } from '../../db/repositories/configRepository.js'; -import { resolveAllCredentials } from '../../db/repositories/credentialsRepository.js'; +import { + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, +} from '../../db/repositories/credentialsRepository.js'; import { projects } from '../../db/schema/index.js'; import { protectedProcedure, router } from '../trpc.js'; @@ -55,6 +60,27 @@ interface ProjectContext { jiraApiToken?: string; } +async function buildCredentialMap( + projectId: string, + orgId: string, +): Promise> { + const [integrationCreds, orgCreds] = await Promise.all([ + resolveAllIntegrationCredentials(projectId), + resolveAllOrgCredentials(orgId), + ]); + + const creds: Record = { ...orgCreds }; + for (const cred of integrationCreds) { + const roles = PROVIDER_CREDENTIAL_ROLES[cred.provider as IntegrationProvider]; + if (!roles) continue; + const roleDef = roles.find((r) => r.role === cred.role); + if (roleDef) { + creds[roleDef.envVarKey] = cred.value; + } + } + return creds; +} + async function resolveProjectContext( projectId: string, userOrgId: string, @@ -74,7 +100,7 @@ async function resolveProjectContext( throw new TRPCError({ code: 'NOT_FOUND' }); } - const creds = await resolveAllCredentials(projectId, project.orgId); + const creds = await buildCredentialMap(projectId, project.orgId); // Resolve JIRA label names from config (with defaults) const jiraLabels = project.jira @@ -97,7 +123,7 @@ async function resolveProjectContext( jiraLabels, trelloApiKey: creds.TRELLO_API_KEY ?? '', trelloToken: creds.TRELLO_TOKEN ?? '', - githubToken: creds.GITHUB_TOKEN ?? '', + githubToken: creds.GITHUB_TOKEN_IMPLEMENTER ?? '', jiraEmail: creds.JIRA_EMAIL ?? '', jiraApiToken: creds.JIRA_API_TOKEN ?? '', }; @@ -408,9 +434,12 @@ export const webhooksRouter = router({ pctx.trelloToken && pctx.boardId ) { - const trelloCallbackUrl = `${baseUrl}/webhook/trello`; + const trelloCallbackUrl = `${baseUrl}/trello/webhook`; const existing = await trelloListWebhooks(pctx); - const duplicate = existing.find((w) => w.callbackURL === trelloCallbackUrl); + const duplicate = existing.find( + (w) => + w.callbackURL === trelloCallbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, + ); if (duplicate) { results.trello = `Already exists: ${duplicate.id}`; @@ -427,9 +456,11 @@ export const webhooksRouter = router({ pctx.jiraApiToken && pctx.jiraBaseUrl ) { - const jiraCallbackUrl = `${baseUrl}/webhook/jira`; + const jiraCallbackUrl = `${baseUrl}/jira/webhook`; const existing = await jiraListWebhooks(pctx); - const duplicate = existing.find((w) => w.url === jiraCallbackUrl); + const duplicate = existing.find( + (w) => w.url === jiraCallbackUrl || w.url === `${baseUrl}/webhook/jira`, + ); if (duplicate) { results.jira = `Already exists: ${duplicate.id}`; @@ -443,9 +474,11 @@ export const webhooksRouter = router({ // GitHub webhook if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { - const githubCallbackUrl = `${baseUrl}/webhook/github`; + const githubCallbackUrl = `${baseUrl}/github/webhook`; const existing = await githubListWebhooks(pctx); - const duplicate = existing.find((w) => w.config.url === githubCallbackUrl); + const duplicate = existing.find( + (w) => w.config.url === githubCallbackUrl || w.config.url === `${baseUrl}/webhook/github`, + ); if (duplicate) { results.github = `Already exists: ${duplicate.id}`; @@ -478,9 +511,12 @@ export const webhooksRouter = router({ // Trello if (!input.githubOnly && !input.jiraOnly && pctx.trelloApiKey && pctx.trelloToken) { - const trelloCallbackUrl = `${baseUrl}/webhook/trello`; + const trelloCallbackUrl = `${baseUrl}/trello/webhook`; const existing = await trelloListWebhooks(pctx); - const matching = existing.filter((w) => w.callbackURL === trelloCallbackUrl); + const matching = existing.filter( + (w) => + w.callbackURL === trelloCallbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, + ); for (const w of matching) { await trelloDeleteWebhook(pctx, w.id); deleted.trello.push(w.id); @@ -489,9 +525,11 @@ export const webhooksRouter = router({ // JIRA if (!input.trelloOnly && !input.githubOnly && pctx.jiraEmail && pctx.jiraApiToken) { - const jiraCallbackUrl = `${baseUrl}/webhook/jira`; + const jiraCallbackUrl = `${baseUrl}/jira/webhook`; const existing = await jiraListWebhooks(pctx); - const matching = existing.filter((w) => w.url === jiraCallbackUrl); + const matching = existing.filter( + (w) => w.url === jiraCallbackUrl || w.url === `${baseUrl}/webhook/jira`, + ); for (const w of matching) { await jiraDeleteWebhook(pctx, w.id); deleted.jira.push(w.id); @@ -500,9 +538,11 @@ export const webhooksRouter = router({ // GitHub if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { - const githubCallbackUrl = `${baseUrl}/webhook/github`; + const githubCallbackUrl = `${baseUrl}/github/webhook`; const existing = await githubListWebhooks(pctx); - const matching = existing.filter((w) => w.config.url === githubCallbackUrl); + const matching = existing.filter( + (w) => w.config.url === githubCallbackUrl || w.config.url === `${baseUrl}/webhook/github`, + ); for (const w of matching) { await githubDeleteWebhook(pctx, w.id); deleted.github.push(w.id); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 1dcb8e25..01fc1f1b 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -12,7 +12,7 @@ import { } from '../agents/shared/runTracking.js'; import { createAgentLogger } from '../agents/utils/logging.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; -import { getProjectSecrets } from '../config/provider.js'; +import { getAllProjectCredentials } from '../config/provider.js'; import { loadPartials } from '../db/repositories/partialsRepository.js'; import { withGitHubToken } from '../github/client.js'; import { getPersonaToken } from '../github/personas.js'; @@ -311,7 +311,7 @@ async function buildBackendInput( const cliToolsDir = new URL('../../bin', import.meta.url).pathname; // Resolve all per-project secrets for subprocess injection - const projectSecrets = await getProjectSecrets(project.id); + const projectSecrets = await getAllProjectCredentials(project.id); // Inject base branch so cascade-tools create-pr uses the correct target automatically if (project.baseBranch) { @@ -417,7 +417,7 @@ async function resolveGitHubToken( return await getPersonaToken(projectId, agentType); } catch { // Fall back to legacy GITHUB_TOKEN for projects not yet migrated - const secrets = await getProjectSecrets(projectId); + const secrets = await getAllProjectCredentials(projectId); return secrets.GITHUB_TOKEN; } } diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts index 6716eb08..fd1cb402 100644 --- a/src/backends/agent-profiles.ts +++ b/src/backends/agent-profiles.ts @@ -2,6 +2,11 @@ import { execFileSync } from 'node:child_process'; import { type AgentCapabilities, getAgentCapabilities } from '../agents/shared/capabilities.js'; export type { AgentCapabilities } from '../agents/shared/capabilities.js'; +import { + buildPRAgentGadgets, + buildReviewGadgets, + buildWorkItemGadgets, +} from '../agents/shared/gadgets.js'; import { formatPRComments, formatPRDetails, @@ -10,41 +15,17 @@ import { formatPRReviews, readPRFileContents, } from '../agents/shared/prFormatting.js'; +import { + buildCIResponsePrompt, + buildCommentResponsePrompt, + buildPRCommentResponsePrompt, + buildReviewPrompt, + buildWorkItemPrompt, +} from '../agents/shared/taskPrompts.js'; import type { ContextFile } from '../agents/utils/setup.js'; -import { AstGrep } from '../gadgets/AstGrep.js'; -import { FileMultiEdit } from '../gadgets/FileMultiEdit.js'; -import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; -import { Finish } from '../gadgets/Finish.js'; import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { ReadFile } from '../gadgets/ReadFile.js'; -import { RipGrep } from '../gadgets/RipGrep.js'; -import { Sleep } from '../gadgets/Sleep.js'; -import { VerifyChanges } from '../gadgets/VerifyChanges.js'; -import { WriteFile } from '../gadgets/WriteFile.js'; import { formatCheckStatus } from '../gadgets/github/core/getPRChecks.js'; -import { - CreatePR, - CreatePRReview, - GetPRChecks, - GetPRComments, - GetPRDetails, - GetPRDiff, - PostPRComment, - ReplyToReviewComment, - UpdatePRComment, -} from '../gadgets/github/index.js'; import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; -import { - AddChecklist, - CreateWorkItem, - ListWorkItems, - PMUpdateChecklistItem, - PostComment, - ReadWorkItem, - UpdateWorkItem, -} from '../gadgets/pm/index.js'; -import { Tmux } from '../gadgets/tmux.js'; -import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { githubClient } from '../github/client.js'; import type { AgentInput } from '../types/index.js'; import { resolveSquintDbPath } from '../utils/squintDb.js'; @@ -138,94 +119,10 @@ export interface AgentProfile { // ============================================================================ // Llmist Gadget Builders // ============================================================================ - -/** - * Build the standard set of gadgets for work-item-based agents (briefing, planning, - * implementation, debug). Mirrors the logic in agents/base.ts getBaseAgentGadgets(). - */ -function buildWorkItemLlmistGadgets(caps: AgentCapabilities): unknown[] { - return [ - // Filesystem gadgets - new ListDirectory(), - new ReadFile(), - new RipGrep(), - new AstGrep(), - ...(caps.canEditFiles - ? [new FileSearchAndReplace(), new FileMultiEdit(), new WriteFile(), new VerifyChanges()] - : []), - // Shell commands - new Tmux(), - new Sleep(), - // Task tracking - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - // GitHub PR creation (gated by capability) - ...(caps.canCreatePR ? [new CreatePR()] : []), - // PM gadgets - new ReadWorkItem(), - new PostComment(), - new UpdateWorkItem(), - new CreateWorkItem(), - new ListWorkItems(), - new AddChecklist(), - ...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem()] : []), - // Session control - new Finish(), - ]; -} - -/** - * Build gadgets for the review agent (read-only, PR-focused). - * Mirrors the authoritative gadget list in agents/review.ts getGadgets(). - */ -function buildReviewLlmistGadgets(): unknown[] { - return [ - new ListDirectory(), - new ReadFile(), - new Tmux(), - new Sleep(), - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - new GetPRDetails(), - new GetPRDiff(), - new GetPRChecks(), - new CreatePRReview(), - new UpdatePRComment(), - new Finish(), - ]; -} - -/** - * Build gadgets for PR-modifying agents (respond-to-review, respond-to-ci, respond-to-pr-comment). - * Includes file editing + GitHub tools but NOT CreatePR (agents push to existing branches). - * Mirrors the authoritative gadget list in agents/shared/gadgets.ts createPRAgentGadgets(). - */ -function buildPRAgentLlmistGadgets(includeReviewComments = false): unknown[] { - return [ - new ListDirectory(), - new ReadFile(), - new FileSearchAndReplace(), - new FileMultiEdit(), - new WriteFile(), - new VerifyChanges(), - new AstGrep(), - new RipGrep(), - new Tmux(), - new Sleep(), - new TodoUpsert(), - new TodoUpdateStatus(), - new TodoDelete(), - new GetPRDetails(), - new GetPRDiff(), - new GetPRChecks(), - new PostPRComment(), - new UpdatePRComment(), - ...(includeReviewComments ? [new GetPRComments(), new ReplyToReviewComment()] : []), - new Finish(), - ]; -} +// All three builder functions below delegate to the shared gadget factories in +// agents/shared/gadgets.ts, which serve as the single source of truth for tool +// sets used by both the llmist backend and the Claude Code backend. +// ============================================================================ // ============================================================================ // Context Fetching Helpers @@ -238,27 +135,22 @@ function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolMani function fetchDirectoryListing(repoDir: string): ContextInjection { const listDirGadget = new ListDirectory(); + // Pass the absolute repoDir path so ListDirectory resolves correctly + // without requiring process.chdir(), which is a dangerous side effect. const params = { comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', + directoryPath: repoDir, maxDepth: 3, includeGitIgnored: false, }; - // ListDirectory uses process.cwd() — we need to be in repoDir - const originalCwd = process.cwd(); - try { - process.chdir(repoDir); - const result = listDirGadget.execute(params); - return { - toolName: 'ListDirectory', - params, - result, - description: 'Pre-fetched codebase structure', - }; - } finally { - process.chdir(originalCwd); - } + const result = listDirGadget.execute(params); + return { + toolName: 'ListDirectory', + params, + result, + description: 'Pre-fetched codebase structure', + }; } function fetchContextFileInjections(contextFiles: ContextFile[]): ContextInjection[] { @@ -519,63 +411,34 @@ async function fetchPRCommentResponseContext( } // ============================================================================ -// Task Prompt Builders +// Task Prompt Builders (thin wrappers around shared/taskPrompts.ts) // ============================================================================ function buildWorkItemTaskPrompt(input: AgentInput): string { - return `Analyze and process the work item with ID: ${input.cardId || 'unknown'}. The work item data has been pre-loaded.`; + return buildWorkItemPrompt(input.cardId || 'unknown'); } function buildCommentResponseTaskPrompt(input: AgentInput): string { const commentText = input.triggerCommentText as string; const commentAuthor = (input.triggerCommentAuthor as string) || 'unknown'; - return `A user (@${commentAuthor}) mentioned you in a comment on work item ${input.cardId || 'unknown'}. - -Their comment: ---- -${commentText} ---- - -The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. -Read the user's comment carefully and respond accordingly. Default to surgical, targeted updates unless they clearly ask for a full rewrite.`; + return buildCommentResponsePrompt(input.cardId || 'unknown', commentText, commentAuthor); } function buildReviewTaskPrompt(input: AgentInput): string { - const prNumber = input.prNumber as number; - - return `Review PR #${prNumber}. - -Examine the code changes carefully and submit your review using CreatePRReview.`; + return buildReviewPrompt(input.prNumber as number); } function buildCITaskPrompt(input: AgentInput): string { - const prNumber = input.prNumber as number; - const prBranch = input.prBranch as string; - - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -CI checks have failed. Analyze the failures and fix them.`; + return buildCIResponsePrompt(input.prBranch as string, input.prNumber as number); } function buildPRCommentResponseTaskPrompt(input: AgentInput): string { - const prNumber = input.prNumber as number; - const prBranch = input.prBranch as string; - const commentBody = input.triggerCommentBody as string; - const commentPath = input.triggerCommentPath as string; - - const pathContext = commentPath ? `\nFile: ${commentPath}` : ''; - - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -A user commented on this PR and mentioned you. Respond to their comment. -${pathContext} - -Their comment: ---- -${commentBody} ---- - -Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader.`; + return buildPRCommentResponsePrompt( + input.prBranch as string, + input.prNumber as number, + input.triggerCommentBody as string, + (input.triggerCommentPath as string) || undefined, + ); } // ============================================================================ @@ -591,7 +454,7 @@ const briefingProfile: AgentProfile = { fetchContext: fetchWorkItemContext, buildTaskPrompt: buildWorkItemTaskPrompt, capabilities: getAgentCapabilities('briefing'), - getLlmistGadgets: (agentType) => buildWorkItemLlmistGadgets(getAgentCapabilities(agentType)), + getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; const planningProfile: AgentProfile = { @@ -602,7 +465,7 @@ const planningProfile: AgentProfile = { fetchContext: fetchWorkItemContext, buildTaskPrompt: buildWorkItemTaskPrompt, capabilities: getAgentCapabilities('planning'), - getLlmistGadgets: (agentType) => buildWorkItemLlmistGadgets(getAgentCapabilities(agentType)), + getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; const reviewProfile: AgentProfile = { @@ -613,7 +476,7 @@ const reviewProfile: AgentProfile = { fetchContext: fetchReviewContext, buildTaskPrompt: buildReviewTaskPrompt, capabilities: getAgentCapabilities('review'), - getLlmistGadgets: (_agentType) => buildReviewLlmistGadgets(), + getLlmistGadgets: (_agentType) => buildReviewGadgets(), async preExecute({ input, logWriter }: PreExecuteParams): Promise { const repoFullName = input.repoFullName as string; @@ -634,7 +497,7 @@ const respondToPlanningCommentProfile: AgentProfile = { fetchContext: fetchWorkItemContext, buildTaskPrompt: buildCommentResponseTaskPrompt, capabilities: getAgentCapabilities('respond-to-planning-comment'), - getLlmistGadgets: (agentType) => buildWorkItemLlmistGadgets(getAgentCapabilities(agentType)), + getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; const respondToCIProfile: AgentProfile = { @@ -652,7 +515,7 @@ const respondToCIProfile: AgentProfile = { fetchContext: fetchCIContext, buildTaskPrompt: buildCITaskPrompt, capabilities: getAgentCapabilities('respond-to-ci'), - getLlmistGadgets: (_agentType) => buildPRAgentLlmistGadgets(), + getLlmistGadgets: (_agentType) => buildPRAgentGadgets(), async preExecute({ input, logWriter }: PreExecuteParams): Promise { const repoFullName = input.repoFullName as string; @@ -678,7 +541,7 @@ const respondToReviewProfile: AgentProfile = { fetchContext: fetchPRCommentResponseContext, buildTaskPrompt: buildPRCommentResponseTaskPrompt, capabilities: getAgentCapabilities('respond-to-review'), - getLlmistGadgets: (_agentType) => buildPRAgentLlmistGadgets(true), + getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), }; const respondToPRCommentProfile: AgentProfile = { @@ -690,7 +553,7 @@ const respondToPRCommentProfile: AgentProfile = { fetchContext: fetchPRCommentResponseContext, buildTaskPrompt: buildPRCommentResponseTaskPrompt, capabilities: getAgentCapabilities('respond-to-pr-comment'), - getLlmistGadgets: (_agentType) => buildPRAgentLlmistGadgets(true), + getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), }; const defaultProfile: AgentProfile = { @@ -701,14 +564,14 @@ const defaultProfile: AgentProfile = { fetchContext: fetchWorkItemContext, buildTaskPrompt: buildWorkItemTaskPrompt, capabilities: getAgentCapabilities('debug'), - getLlmistGadgets: (agentType) => buildWorkItemLlmistGadgets(getAgentCapabilities(agentType)), + getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; const implementationProfile: AgentProfile = { ...defaultProfile, needsGitHubToken: true, capabilities: getAgentCapabilities('implementation'), - getLlmistGadgets: (agentType) => buildWorkItemLlmistGadgets(getAgentCapabilities(agentType)), + getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), }; // ============================================================================ diff --git a/src/cli/dashboard/projects/integration-set.ts b/src/cli/dashboard/projects/integration-set.ts index 26c4b216..6a026d51 100644 --- a/src/cli/dashboard/projects/integration-set.ts +++ b/src/cli/dashboard/projects/integration-set.ts @@ -10,8 +10,17 @@ export default class ProjectsIntegrationSet extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, - type: Flags.string({ description: 'Integration type (e.g. trello)', required: true }), + category: Flags.string({ + description: 'Integration category (pm or scm)', + required: true, + options: ['pm', 'scm'], + }), + provider: Flags.string({ + description: 'Integration provider (trello, jira, github)', + required: true, + }), config: Flags.string({ description: 'Config as JSON string', required: true }), + triggers: Flags.string({ description: 'Triggers as JSON string' }), }; async run(): Promise { @@ -24,11 +33,22 @@ export default class ProjectsIntegrationSet extends DashboardCommand { this.error('Invalid JSON in --config flag.'); } + let triggers: Record | undefined; + if (flags.triggers) { + try { + triggers = JSON.parse(flags.triggers) as Record; + } catch { + this.error('Invalid JSON in --triggers flag.'); + } + } + try { await this.client.projects.integrations.upsert.mutate({ projectId: args.id, - type: flags.type, + category: flags.category as 'pm' | 'scm', + provider: flags.provider, config, + triggers, }); if (flags.json) { @@ -36,7 +56,7 @@ export default class ProjectsIntegrationSet extends DashboardCommand { return; } - this.log(`Set ${flags.type} integration for project: ${args.id}`); + this.log(`Set ${flags.category}/${flags.provider} integration for project: ${args.id}`); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/projects/integrations.ts b/src/cli/dashboard/projects/integrations.ts index 1dc9ebd9..1386d64b 100644 --- a/src/cli/dashboard/projects/integrations.ts +++ b/src/cli/dashboard/projects/integrations.ts @@ -31,8 +31,10 @@ export default class ProjectsIntegrations extends DashboardCommand { } for (const integration of integrations as unknown as Array>) { - this.log(`\nType: ${integration.type}`); + this.log(`\nCategory: ${integration.category}`); + this.log(`Provider: ${integration.provider}`); this.log(`Config: ${JSON.stringify(integration.config, null, 2)}`); + this.log(`Triggers: ${JSON.stringify(integration.triggers, null, 2)}`); } } catch (err) { this.handleError(err); diff --git a/src/cli/dashboard/projects/override-rm.ts b/src/cli/dashboard/projects/override-rm.ts index d2955172..7a19dc1c 100644 --- a/src/cli/dashboard/projects/override-rm.ts +++ b/src/cli/dashboard/projects/override-rm.ts @@ -1,8 +1,10 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; -export default class ProjectsOverrideRm extends DashboardCommand { - static override description = 'Remove a credential override from a project.'; +export default class ProjectsIntegrationCredentialRm extends DashboardCommand { + static override description = 'Unlink a credential from an integration role for a project.'; + + static override aliases = ['projects:integration-credential-rm']; static override args = { id: Args.string({ description: 'Project ID', required: true }), @@ -10,34 +12,33 @@ export default class ProjectsOverrideRm extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, - key: Flags.string({ description: 'Environment variable key', required: true }), - 'agent-type': Flags.string({ description: 'Remove agent-scoped override' }), + category: Flags.string({ + description: 'Integration category (pm or scm)', + required: true, + options: ['pm', 'scm'], + }), + role: Flags.string({ + description: 'Credential role to unlink (e.g. api_key, token, implementer_token)', + required: true, + }), }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsOverrideRm); + const { args, flags } = await this.parse(ProjectsIntegrationCredentialRm); try { - if (flags['agent-type']) { - await this.client.projects.credentialOverrides.removeAgent.mutate({ - projectId: args.id, - envVarKey: flags.key, - agentType: flags['agent-type'], - }); - } else { - await this.client.projects.credentialOverrides.remove.mutate({ - projectId: args.id, - envVarKey: flags.key, - }); - } + await this.client.projects.integrationCredentials.remove.mutate({ + projectId: args.id, + category: flags.category as 'pm' | 'scm', + role: flags.role, + }); if (flags.json) { this.outputJson({ ok: true }); return; } - const scope = flags['agent-type'] ? ` (agent: ${flags['agent-type']})` : ''; - this.log(`Removed override ${flags.key}${scope}`); + this.log(`Removed ${flags.category}/${flags.role} credential link`); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/projects/override-set.ts b/src/cli/dashboard/projects/override-set.ts index c2cc32bc..9143fcac 100644 --- a/src/cli/dashboard/projects/override-set.ts +++ b/src/cli/dashboard/projects/override-set.ts @@ -1,8 +1,10 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; -export default class ProjectsOverrideSet extends DashboardCommand { - static override description = 'Set a credential override for a project.'; +export default class ProjectsIntegrationCredentialSet extends DashboardCommand { + static override description = 'Link a credential to an integration role for a project.'; + + static override aliases = ['projects:integration-credential-set']; static override args = { id: Args.string({ description: 'Project ID', required: true }), @@ -10,40 +12,35 @@ export default class ProjectsOverrideSet extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, - key: Flags.string({ - description: 'Environment variable key (e.g. GITHUB_TOKEN_IMPLEMENTER)', + category: Flags.string({ + description: 'Integration category (pm or scm)', + required: true, + options: ['pm', 'scm'], + }), + role: Flags.string({ + description: 'Credential role (e.g. api_key, token, implementer_token)', required: true, }), - 'credential-id': Flags.integer({ description: 'Credential ID to use', required: true }), - 'agent-type': Flags.string({ description: 'Scope to specific agent type' }), + 'credential-id': Flags.integer({ description: 'Credential ID to link', required: true }), }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsOverrideSet); + const { args, flags } = await this.parse(ProjectsIntegrationCredentialSet); try { - if (flags['agent-type']) { - await this.client.projects.credentialOverrides.setAgent.mutate({ - projectId: args.id, - envVarKey: flags.key, - agentType: flags['agent-type'], - credentialId: flags['credential-id'], - }); - } else { - await this.client.projects.credentialOverrides.set.mutate({ - projectId: args.id, - envVarKey: flags.key, - credentialId: flags['credential-id'], - }); - } + await this.client.projects.integrationCredentials.set.mutate({ + projectId: args.id, + category: flags.category as 'pm' | 'scm', + role: flags.role, + credentialId: flags['credential-id'], + }); if (flags.json) { this.outputJson({ ok: true }); return; } - const scope = flags['agent-type'] ? ` (agent: ${flags['agent-type']})` : ''; - this.log(`Set override ${flags.key} → credential #${flags['credential-id']}${scope}`); + this.log(`Set ${flags.category}/${flags.role} → credential #${flags['credential-id']}`); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/projects/overrides.ts b/src/cli/dashboard/projects/overrides.ts index 70c8acba..b4050182 100644 --- a/src/cli/dashboard/projects/overrides.ts +++ b/src/cli/dashboard/projects/overrides.ts @@ -1,8 +1,10 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; -export default class ProjectsOverrides extends DashboardCommand { - static override description = 'Show credential overrides for a project.'; +export default class ProjectsIntegrationCredentials extends DashboardCommand { + static override description = 'Show integration credentials for a project.'; + + static override aliases = ['projects:integration-credentials']; static override args = { id: Args.string({ description: 'Project ID', required: true }), @@ -10,30 +12,47 @@ export default class ProjectsOverrides extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, + category: Flags.string({ + description: 'Filter by integration category (pm or scm)', + options: ['pm', 'scm'], + }), }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsOverrides); + const { args, flags } = await this.parse(ProjectsIntegrationCredentials); try { - const overrides = await this.client.projects.credentialOverrides.list.query({ - projectId: args.id, - }); + const categories = flags.category + ? [flags.category as 'pm' | 'scm'] + : (['pm', 'scm'] as const); + + const allCreds: Array> = []; + + for (const category of categories) { + const creds = await this.client.projects.integrationCredentials.list.query({ + projectId: args.id, + category, + }); + for (const c of creds as unknown as Array>) { + allCreds.push({ ...c, category }); + } + } if (flags.json) { - this.outputJson(overrides); + this.outputJson(allCreds); return; } - if (!overrides || (Array.isArray(overrides) && overrides.length === 0)) { - this.log('No credential overrides configured.'); + if (allCreds.length === 0) { + this.log('No integration credentials configured.'); return; } - this.outputTable(overrides as unknown as Record[], [ - { key: 'envVarKey', header: 'Key' }, + this.outputTable(allCreds, [ + { key: 'category', header: 'Category' }, + { key: 'role', header: 'Role' }, { key: 'credentialId', header: 'Credential ID' }, - { key: 'agentType', header: 'Agent Type', format: (v) => String(v ?? '(all)') }, + { key: 'credentialName', header: 'Credential Name' }, ]); } catch (err) { this.handleError(err); diff --git a/src/cli/dashboard/runs/list.ts b/src/cli/dashboard/runs/list.ts index 2ed2654d..de9129d6 100644 --- a/src/cli/dashboard/runs/list.ts +++ b/src/cli/dashboard/runs/list.ts @@ -39,7 +39,9 @@ export default class RunsList extends DashboardCommand { return; } - this.outputTable(runs as unknown as Record[], [ + const { data, total } = runs as { data: Record[]; total: number }; + + this.outputTable(data, [ { key: 'id', header: 'ID', format: (v) => String(v ?? '').slice(0, 8) }, { key: 'projectId', header: 'Project' }, { key: 'agentType', header: 'Agent' }, @@ -48,6 +50,10 @@ export default class RunsList extends DashboardCommand { { key: 'durationMs', header: 'Duration', format: formatDuration }, { key: 'costUsd', header: 'Cost', format: formatCost }, ]); + + if (total > data.length) { + this.log(`\nShowing ${data.length} of ${total} runs.`); + } } catch (err) { this.handleError(err); } diff --git a/src/config/index.ts b/src/config/index.ts index 7aa08a00..52423c33 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,10 +5,10 @@ export { findProjectByBoardId, findProjectByRepo, findProjectById, - getAgentCredential, - getProjectSecret, - getProjectSecretOrNull, - getProjectSecrets, + getIntegrationCredential, + getIntegrationCredentialOrNull, + getOrgCredential, + getAllProjectCredentials, invalidateConfigCache, } from './provider.js'; export { validateConfig, ProjectConfigSchema, CascadeConfigSchema } from './schema.js'; diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts new file mode 100644 index 00000000..74207f4f --- /dev/null +++ b/src/config/integrationRoles.ts @@ -0,0 +1,33 @@ +export type IntegrationCategory = 'pm' | 'scm'; +export type IntegrationProvider = 'trello' | 'jira' | 'github'; + +export const PROVIDER_CATEGORY: Record = { + trello: 'pm', + jira: 'pm', + github: 'scm', +}; + +export interface CredentialRoleDef { + role: string; + label: string; + envVarKey: string; // used when building flat env maps for workers +} + +export const PROVIDER_CREDENTIAL_ROLES: Record = { + trello: [ + { role: 'api_key', label: 'API Key', envVarKey: 'TRELLO_API_KEY' }, + { role: 'token', label: 'Token', envVarKey: 'TRELLO_TOKEN' }, + ], + jira: [ + { role: 'email', label: 'Email', envVarKey: 'JIRA_EMAIL' }, + { role: 'api_token', label: 'API Token', envVarKey: 'JIRA_API_TOKEN' }, + ], + github: [ + { + role: 'implementer_token', + label: 'Implementer Token', + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + }, + { role: 'reviewer_token', label: 'Reviewer Token', envVarKey: 'GITHUB_TOKEN_REVIEWER' }, + ], +}; diff --git a/src/config/projects.ts b/src/config/projects.ts index 35df8352..8cbd2075 100644 --- a/src/config/projects.ts +++ b/src/config/projects.ts @@ -1,16 +1,13 @@ import type { ProjectConfig } from '../types/index.js'; -import { getProjectSecretOrNull } from './provider.js'; +import { getIntegrationCredentialOrNull } from './provider.js'; export async function getProjectGitHubToken(project: ProjectConfig): Promise { - // Prefer GITHUB_TOKEN_IMPLEMENTER (new dual-persona model) - const implementerToken = await getProjectSecretOrNull(project.id, 'GITHUB_TOKEN_IMPLEMENTER'); + const implementerToken = await getIntegrationCredentialOrNull( + project.id, + 'scm', + 'implementer_token', + ); if (implementerToken) return implementerToken; - // Fall back to legacy GITHUB_TOKEN for projects not yet migrated - const legacyToken = await getProjectSecretOrNull(project.id, 'GITHUB_TOKEN'); - if (legacyToken) return legacyToken; - - throw new Error( - `Missing GITHUB_TOKEN_IMPLEMENTER (or legacy GITHUB_TOKEN) in database for project '${project.id}'`, - ); + throw new Error(`Missing implementer token (SCM integration) for project '${project.id}'`); } diff --git a/src/config/provider.ts b/src/config/provider.ts index 5b1059fa..eb89c57e 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -10,12 +10,15 @@ import { loadConfigFromDb, } from '../db/repositories/configRepository.js'; import { - resolveAgentCredential, - resolveAllCredentials, - resolveCredential, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, + resolveIntegrationCredential, + resolveOrgCredential, } from '../db/repositories/credentialsRepository.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; import { configCache } from './configCache.js'; +import { PROVIDER_CREDENTIAL_ROLES } from './integrationRoles.js'; +import type { IntegrationProvider } from './integrationRoles.js'; /** * Permanent secrets store — no TTL. Secrets set at worker startup persist @@ -117,63 +120,137 @@ async function getOrgIdForProject(projectId: string): Promise { return orgId; } -export async function getProjectSecret(projectId: string, key: string): Promise { +// ============================================================================ +// Integration credentials — direct by category + role +// ============================================================================ + +/** + * Resolve an integration credential for a project by category and role. + * Throws if the credential is not found. + */ +export async function getIntegrationCredential( + projectId: string, + category: string, + role: string, +): Promise { // Check permanent secrets store first (populated at worker startup) const cachedSecrets = secretsStore.get(projectId); - if (cachedSecrets && key in cachedSecrets) { - return cachedSecrets[key]; + if (cachedSecrets) { + // Map role to env var key for cache lookup + const envKey = roleToEnvVarKey(category, role); + if (envKey && envKey in cachedSecrets) { + return cachedSecrets[envKey]; + } } - // Resolve via credentials system (project override → org default) - const orgId = await getOrgIdForProject(projectId); - const dbValue = await resolveCredential(projectId, orgId, key); - if (dbValue) return dbValue; + const value = await resolveIntegrationCredential(projectId, category, role); + if (value) return value; - throw new Error(`Secret '${key}' not found for project '${projectId}' in database`); + throw new Error( + `Integration credential '${category}/${role}' not found for project '${projectId}'`, + ); } -export async function getProjectSecretOrNull( +/** + * Resolve an integration credential for a project, returning null if not found. + */ +export async function getIntegrationCredentialOrNull( projectId: string, - key: string, + category: string, + role: string, ): Promise { - try { - return await getProjectSecret(projectId, key); - } catch { - return null; + // Check permanent secrets store first + const cachedSecrets = secretsStore.get(projectId); + if (cachedSecrets) { + const envKey = roleToEnvVarKey(category, role); + if (envKey && envKey in cachedSecrets) { + return cachedSecrets[envKey]; + } } -} -export async function getProjectSecrets(projectId: string): Promise> { - const cached = secretsStore.get(projectId); - if (cached) return cached; - - const orgId = await getOrgIdForProject(projectId); - const secrets = await resolveAllCredentials(projectId, orgId); - secretsStore.set(projectId, secrets); - return secrets; + return resolveIntegrationCredential(projectId, category, role); } +// ============================================================================ +// Non-integration (org-scoped) credentials +// ============================================================================ + /** - * Resolve a credential for a specific agent type. - * Resolution: cache → agent+project override → project override → org default → null. + * Resolve a non-integration org-scoped credential by env var key. + * Used for LLM API keys, etc. */ -export async function getAgentCredential( +export async function getOrgCredential( projectId: string, - agentType: string, - key: string, + envVarKey: string, ): Promise { - // Check permanent secrets store first (from CASCADE_CREDENTIALS env var in workers) + // Check permanent secrets store first const cachedSecrets = secretsStore.get(projectId); - if (cachedSecrets && key in cachedSecrets) { - return cachedSecrets[key]; + if (cachedSecrets && envVarKey in cachedSecrets) { + return cachedSecrets[envVarKey]; } - // Fall back to DB resolution (agent override → project override → org default) const orgId = await getOrgIdForProject(projectId); - return resolveAgentCredential(projectId, orgId, agentType, key); + return resolveOrgCredential(orgId, envVarKey); +} + +// ============================================================================ +// All credentials as flat env-var-key map (for worker environments) +// ============================================================================ + +/** + * Build a flat env-var-key → value map of all credentials for a project. + * 1. Loads all integration credentials and maps role→envVarKey + * 2. Loads all org-default non-integration credentials + * 3. Merges integration credentials over org defaults + */ +export async function getAllProjectCredentials(projectId: string): Promise> { + const cached = secretsStore.get(projectId); + if (cached) return cached; + + const orgId = await getOrgIdForProject(projectId); + + const [integrationCreds, orgCreds] = await Promise.all([ + resolveAllIntegrationCredentials(projectId), + resolveAllOrgCredentials(orgId), + ]); + + // Start with org defaults + const result: Record = { ...orgCreds }; + + // Overlay integration credentials (mapped by role→envVarKey) + for (const cred of integrationCreds) { + const roles = PROVIDER_CREDENTIAL_ROLES[cred.provider as IntegrationProvider]; + if (!roles) continue; + const roleDef = roles.find((r) => r.role === cred.role); + if (roleDef) { + result[roleDef.envVarKey] = cred.value; + } + } + + secretsStore.set(projectId, result); + return result; } export function invalidateConfigCache(): void { configCache.invalidate(); secretsStore.clear(); } + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** + * Map a category+role pair to the corresponding env var key. + * Used for cache lookups in the secrets store. + */ +function roleToEnvVarKey(category: string, role: string): string | undefined { + // Look through all providers in the category to find the role + for (const [provider, roles] of Object.entries(PROVIDER_CREDENTIAL_ROLES)) { + const providerCategory = provider === 'trello' || provider === 'jira' ? 'pm' : 'scm'; + if (providerCategory !== category) continue; + const roleDef = roles.find((r) => r.role === role); + if (roleDef) return roleDef.envVarKey; + } + return undefined; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 2346c334..f4323ab8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,4 +1,9 @@ import { z } from 'zod'; +import { + GitHubTriggerConfigSchema, + JiraTriggerConfigSchema, + TrelloTriggerConfigSchema, +} from './triggerConfig.js'; const AgentBackendConfigSchema = z.object({ default: z.string().default('llmist'), @@ -24,6 +29,7 @@ const JiraConfigSchema = z.object({ readyToProcess: z.string().default('cascade-ready'), }) .optional(), + triggers: JiraTriggerConfigSchema.partial().optional(), }); export const ProjectConfigSchema = z.object({ @@ -50,11 +56,22 @@ export const ProjectConfigSchema = z.object({ cost: z.string().optional(), }) .optional(), + triggers: TrelloTriggerConfigSchema.partial().optional(), }) .optional(), jira: JiraConfigSchema.optional(), + /** + * GitHub-specific configuration, including trigger toggles. + * Separate from trello/jira because GitHub integration is always present for code operations. + */ + github: z + .object({ + triggers: GitHubTriggerConfigSchema.partial().optional(), + }) + .optional(), + prompts: z.record(z.string()).optional(), model: z.string().optional(), agentModels: z.record(z.string()).optional(), diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts new file mode 100644 index 00000000..989eef5f --- /dev/null +++ b/src/config/triggerConfig.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +// ============================================================================ +// Trigger Config Schemas +// ============================================================================ + +/** + * Trigger configuration for Trello integrations. + * All triggers default to `true` for backward compatibility. + */ +export const TrelloTriggerConfigSchema = z.object({ + cardMovedToBriefing: z.boolean().default(true), + cardMovedToPlanning: z.boolean().default(true), + cardMovedToTodo: z.boolean().default(true), + readyToProcessLabel: z.boolean().default(true), + commentMention: z.boolean().default(true), +}); + +/** + * Trigger configuration for JIRA integrations. + * All triggers default to `true` for backward compatibility. + */ +export const JiraTriggerConfigSchema = z.object({ + issueTransitioned: z.boolean().default(true), + readyToProcessLabel: z.boolean().default(true), + commentMention: z.boolean().default(true), +}); + +/** + * Trigger configuration for GitHub integrations. + * Existing triggers default to `true`; new triggers (`reviewRequested`, `prOpened`) default to `false`. + */ +export const GitHubTriggerConfigSchema = z.object({ + checkSuiteSuccess: z.boolean().default(true), + checkSuiteFailure: z.boolean().default(true), + prReviewSubmitted: z.boolean().default(true), + prCommentMention: z.boolean().default(true), + prReadyToMerge: z.boolean().default(true), + prMerged: z.boolean().default(true), + /** New trigger: fires review agent when review is requested from a CASCADE persona. Default false (opt-in). */ + reviewRequested: z.boolean().default(false), + /** PR opened trigger. Default false (disabled until reviewed). */ + prOpened: z.boolean().default(false), +}); + +export type TrelloTriggerConfig = z.infer; +export type JiraTriggerConfig = z.infer; +export type GitHubTriggerConfig = z.infer; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Resolve whether a Trello trigger is enabled based on project trigger config. + * Returns `true` (enabled) when no config is present (backward compatible). + */ +export function resolveTrelloTriggerEnabled( + config: Partial | undefined, + key: keyof TrelloTriggerConfig, +): boolean { + if (!config) return true; + const value = config[key]; + return value === undefined ? true : value; +} + +/** + * Resolve whether a JIRA trigger is enabled based on project trigger config. + * Returns `true` (enabled) when no config is present (backward compatible). + */ +export function resolveJiraTriggerEnabled( + config: Partial | undefined, + key: keyof JiraTriggerConfig, +): boolean { + if (!config) return true; + const value = config[key]; + return value === undefined ? true : value; +} + +/** + * Resolve whether a GitHub trigger is enabled based on project trigger config. + * For new opt-in triggers (reviewRequested, prOpened), returns `false` when no config is present. + */ +export function resolveGitHubTriggerEnabled( + config: Partial | undefined, + key: keyof GitHubTriggerConfig, +): boolean { + if (!config) { + // New triggers that are opt-in default to false even without config + if (key === 'reviewRequested' || key === 'prOpened') return false; + return true; + } + const value = config[key]; + if (value === undefined) { + // New triggers that are opt-in default to false + if (key === 'reviewRequested' || key === 'prOpened') return false; + return true; + } + return value; +} diff --git a/src/db/migrations/0013_integration_model_refactor.sql b/src/db/migrations/0013_integration_model_refactor.sql new file mode 100644 index 00000000..1976ac6f --- /dev/null +++ b/src/db/migrations/0013_integration_model_refactor.sql @@ -0,0 +1,190 @@ +-- 0013_integration_model_refactor.sql +-- Refactor integrations to first-class model with category/provider/triggers, +-- integration_credentials join table, and drop project_credential_overrides. + +BEGIN; + +-- ============================================================================ +-- 1. Add new columns to project_integrations +-- ============================================================================ + +ALTER TABLE project_integrations + ADD COLUMN category TEXT, + ADD COLUMN provider TEXT, + ADD COLUMN triggers JSONB NOT NULL DEFAULT '{}'; + +-- ============================================================================ +-- 2. Backfill category and provider from type +-- ============================================================================ + +UPDATE project_integrations +SET provider = type, + category = CASE + WHEN type IN ('trello', 'jira') THEN 'pm' + WHEN type = 'github' THEN 'scm' + END; + +-- ============================================================================ +-- 3. Make category and provider NOT NULL +-- ============================================================================ + +ALTER TABLE project_integrations + ALTER COLUMN category SET NOT NULL, + ALTER COLUMN provider SET NOT NULL; + +-- ============================================================================ +-- 4. Extract triggers from config JSONB into triggers column +-- ============================================================================ + +UPDATE project_integrations +SET triggers = COALESCE(config->'triggers', '{}'), + config = config - 'triggers' +WHERE config ? 'triggers'; + +-- ============================================================================ +-- 5. Drop old unique index and type column +-- ============================================================================ + +DROP INDEX IF EXISTS uq_project_integrations_project_type; +ALTER TABLE project_integrations DROP COLUMN type; + +-- ============================================================================ +-- 6. Create new unique index on (project_id, category) +-- ============================================================================ + +CREATE UNIQUE INDEX uq_project_integrations_project_category + ON project_integrations (project_id, category); + +-- ============================================================================ +-- 7. Add CHECK constraint for valid category/provider combinations +-- ============================================================================ + +ALTER TABLE project_integrations + ADD CONSTRAINT chk_integration_category_provider + CHECK ( + (category = 'pm' AND provider IN ('trello', 'jira')) + OR (category = 'scm' AND provider IN ('github')) + ); + +-- ============================================================================ +-- 8. Recreate expression indexes for config lookups +-- ============================================================================ + +CREATE INDEX idx_integrations_trello_board_id + ON project_integrations ((config->>'boardId')) + WHERE provider = 'trello'; + +CREATE INDEX idx_integrations_jira_project_key + ON project_integrations ((config->>'projectKey')) + WHERE provider = 'jira'; + +-- ============================================================================ +-- 9. Create integration_credentials table +-- ============================================================================ + +CREATE TABLE integration_credentials ( + id SERIAL PRIMARY KEY, + integration_id INTEGER NOT NULL REFERENCES project_integrations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE RESTRICT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT uq_integration_credentials_integration_role UNIQUE (integration_id, role), + CONSTRAINT chk_integration_credential_role CHECK ( + role IN ('api_key', 'token', 'email', 'api_token', 'implementer_token', 'reviewer_token') + ) +); + +CREATE INDEX idx_integration_credentials_credential_id + ON integration_credentials (credential_id); + +-- ============================================================================ +-- 10. Migrate GitHub credential references from config JSONB +-- ============================================================================ + +INSERT INTO integration_credentials (integration_id, role, credential_id) +SELECT pi.id, 'implementer_token', (pi.config->>'implementerCredentialId')::integer +FROM project_integrations pi +WHERE pi.provider = 'github' + AND pi.config->>'implementerCredentialId' IS NOT NULL + AND (pi.config->>'implementerCredentialId')::integer IS NOT NULL; + +INSERT INTO integration_credentials (integration_id, role, credential_id) +SELECT pi.id, 'reviewer_token', (pi.config->>'reviewerCredentialId')::integer +FROM project_integrations pi +WHERE pi.provider = 'github' + AND pi.config->>'reviewerCredentialId' IS NOT NULL + AND (pi.config->>'reviewerCredentialId')::integer IS NOT NULL; + +-- ============================================================================ +-- 11. Strip implementerCredentialId/reviewerCredentialId from GitHub config +-- ============================================================================ + +UPDATE project_integrations +SET config = config - 'implementerCredentialId' - 'reviewerCredentialId' +WHERE provider = 'github'; + +-- ============================================================================ +-- 12. Migrate credentials from project_credential_overrides into +-- integration_credentials (PL/pgSQL loop per provider) +-- ============================================================================ + +DO $$ +DECLARE + r RECORD; + integration_row RECORD; + role_name TEXT; +BEGIN + -- Map env_var_key → (category, role) + FOR r IN + SELECT pco.project_id, pco.env_var_key, pco.credential_id + FROM project_credential_overrides pco + WHERE pco.agent_type IS NULL -- project-wide overrides only + LOOP + -- Determine category and role from env_var_key + CASE r.env_var_key + WHEN 'TRELLO_API_KEY' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'pm' AND provider = 'trello'; + role_name := 'api_key'; + WHEN 'TRELLO_TOKEN' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'pm' AND provider = 'trello'; + role_name := 'token'; + WHEN 'JIRA_EMAIL' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'pm' AND provider = 'jira'; + role_name := 'email'; + WHEN 'JIRA_API_TOKEN' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'pm' AND provider = 'jira'; + role_name := 'api_token'; + WHEN 'GITHUB_TOKEN_IMPLEMENTER' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'scm' AND provider = 'github'; + role_name := 'implementer_token'; + WHEN 'GITHUB_TOKEN_REVIEWER' THEN + SELECT id INTO integration_row FROM project_integrations + WHERE project_id = r.project_id AND category = 'scm' AND provider = 'github'; + role_name := 'reviewer_token'; + ELSE + CONTINUE; -- Skip non-integration credentials (e.g., LLM API keys) + END CASE; + + -- Only insert if the integration exists and no duplicate + IF integration_row.id IS NOT NULL THEN + INSERT INTO integration_credentials (integration_id, role, credential_id) + VALUES (integration_row.id, role_name, r.credential_id) + ON CONFLICT (integration_id, role) DO NOTHING; + END IF; + END LOOP; +END +$$; + +-- ============================================================================ +-- 13. Drop project_credential_overrides table +-- ============================================================================ + +DROP TABLE IF EXISTS project_credential_overrides; + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index d5e3d81c..b1e59311 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1747000000000, "tag": "0012_llm_calls_realtime", "breakpoints": false + }, + { + "idx": 12, + "version": "7", + "when": 1748000000000, + "tag": "0013_integration_model_refactor", + "breakpoints": false } ] } diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 1b36abd6..9f141e25 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -20,6 +20,9 @@ interface JiraIntegrationConfig { labels?: Record; } +// biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) +type GitHubIntegrationConfig = {}; + interface DefaultsRow { model: string | null; maxIterations: number | null; @@ -79,11 +82,23 @@ function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentC type ProjectRow = typeof projects.$inferSelect; +interface IntegrationRow { + category: string; + provider: string; + config: unknown; + triggers: unknown; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherently maps multiple integration types function mapProjectRow( row: ProjectRow, projectAgentConfigs: AgentConfigRow[], trelloConfig?: TrelloIntegrationConfig, + trelloTriggers?: Record, jiraConfig?: JiraIntegrationConfig, + jiraTriggers?: Record, + _githubConfig?: GitHubIntegrationConfig, + githubTriggers?: Record, ): Record { const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); @@ -111,6 +126,9 @@ function mapProjectRow( lists: trelloConfig.lists, labels: trelloConfig.labels, customFields: trelloConfig.customFields, + ...(trelloTriggers && Object.keys(trelloTriggers).length > 0 + ? { triggers: trelloTriggers } + : {}), }; } @@ -122,9 +140,14 @@ function mapProjectRow( issueTypes: jiraConfig.issueTypes, customFields: jiraConfig.customFields, labels: jiraConfig.labels, + ...(jiraTriggers && Object.keys(jiraTriggers).length > 0 ? { triggers: jiraTriggers } : {}), }; } + if (githubTriggers && Object.keys(githubTriggers).length > 0) { + project.github = { triggers: githubTriggers }; + } + if (row.agentBackend) { project.agentBackend = { default: row.agentBackend, @@ -136,6 +159,21 @@ function mapProjectRow( return project; } +function extractIntegrationConfigs(integrations: IntegrationRow[]) { + const trelloRow = integrations.find((i) => i.provider === 'trello'); + const jiraRow = integrations.find((i) => i.provider === 'jira'); + const githubRow = integrations.find((i) => i.provider === 'github'); + + return { + trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined, + trelloTriggers: (trelloRow?.triggers ?? undefined) as Record | undefined, + jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined, + jiraTriggers: (jiraRow?.triggers ?? undefined) as Record | undefined, + githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined, + githubTriggers: (githubRow?.triggers ?? undefined) as Record | undefined, + }; +} + async function loadAgentConfigs(): Promise { const db = getDb(); return db.select().from(agentConfigs); @@ -191,14 +229,25 @@ export async function loadConfigFromDb(): Promise { const rawConfig = { defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs), projects: projectRows.map((row) => { - const integrations = integrationsByProject.get(row.id) ?? []; - const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as - | TrelloIntegrationConfig - | undefined; - const jiraConfig = integrations.find((i) => i.type === 'jira')?.config as - | JiraIntegrationConfig - | undefined; - return mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? [], trelloConfig, jiraConfig); + const integrations = (integrationsByProject.get(row.id) ?? []) as IntegrationRow[]; + const { + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + } = extractIntegrationConfigs(integrations); + return mapProjectRow( + row, + projectAgentConfigsMap.get(row.id) ?? [], + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + ); }), }; @@ -230,16 +279,24 @@ async function findProjectConfigFromDb( db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, row.id)), ]); - const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as - | TrelloIntegrationConfig - | undefined; - const jiraConfig = integrations.find((i) => i.type === 'jira')?.config as - | JiraIntegrationConfig - | undefined; + const integrationRows = integrations as IntegrationRow[]; + const { trelloConfig, trelloTriggers, jiraConfig, jiraTriggers, githubConfig, githubTriggers } = + extractIntegrationConfigs(integrationRows); const rawConfig = { defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [mapProjectRow(row, projectAcs, trelloConfig, jiraConfig)], + projects: [ + mapProjectRow( + row, + projectAcs, + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + ), + ], }; const config = validateConfig(rawConfig); return { project: config.projects[0], config }; @@ -255,14 +312,14 @@ type ProjectWithConfig = { project: ProjectConfig; config: CascadeConfig }; const boardIdWhereClause = (boardId: string) => sql`${projects.id} IN ( SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations} - WHERE ${projectIntegrations.type} = 'trello' + WHERE ${projectIntegrations.provider} = 'trello' AND ${projectIntegrations.config}->>'boardId' = ${boardId} )`; const jiraProjectKeyWhereClause = (projectKey: string) => sql`${projects.id} IN ( SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations} - WHERE ${projectIntegrations.type} = 'jira' + WHERE ${projectIntegrations.provider} = 'jira' AND ${projectIntegrations.config}->>'projectKey' = ${projectKey} )`; diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 834db0b5..4a1e7ab6 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -1,128 +1,126 @@ -import { and, eq, isNull } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { getDb } from '../client.js'; import { decryptCredential, encryptCredential } from '../crypto.js'; -import { credentials, projectCredentialOverrides } from '../schema/index.js'; +import { credentials, integrationCredentials, projectIntegrations } from '../schema/index.js'; + +// ============================================================================ +// Integration credential resolution +// ============================================================================ /** - * Resolve a single credential for a project. - * Resolution order: - * 1. Project-level override (project_credential_overrides WHERE agent_type IS NULL) - * 2. Org-level default (credentials WHERE org_id AND env_var_key AND is_default) - * 3. null + * Resolve a single integration credential for a project by category and role. + * Joins integration_credentials → credentials via the project's integration. */ -export async function resolveCredential( +export async function resolveIntegrationCredential( projectId: string, - orgId: string, - envVarKey: string, + category: string, + role: string, ): Promise { const db = getDb(); - // 1. Check project override (project-wide, not agent-scoped) - const [override] = await db - .select({ value: credentials.value }) - .from(projectCredentialOverrides) - .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - isNull(projectCredentialOverrides.agentType), - ), - ); - if (override) return decryptCredential(override.value, orgId); - - // 2. Check org default - const [orgDefault] = await db - .select({ value: credentials.value }) - .from(credentials) + const [row] = await db + .select({ value: credentials.value, orgId: credentials.orgId }) + .from(integrationCredentials) + .innerJoin( + projectIntegrations, + eq(integrationCredentials.integrationId, projectIntegrations.id), + ) + .innerJoin(credentials, eq(integrationCredentials.credentialId, credentials.id)) .where( and( - eq(credentials.orgId, orgId), - eq(credentials.envVarKey, envVarKey), - eq(credentials.isDefault, true), + eq(projectIntegrations.projectId, projectId), + eq(projectIntegrations.category, category), + eq(integrationCredentials.role, role), ), ); - if (orgDefault) return decryptCredential(orgDefault.value, orgId); - return null; + if (!row) return null; + return decryptCredential(row.value, row.orgId); } /** - * Resolve a credential for a specific agent type and project. - * Resolution order: - * 1. Agent+project override (WHERE project_id AND env_var_key AND agent_type) - * 2. Falls through to resolveCredential() (project override → org default → null) + * Resolve all integration credentials for all of a project's integrations. + * Returns an array of { category, provider, role, value }. */ -export async function resolveAgentCredential( +export async function resolveAllIntegrationCredentials( projectId: string, +): Promise<{ category: string; provider: string; role: string; value: string }[]> { + const db = getDb(); + + const rows = await db + .select({ + category: projectIntegrations.category, + provider: projectIntegrations.provider, + role: integrationCredentials.role, + value: credentials.value, + orgId: credentials.orgId, + }) + .from(integrationCredentials) + .innerJoin( + projectIntegrations, + eq(integrationCredentials.integrationId, projectIntegrations.id), + ) + .innerJoin(credentials, eq(integrationCredentials.credentialId, credentials.id)) + .where(eq(projectIntegrations.projectId, projectId)); + + return rows.map((row) => ({ + category: row.category, + provider: row.provider, + role: row.role, + value: decryptCredential(row.value, row.orgId), + })); +} + +// ============================================================================ +// Org-scoped credential resolution (non-integration secrets like LLM API keys) +// ============================================================================ + +/** + * Resolve an org-level default credential by env var key. + * Used for non-integration secrets (LLM API keys, etc.). + */ +export async function resolveOrgCredential( orgId: string, - agentType: string, envVarKey: string, ): Promise { const db = getDb(); - - // 1. Check agent-scoped override - const [agentOverride] = await db + const [row] = await db .select({ value: credentials.value }) - .from(projectCredentialOverrides) - .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) + .from(credentials) .where( and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - eq(projectCredentialOverrides.agentType, agentType), + eq(credentials.orgId, orgId), + eq(credentials.envVarKey, envVarKey), + eq(credentials.isDefault, true), ), ); - if (agentOverride) return decryptCredential(agentOverride.value, orgId); - // 2. Fall through to project override → org default - return resolveCredential(projectId, orgId, envVarKey); + if (!row) return null; + return decryptCredential(row.value, orgId); } /** - * Resolve all credentials for a project as a key-value map. - * Merges org defaults with project overrides (overrides win). + * Resolve all org-default credentials as a key-value map. */ -export async function resolveAllCredentials( - projectId: string, - orgId: string, -): Promise> { +export async function resolveAllOrgCredentials(orgId: string): Promise> { const db = getDb(); const result: Record = {}; - // Load org defaults - const orgDefaults = await db + const rows = await db .select({ envVarKey: credentials.envVarKey, value: credentials.value }) .from(credentials) .where(and(eq(credentials.orgId, orgId), eq(credentials.isDefault, true))); - for (const row of orgDefaults) { - result[row.envVarKey] = decryptCredential(row.value, orgId); - } - - // Load project-wide overrides (overwrite org defaults) — excludes agent-scoped overrides - const overrides = await db - .select({ - envVarKey: projectCredentialOverrides.envVarKey, - value: credentials.value, - }) - .from(projectCredentialOverrides) - .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - isNull(projectCredentialOverrides.agentType), - ), - ); - - for (const row of overrides) { + for (const row of rows) { result[row.envVarKey] = decryptCredential(row.value, orgId); } return result; } -// --- CRUD for credentials --- +// ============================================================================ +// CRUD for credentials (org-scoped pool) +// ============================================================================ export async function createCredential(params: { orgId: string; @@ -185,104 +183,3 @@ export async function listOrgCredentials( const rows = await db.select().from(credentials).where(eq(credentials.orgId, orgId)); return rows.map((row) => ({ ...row, value: decryptCredential(row.value, orgId) })); } - -// --- Override management (project-wide) --- - -export async function setProjectCredentialOverride( - projectId: string, - envVarKey: string, - credentialId: number, -): Promise { - const db = getDb(); - // Upsert: use raw SQL conflict target for partial index (agent_type IS NULL) - // Drizzle's onConflictDoUpdate doesn't support WHERE on conflict target, - // so we delete-then-insert to match the partial unique index. - await db - .delete(projectCredentialOverrides) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - isNull(projectCredentialOverrides.agentType), - ), - ); - await db - .insert(projectCredentialOverrides) - .values({ projectId, envVarKey, credentialId, agentType: null }); -} - -export async function removeProjectCredentialOverride( - projectId: string, - envVarKey: string, -): Promise { - const db = getDb(); - await db - .delete(projectCredentialOverrides) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - isNull(projectCredentialOverrides.agentType), - ), - ); -} - -export async function listProjectOverrides( - projectId: string, -): Promise< - { envVarKey: string; credentialId: number; credentialName: string; agentType: string | null }[] -> { - const db = getDb(); - const rows = await db - .select({ - envVarKey: projectCredentialOverrides.envVarKey, - credentialId: projectCredentialOverrides.credentialId, - credentialName: credentials.name, - agentType: projectCredentialOverrides.agentType, - }) - .from(projectCredentialOverrides) - .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) - .where(eq(projectCredentialOverrides.projectId, projectId)); - return rows; -} - -// --- Override management (agent-scoped) --- - -export async function setAgentCredentialOverride( - projectId: string, - envVarKey: string, - agentType: string, - credentialId: number, -): Promise { - const db = getDb(); - // Delete-then-insert to match partial unique index (agent_type IS NOT NULL) - await db - .delete(projectCredentialOverrides) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - eq(projectCredentialOverrides.agentType, agentType), - ), - ); - await db - .insert(projectCredentialOverrides) - .values({ projectId, envVarKey, credentialId, agentType }); -} - -export async function removeAgentCredentialOverride( - projectId: string, - envVarKey: string, - agentType: string, -): Promise { - const db = getDb(); - await db - .delete(projectCredentialOverrides) - .where( - and( - eq(projectCredentialOverrides.projectId, projectId), - eq(projectCredentialOverrides.envVarKey, envVarKey), - eq(projectCredentialOverrides.agentType, agentType), - ), - ); -} diff --git a/src/db/repositories/settingsRepository.ts b/src/db/repositories/settingsRepository.ts index fecd48b8..0de53c5c 100644 --- a/src/db/repositories/settingsRepository.ts +++ b/src/db/repositories/settingsRepository.ts @@ -3,6 +3,8 @@ import { getDb } from '../client.js'; import { agentConfigs, cascadeDefaults, + credentials, + integrationCredentials, organizations, projectIntegrations, projects, @@ -148,24 +150,93 @@ export async function listProjectIntegrations(projectId: string) { return db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)); } +export async function getIntegrationByProjectAndCategory(projectId: string, category: string) { + const db = getDb(); + const [row] = await db + .select() + .from(projectIntegrations) + .where( + and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)), + ); + return row ?? null; +} + export async function upsertProjectIntegration( projectId: string, - type: string, + category: string, + provider: string, config: Record, + triggers: Record = {}, ) { const db = getDb(); // Delete then insert to handle the unique constraint await db .delete(projectIntegrations) - .where(and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.type, type))); - await db.insert(projectIntegrations).values({ projectId, type, config }); + .where( + and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)), + ); + const [row] = await db + .insert(projectIntegrations) + .values({ projectId, category, provider, config, triggers }) + .returning(); + return row; } -export async function deleteProjectIntegration(projectId: string, type: string) { +export async function deleteProjectIntegration(projectId: string, category: string) { const db = getDb(); await db .delete(projectIntegrations) - .where(and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.type, type))); + .where( + and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)), + ); +} + +// ============================================================================ +// Integration Credentials +// ============================================================================ + +export async function listIntegrationCredentials(integrationId: number) { + const db = getDb(); + return db + .select({ + id: integrationCredentials.id, + role: integrationCredentials.role, + credentialId: integrationCredentials.credentialId, + credentialName: credentials.name, + }) + .from(integrationCredentials) + .innerJoin(credentials, eq(integrationCredentials.credentialId, credentials.id)) + .where(eq(integrationCredentials.integrationId, integrationId)); +} + +export async function setIntegrationCredential( + integrationId: number, + role: string, + credentialId: number, +) { + const db = getDb(); + // Upsert: delete + insert to handle unique constraint + await db + .delete(integrationCredentials) + .where( + and( + eq(integrationCredentials.integrationId, integrationId), + eq(integrationCredentials.role, role), + ), + ); + await db.insert(integrationCredentials).values({ integrationId, role, credentialId }); +} + +export async function removeIntegrationCredential(integrationId: number, role: string) { + const db = getDb(); + await db + .delete(integrationCredentials) + .where( + and( + eq(integrationCredentials.integrationId, integrationId), + eq(integrationCredentials.role, role), + ), + ); } // ============================================================================ diff --git a/src/db/schema/credentials.ts b/src/db/schema/credentials.ts index 9e16f443..53296b63 100644 --- a/src/db/schema/credentials.ts +++ b/src/db/schema/credentials.ts @@ -1,6 +1,5 @@ -import { boolean, index, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, index, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; import { organizations } from './organizations.js'; -import { projects } from './projects.js'; export const credentials = pgTable( 'credentials', @@ -25,27 +24,3 @@ export const credentials = pgTable( // This is enforced by the migration SQL directly. ], ); - -export const projectCredentialOverrides = pgTable( - 'project_credential_overrides', - { - id: serial('id').primaryKey(), - projectId: text('project_id') - .notNull() - .references(() => projects.id, { onDelete: 'cascade' }), - envVarKey: text('env_var_key').notNull(), - credentialId: integer('credential_id') - .notNull() - .references(() => credentials.id, { onDelete: 'cascade' }), - agentType: text('agent_type'), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at') - .defaultNow() - .$onUpdate(() => new Date()), - }, - () => [ - // Partial unique indexes enforced via migration SQL: - // - (project_id, env_var_key) WHERE agent_type IS NULL — project-wide overrides - // - (project_id, env_var_key, agent_type) WHERE agent_type IS NOT NULL — agent-scoped - ], -); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index a505e6e6..2c22dac5 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,8 +1,8 @@ -export { credentials, projectCredentialOverrides } from './credentials.js'; +export { credentials } from './credentials.js'; export { cascadeDefaults } from './defaults.js'; export { organizations } from './organizations.js'; export { agentConfigs } from './agentConfigs.js'; -export { projectIntegrations } from './integrations.js'; +export { integrationCredentials, projectIntegrations } from './integrations.js'; export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; export { promptPartials } from './promptPartials.js'; diff --git a/src/db/schema/integrations.ts b/src/db/schema/integrations.ts index 14e4f801..af1ed8e0 100644 --- a/src/db/schema/integrations.ts +++ b/src/db/schema/integrations.ts @@ -1,4 +1,14 @@ -import { jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { + index, + integer, + jsonb, + pgTable, + serial, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import { credentials } from './credentials.js'; import { projects } from './projects.js'; export const projectIntegrations = pgTable( @@ -8,12 +18,38 @@ export const projectIntegrations = pgTable( projectId: text('project_id') .notNull() .references(() => projects.id, { onDelete: 'cascade' }), - type: text('type').notNull(), - config: jsonb('config').notNull(), + category: text('category').notNull(), // 'pm' | 'scm' + provider: text('provider').notNull(), // 'trello' | 'jira' | 'github' + config: jsonb('config').notNull().default({}), + triggers: jsonb('triggers').notNull().default({}), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => new Date()), }, - (table) => [uniqueIndex('uq_project_integrations_project_type').on(table.projectId, table.type)], + (table) => [ + uniqueIndex('uq_project_integrations_project_category').on(table.projectId, table.category), + ], +); + +export const integrationCredentials = pgTable( + 'integration_credentials', + { + id: serial('id').primaryKey(), + integrationId: integer('integration_id') + .notNull() + .references(() => projectIntegrations.id, { onDelete: 'cascade' }), + role: text('role').notNull(), + credentialId: integer('credential_id') + .notNull() + .references(() => credentials.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex('uq_integration_credentials_integration_role').on(table.integrationId, table.role), + index('idx_integration_credentials_credential_id').on(table.credentialId), + ], ); diff --git a/src/github/personas.ts b/src/github/personas.ts index a2474ace..ee6789af 100644 --- a/src/github/personas.ts +++ b/src/github/personas.ts @@ -1,4 +1,4 @@ -import { getAgentCredential } from '../config/provider.js'; +import { getIntegrationCredential } from '../config/provider.js'; import { logger } from '../utils/logging.js'; import { getGitHubUserForToken } from './client.js'; @@ -29,38 +29,24 @@ const AGENT_PERSONA_MAP: Record = { debug: 'implementer', }; -const PERSONA_TOKEN_KEYS: Record = { - implementer: 'GITHUB_TOKEN_IMPLEMENTER', - reviewer: 'GITHUB_TOKEN_REVIEWER', -}; - export function getPersonaForAgentType(agentType: string): GitHubPersona { return AGENT_PERSONA_MAP[agentType] ?? 'implementer'; } -export function getTokenKeyForPersona(persona: GitHubPersona): string { - return PERSONA_TOKEN_KEYS[persona]; -} - // ============================================================================ // Token Resolution // ============================================================================ /** * Resolve the correct GitHub token for a project + agent type based on persona. - * Uses agent-scoped credential overrides for flexibility. - * Throws if no token is found (no fallback to legacy GITHUB_TOKEN). + * Uses integration credentials linked to the SCM integration. + * Throws if no token is found. */ export async function getPersonaToken(projectId: string, agentType: string): Promise { const persona = getPersonaForAgentType(agentType); - const tokenKey = PERSONA_TOKEN_KEYS[persona]; + const role = persona === 'implementer' ? 'implementer_token' : 'reviewer_token'; - const token = await getAgentCredential(projectId, agentType, tokenKey); - if (token) return token; - - throw new Error( - `Missing ${tokenKey} for project '${projectId}' (agent: ${agentType}, persona: ${persona}). Configure credentials via the dashboard or CLI.`, - ); + return getIntegrationCredential(projectId, 'scm', role); } // ============================================================================ @@ -72,24 +58,8 @@ export async function getPersonaToken(projectId: string, agentType: string): Pro * Always queries the database and GitHub API for fresh data. */ export async function resolvePersonaIdentities(projectId: string): Promise { - // Resolve both tokens — use getAgentCredential with a representative agent type - const implementerToken = await getAgentCredential( - projectId, - 'implementation', - PERSONA_TOKEN_KEYS.implementer, - ); - const reviewerToken = await getAgentCredential(projectId, 'review', PERSONA_TOKEN_KEYS.reviewer); - - if (!implementerToken) { - throw new Error( - `Missing GITHUB_TOKEN_IMPLEMENTER for project '${projectId}'. Both persona tokens are required.`, - ); - } - if (!reviewerToken) { - throw new Error( - `Missing GITHUB_TOKEN_REVIEWER for project '${projectId}'. Both persona tokens are required.`, - ); - } + const implementerToken = await getIntegrationCredential(projectId, 'scm', 'implementer_token'); + const reviewerToken = await getIntegrationCredential(projectId, 'scm', 'reviewer_token'); const [implementerLogin, reviewerLogin] = await Promise.all([ getGitHubUserForToken(implementerToken), @@ -98,12 +68,12 @@ export async function resolvePersonaIdentities(projectId: string): Promise let jiraApiToken: string; let jiraBaseUrl: string; try { - jiraEmail = await getProjectSecret(job.projectId, 'JIRA_EMAIL'); - jiraApiToken = await getProjectSecret(job.projectId, 'JIRA_API_TOKEN'); - jiraBaseUrl = await getProjectSecret(job.projectId, 'JIRA_BASE_URL'); + jiraEmail = await getIntegrationCredential(job.projectId, 'pm', 'email'); + jiraApiToken = await getIntegrationCredential(job.projectId, 'pm', 'api_token'); + const project = await findProjectById(job.projectId); + jiraBaseUrl = project?.jira?.baseUrl ?? ''; + if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); } catch { console.warn('[Notifications] Missing JIRA credentials in DB, skipping timeout notification'); return; diff --git a/src/router/pre-actions.ts b/src/router/pre-actions.ts index 121e2382..c300a967 100644 --- a/src/router/pre-actions.ts +++ b/src/router/pre-actions.ts @@ -1,4 +1,4 @@ -import { findProjectByRepo, getProjectSecret } from '../config/provider.js'; +import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; import type { GitHubJob } from './queue.js'; /** @@ -79,7 +79,7 @@ export async function addEyesReactionToPR(job: GitHubJob): Promise { // Get reviewer token let reviewerToken: string; try { - reviewerToken = await getProjectSecret(project.id, 'GITHUB_TOKEN_REVIEWER'); + reviewerToken = await getIntegrationCredential(project.id, 'scm', 'reviewer_token'); } catch { console.warn('[PreActions] Missing GITHUB_TOKEN_REVIEWER, skipping eyes reaction'); return; diff --git a/src/router/reactions.ts b/src/router/reactions.ts index c80deb4d..fa29da4b 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -1,16 +1,20 @@ /** * Immediate acknowledgment reactions on webhook acceptance. * - * Fires a platform-native reaction (💭 or 👀) on the source comment + * Fires a platform-native reaction (👀) on the source comment * to signal "message received, processing" before the worker container - * even starts. Uses raw fetch() with no client library dependencies, - * following the notifications.ts pattern. + * even starts. * * Errors are always caught and logged — never propagated. */ import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectByRepo, getProjectSecret } from '../config/provider.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../config/provider.js'; +import { trelloClient, withTrelloCredentials } from '../trello/client.js'; // In-memory JIRA CloudId cache keyed by baseUrl const jiraCloudIdCache = new Map(); @@ -74,24 +78,22 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< let trelloApiKey: string; let trelloToken: string; try { - trelloApiKey = await getProjectSecret(projectId, 'TRELLO_API_KEY'); - trelloToken = await getProjectSecret(projectId, 'TRELLO_TOKEN'); + trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); } catch { console.warn('[Reactions] Missing Trello credentials, skipping reaction'); return; } - const url = `https://api.trello.com/1/actions/${actionId}/reactions?key=${trelloApiKey}&token=${trelloToken}`; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ shortName: 'thought_balloon', native: '💭', unified: '1f4ad' }), - }); + const emoji = { shortName: 'eyes', native: '👀', unified: '1f440' }; - if (!response.ok) { - console.warn('[Reactions] Trello reaction failed:', response.status, await response.text()); - } else { + try { + await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, async () => { + await trelloClient.addActionReaction(actionId, emoji); + }); console.log('[Reactions] Trello reaction sent for action:', actionId); + } catch (err) { + console.warn('[Reactions] Trello reaction failed:', String(err)); } } @@ -170,9 +172,12 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise): Promise { const projectId = await extractProjectIdFromJob(job.data); if (projectId) { try { - const secrets = await getProjectSecrets(projectId); + const secrets = await getAllProjectCredentials(projectId); env.push(`CASCADE_CREDENTIALS=${JSON.stringify(secrets)}`); env.push(`CASCADE_CREDENTIALS_PROJECT_ID=${projectId}`); } catch (err) { diff --git a/src/triggers/github/check-suite-failure.ts b/src/triggers/github/check-suite-failure.ts index f5aa8473..a12d4ff1 100644 --- a/src/triggers/github/check-suite-failure.ts +++ b/src/triggers/github/check-suite-failure.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -21,6 +22,11 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubCheckSuitePayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'checkSuiteFailure')) { + return false; + } + const payload = ctx.payload; // Only trigger on completed check suites with failure conclusion diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index add37ded..ae2d879c 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { type CheckSuiteStatus, githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -61,6 +62,11 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubCheckSuitePayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'checkSuiteSuccess')) { + return false; + } + const payload = ctx.payload; // Only trigger on completed check suites with success conclusion diff --git a/src/triggers/github/pr-comment-mention.ts b/src/triggers/github/pr-comment-mention.ts index 1a6884e1..b1a273fd 100644 --- a/src/triggers/github/pr-comment-mention.ts +++ b/src/triggers/github/pr-comment-mention.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { githubClient } from '../../github/client.js'; import { isCascadeBot } from '../../github/personas.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; @@ -18,6 +19,11 @@ export class PRCommentMentionTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'github') return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prCommentMention')) { + return false; + } + // Match issue_comment.created on PRs if (isGitHubIssueCommentPayload(ctx.payload)) { if (ctx.payload.action !== 'created') return false; diff --git a/src/triggers/github/pr-merged.ts b/src/triggers/github/pr-merged.ts index 5b06a26d..2e8b15e6 100644 --- a/src/triggers/github/pr-merged.ts +++ b/src/triggers/github/pr-merged.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { githubClient } from '../../github/client.js'; import { trelloClient } from '../../trello/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; @@ -12,6 +13,12 @@ export class PRMergedTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'github') return false; if (!isGitHubPullRequestPayload(ctx.payload)) return false; + + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prMerged')) { + return false; + } + return ctx.payload.action === 'closed'; } diff --git a/src/triggers/github/pr-opened.ts b/src/triggers/github/pr-opened.ts index fab79661..5fe88bff 100644 --- a/src/triggers/github/pr-opened.ts +++ b/src/triggers/github/pr-opened.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isGitHubPullRequestPayload } from './types.js'; @@ -15,6 +16,11 @@ export class PROpenedTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubPullRequestPayload(ctx.payload)) return false; + // Check trigger config — opt-in trigger, default disabled + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prOpened')) { + return false; + } + // Only trigger on newly opened PRs if (ctx.payload.action !== 'opened') return false; diff --git a/src/triggers/github/pr-ready-to-merge.ts b/src/triggers/github/pr-ready-to-merge.ts index 1972d839..367632c6 100644 --- a/src/triggers/github/pr-ready-to-merge.ts +++ b/src/triggers/github/pr-ready-to-merge.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { githubClient } from '../../github/client.js'; import { trelloClient } from '../../trello/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; @@ -17,6 +18,11 @@ export class PRReadyToMergeTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'github') return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prReadyToMerge')) { + return false; + } + // Trigger on either check_suite completion (success) or review submission (approved) if (isGitHubCheckSuitePayload(ctx.payload)) { const payload = ctx.payload; diff --git a/src/triggers/github/pr-review-submitted.ts b/src/triggers/github/pr-review-submitted.ts index 7066bc47..cef45830 100644 --- a/src/triggers/github/pr-review-submitted.ts +++ b/src/triggers/github/pr-review-submitted.ts @@ -1,3 +1,4 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; import { getPersonaForLogin } from '../../github/personas.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -12,6 +13,11 @@ export class PRReviewSubmittedTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubPullRequestReviewPayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prReviewSubmitted')) { + return false; + } + // Only trigger on submitted reviews, not edits or dismissals if (ctx.payload.action !== 'submitted') return false; diff --git a/src/triggers/github/review-requested.ts b/src/triggers/github/review-requested.ts new file mode 100644 index 00000000..a636c55b --- /dev/null +++ b/src/triggers/github/review-requested.ts @@ -0,0 +1,101 @@ +import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { isCascadeBot } from '../../github/personas.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { type GitHubPullRequestPayload, isGitHubPullRequestPayload } from './types.js'; +import { extractWorkItemId } from './utils.js'; + +/** + * Trigger that fires the review agent when review is requested from a CASCADE persona account. + * + * This trigger: + * 1. Fires on `pull_request.review_requested` events + * 2. Checks if the requested reviewer is a CASCADE persona (implementer OR reviewer) + * 3. Fires the `review` agent with PR number and work item ID from PR body + * + * Default: **disabled** (opt-in via trigger config). Enable by setting + * `github.triggers.reviewRequested = true` in integration config. + * + * Registration: should be registered BEFORE CheckSuiteSuccessTrigger so that + * both triggers can independently fire review. The HEAD-SHA dedup in + * CheckSuiteSuccessTrigger prevents double-reviews. + */ +export class ReviewRequestedTrigger implements TriggerHandler { + name = 'review-requested'; + description = 'Triggers review agent when review is requested from a CASCADE persona account'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'github') return false; + if (!isGitHubPullRequestPayload(ctx.payload)) return false; + + // Only trigger on review_requested events + if (ctx.payload.action !== 'review_requested') return false; + + // Check trigger config — opt-in trigger, default disabled + if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'reviewRequested')) { + return false; + } + + return true; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as GitHubPullRequestPayload; + const prNumber = payload.pull_request.number; + + // Require persona identities for bot detection + if (!ctx.personaIdentities) { + logger.warn('No persona identities available, skipping review-requested trigger', { + prNumber, + }); + return null; + } + + // Check if the requested reviewer is a CASCADE persona + const requestedReviewer = payload.requested_reviewer?.login; + if (!requestedReviewer) { + logger.debug('No requested reviewer in payload, skipping', { prNumber }); + return null; + } + + if (!isCascadeBot(requestedReviewer, ctx.personaIdentities)) { + logger.debug('Requested reviewer is not a CASCADE persona, skipping', { + prNumber, + requestedReviewer, + personas: ctx.personaIdentities, + }); + return null; + } + + const prBody = payload.pull_request.body; + const workItemId = extractWorkItemId(prBody, ctx.project); + + if (!workItemId) { + logger.info('PR does not have work item reference, skipping review-requested trigger', { + prNumber, + }); + return null; + } + + logger.info('Review requested from CASCADE persona, triggering review agent', { + prNumber, + requestedReviewer, + workItemId, + }); + + return { + agentType: 'review', + agentInput: { + prNumber, + prBranch: payload.pull_request.head.ref, + repoFullName: payload.repository.full_name, + headSha: payload.pull_request.head.sha, + triggerType: 'review-requested', + cardId: workItemId, + }, + prNumber, + cardId: workItemId, + workItemId, + }; + } +} diff --git a/src/triggers/github/types.ts b/src/triggers/github/types.ts index eb4a12e5..1d34f09c 100644 --- a/src/triggers/github/types.ts +++ b/src/triggers/github/types.ts @@ -152,7 +152,8 @@ export interface GitHubPullRequestPayload { | 'synchronize' | 'edited' | 'ready_for_review' - | 'converted_to_draft'; + | 'converted_to_draft' + | 'review_requested'; number: number; pull_request: { number: number; @@ -171,6 +172,13 @@ export interface GitHubPullRequestPayload { user: { login: string; }; + requested_reviewers?: Array<{ + login: string; + }>; + }; + /** Present on review_requested events — the reviewer just added */ + requested_reviewer?: { + login: string; }; repository: { full_name: string; // owner/repo diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 978e3dc9..1d6ff973 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,4 +1,4 @@ -import { getProjectSecret, loadProjectConfigByRepo } from '../../config/provider.js'; +import { getIntegrationCredentialOrNull, loadProjectConfigByRepo } from '../../config/provider.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas.js'; @@ -79,8 +79,12 @@ async function executeGitHubAgent( project: ProjectConfig, config: CascadeConfig, ): Promise { - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); + const trelloApiKey = await getIntegrationCredentialOrNull(project.id, 'pm', 'api_key').then( + (v) => v ?? '', + ); + const trelloToken = await getIntegrationCredentialOrNull(project.id, 'pm', 'token').then( + (v) => v ?? '', + ); const githubToken = await getPersonaToken(project.id, result.agentType); const restoreLlmEnv = await injectLlmApiKeys(project.id); @@ -189,8 +193,12 @@ export async function processGitHubWebhook( const { project, config } = projectConfig; // Resolve credentials early — trigger handlers may call GitHub/Trello APIs - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); + const trelloApiKey = await getIntegrationCredentialOrNull(project.id, 'pm', 'api_key').then( + (v) => v ?? '', + ); + const trelloToken = await getIntegrationCredentialOrNull(project.id, 'pm', 'token').then( + (v) => v ?? '', + ); // Resolve persona identities and use implementer token for webhook processing const personaIdentities = await resolvePersonaIdentities(project.id); diff --git a/src/triggers/index.ts b/src/triggers/index.ts index 5b57d980..75f311b3 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -1,10 +1,11 @@ import { CheckSuiteFailureTrigger } from './github/check-suite-failure.js'; import { CheckSuiteSuccessTrigger } from './github/check-suite-success.js'; import { PRCommentMentionTrigger } from './github/pr-comment-mention.js'; -// import { PROpenedTrigger } from './github/pr-opened.js'; import { PRMergedTrigger } from './github/pr-merged.js'; +import { PROpenedTrigger } from './github/pr-opened.js'; import { PRReadyToMergeTrigger } from './github/pr-ready-to-merge.js'; import { PRReviewSubmittedTrigger } from './github/pr-review-submitted.js'; +import { ReviewRequestedTrigger } from './github/review-requested.js'; import { JiraCommentMentionTrigger } from './jira/comment-mention.js'; import { JiraIssueTransitionedTrigger } from './jira/issue-transitioned.js'; import { JiraReadyToProcessLabelTrigger } from './jira/label-added.js'; @@ -53,8 +54,8 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { registry.register(new JiraReadyToProcessLabelTrigger()); // GitHub: PR opened trigger (initial review on new PRs) - // DISABLED: Triggers respond-to-review which has file editing gadgets - needs review - // registry.register(new PROpenedTrigger()); + // Opt-in: disabled by default via trigger config (github.triggers.prOpened = false) + registry.register(new PROpenedTrigger()); // GitHub: PR comment @mention trigger (runs respond-to-pr-comment when reviewer is @mentioned) // Must be registered before other comment triggers so it can intercept mentions and fall through otherwise @@ -63,6 +64,11 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { // GitHub: PR review submission trigger (when someone submits a review) registry.register(new PRReviewSubmittedTrigger()); + // GitHub: Review requested trigger (runs review agent when review is requested from CASCADE persona) + // Opt-in: disabled by default via trigger config (github.triggers.reviewRequested = false) + // Registered before CheckSuiteSuccessTrigger so both can independently trigger review + registry.register(new ReviewRequestedTrigger()); + // GitHub: Check suite failure trigger (runs implementation agent to fix) registry.register(new CheckSuiteFailureTrigger()); diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts index 9abcbad5..8e991f10 100644 --- a/src/triggers/jira/comment-mention.ts +++ b/src/triggers/jira/comment-mention.ts @@ -5,6 +5,7 @@ * Runs the respond-to-planning-comment agent. */ +import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; import { jiraClient } from '../../jira/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -109,6 +110,11 @@ export class JiraCommentMentionTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'jira') return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'commentMention')) { + return false; + } + const payload = ctx.payload as JiraWebhookPayload; return payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated'; } diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts index b4e2ce3c..a4531c19 100644 --- a/src/triggers/jira/issue-transitioned.ts +++ b/src/triggers/jira/issue-transitioned.ts @@ -5,6 +5,7 @@ * a CASCADE agent type (briefing, planning, implementation). */ +import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -49,6 +50,11 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'jira') return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'issueTransitioned')) { + return false; + } + const payload = ctx.payload as JiraWebhookPayload; if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 1f10c693..b46efd10 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -10,6 +10,7 @@ * explicitly excludes events that also contain a status change in the changelog. */ +import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -64,6 +65,11 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { matches(ctx: TriggerContext): boolean { if (ctx.source !== 'jira') return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'readyToProcessLabel')) { + return false; + } + const payload = ctx.payload as JiraLabelPayload; if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts index 651d7ad9..2a431266 100644 --- a/src/triggers/jira/webhook-handler.ts +++ b/src/triggers/jira/webhook-handler.ts @@ -6,7 +6,10 @@ * and dispatches to the trigger registry. */ -import { getProjectSecret, loadProjectConfigByJiraProjectKey } from '../../config/provider.js'; +import { + getIntegrationCredential, + loadProjectConfigByJiraProjectKey, +} from '../../config/provider.js'; import { withGitHubToken } from '../../github/client.js'; import { getPersonaToken } from '../../github/personas.js'; import { withJiraCredentials } from '../../jira/client.js'; @@ -72,10 +75,9 @@ async function executeJiraAgent( project: ProjectConfig, config: CascadeConfig, ): Promise { - const jiraEmail = await getProjectSecret(project.id, 'JIRA_EMAIL'); - const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); - const jiraBaseUrl = - project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); + const jiraEmail = await getIntegrationCredential(project.id, 'pm', 'email'); + const jiraApiToken = await getIntegrationCredential(project.id, 'pm', 'api_token'); + const jiraBaseUrl = project.jira?.baseUrl ?? ''; const githubToken = await getPersonaToken(project.id, result.agentType); const restoreLlmEnv = await injectLlmApiKeys(project.id); @@ -143,10 +145,9 @@ export async function processJiraWebhook( const { project, config } = projectConfig; // Establish JIRA credential + PM provider scope - const jiraEmail = await getProjectSecret(project.id, 'JIRA_EMAIL'); - const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); - const jiraBaseUrl = - project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); + const jiraEmail = await getIntegrationCredential(project.id, 'pm', 'email'); + const jiraApiToken = await getIntegrationCredential(project.id, 'pm', 'api_token'); + const jiraBaseUrl = project.jira?.baseUrl ?? ''; const pmProvider = createPMProvider(project); await withJiraCredentials( diff --git a/src/triggers/trello/card-moved.ts b/src/triggers/trello/card-moved.ts index 926204a9..18cc8d47 100644 --- a/src/triggers/trello/card-moved.ts +++ b/src/triggers/trello/card-moved.ts @@ -1,3 +1,4 @@ +import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; import type { TrelloWebhookPayload, TriggerContext, @@ -15,6 +16,7 @@ interface CardMovedConfig { description: string; listKey: 'briefing' | 'planning' | 'todo'; agentType: string; + triggerConfigKey: 'cardMovedToBriefing' | 'cardMovedToPlanning' | 'cardMovedToTodo'; } function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { @@ -26,6 +28,11 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { if (ctx.source !== 'trello') return false; if (!isTrelloWebhookPayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, config.triggerConfigKey)) { + return false; + } + const payload = ctx.payload; const targetListId = ctx.project.trello?.lists[config.listKey]; @@ -68,6 +75,7 @@ export const CardMovedToBriefingTrigger = createCardMovedTrigger({ description: 'Triggers briefing agent when card moved to briefing list', listKey: 'briefing', agentType: 'briefing', + triggerConfigKey: 'cardMovedToBriefing', }); export const CardMovedToPlanningTrigger = createCardMovedTrigger({ @@ -75,6 +83,7 @@ export const CardMovedToPlanningTrigger = createCardMovedTrigger({ description: 'Triggers planning agent when card moved to planning list', listKey: 'planning', agentType: 'planning', + triggerConfigKey: 'cardMovedToPlanning', }); export const CardMovedToTodoTrigger = createCardMovedTrigger({ @@ -82,4 +91,5 @@ export const CardMovedToTodoTrigger = createCardMovedTrigger({ description: 'Triggers implementation agent when card moved to TODO list', listKey: 'todo', agentType: 'implementation', + triggerConfigKey: 'cardMovedToTodo', }); diff --git a/src/triggers/trello/comment-mention.ts b/src/triggers/trello/comment-mention.ts index 18c5f234..d116a0c1 100644 --- a/src/triggers/trello/comment-mention.ts +++ b/src/triggers/trello/comment-mention.ts @@ -1,3 +1,4 @@ +import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; import { trelloClient } from '../../trello/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -35,6 +36,11 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { if (ctx.source !== 'trello') return false; if (!isTrelloWebhookPayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, 'commentMention')) { + return false; + } + return ctx.payload.action.type === 'commentCard'; } diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index 8a490330..d1df5c79 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -1,3 +1,4 @@ +import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; import { trelloClient } from '../../trello/client.js'; import { logger } from '../../utils/logging.js'; import type { @@ -16,6 +17,11 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { if (ctx.source !== 'trello') return false; if (!isTrelloWebhookPayload(ctx.payload)) return false; + // Check trigger config — default enabled for backward compatibility + if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, 'readyToProcessLabel')) { + return false; + } + const payload = ctx.payload; const readyLabelId = ctx.project.trello?.labels.readyToProcess; diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index aecb36ee..cb675683 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -1,4 +1,4 @@ -import { getProjectSecret, loadProjectConfigByBoardId } from '../../config/provider.js'; +import { getIntegrationCredential, loadProjectConfigByBoardId } from '../../config/provider.js'; import { withGitHubToken } from '../../github/client.js'; import { getPersonaToken } from '../../github/personas.js'; import { @@ -37,8 +37,8 @@ async function executeAgent( project: ProjectConfig, config: CascadeConfig, ): Promise { - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + const trelloApiKey = await getIntegrationCredential(project.id, 'pm', 'api_key'); + const trelloToken = await getIntegrationCredential(project.id, 'pm', 'token'); const githubToken = await getPersonaToken(project.id, result.agentType); const restoreLlmEnv = await injectLlmApiKeys(project.id); @@ -154,8 +154,8 @@ export async function processTrelloWebhook( const { project, config } = projectConfig; // Establish Trello credential + PM provider scope for all downstream operations - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + const trelloApiKey = await getIntegrationCredential(project.id, 'pm', 'api_key'); + const trelloToken = await getIntegrationCredential(project.id, 'pm', 'token'); const pmProvider = createPMProvider(project); await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => diff --git a/src/types/index.ts b/src/types/index.ts index ef48b9b2..a2c9270f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,7 +14,12 @@ export interface AgentInput { prBranch?: string; repoFullName?: string; headSha?: string; - triggerType?: 'check-failure' | 'feature-implementation' | 'ci-success' | 'manual'; + triggerType?: + | 'check-failure' + | 'feature-implementation' + | 'ci-success' + | 'review-requested' + | 'manual'; // Debug agent fields logDir?: string; diff --git a/src/utils/envScrub.ts b/src/utils/envScrub.ts index e9313180..c88aee12 100644 --- a/src/utils/envScrub.ts +++ b/src/utils/envScrub.ts @@ -24,7 +24,7 @@ const SENSITIVE_ENV_KEYS = [ * * Call this AFTER: * - Database connection pool is initialized (getDb()) - * - Project credentials are decrypted and cached (getProjectSecrets()) + * - Project credentials are decrypted and cached (getAllProjectCredentials()) * * After scrubbing: * - Database pool continues to work (uses cached connection string) diff --git a/src/utils/llmEnv.ts b/src/utils/llmEnv.ts index b070caef..6d2f1f5f 100644 --- a/src/utils/llmEnv.ts +++ b/src/utils/llmEnv.ts @@ -1,4 +1,4 @@ -import { getProjectSecretOrNull } from '../config/provider.js'; +import { getOrgCredential } from '../config/provider.js'; import { logger } from './logging.js'; // Keys that llmist reads from process.env for provider discovery @@ -9,7 +9,7 @@ export async function injectLlmApiKeys(projectId: string): Promise<() => void> { for (const key of LLM_ENV_KEYS) { snapshot[key] = process.env[key]; - const value = await getProjectSecretOrNull(projectId, key); + const value = await getOrgCredential(projectId, key); if (value) { process.env[key] = value; logger.debug('Injected LLM API key from DB', { key, projectId }); diff --git a/tests/unit/agents/shared/gadgets.test.ts b/tests/unit/agents/shared/gadgets.test.ts new file mode 100644 index 00000000..1f00d901 --- /dev/null +++ b/tests/unit/agents/shared/gadgets.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Create a mock class with the given name so constructor.name works in assertions */ +function mockClass(name: string) { + const cls = { [name]: class {} }[name]; + return vi.fn().mockImplementation(() => new cls()); +} + +vi.mock('../../../../src/gadgets/AstGrep.js', () => ({ AstGrep: mockClass('AstGrep') })); +vi.mock('../../../../src/gadgets/FileMultiEdit.js', () => ({ + FileMultiEdit: mockClass('FileMultiEdit'), +})); +vi.mock('../../../../src/gadgets/FileSearchAndReplace.js', () => ({ + FileSearchAndReplace: mockClass('FileSearchAndReplace'), +})); +vi.mock('../../../../src/gadgets/Finish.js', () => ({ Finish: mockClass('Finish') })); +vi.mock('../../../../src/gadgets/ListDirectory.js', () => ({ + ListDirectory: mockClass('ListDirectory'), +})); +vi.mock('../../../../src/gadgets/ReadFile.js', () => ({ ReadFile: mockClass('ReadFile') })); +vi.mock('../../../../src/gadgets/RipGrep.js', () => ({ RipGrep: mockClass('RipGrep') })); +vi.mock('../../../../src/gadgets/Sleep.js', () => ({ Sleep: mockClass('Sleep') })); +vi.mock('../../../../src/gadgets/VerifyChanges.js', () => ({ + VerifyChanges: mockClass('VerifyChanges'), +})); +vi.mock('../../../../src/gadgets/WriteFile.js', () => ({ WriteFile: mockClass('WriteFile') })); +vi.mock('../../../../src/gadgets/github/index.js', () => ({ + CreatePR: mockClass('CreatePR'), + CreatePRReview: mockClass('CreatePRReview'), + GetPRChecks: mockClass('GetPRChecks'), + GetPRComments: mockClass('GetPRComments'), + GetPRDetails: mockClass('GetPRDetails'), + GetPRDiff: mockClass('GetPRDiff'), + PostPRComment: mockClass('PostPRComment'), + ReplyToReviewComment: mockClass('ReplyToReviewComment'), + UpdatePRComment: mockClass('UpdatePRComment'), +})); +vi.mock('../../../../src/gadgets/pm/index.js', () => ({ + AddChecklist: mockClass('AddChecklist'), + CreateWorkItem: mockClass('CreateWorkItem'), + ListWorkItems: mockClass('ListWorkItems'), + PMUpdateChecklistItem: mockClass('PMUpdateChecklistItem'), + PostComment: mockClass('PostComment'), + ReadWorkItem: mockClass('ReadWorkItem'), + UpdateWorkItem: mockClass('UpdateWorkItem'), +})); +vi.mock('../../../../src/gadgets/tmux.js', () => ({ Tmux: mockClass('Tmux') })); +vi.mock('../../../../src/gadgets/todo/index.js', () => ({ + TodoUpsert: mockClass('TodoUpsert'), + TodoUpdateStatus: mockClass('TodoUpdateStatus'), + TodoDelete: mockClass('TodoDelete'), +})); + +import type { AgentCapabilities } from '../../../../src/agents/shared/capabilities.js'; +import { + buildPRAgentGadgets, + buildReviewGadgets, + buildWorkItemGadgets, + createPRAgentGadgets, +} from '../../../../src/agents/shared/gadgets.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function names(gadgets: unknown[]): string[] { + return gadgets.map((g) => (g as object).constructor.name); +} + +const FULL_CAPS: AgentCapabilities = { + canEditFiles: true, + canCreatePR: true, + canUpdateChecklists: true, + isReadOnly: false, +}; + +const READ_ONLY_CAPS: AgentCapabilities = { + canEditFiles: false, + canCreatePR: false, + canUpdateChecklists: false, + isReadOnly: true, +}; + +describe('buildWorkItemGadgets', () => { + it('always includes base read gadgets and session control', () => { + const gadgets = names(buildWorkItemGadgets(FULL_CAPS)); + expect(gadgets).toContain('ListDirectory'); + expect(gadgets).toContain('ReadFile'); + expect(gadgets).toContain('RipGrep'); + expect(gadgets).toContain('AstGrep'); + expect(gadgets).toContain('Tmux'); + expect(gadgets).toContain('Sleep'); + expect(gadgets).toContain('TodoUpsert'); + expect(gadgets).toContain('TodoUpdateStatus'); + expect(gadgets).toContain('TodoDelete'); + expect(gadgets).toContain('ReadWorkItem'); + expect(gadgets).toContain('PostComment'); + expect(gadgets).toContain('Finish'); + }); + + it('includes file-editing gadgets when canEditFiles is true', () => { + const gadgets = names(buildWorkItemGadgets(FULL_CAPS)); + expect(gadgets).toContain('FileSearchAndReplace'); + expect(gadgets).toContain('FileMultiEdit'); + expect(gadgets).toContain('WriteFile'); + expect(gadgets).toContain('VerifyChanges'); + }); + + it('excludes file-editing gadgets when canEditFiles is false', () => { + const gadgets = names(buildWorkItemGadgets(READ_ONLY_CAPS)); + expect(gadgets).not.toContain('FileSearchAndReplace'); + expect(gadgets).not.toContain('FileMultiEdit'); + expect(gadgets).not.toContain('WriteFile'); + expect(gadgets).not.toContain('VerifyChanges'); + }); + + it('includes CreatePR when canCreatePR is true', () => { + const gadgets = names(buildWorkItemGadgets(FULL_CAPS)); + expect(gadgets).toContain('CreatePR'); + }); + + it('excludes CreatePR when canCreatePR is false', () => { + const gadgets = names(buildWorkItemGadgets(READ_ONLY_CAPS)); + expect(gadgets).not.toContain('CreatePR'); + }); + + it('includes PMUpdateChecklistItem when canUpdateChecklists is true', () => { + const gadgets = names(buildWorkItemGadgets(FULL_CAPS)); + expect(gadgets).toContain('PMUpdateChecklistItem'); + }); + + it('excludes PMUpdateChecklistItem when canUpdateChecklists is false', () => { + const gadgets = names(buildWorkItemGadgets(READ_ONLY_CAPS)); + expect(gadgets).not.toContain('PMUpdateChecklistItem'); + }); +}); + +describe('buildReviewGadgets', () => { + it('includes PR review gadgets', () => { + const gadgets = names(buildReviewGadgets()); + expect(gadgets).toContain('GetPRDetails'); + expect(gadgets).toContain('GetPRDiff'); + expect(gadgets).toContain('GetPRChecks'); + expect(gadgets).toContain('CreatePRReview'); + expect(gadgets).toContain('UpdatePRComment'); + expect(gadgets).toContain('Finish'); + }); + + it('does not include file-editing gadgets (read-only)', () => { + const gadgets = names(buildReviewGadgets()); + expect(gadgets).not.toContain('FileSearchAndReplace'); + expect(gadgets).not.toContain('WriteFile'); + expect(gadgets).not.toContain('CreatePR'); + }); + + it('does not include PostPRComment (submits via CreatePRReview)', () => { + const gadgets = names(buildReviewGadgets()); + expect(gadgets).not.toContain('PostPRComment'); + }); +}); + +describe('buildPRAgentGadgets', () => { + it('includes file editing and GitHub PR tools', () => { + const gadgets = names(buildPRAgentGadgets()); + expect(gadgets).toContain('FileSearchAndReplace'); + expect(gadgets).toContain('FileMultiEdit'); + expect(gadgets).toContain('WriteFile'); + expect(gadgets).toContain('VerifyChanges'); + expect(gadgets).toContain('GetPRDetails'); + expect(gadgets).toContain('GetPRDiff'); + expect(gadgets).toContain('GetPRChecks'); + expect(gadgets).toContain('PostPRComment'); + expect(gadgets).toContain('Finish'); + }); + + it('does not include CreatePR (pushes to existing branch)', () => { + const gadgets = names(buildPRAgentGadgets()); + expect(gadgets).not.toContain('CreatePR'); + }); + + it('excludes review comment tools by default', () => { + const gadgets = names(buildPRAgentGadgets()); + expect(gadgets).not.toContain('GetPRComments'); + expect(gadgets).not.toContain('ReplyToReviewComment'); + }); + + it('includes review comment tools when includeReviewComments is true', () => { + const gadgets = names(buildPRAgentGadgets({ includeReviewComments: true })); + expect(gadgets).toContain('GetPRComments'); + expect(gadgets).toContain('ReplyToReviewComment'); + }); +}); + +describe('createPRAgentGadgets (deprecated alias)', () => { + it('delegates to buildPRAgentGadgets', () => { + const via_new = names(buildPRAgentGadgets()); + const via_old = names(createPRAgentGadgets()); + expect(via_old).toEqual(via_new); + }); + + it('passes includeReviewComments through', () => { + const with_reviews = names(createPRAgentGadgets({ includeReviewComments: true })); + expect(with_reviews).toContain('GetPRComments'); + expect(with_reviews).toContain('ReplyToReviewComment'); + }); +}); diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts new file mode 100644 index 00000000..024767a2 --- /dev/null +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock getPMProvider to control the PM type +vi.mock('../../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), +})); + +import { buildPromptContext } from '../../../../src/agents/shared/promptContext.js'; +import { getPMProvider } from '../../../../src/pm/index.js'; +import { createMockPMProvider } from '../../../helpers/mockPMProvider.js'; + +const mockGetPMProvider = vi.mocked(getPMProvider); + +function makeProject(overrides: Record = {}) { + return { + id: 'test-project', + name: 'Test Project', + repo: 'owner/repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + trello: { + boardId: 'board1', + lists: { + briefing: 'list1', + planning: 'list2', + todo: 'list3', + stories: 'list-stories', + debug: 'list-debug', + }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + ...overrides, + }; +} + +describe('buildPromptContext', () => { + describe('with Trello provider', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.type = 'trello'; + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('sets workItemNoun to "card" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNoun).toBe('card'); + }); + + it('sets workItemNounPlural to "cards" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounPlural).toBe('cards'); + }); + + it('sets workItemNounCap to "Card" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounCap).toBe('Card'); + }); + + it('sets workItemNounPluralCap to "Cards" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounPluralCap).toBe('Cards'); + }); + + it('sets pmName to "Trello"', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.pmName).toBe('Trello'); + }); + + it('sets pmType to "trello"', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.pmType).toBe('trello'); + }); + + it('generates cardUrl from provider', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.cardUrl).toBe('https://trello.com/c/card123'); + }); + + it('sets cardId from parameter', () => { + const ctx = buildPromptContext('card-abc', makeProject() as never); + expect(ctx.cardId).toBe('card-abc'); + }); + + it('includes storiesListId from project trello config', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.storiesListId).toBe('list-stories'); + }); + + it('includes processedLabelId from project trello config', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.processedLabelId).toBe('label2'); + }); + }); + + describe('with JIRA provider', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.type = 'jira' as never; + mockProvider.getWorkItemUrl = vi.fn( + (id: string) => `https://company.atlassian.net/browse/${id}`, + ); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('sets workItemNoun to "issue" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNoun).toBe('issue'); + }); + + it('sets workItemNounPlural to "issues" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounPlural).toBe('issues'); + }); + + it('sets workItemNounCap to "Issue" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounCap).toBe('Issue'); + }); + + it('sets workItemNounPluralCap to "Issues" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounPluralCap).toBe('Issues'); + }); + + it('sets pmName to "JIRA"', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.pmName).toBe('JIRA'); + }); + + it('sets pmType to "jira"', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.pmType).toBe('jira'); + }); + }); + + describe('with prContext', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + const prContext = { + prNumber: 42, + prBranch: 'feature/my-branch', + repoFullName: 'owner/repo', + headSha: 'abc123def456', + }; + + it('includes prNumber', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.prNumber).toBe(42); + }); + + it('includes prBranch', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.prBranch).toBe('feature/my-branch'); + }); + + it('includes repoFullName', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.repoFullName).toBe('owner/repo'); + }); + + it('includes headSha', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.headSha).toBe('abc123def456'); + }); + + it('includes triggerType', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.triggerType).toBe('check_suite'); + }); + }); + + describe('with debugContext', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + const debugContext = { + logDir: '/tmp/logs/debug-session', + originalCardId: 'original-card-id', + originalCardName: 'My Feature Card', + originalCardUrl: 'https://trello.com/c/abc', + detectedAgentType: 'implementation', + }; + + it('includes logDir', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.logDir).toBe('/tmp/logs/debug-session'); + }); + + it('includes originalCardName', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.originalCardName).toBe('My Feature Card'); + }); + + it('includes originalCardUrl', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.originalCardUrl).toBe('https://trello.com/c/abc'); + }); + + it('includes detectedAgentType', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.detectedAgentType).toBe('implementation'); + }); + + it('includes debugListId from project trello config', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.debugListId).toBe('list-debug'); + }); + }); + + describe('without optional contexts', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn(() => undefined); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('has undefined prNumber when no prContext', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.prNumber).toBeUndefined(); + }); + + it('has undefined logDir when no debugContext', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.logDir).toBeUndefined(); + }); + + it('handles undefined cardId', () => { + const ctx = buildPromptContext(undefined, makeProject() as never); + expect(ctx.cardId).toBeUndefined(); + expect(ctx.cardUrl).toBeUndefined(); + }); + + it('includes projectId from project', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.projectId).toBe('test-project'); + }); + + it('includes baseBranch from project', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.baseBranch).toBe('main'); + }); + }); +}); diff --git a/tests/unit/agents/shared/taskPrompts.test.ts b/tests/unit/agents/shared/taskPrompts.test.ts new file mode 100644 index 00000000..150b3cf4 --- /dev/null +++ b/tests/unit/agents/shared/taskPrompts.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCIResponsePrompt, + buildCheckFailurePrompt, + buildCommentResponsePrompt, + buildDebugPrompt, + buildPRCommentResponsePrompt, + buildReviewPrompt, + buildWorkItemPrompt, +} from '../../../../src/agents/shared/taskPrompts.js'; + +describe('buildWorkItemPrompt', () => { + it('includes the card ID', () => { + const prompt = buildWorkItemPrompt('abc123'); + expect(prompt).toContain('abc123'); + }); + + it('asks the agent to process the work item', () => { + const prompt = buildWorkItemPrompt('card-99'); + expect(prompt).toContain('work item'); + }); +}); + +describe('buildCommentResponsePrompt', () => { + it('includes card ID, comment text, and author', () => { + const prompt = buildCommentResponsePrompt('card-42', 'Please add tests', 'alice'); + expect(prompt).toContain('card-42'); + expect(prompt).toContain('Please add tests'); + expect(prompt).toContain('@alice'); + }); + + it('instructs surgical updates by default', () => { + const prompt = buildCommentResponsePrompt('card-1', 'Fix the typo', 'bob'); + expect(prompt).toContain('surgical'); + }); + + it('mentions that work item data is pre-loaded', () => { + const prompt = buildCommentResponsePrompt('card-1', 'Update docs', 'carol'); + expect(prompt).toContain('pre-loaded'); + }); +}); + +describe('buildReviewPrompt', () => { + it('includes the PR number', () => { + const prompt = buildReviewPrompt(42); + expect(prompt).toContain('PR #42'); + }); + + it('instructs to use CreatePRReview', () => { + const prompt = buildReviewPrompt(7); + expect(prompt).toContain('CreatePRReview'); + }); +}); + +describe('buildCIResponsePrompt', () => { + it('includes branch and PR number', () => { + const prompt = buildCIResponsePrompt('fix/ci-errors', 99); + expect(prompt).toContain('fix/ci-errors'); + expect(prompt).toContain('PR #99'); + }); + + it('mentions CI checks have failed', () => { + const prompt = buildCIResponsePrompt('main', 1); + expect(prompt).toContain('CI checks have failed'); + }); +}); + +describe('buildPRCommentResponsePrompt', () => { + it('includes PR number, branch, and comment body', () => { + const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Can you fix the typo?'); + expect(prompt).toContain('PR #55'); + expect(prompt).toContain('feat/new'); + expect(prompt).toContain('Can you fix the typo?'); + }); + + it('includes file path when provided', () => { + const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Fix this line', 'src/utils.ts'); + expect(prompt).toContain('src/utils.ts'); + }); + + it('omits file path when not provided', () => { + const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Looks good overall!'); + expect(prompt).not.toContain('File:'); + }); + + it('omits file path when empty string provided', () => { + const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'LGTM', ''); + expect(prompt).not.toContain('File:'); + }); + + it('instructs surgical changes by default', () => { + const prompt = buildPRCommentResponsePrompt('main', 1, 'Please refactor'); + expect(prompt).toContain('surgical'); + }); +}); + +describe('buildCheckFailurePrompt', () => { + const prContext = { + prNumber: 33, + prBranch: 'fix/flaky-test', + repoFullName: 'acme/widgets', + headSha: 'abc123', + }; + + it('includes PR number and branch', () => { + const prompt = buildCheckFailurePrompt(prContext); + expect(prompt).toContain('PR #33'); + expect(prompt).toContain('fix/flaky-test'); + }); + + it('includes owner and repo from repoFullName', () => { + const prompt = buildCheckFailurePrompt(prContext); + expect(prompt).toContain('acme'); + expect(prompt).toContain('widgets'); + }); + + it('provides investigation steps', () => { + const prompt = buildCheckFailurePrompt(prContext); + expect(prompt).toContain('gh run list'); + expect(prompt).toContain('gh run view'); + }); +}); + +describe('buildDebugPrompt', () => { + const debugContext = { + logDir: '/tmp/logs/abc', + originalCardName: 'Fix the login bug', + originalCardUrl: 'https://trello.com/c/abc', + detectedAgentType: 'implementation', + }; + + it('includes the log directory', () => { + const prompt = buildDebugPrompt(debugContext); + expect(prompt).toContain('/tmp/logs/abc'); + }); + + it('includes the original card name', () => { + const prompt = buildDebugPrompt(debugContext); + expect(prompt).toContain('Fix the login bug'); + }); + + it('includes the detected agent type', () => { + const prompt = buildDebugPrompt(debugContext); + expect(prompt).toContain('implementation'); + }); +}); diff --git a/tests/unit/api/access-control.test.ts b/tests/unit/api/access-control.test.ts index 26285a0f..1cc04466 100644 --- a/tests/unit/api/access-control.test.ts +++ b/tests/unit/api/access-control.test.ts @@ -61,11 +61,6 @@ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ createCredential: (...args: unknown[]) => mockCreateCredential(...args), updateCredential: (...args: unknown[]) => mockUpdateCredential(...args), deleteCredential: (...args: unknown[]) => mockDeleteCredential(...args), - listProjectOverrides: vi.fn(), - setProjectCredentialOverride: vi.fn(), - removeProjectCredentialOverride: vi.fn(), - setAgentCredentialOverride: vi.fn(), - removeAgentCredentialOverride: vi.fn(), })); const mockDbSelect = vi.fn(); diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index 892c4173..105fd882 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -52,6 +52,10 @@ vi.mock('../../../src/db/repositories/settingsRepository.js', () => ({ listProjectIntegrations: vi.fn(), upsertProjectIntegration: vi.fn(), deleteProjectIntegration: vi.fn(), + getIntegrationByProjectAndCategory: vi.fn(), + listIntegrationCredentials: vi.fn(), + setIntegrationCredential: vi.fn(), + removeIntegrationCredential: vi.fn(), listAgentConfigs: vi.fn(), createAgentConfig: vi.fn(), updateAgentConfig: vi.fn(), @@ -64,12 +68,8 @@ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ createCredential: vi.fn(), updateCredential: vi.fn(), deleteCredential: vi.fn(), - listProjectOverrides: vi.fn(), - setProjectCredentialOverride: vi.fn(), - removeProjectCredentialOverride: vi.fn(), - setAgentCredentialOverride: vi.fn(), - removeAgentCredentialOverride: vi.fn(), - resolveAllCredentials: vi.fn(), + resolveAllIntegrationCredentials: vi.fn(), + resolveAllOrgCredentials: vi.fn(), })); vi.mock('../../../src/db/repositories/configRepository.js', () => ({ @@ -117,11 +117,9 @@ describe('appRouter', () => { expect(procedures).toContain('projects.integrations.list'); expect(procedures).toContain('projects.integrations.upsert'); expect(procedures).toContain('projects.integrations.delete'); - expect(procedures).toContain('projects.credentialOverrides.list'); - expect(procedures).toContain('projects.credentialOverrides.set'); - expect(procedures).toContain('projects.credentialOverrides.remove'); - expect(procedures).toContain('projects.credentialOverrides.setAgent'); - expect(procedures).toContain('projects.credentialOverrides.removeAgent'); + expect(procedures).toContain('projects.integrationCredentials.list'); + expect(procedures).toContain('projects.integrationCredentials.set'); + expect(procedures).toContain('projects.integrationCredentials.remove'); }); it('has organization sub-router with all procedures', () => { diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 61bd486d..7b641a1c 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -16,6 +16,10 @@ const mockDeleteProject = vi.fn(); const mockListProjectIntegrations = vi.fn(); const mockUpsertProjectIntegration = vi.fn(); const mockDeleteProjectIntegration = vi.fn(); +const mockGetIntegrationByProjectAndCategory = vi.fn(); +const mockListIntegrationCredentials = vi.fn(); +const mockSetIntegrationCredential = vi.fn(); +const mockRemoveIntegrationCredential = vi.fn(); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listProjectsFull: (...args: unknown[]) => mockListProjectsFull(...args), @@ -26,22 +30,14 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listProjectIntegrations: (...args: unknown[]) => mockListProjectIntegrations(...args), upsertProjectIntegration: (...args: unknown[]) => mockUpsertProjectIntegration(...args), deleteProjectIntegration: (...args: unknown[]) => mockDeleteProjectIntegration(...args), + getIntegrationByProjectAndCategory: (...args: unknown[]) => + mockGetIntegrationByProjectAndCategory(...args), + listIntegrationCredentials: (...args: unknown[]) => mockListIntegrationCredentials(...args), + setIntegrationCredential: (...args: unknown[]) => mockSetIntegrationCredential(...args), + removeIntegrationCredential: (...args: unknown[]) => mockRemoveIntegrationCredential(...args), })); -const mockListProjectOverrides = vi.fn(); -const mockSetProjectCredentialOverride = vi.fn(); -const mockRemoveProjectCredentialOverride = vi.fn(); -const mockSetAgentCredentialOverride = vi.fn(); -const mockRemoveAgentCredentialOverride = vi.fn(); - -vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ - listProjectOverrides: (...args: unknown[]) => mockListProjectOverrides(...args), - setProjectCredentialOverride: (...args: unknown[]) => mockSetProjectCredentialOverride(...args), - removeProjectCredentialOverride: (...args: unknown[]) => - mockRemoveProjectCredentialOverride(...args), - setAgentCredentialOverride: (...args: unknown[]) => mockSetAgentCredentialOverride(...args), - removeAgentCredentialOverride: (...args: unknown[]) => mockRemoveAgentCredentialOverride(...args), -})); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({})); // Mock getDb for ownership checks const mockDbSelect = vi.fn(); @@ -253,7 +249,15 @@ describe('projectsRouter', () => { describe('list', () => { it('lists integrations after verifying ownership', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); - const integrations = [{ id: 1, type: 'trello', config: { boardId: 'abc' } }]; + const integrations = [ + { + id: 1, + category: 'pm', + provider: 'trello', + config: { boardId: 'abc' }, + triggers: {}, + }, + ]; mockListProjectIntegrations.mockResolvedValue(integrations); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); @@ -280,13 +284,18 @@ describe('projectsRouter', () => { await caller.integrations.upsert({ projectId: 'p1', - type: 'trello', + category: 'pm', + provider: 'trello', config: { boardId: 'abc123' }, }); - expect(mockUpsertProjectIntegration).toHaveBeenCalledWith('p1', 'trello', { - boardId: 'abc123', - }); + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( + 'p1', + 'pm', + 'trello', + { boardId: 'abc123' }, + undefined, + ); }); }); @@ -296,48 +305,64 @@ describe('projectsRouter', () => { mockDeleteProjectIntegration.mockResolvedValue(undefined); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await caller.integrations.delete({ projectId: 'p1', type: 'trello' }); + await caller.integrations.delete({ projectId: 'p1', category: 'pm' }); - expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'trello'); + expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm'); }); }); }); // ============================================================================ - // Credential Overrides sub-router + // Integration Credentials sub-router // ============================================================================ - describe('credentialOverrides', () => { + describe('integrationCredentials', () => { describe('list', () => { - it('lists overrides after verifying ownership', async () => { + it('lists credentials after verifying ownership', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); - const overrides = [ - { envVarKey: 'GITHUB_TOKEN', credentialId: 42, credentialName: 'Bot', agentType: null }, - ]; - mockListProjectOverrides.mockResolvedValue(overrides); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 10 }); + const creds = [{ role: 'api_key', credentialId: 42, credentialName: 'Key' }]; + mockListIntegrationCredentials.mockResolvedValue(creds); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - const result = await caller.credentialOverrides.list({ projectId: 'p1' }); + const result = await caller.integrationCredentials.list({ + projectId: 'p1', + category: 'pm', + }); - expect(result).toEqual(overrides); + expect(result).toEqual(creds); + }); + + it('returns empty when integration not found', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockGetIntegrationByProjectAndCategory.mockResolvedValue(null); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.integrationCredentials.list({ + projectId: 'p1', + category: 'scm', + }); + + expect(result).toEqual([]); }); }); describe('set', () => { - it('sets override after verifying project and credential ownership', async () => { - // First call: verify project, second call: verify credential - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); - mockSetProjectCredentialOverride.mockResolvedValue(undefined); + it('sets credential after verifying project and credential ownership', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // credential + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 10 }); + mockSetIntegrationCredential.mockResolvedValue(undefined); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await caller.credentialOverrides.set({ + await caller.integrationCredentials.set({ projectId: 'p1', - envVarKey: 'GITHUB_TOKEN', + category: 'pm', + role: 'api_key', credentialId: 42, }); - expect(mockSetProjectCredentialOverride).toHaveBeenCalledWith('p1', 'GITHUB_TOKEN', 42); + expect(mockSetIntegrationCredential).toHaveBeenCalledWith(10, 'api_key', 42); }); it('throws NOT_FOUND when credential belongs to different org', async () => { @@ -346,9 +371,10 @@ describe('projectsRouter', () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect( - caller.credentialOverrides.set({ + caller.integrationCredentials.set({ projectId: 'p1', - envVarKey: 'KEY', + category: 'pm', + role: 'api_key', credentialId: 99, }), ).rejects.toMatchObject({ code: 'NOT_FOUND' }); @@ -356,60 +382,19 @@ describe('projectsRouter', () => { }); describe('remove', () => { - it('removes override after verifying ownership', async () => { - mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); - mockRemoveProjectCredentialOverride.mockResolvedValue(undefined); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.credentialOverrides.remove({ - projectId: 'p1', - envVarKey: 'GITHUB_TOKEN', - }); - - expect(mockRemoveProjectCredentialOverride).toHaveBeenCalledWith('p1', 'GITHUB_TOKEN'); - }); - }); - - describe('setAgent', () => { - it('sets agent-scoped override after verifying both ownerships', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // credential - mockSetAgentCredentialOverride.mockResolvedValue(undefined); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.credentialOverrides.setAgent({ - projectId: 'p1', - envVarKey: 'GITHUB_TOKEN', - agentType: 'review', - credentialId: 42, - }); - - expect(mockSetAgentCredentialOverride).toHaveBeenCalledWith( - 'p1', - 'GITHUB_TOKEN', - 'review', - 42, - ); - }); - }); - - describe('removeAgent', () => { - it('removes agent-scoped override after verifying ownership', async () => { + it('removes credential after verifying ownership', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); - mockRemoveAgentCredentialOverride.mockResolvedValue(undefined); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 10 }); + mockRemoveIntegrationCredential.mockResolvedValue(undefined); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await caller.credentialOverrides.removeAgent({ + await caller.integrationCredentials.remove({ projectId: 'p1', - envVarKey: 'GITHUB_TOKEN', - agentType: 'review', + category: 'pm', + role: 'api_key', }); - expect(mockRemoveAgentCredentialOverride).toHaveBeenCalledWith( - 'p1', - 'GITHUB_TOKEN', - 'review', - ); + expect(mockRemoveIntegrationCredential).toHaveBeenCalledWith(10, 'api_key'); }); }); }); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index dbf954b1..66cd420c 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -4,7 +4,8 @@ import type { TRPCContext } from '../../../../src/api/trpc.js'; // --- Mock dependencies --- const mockFindProjectByIdFromDb = vi.fn(); -const mockResolveAllCredentials = vi.fn(); +const mockResolveAllIntegrationCredentials = vi.fn(); +const mockResolveAllOrgCredentials = vi.fn(); const mockDbSelect = vi.fn(); const mockDbFrom = vi.fn(); @@ -25,7 +26,9 @@ vi.mock('../../../../src/db/repositories/configRepository.js', () => ({ })); vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ - resolveAllCredentials: (...args: unknown[]) => mockResolveAllCredentials(...args), + resolveAllIntegrationCredentials: (...args: unknown[]) => + mockResolveAllIntegrationCredentials(...args), + resolveAllOrgCredentials: (...args: unknown[]) => mockResolveAllOrgCredentials(...args), })); // Mock global fetch for Trello API calls @@ -91,11 +94,12 @@ function setupJiraProjectContext() { mockDbFrom.mockReturnValue({ where: mockDbWhere }); mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockFindProjectByIdFromDb.mockResolvedValue(mockJiraProject); - mockResolveAllCredentials.mockResolvedValue({ - JIRA_EMAIL: 'bot@example.com', - JIRA_API_TOKEN: 'jira-token-123', - GITHUB_TOKEN: 'ghp_test123', - }); + mockResolveAllIntegrationCredentials.mockResolvedValue([ + { category: 'pm', provider: 'jira', role: 'email', value: 'bot@example.com' }, + { category: 'pm', provider: 'jira', role: 'api_token', value: 'jira-token-123' }, + { category: 'scm', provider: 'github', role: 'implementer_token', value: 'ghp_test123' }, + ]); + mockResolveAllOrgCredentials.mockResolvedValue({}); } function setupProjectContext(opts?: { noTrello?: boolean; noGithub?: boolean }) { @@ -103,11 +107,24 @@ function setupProjectContext(opts?: { noTrello?: boolean; noGithub?: boolean }) mockDbFrom.mockReturnValue({ where: mockDbWhere }); mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockFindProjectByIdFromDb.mockResolvedValue(mockProject); - mockResolveAllCredentials.mockResolvedValue({ - TRELLO_API_KEY: opts?.noTrello ? undefined : 'trello-key', - TRELLO_TOKEN: opts?.noTrello ? undefined : 'trello-token', - GITHUB_TOKEN: opts?.noGithub ? undefined : 'ghp_test123', - }); + const integrationCreds: { category: string; provider: string; role: string; value: string }[] = + []; + if (!opts?.noTrello) { + integrationCreds.push( + { category: 'pm', provider: 'trello', role: 'api_key', value: 'trello-key' }, + { category: 'pm', provider: 'trello', role: 'token', value: 'trello-token' }, + ); + } + if (!opts?.noGithub) { + integrationCreds.push({ + category: 'scm', + provider: 'github', + role: 'implementer_token', + value: 'ghp_test123', + }); + } + mockResolveAllIntegrationCredentials.mockResolvedValue(integrationCreds); + mockResolveAllOrgCredentials.mockResolvedValue({}); } describe('webhooksRouter', () => { @@ -196,6 +213,35 @@ describe('webhooksRouter', () => { code: 'UNAUTHORIZED', }); }); + + it('does not use legacy GITHUB_TOKEN org default for github operations', async () => { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectByIdFromDb.mockResolvedValue(mockProject); + + // No GitHub integration credential linked + mockResolveAllIntegrationCredentials.mockResolvedValue([ + { category: 'pm', provider: 'trello', role: 'api_key', value: 'trello-key' }, + { category: 'pm', provider: 'trello', role: 'token', value: 'trello-token' }, + ]); + // Org default has a legacy GITHUB_TOKEN — should be ignored + mockResolveAllOrgCredentials.mockResolvedValue({ + GITHUB_TOKEN: 'ghp_legacy_should_not_be_used', + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ projectId: 'my-project' }); + + // GITHUB_TOKEN_IMPLEMENTER was not set, so GitHub webhooks should not be listed + expect(result.github).toEqual([]); + expect(mockListWebhooks).not.toHaveBeenCalled(); + }); }); describe('create', () => { @@ -214,7 +260,7 @@ describe('webhooksRouter', () => { json: () => Promise.resolve({ id: 'tw-new', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }), @@ -224,7 +270,7 @@ describe('webhooksRouter', () => { mockCreateWebhook.mockResolvedValue({ data: { id: 42, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: ['pull_request'], active: true, }, @@ -249,7 +295,7 @@ describe('webhooksRouter', () => { Promise.resolve([ { id: 'tw-existing', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }, @@ -260,7 +306,7 @@ describe('webhooksRouter', () => { data: [ { id: 99, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: ['push'], active: true, }, @@ -284,7 +330,7 @@ describe('webhooksRouter', () => { mockCreateWebhook.mockResolvedValue({ data: { id: 1, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: [], active: true, }, @@ -299,7 +345,7 @@ describe('webhooksRouter', () => { expect(mockCreateWebhook).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ - url: 'http://example.com/webhook/github', + url: 'http://example.com/github/webhook', }), }), ); @@ -315,7 +361,7 @@ describe('webhooksRouter', () => { json: () => Promise.resolve({ id: 'tw-new', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }), @@ -340,7 +386,7 @@ describe('webhooksRouter', () => { mockCreateWebhook.mockResolvedValue({ data: { id: 1, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: [], active: true, }, @@ -379,7 +425,7 @@ describe('webhooksRouter', () => { Promise.resolve({ id: 100, name: 'cascade-webhook', - url: 'http://example.com/webhook/jira', + url: 'http://example.com/jira/webhook', events: [], enabled: true, }), @@ -403,7 +449,7 @@ describe('webhooksRouter', () => { mockCreateWebhook.mockResolvedValue({ data: { id: 1, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: [], active: true, }, @@ -438,7 +484,7 @@ describe('webhooksRouter', () => { Promise.resolve({ id: 101, name: 'cascade-webhook', - url: 'http://example.com/webhook/jira', + url: 'http://example.com/jira/webhook', events: [], enabled: true, }), @@ -452,7 +498,7 @@ describe('webhooksRouter', () => { mockCreateWebhook.mockResolvedValue({ data: { id: 1, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: [], active: true, }, @@ -481,7 +527,7 @@ describe('webhooksRouter', () => { Promise.resolve([ { id: 'tw-1', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }, @@ -494,7 +540,7 @@ describe('webhooksRouter', () => { data: [ { id: 10, - config: { url: 'http://example.com/webhook/github' }, + config: { url: 'http://example.com/github/webhook' }, events: [], active: true, }, @@ -541,13 +587,13 @@ describe('webhooksRouter', () => { Promise.resolve([ { id: 'tw-1', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }, { id: 'tw-2', - callbackURL: 'http://example.com/webhook/trello', + callbackURL: 'http://example.com/trello/webhook', idModel: 'board-123', active: true, }, diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 788bbe80..ef895877 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -50,8 +50,7 @@ vi.mock('../../../src/utils/logging.js', () => ({ })); vi.mock('../../../src/config/provider.js', () => ({ - getProjectSecrets: vi.fn(), - getAgentCredential: vi.fn(), + getAllProjectCredentials: vi.fn(), })); vi.mock('../../../src/github/client.js', () => ({ @@ -107,7 +106,7 @@ import { executeWithBackend } from '../../../src/backends/adapter.js'; import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; import { createProgressMonitor } from '../../../src/backends/progress.js'; import type { AgentBackend } from '../../../src/backends/types.js'; -import { getProjectSecrets } from '../../../src/config/provider.js'; +import { getAllProjectCredentials } from '../../../src/config/provider.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../../../src/utils/cascadeEnv.js'; import { @@ -130,7 +129,7 @@ const mockCleanupLogFile = vi.mocked(cleanupLogFile); const mockCleanupLogDirectory = vi.mocked(cleanupLogDirectory); const mockClearWatchdogCleanup = vi.mocked(clearWatchdogCleanup); const mockCreateProgressMonitor = vi.mocked(createProgressMonitor); -const mockGetProjectSecrets = vi.mocked(getProjectSecrets); +const mockGetAllProjectCredentials = vi.mocked(getAllProjectCredentials); const mockGetAgentProfile = vi.mocked(getAgentProfile); function makeProject(): ProjectConfig { @@ -223,7 +222,7 @@ function setupMocks() { contextFiles: [], } as never); mockCreateProgressMonitor.mockReturnValue(null); - mockGetProjectSecrets.mockResolvedValue({}); + mockGetAllProjectCredentials.mockResolvedValue({}); mockGetAgentProfile.mockReturnValue(makeMockProfile()); return mockLoggerInstance; } @@ -541,7 +540,7 @@ describe('executeWithBackend', () => { it('resolves per-project secrets and passes them to backend', async () => { setupMocks(); - mockGetProjectSecrets.mockResolvedValue({ + mockGetAllProjectCredentials.mockResolvedValue({ GITHUB_TOKEN: 'proj-gh-token', TRELLO_API_KEY: 'proj-trello-key', }); @@ -551,7 +550,7 @@ describe('executeWithBackend', () => { await executeWithBackend(backend, 'implementation', input); - expect(mockGetProjectSecrets).toHaveBeenCalledWith('test'); + expect(mockGetAllProjectCredentials).toHaveBeenCalledWith('test'); const backendInput = vi.mocked(backend.execute).mock.calls[0][0]; expect(backendInput.projectSecrets).toEqual({ @@ -592,7 +591,7 @@ describe('executeWithBackend', () => { it('includes CASCADE_BASE_BRANCH even when no other per-project secrets exist', async () => { setupMocks(); - mockGetProjectSecrets.mockResolvedValue({}); + mockGetAllProjectCredentials.mockResolvedValue({}); const backend = makeMockBackend(); const input = makeInput(); diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index 109284d6..a30e22c8 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -9,9 +9,10 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ })); vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ - resolveCredential: vi.fn(), - resolveAgentCredential: vi.fn(), - resolveAllCredentials: vi.fn(), + resolveIntegrationCredential: vi.fn(), + resolveAllIntegrationCredentials: vi.fn(), + resolveOrgCredential: vi.fn(), + resolveAllOrgCredentials: vi.fn(), })); import { getProjectGitHubToken } from '../../../src/config/projects.js'; @@ -19,9 +20,9 @@ import { findProjectByBoardId, findProjectById, findProjectByRepo, - getProjectSecret, - getProjectSecretOrNull, - getProjectSecrets, + getAllProjectCredentials, + getIntegrationCredential, + getIntegrationCredentialOrNull, invalidateConfigCache, loadConfig, } from '../../../src/config/provider.js'; @@ -32,8 +33,9 @@ import { loadConfigFromDb, } from '../../../src/db/repositories/configRepository.js'; import { - resolveAllCredentials, - resolveCredential, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, + resolveIntegrationCredential, } from '../../../src/db/repositories/credentialsRepository.js'; describe('config provider', () => { @@ -167,158 +169,87 @@ describe('config provider', () => { }); }); - describe('getProjectSecret', () => { - it('returns DB credential when available', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue('db-secret-value'); + describe('getIntegrationCredential', () => { + it('resolves credential from DB', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue('db-secret-value'); - const result = await getProjectSecret('project1', 'TRELLO_API_KEY'); + const result = await getIntegrationCredential('project1', 'pm', 'api_key'); expect(result).toBe('db-secret-value'); }); - it('throws when secret not found in DB', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue(null); + it('throws when credential not found', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue(null); - await expect(getProjectSecret('project1', 'MISSING_KEY')).rejects.toThrow( - "Secret 'MISSING_KEY' not found for project 'project1' in database", + await expect(getIntegrationCredential('project1', 'pm', 'api_key')).rejects.toThrow( + "Integration credential 'pm/api_key' not found for project 'project1'", ); }); }); - describe('getProjectSecret - cached secrets path', () => { - it('returns from cached secrets without hitting resolveCredential', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveAllCredentials).mockResolvedValue({ - TRELLO_API_KEY: 'cached-value', - GITHUB_TOKEN: 'cached-gh-token', - }); - - // Populate the secrets cache via getProjectSecrets - await getProjectSecrets('project1'); - - vi.clearAllMocks(); - - // Now getProjectSecret should use the cached secrets - const result = await getProjectSecret('project1', 'TRELLO_API_KEY'); - expect(result).toBe('cached-value'); - - // resolveCredential should NOT have been called (cache hit) - expect(resolveCredential).not.toHaveBeenCalled(); - }); - }); - - describe('getProjectSecretOrNull', () => { + describe('getIntegrationCredentialOrNull', () => { it('returns credential value when found', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue('secret-value'); + vi.mocked(resolveIntegrationCredential).mockResolvedValue('secret-value'); - const result = await getProjectSecretOrNull('project1', 'TRELLO_API_KEY'); + const result = await getIntegrationCredentialOrNull('project1', 'scm', 'implementer_token'); expect(result).toBe('secret-value'); }); it('returns null when no credential found', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue(null); + vi.mocked(resolveIntegrationCredential).mockResolvedValue(null); - const result = await getProjectSecretOrNull('project1', 'NO_SUCH_KEY'); + const result = await getIntegrationCredentialOrNull('project1', 'scm', 'implementer_token'); expect(result).toBeNull(); }); }); - describe('getProjectSecrets', () => { - it('resolves all credentials via org ID', async () => { + describe('getAllProjectCredentials', () => { + it('resolves all credentials via integration + org defaults', async () => { vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveAllCredentials).mockResolvedValue({ - GITHUB_TOKEN: 'ghp_abc', - TRELLO_API_KEY: 'trello123', - }); + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([ + { category: 'pm', provider: 'trello', role: 'api_key', value: 'trello123' }, + ]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({}); - const result = await getProjectSecrets('project1'); + const result = await getAllProjectCredentials('project1'); expect(result).toEqual({ - GITHUB_TOKEN: 'ghp_abc', TRELLO_API_KEY: 'trello123', }); - expect(resolveAllCredentials).toHaveBeenCalledWith('project1', 'default'); }); it('caches secrets after first call', async () => { vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveAllCredentials).mockResolvedValue({ KEY: 'val' }); + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({ KEY: 'val' }); - await getProjectSecrets('project1'); - await getProjectSecrets('project1'); + await getAllProjectCredentials('project1'); + await getAllProjectCredentials('project1'); - // resolveAllCredentials called only once - expect(resolveAllCredentials).toHaveBeenCalledTimes(1); + expect(resolveAllIntegrationCredentials).toHaveBeenCalledTimes(1); }); it('returns empty object when no credentials exist', async () => { vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject2); - vi.mocked(resolveAllCredentials).mockResolvedValue({}); + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({}); - const result = await getProjectSecrets('project2'); + const result = await getAllProjectCredentials('project2'); expect(result).toEqual({}); }); }); - describe('orgId resolution', () => { - it('caches org ID after first lookup', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue('value1'); - - // Two calls for the same project - await getProjectSecret('project1', 'KEY1'); - await getProjectSecret('project1', 'KEY2'); - - // findProjectByIdFromDb called once (for orgId), not twice - expect(findProjectByIdFromDb).toHaveBeenCalledTimes(1); - }); - - it('throws when project not found', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(undefined); - - await expect(getProjectSecret('nonexistent', 'KEY')).rejects.toThrow( - 'Project not found: nonexistent', - ); - }); - - it('uses project-specific org ID', async () => { - const customOrgProject = { ...mockProject1, orgId: 'acme-corp' }; - vi.mocked(findProjectByIdFromDb).mockResolvedValue(customOrgProject); - vi.mocked(resolveCredential).mockResolvedValue('value1'); - - await getProjectSecret('project1', 'KEY'); - - expect(resolveCredential).toHaveBeenCalledWith('project1', 'acme-corp', 'KEY'); - }); - }); - describe('getProjectGitHubToken', () => { - it('returns GITHUB_TOKEN_IMPLEMENTER when available', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue('implementer-token'); + it('returns implementer token when available', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue('implementer-token'); const result = await getProjectGitHubToken(mockConfig.projects[0]); expect(result).toBe('implementer-token'); }); - it('falls back to legacy GITHUB_TOKEN when IMPLEMENTER token is missing', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential) - .mockResolvedValueOnce(null) // GITHUB_TOKEN_IMPLEMENTER not found - .mockResolvedValueOnce('legacy-token'); // GITHUB_TOKEN found - - const result = await getProjectGitHubToken(mockConfig.projects[0]); - expect(result).toBe('legacy-token'); - }); - - it('throws when neither IMPLEMENTER nor legacy token exists', async () => { - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); - vi.mocked(resolveCredential).mockResolvedValue(null); + it('throws when implementer token is missing', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue(null); await expect(getProjectGitHubToken(mockConfig.projects[0])).rejects.toThrow( - "Missing GITHUB_TOKEN_IMPLEMENTER (or legacy GITHUB_TOKEN) in database for project 'project1'", + "Missing implementer token (SCM integration) for project 'project1'", ); }); }); diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts index 6ef4364d..b09ea84b 100644 --- a/tests/unit/config/provider.test.ts +++ b/tests/unit/config/provider.test.ts @@ -10,9 +10,10 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ })); vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ - resolveCredential: vi.fn(), - resolveAgentCredential: vi.fn(), - resolveAllCredentials: vi.fn(), + resolveIntegrationCredential: vi.fn(), + resolveAllIntegrationCredentials: vi.fn(), + resolveOrgCredential: vi.fn(), + resolveAllOrgCredentials: vi.fn(), })); // Mock configCache @@ -38,10 +39,10 @@ import { findProjectById, findProjectByJiraProjectKey, findProjectByRepo, - getAgentCredential, - getProjectSecret, - getProjectSecretOrNull, - getProjectSecrets, + getAllProjectCredentials, + getIntegrationCredential, + getIntegrationCredentialOrNull, + getOrgCredential, invalidateConfigCache, loadConfig, setSecrets, @@ -54,9 +55,10 @@ import { loadConfigFromDb, } from '../../../src/db/repositories/configRepository.js'; import { - resolveAgentCredential, - resolveAllCredentials, - resolveCredential, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, + resolveIntegrationCredential, + resolveOrgCredential, } from '../../../src/db/repositories/credentialsRepository.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; @@ -273,265 +275,153 @@ describe('config/provider', () => { }); }); - describe('getProjectSecret', () => { - beforeEach(() => { - // Mock getOrgIdForProject helper - vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); - }); - - it('returns cached secret when available', async () => { - setSecrets('proj1', { GITHUB_TOKEN: 'ghp_cached' }); - - const result = await getProjectSecret('proj1', 'GITHUB_TOKEN'); - - expect(result).toBe('ghp_cached'); - expect(resolveCredential).not.toHaveBeenCalled(); - }); - - it('resolves from credentials repository when not cached', async () => { - vi.mocked(resolveCredential).mockResolvedValue('ghp_resolved'); + describe('getIntegrationCredential', () => { + it('returns cached credential from secrets store', async () => { + setSecrets('proj1', { TRELLO_API_KEY: 'cached-key' }); - const result = await getProjectSecret('proj1', 'GITHUB_TOKEN'); + const result = await getIntegrationCredential('proj1', 'pm', 'api_key'); - expect(result).toBe('ghp_resolved'); - expect(resolveCredential).toHaveBeenCalledWith('proj1', 'org1', 'GITHUB_TOKEN'); + expect(result).toBe('cached-key'); + expect(resolveIntegrationCredential).not.toHaveBeenCalled(); }); - it('caches org ID for project on first resolution', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); - vi.mocked(resolveCredential).mockResolvedValue('ghp_token'); - - await getProjectSecret('proj1', 'GITHUB_TOKEN'); - - expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); - }); - - it('reuses cached org ID for subsequent secret resolutions', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveCredential).mockResolvedValue('ghp_token'); - - await getProjectSecret('proj1', 'GITHUB_TOKEN'); - - expect(findProjectByIdFromDb).not.toHaveBeenCalled(); - expect(resolveCredential).toHaveBeenCalledWith('proj1', 'org1', 'GITHUB_TOKEN'); - }); + it('resolves from DB when not in secrets store', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue('db-value'); - it('throws error when secret not found', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveCredential).mockResolvedValue(null); + const result = await getIntegrationCredential('proj1', 'pm', 'api_key'); - await expect(getProjectSecret('proj1', 'MISSING_KEY')).rejects.toThrow( - "Secret 'MISSING_KEY' not found for project 'proj1' in database", - ); + expect(result).toBe('db-value'); + expect(resolveIntegrationCredential).toHaveBeenCalledWith('proj1', 'pm', 'api_key'); }); - it('throws when project not found', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); - vi.mocked(findProjectByIdFromDb).mockResolvedValue(undefined); + it('throws when credential not found', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue(null); - await expect(getProjectSecret('proj1', 'GITHUB_TOKEN')).rejects.toThrow( - 'Project not found: proj1', + await expect(getIntegrationCredential('proj1', 'pm', 'api_key')).rejects.toThrow( + "Integration credential 'pm/api_key' not found for project 'proj1'", ); }); }); - describe('getProjectSecretOrNull', () => { - beforeEach(() => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - }); - - it('returns secret when found', async () => { - setSecrets('proj1', { KEY: 'value' }); + describe('getIntegrationCredentialOrNull', () => { + it('returns cached credential from secrets store', async () => { + setSecrets('proj1', { GITHUB_TOKEN_IMPLEMENTER: 'cached-token' }); - const result = await getProjectSecretOrNull('proj1', 'KEY'); + const result = await getIntegrationCredentialOrNull('proj1', 'scm', 'implementer_token'); - expect(result).toBe('value'); + expect(result).toBe('cached-token'); }); - it('returns null when secret not found', async () => { - vi.mocked(resolveCredential).mockResolvedValue(null); + it('returns null when credential not found', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue(null); - const result = await getProjectSecretOrNull('proj1', 'MISSING'); + const result = await getIntegrationCredentialOrNull('proj1', 'scm', 'implementer_token'); expect(result).toBeNull(); }); - it('returns null when getProjectSecret throws', async () => { - vi.mocked(resolveCredential).mockRejectedValue(new Error('DB error')); + it('returns value from DB when found', async () => { + vi.mocked(resolveIntegrationCredential).mockResolvedValue('db-token'); - const result = await getProjectSecretOrNull('proj1', 'KEY'); + const result = await getIntegrationCredentialOrNull('proj1', 'scm', 'implementer_token'); - expect(result).toBeNull(); + expect(result).toBe('db-token'); }); }); - describe('getProjectSecrets', () => { + describe('getOrgCredential', () => { beforeEach(() => { vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); }); - it('returns cached secrets when available', async () => { - const secrets = { GITHUB_TOKEN: 'ghp_123', TRELLO_API_KEY: 'trello_abc' }; - setSecrets('proj1', secrets); + it('returns cached credential from secrets store', async () => { + setSecrets('proj1', { OPENROUTER_API_KEY: 'cached-or-key' }); - const result = await getProjectSecrets('proj1'); + const result = await getOrgCredential('proj1', 'OPENROUTER_API_KEY'); - expect(result).toBe(secrets); - expect(resolveAllCredentials).not.toHaveBeenCalled(); + expect(result).toBe('cached-or-key'); + expect(resolveOrgCredential).not.toHaveBeenCalled(); }); - it('loads all credentials from repository when not cached', async () => { - const secrets = { GITHUB_TOKEN: 'ghp_123', TRELLO_API_KEY: 'trello_abc' }; - vi.mocked(resolveAllCredentials).mockResolvedValue(secrets); + it('resolves from DB via org ID', async () => { + vi.mocked(resolveOrgCredential).mockResolvedValue('org-value'); - const result = await getProjectSecrets('proj1'); + const result = await getOrgCredential('proj1', 'OPENROUTER_API_KEY'); - expect(result).toEqual(secrets); - expect(resolveAllCredentials).toHaveBeenCalledWith('proj1', 'org1'); + expect(result).toBe('org-value'); + expect(resolveOrgCredential).toHaveBeenCalledWith('org1', 'OPENROUTER_API_KEY'); }); - it('caches resolved secrets for future access', async () => { - const secrets = { KEY: 'value' }; - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAllCredentials).mockResolvedValue(secrets); + it('returns null when credential not found', async () => { + vi.mocked(resolveOrgCredential).mockResolvedValue(null); - await getProjectSecrets('proj1'); + const result = await getOrgCredential('proj1', 'MISSING'); - // After resolving, subsequent calls should return cached value - const result = await getProjectSecrets('proj1'); - expect(result).toEqual(secrets); - // resolveAllCredentials called only once (second call uses secretsStore) - expect(resolveAllCredentials).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); }); - it('resolves org ID once and caches it', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); - vi.mocked(resolveAllCredentials).mockResolvedValue({}); - - await getProjectSecrets('proj1'); + it('throws when project not found', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(undefined); - expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); + await expect(getOrgCredential('proj1', 'KEY')).rejects.toThrow('Project not found: proj1'); }); }); - describe('getAgentCredential', () => { + describe('getAllProjectCredentials', () => { beforeEach(() => { vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); }); - it('returns cached credential when available (bypasses DB)', async () => { - setSecrets('proj1', { GITHUB_TOKEN_REVIEWER: 'ghp_cached_reviewer' }); - - const result = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN_REVIEWER'); - - expect(result).toBe('ghp_cached_reviewer'); - expect(resolveAgentCredential).not.toHaveBeenCalled(); - expect(findProjectByIdFromDb).not.toHaveBeenCalled(); - }); - - it('falls back to DB when cache exists but key is missing', async () => { - setSecrets('proj1', { OTHER_KEY: 'other_value' }); - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAgentCredential).mockResolvedValue('ghp_from_db'); - - const result = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN_REVIEWER'); - - expect(result).toBe('ghp_from_db'); - expect(resolveAgentCredential).toHaveBeenCalledWith( - 'proj1', - 'org1', - 'review', - 'GITHUB_TOKEN_REVIEWER', - ); - }); - - it('falls back to DB when secretsStore has no entry', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAgentCredential).mockResolvedValue('ghp_from_db'); - - const result = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN_REVIEWER'); - - expect(result).toBe('ghp_from_db'); - expect(resolveAgentCredential).toHaveBeenCalled(); - }); - - it('resolves agent-specific credential', async () => { - vi.mocked(resolveAgentCredential).mockResolvedValue('ghp_agent_token'); + it('returns cached secrets when available', async () => { + const secrets = { GITHUB_TOKEN_IMPLEMENTER: 'ghp_123', TRELLO_API_KEY: 'trello_abc' }; + setSecrets('proj1', secrets); - const result = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + const result = await getAllProjectCredentials('proj1'); - expect(result).toBe('ghp_agent_token'); - expect(resolveAgentCredential).toHaveBeenCalledWith( - 'proj1', - 'org1', - 'review', - 'GITHUB_TOKEN', - ); + expect(result).toBe(secrets); + expect(resolveAllIntegrationCredentials).not.toHaveBeenCalled(); }); - it('returns null when agent credential not found', async () => { - vi.mocked(resolveAgentCredential).mockResolvedValue(null); + it('loads all credentials from repositories when not cached', async () => { + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([ + { category: 'pm', provider: 'trello', role: 'api_key', value: 'trello-key' }, + { category: 'pm', provider: 'trello', role: 'token', value: 'trello-token' }, + { category: 'scm', provider: 'github', role: 'implementer_token', value: 'ghp_impl' }, + ]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({ + OPENROUTER_API_KEY: 'or-key', + }); - const result = await getAgentCredential('proj1', 'review', 'MISSING_KEY'); + const result = await getAllProjectCredentials('proj1'); - expect(result).toBeNull(); + expect(result).toEqual({ + OPENROUTER_API_KEY: 'or-key', + TRELLO_API_KEY: 'trello-key', + TRELLO_TOKEN: 'trello-token', + GITHUB_TOKEN_IMPLEMENTER: 'ghp_impl', + }); }); - it('caches org ID for subsequent agent credential resolutions', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue(null); - vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject); - vi.mocked(resolveAgentCredential).mockResolvedValue('token'); + it('caches resolved secrets for future access', async () => { + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({ KEY: 'value' }); - await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); + await getAllProjectCredentials('proj1'); + const result = await getAllProjectCredentials('proj1'); - expect(configCache.setOrgIdForProject).toHaveBeenCalledWith('proj1', 'org1'); + expect(result).toEqual({ KEY: 'value' }); + // Called only once (second call uses secretsStore) + expect(resolveAllIntegrationCredentials).toHaveBeenCalledTimes(1); }); - it('uses cached org ID when available', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAgentCredential).mockResolvedValue('token'); - - await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); - - expect(findProjectByIdFromDb).not.toHaveBeenCalled(); - expect(resolveAgentCredential).toHaveBeenCalledWith( - 'proj1', - 'org1', - 'review', - 'GITHUB_TOKEN', - ); - }); + it('returns empty object when no credentials exist', async () => { + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({}); - it('resolves for different agent types independently', async () => { - vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAgentCredential) - .mockResolvedValueOnce('token_review') - .mockResolvedValueOnce('token_impl'); - - const result1 = await getAgentCredential('proj1', 'review', 'GITHUB_TOKEN'); - const result2 = await getAgentCredential('proj1', 'implementation', 'GITHUB_TOKEN'); - - expect(result1).toBe('token_review'); - expect(result2).toBe('token_impl'); - expect(resolveAgentCredential).toHaveBeenNthCalledWith( - 1, - 'proj1', - 'org1', - 'review', - 'GITHUB_TOKEN', - ); - expect(resolveAgentCredential).toHaveBeenNthCalledWith( - 2, - 'proj1', - 'org1', - 'implementation', - 'GITHUB_TOKEN', - ); + const result = await getAllProjectCredentials('proj1'); + expect(result).toEqual({}); }); }); @@ -543,18 +433,18 @@ describe('config/provider', () => { }); it('clears all cached data including secretsStore', async () => { - // Setup secrets in the permanent store setSecrets('proj1', { KEY: 'val' }); invalidateConfigCache(); expect(configCache.invalidate).toHaveBeenCalled(); - // Verify secretsStore was cleared — getProjectSecrets should hit DB + // Verify secretsStore was cleared — getAllProjectCredentials should hit DB vi.mocked(configCache.getOrgIdForProject).mockReturnValue('org1'); - vi.mocked(resolveAllCredentials).mockResolvedValue({ KEY: 'from_db' }); - const result = await getProjectSecrets('proj1'); + vi.mocked(resolveAllIntegrationCredentials).mockResolvedValue([]); + vi.mocked(resolveAllOrgCredentials).mockResolvedValue({ KEY: 'from_db' }); + const result = await getAllProjectCredentials('proj1'); expect(result).toEqual({ KEY: 'from_db' }); - expect(resolveAllCredentials).toHaveBeenCalled(); + expect(resolveAllOrgCredentials).toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts new file mode 100644 index 00000000..c2acf941 --- /dev/null +++ b/tests/unit/config/triggerConfig.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + GitHubTriggerConfigSchema, + JiraTriggerConfigSchema, + TrelloTriggerConfigSchema, + resolveGitHubTriggerEnabled, + resolveJiraTriggerEnabled, + resolveTrelloTriggerEnabled, +} from '../../../src/config/triggerConfig.js'; + +describe('TrelloTriggerConfigSchema', () => { + it('defaults all fields to true', () => { + const result = TrelloTriggerConfigSchema.parse({}); + expect(result).toEqual({ + cardMovedToBriefing: true, + cardMovedToPlanning: true, + cardMovedToTodo: true, + readyToProcessLabel: true, + commentMention: true, + }); + }); + + it('accepts explicit false values', () => { + const result = TrelloTriggerConfigSchema.parse({ + cardMovedToPlanning: false, + readyToProcessLabel: false, + }); + expect(result.cardMovedToPlanning).toBe(false); + expect(result.readyToProcessLabel).toBe(false); + expect(result.cardMovedToBriefing).toBe(true); // default still true + }); +}); + +describe('JiraTriggerConfigSchema', () => { + it('defaults all fields to true', () => { + const result = JiraTriggerConfigSchema.parse({}); + expect(result).toEqual({ + issueTransitioned: true, + readyToProcessLabel: true, + commentMention: true, + }); + }); +}); + +describe('GitHubTriggerConfigSchema', () => { + it('defaults existing triggers to true', () => { + const result = GitHubTriggerConfigSchema.parse({}); + expect(result.checkSuiteSuccess).toBe(true); + expect(result.checkSuiteFailure).toBe(true); + expect(result.prReviewSubmitted).toBe(true); + expect(result.prCommentMention).toBe(true); + expect(result.prReadyToMerge).toBe(true); + expect(result.prMerged).toBe(true); + }); + + it('defaults new opt-in triggers to false', () => { + const result = GitHubTriggerConfigSchema.parse({}); + expect(result.reviewRequested).toBe(false); + expect(result.prOpened).toBe(false); + }); +}); + +describe('resolveTrelloTriggerEnabled', () => { + it('returns true when config is undefined (backward compatible)', () => { + expect(resolveTrelloTriggerEnabled(undefined, 'cardMovedToBriefing')).toBe(true); + expect(resolveTrelloTriggerEnabled(undefined, 'readyToProcessLabel')).toBe(true); + expect(resolveTrelloTriggerEnabled(undefined, 'commentMention')).toBe(true); + }); + + it('returns true when key is not present in config', () => { + expect(resolveTrelloTriggerEnabled({}, 'cardMovedToBriefing')).toBe(true); + }); + + it('returns false when key is explicitly disabled', () => { + expect(resolveTrelloTriggerEnabled({ cardMovedToBriefing: false }, 'cardMovedToBriefing')).toBe( + false, + ); + }); + + it('returns true when key is explicitly enabled', () => { + expect(resolveTrelloTriggerEnabled({ cardMovedToPlanning: true }, 'cardMovedToPlanning')).toBe( + true, + ); + }); +}); + +describe('resolveJiraTriggerEnabled', () => { + it('returns true when config is undefined (backward compatible)', () => { + expect(resolveJiraTriggerEnabled(undefined, 'issueTransitioned')).toBe(true); + expect(resolveJiraTriggerEnabled(undefined, 'readyToProcessLabel')).toBe(true); + expect(resolveJiraTriggerEnabled(undefined, 'commentMention')).toBe(true); + }); + + it('returns false when key is explicitly disabled', () => { + expect(resolveJiraTriggerEnabled({ issueTransitioned: false }, 'issueTransitioned')).toBe( + false, + ); + }); + + it('returns true when config is empty (no explicit settings)', () => { + expect(resolveJiraTriggerEnabled({}, 'issueTransitioned')).toBe(true); + }); +}); + +describe('resolveGitHubTriggerEnabled', () => { + it('returns true for existing triggers when config is undefined', () => { + expect(resolveGitHubTriggerEnabled(undefined, 'checkSuiteSuccess')).toBe(true); + expect(resolveGitHubTriggerEnabled(undefined, 'checkSuiteFailure')).toBe(true); + expect(resolveGitHubTriggerEnabled(undefined, 'prReviewSubmitted')).toBe(true); + expect(resolveGitHubTriggerEnabled(undefined, 'prCommentMention')).toBe(true); + expect(resolveGitHubTriggerEnabled(undefined, 'prReadyToMerge')).toBe(true); + expect(resolveGitHubTriggerEnabled(undefined, 'prMerged')).toBe(true); + }); + + it('returns false for opt-in triggers when config is undefined', () => { + expect(resolveGitHubTriggerEnabled(undefined, 'reviewRequested')).toBe(false); + expect(resolveGitHubTriggerEnabled(undefined, 'prOpened')).toBe(false); + }); + + it('returns false for opt-in triggers when config is empty', () => { + expect(resolveGitHubTriggerEnabled({}, 'reviewRequested')).toBe(false); + expect(resolveGitHubTriggerEnabled({}, 'prOpened')).toBe(false); + }); + + it('returns true for opt-in triggers when explicitly enabled', () => { + expect(resolveGitHubTriggerEnabled({ reviewRequested: true }, 'reviewRequested')).toBe(true); + expect(resolveGitHubTriggerEnabled({ prOpened: true }, 'prOpened')).toBe(true); + }); + + it('returns false when existing trigger is explicitly disabled', () => { + expect(resolveGitHubTriggerEnabled({ checkSuiteSuccess: false }, 'checkSuiteSuccess')).toBe( + false, + ); + }); +}); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index 11b055c5..1def43a7 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -56,13 +56,15 @@ const projectRowWithBackend = { const trelloIntegration = { id: 1, projectId: 'proj1', - type: 'trello' as const, + category: 'pm' as const, + provider: 'trello' as const, config: { boardId: 'board123', lists: { todo: 'list-todo', done: 'list-done' }, labels: { processing: 'label-proc' }, customFields: { cost: 'cf-cost' }, }, + triggers: {}, createdAt: new Date(), updatedAt: new Date(), }; @@ -70,13 +72,15 @@ const trelloIntegration = { const jiraIntegration = { id: 3, projectId: 'proj1', - type: 'jira' as const, + category: 'pm' as const, + provider: 'jira' as const, config: { projectKey: 'PROJ', baseUrl: 'https://test.atlassian.net', statuses: { briefing: 'Briefing', planning: 'Planning', todo: 'To Do' }, labels: { processing: 'my-proc', readyToProcess: 'my-ready' }, }, + triggers: {}, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index b8bc3da2..3d20d8ff 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -11,22 +11,17 @@ import { createCredential, deleteCredential, listOrgCredentials, - listProjectOverrides, - removeAgentCredentialOverride, - removeProjectCredentialOverride, - resolveAgentCredential, - resolveAllCredentials, - resolveCredential, - setAgentCredentialOverride, - setProjectCredentialOverride, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, + resolveIntegrationCredential, + resolveOrgCredential, updateCredential, } from '../../../../src/db/repositories/credentialsRepository.js'; /** * Creates a mock Drizzle query chain that supports the common patterns: - * select().from().where(), select().from().innerJoin().where(), - * insert().values().returning(), insert().values().onConflictDoUpdate(), - * update().set().where(), delete().from().where() + * select().from().innerJoin().where(), select().from().innerJoin().innerJoin().where(), + * insert().values().returning(), update().set().where(), delete().from().where() */ function createMockDb() { const chain: Record> = {}; @@ -34,10 +29,12 @@ function createMockDb() { // Terminal methods that return results chain.where = vi.fn().mockResolvedValue([]); chain.returning = vi.fn().mockResolvedValue([]); - chain.onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); // Chain methods - chain.innerJoin = vi.fn().mockReturnValue({ where: chain.where }); + chain.innerJoin = vi.fn().mockReturnValue({ + where: chain.where, + innerJoin: vi.fn().mockReturnValue({ where: chain.where }), + }); chain.from = vi.fn().mockReturnValue({ where: chain.where, innerJoin: chain.innerJoin, @@ -45,7 +42,6 @@ function createMockDb() { chain.set = vi.fn().mockReturnValue({ where: chain.where }); chain.values = vi.fn().mockReturnValue({ returning: chain.returning, - onConflictDoUpdate: chain.onConflictDoUpdate, }); const db = { @@ -71,150 +67,95 @@ describe('credentialsRepository', () => { vi.clearAllMocks(); }); - describe('resolveCredential', () => { - it('returns project override value when found', async () => { - // First query (project override) returns a result - mockDb.chain.where.mockResolvedValueOnce([{ value: 'project-override-secret' }]); - - const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); - expect(result).toBe('project-override-secret'); - - // Should only call select once (found override, short-circuits) - expect(mockDb.db.select).toHaveBeenCalledTimes(1); - }); - - it('falls back to org default when no project override', async () => { - // First query (project override) returns empty - mockDb.chain.where.mockResolvedValueOnce([]); - // Second query (org default) returns a result - mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-default-secret' }]); + describe('resolveIntegrationCredential', () => { + it('returns decrypted value when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'trello-api-key', orgId: 'org1' }]); - const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); - expect(result).toBe('org-default-secret'); - - // Two selects: override check + org default check - expect(mockDb.db.select).toHaveBeenCalledTimes(2); + const result = await resolveIntegrationCredential('proj1', 'pm', 'api_key'); + expect(result).toBe('trello-api-key'); }); - it('returns null when neither override nor org default exists', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); + it('returns null when not found', async () => { mockDb.chain.where.mockResolvedValueOnce([]); - const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); + const result = await resolveIntegrationCredential('proj1', 'pm', 'api_key'); expect(result).toBeNull(); }); }); - describe('resolveAllCredentials', () => { - it('merges org defaults with project overrides', async () => { - // First query: org defaults + describe('resolveAllIntegrationCredentials', () => { + it('returns all integration credentials for a project', async () => { mockDb.chain.where.mockResolvedValueOnce([ - { envVarKey: 'GITHUB_TOKEN', value: 'org-gh-token' }, - { envVarKey: 'TRELLO_API_KEY', value: 'org-trello-key' }, - ]); - // Second query: project overrides - mockDb.chain.where.mockResolvedValueOnce([ - { envVarKey: 'GITHUB_TOKEN', value: 'project-gh-token' }, + { category: 'pm', provider: 'trello', role: 'api_key', value: 'tkey', orgId: 'org1' }, + { category: 'pm', provider: 'trello', role: 'token', value: 'ttoken', orgId: 'org1' }, + { + category: 'scm', + provider: 'github', + role: 'implementer_token', + value: 'ghp_impl', + orgId: 'org1', + }, ]); - const result = await resolveAllCredentials('proj1', 'org1'); - expect(result).toEqual({ - GITHUB_TOKEN: 'project-gh-token', // override wins - TRELLO_API_KEY: 'org-trello-key', // org default kept + const result = await resolveAllIntegrationCredentials('proj1'); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + category: 'pm', + provider: 'trello', + role: 'api_key', + value: 'tkey', + }); + expect(result[2]).toEqual({ + category: 'scm', + provider: 'github', + role: 'implementer_token', + value: 'ghp_impl', }); }); - it('returns only org defaults when no overrides', async () => { - mockDb.chain.where.mockResolvedValueOnce([{ envVarKey: 'KEY1', value: 'val1' }]); - mockDb.chain.where.mockResolvedValueOnce([]); // no overrides - - const result = await resolveAllCredentials('proj1', 'org1'); - expect(result).toEqual({ KEY1: 'val1' }); - }); - - it('returns empty when no credentials exist', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); + it('returns empty array when no integration credentials exist', async () => { mockDb.chain.where.mockResolvedValueOnce([]); - const result = await resolveAllCredentials('proj1', 'org1'); - expect(result).toEqual({}); + const result = await resolveAllIntegrationCredentials('proj1'); + expect(result).toEqual([]); }); }); - describe('resolveAgentCredential', () => { - it('returns agent-scoped override when found', async () => { - // First query (agent override) returns a result - mockDb.chain.where.mockResolvedValueOnce([{ value: 'agent-override-secret' }]); - - const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); - expect(result).toBe('agent-override-secret'); - - // Should only call select once (found agent override, short-circuits) - expect(mockDb.db.select).toHaveBeenCalledTimes(1); - }); - - it('falls through to project override when no agent override', async () => { - // First query (agent override) returns empty - mockDb.chain.where.mockResolvedValueOnce([]); - // Second query (project override via resolveCredential) returns a result - mockDb.chain.where.mockResolvedValueOnce([{ value: 'project-override-secret' }]); - - const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); - expect(result).toBe('project-override-secret'); + describe('resolveOrgCredential', () => { + it('returns value when org default exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'or-api-key' }]); - // Two selects: agent override check + project override check - expect(mockDb.db.select).toHaveBeenCalledTimes(2); + const result = await resolveOrgCredential('org1', 'OPENROUTER_API_KEY'); + expect(result).toBe('or-api-key'); }); - it('falls through to org default when no agent or project override', async () => { - // First query (agent override) returns empty + it('returns null when no org default', async () => { mockDb.chain.where.mockResolvedValueOnce([]); - // Second query (project override) returns empty - mockDb.chain.where.mockResolvedValueOnce([]); - // Third query (org default) returns a result - mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-default-secret' }]); - - const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); - expect(result).toBe('org-default-secret'); - expect(mockDb.db.select).toHaveBeenCalledTimes(3); - }); - - it('returns null when no override at any level', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); - mockDb.chain.where.mockResolvedValueOnce([]); - mockDb.chain.where.mockResolvedValueOnce([]); - - const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); + const result = await resolveOrgCredential('org1', 'MISSING_KEY'); expect(result).toBeNull(); }); }); - describe('setAgentCredentialOverride', () => { - it('deletes then inserts agent-scoped override', async () => { - mockDb.chain.where.mockResolvedValueOnce(undefined); // delete - mockDb.chain.returning.mockResolvedValueOnce([]); // insert (no returning needed) - - await setAgentCredentialOverride('proj1', 'GITHUB_TOKEN', 'review', 42); + describe('resolveAllOrgCredentials', () => { + it('returns all org default credentials as key-value map', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'OPENROUTER_API_KEY', value: 'or-key' }, + { envVarKey: 'ANTHROPIC_API_KEY', value: 'ant-key' }, + ]); - expect(mockDb.db.delete).toHaveBeenCalledTimes(1); - expect(mockDb.db.insert).toHaveBeenCalledTimes(1); - expect(mockDb.chain.values).toHaveBeenCalledWith({ - projectId: 'proj1', - envVarKey: 'GITHUB_TOKEN', - credentialId: 42, - agentType: 'review', + const result = await resolveAllOrgCredentials('org1'); + expect(result).toEqual({ + OPENROUTER_API_KEY: 'or-key', + ANTHROPIC_API_KEY: 'ant-key', }); }); - }); - - describe('removeAgentCredentialOverride', () => { - it('deletes agent-scoped override', async () => { - mockDb.chain.where.mockResolvedValueOnce(undefined); - await removeAgentCredentialOverride('proj1', 'GITHUB_TOKEN', 'review'); + it('returns empty object when no credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); - expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + const result = await resolveAllOrgCredentials('org1'); + expect(result).toEqual({}); }); }); @@ -374,50 +315,4 @@ describe('credentialsRepository', () => { expect(result).toEqual([]); }); }); - - describe('setProjectCredentialOverride', () => { - it('deletes then inserts project-wide override', async () => { - mockDb.chain.where.mockResolvedValueOnce(undefined); // delete - - await setProjectCredentialOverride('proj1', 'GITHUB_TOKEN', 42); - - expect(mockDb.db.delete).toHaveBeenCalledTimes(1); - expect(mockDb.db.insert).toHaveBeenCalledTimes(1); - expect(mockDb.chain.values).toHaveBeenCalledWith({ - projectId: 'proj1', - envVarKey: 'GITHUB_TOKEN', - credentialId: 42, - agentType: null, - }); - }); - }); - - describe('removeProjectCredentialOverride', () => { - it('deletes override for project and key', async () => { - mockDb.chain.where.mockResolvedValueOnce(undefined); - - await removeProjectCredentialOverride('proj1', 'GITHUB_TOKEN'); - - expect(mockDb.db.delete).toHaveBeenCalledTimes(1); - }); - }); - - describe('listProjectOverrides', () => { - it('returns overrides with credential names', async () => { - const mockOverrides = [ - { envVarKey: 'GITHUB_TOKEN', credentialId: 42, credentialName: 'Bot Token' }, - ]; - mockDb.chain.where.mockResolvedValueOnce(mockOverrides); - - const result = await listProjectOverrides('proj1'); - expect(result).toEqual(mockOverrides); - }); - - it('returns empty array when no overrides', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); - - const result = await listProjectOverrides('proj1'); - expect(result).toEqual([]); - }); - }); }); diff --git a/tests/unit/db/repositories/settingsRepository.test.ts b/tests/unit/db/repositories/settingsRepository.test.ts index 2f1ce12f..70a2fad7 100644 --- a/tests/unit/db/repositories/settingsRepository.test.ts +++ b/tests/unit/db/repositories/settingsRepository.test.ts @@ -236,7 +236,9 @@ describe('settingsRepository', () => { describe('listProjectIntegrations', () => { it('returns integrations for project', async () => { - const integrations = [{ id: 1, projectId: 'p1', type: 'trello', config: {} }]; + const integrations = [ + { id: 1, projectId: 'p1', category: 'pm', provider: 'trello', config: {}, triggers: {} }, + ]; mockDb.chain.where.mockResolvedValueOnce(integrations); const result = await listProjectIntegrations('p1'); @@ -248,14 +250,16 @@ describe('settingsRepository', () => { it('deletes then inserts integration', async () => { mockDb.chain.where.mockResolvedValueOnce(undefined); // delete - await upsertProjectIntegration('p1', 'trello', { boardId: 'abc' }); + await upsertProjectIntegration('p1', 'pm', 'trello', { boardId: 'abc' }); expect(mockDb.db.delete).toHaveBeenCalledTimes(1); expect(mockDb.db.insert).toHaveBeenCalledTimes(1); expect(mockDb.chain.values).toHaveBeenCalledWith({ projectId: 'p1', - type: 'trello', + category: 'pm', + provider: 'trello', config: { boardId: 'abc' }, + triggers: {}, }); }); }); diff --git a/tests/unit/gadgets/fileInsertContent.test.ts b/tests/unit/gadgets/fileInsertContent.test.ts new file mode 100644 index 00000000..01d21bb7 --- /dev/null +++ b/tests/unit/gadgets/fileInsertContent.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock readTracking so we don't have to pre-mark files +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + assertFileRead: vi.fn(), // No-op — skip read guard + markFileRead: vi.fn(), + hasReadFile: vi.fn().mockReturnValue(true), + clearReadTracking: vi.fn(), + invalidateFileRead: vi.fn(), + hasListedDirectory: vi.fn().mockReturnValue(false), + markDirectoryListed: vi.fn(), +})); + +// Mock post-edit checks to avoid running tsc/biome +vi.mock('../../../src/gadgets/shared/postEditChecks.js', () => ({ + runPostEditChecks: vi.fn().mockReturnValue(null), +})); + +// Mock diagnosticState to avoid side effects +vi.mock('../../../src/gadgets/shared/diagnosticState.js', () => ({ + updateDiagnosticState: vi.fn(), + formatDiagnosticStatus: vi + .fn() + .mockReturnValue('## Diagnostic Status\n\n✅ All edited files pass type checking'), + runDiagnosticsWithTracking: vi.fn().mockReturnValue(null), + clearDiagnosticState: vi.fn(), + trackModifiedFile: vi.fn(), + getModifiedFiles: vi.fn().mockReturnValue([]), + clearModifiedFiles: vi.fn(), + recordEditFailure: vi.fn().mockReturnValue(1), + clearEditFailure: vi.fn(), + clearEditFailures: vi.fn(), + recordDiagnosticLoop: vi.fn().mockReturnValue(1), + clearDiagnosticLoop: vi.fn(), + getDiagnosticLoopFiles: vi.fn().mockReturnValue(new Map()), + hasAnyDiagnosticErrors: vi.fn().mockReturnValue(false), + getFilesWithErrors: vi.fn().mockReturnValue([]), +})); + +import { FileInsertContent } from '../../../src/gadgets/FileInsertContent.js'; + +let tmpDir: string; +let gadget: FileInsertContent; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cascade-test-insert-')); + gadget = new FileInsertContent(); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function createFile(name: string, content: string): string { + const filePath = join(tmpDir, name); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +describe('FileInsertContent', () => { + describe('insert before line', () => { + it('inserts content before line 1 (prepend)', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'before', + content: 'newline', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('newline\nline1\nline2\nline3\n'); + expect(result).toContain('Inserted 1 line before line 1'); + }); + + it('inserts content before a middle line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'before', + content: 'inserted', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\ninserted\nline2\nline3\n'); + }); + + it('appends at end when line exceeds file length with mode=before', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 999, + mode: 'before', + content: 'appended', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('appended'); + expect(result).toContain('Appended'); + }); + + it('inserts multiline content before a line', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'before', + content: 'newA\nnewB', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nnewA\nnewB\nline2\n'); + }); + }); + + describe('insert after line', () => { + it('inserts content after line 1', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'inserted', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\ninserted\nline2\nline3\n'); + }); + + it('appends at end when line >= line count', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 100, + mode: 'after', + content: 'appended', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('appended'); + expect(result).toContain('Appended'); + }); + + it('inserts multiline content after a line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'newA\nnewB', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nnewA\nnewB\nline2\nline3\n'); + }); + + it('returns output with status=success for non-TS files', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'new content', + }); + + expect(result).toContain('status=success'); + }); + }); + + describe('output format', () => { + it('output includes the file path', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'new', + }); + + expect(result).toContain(`path=${filePath}`); + }); + + it('output contains context lines around the insertion point', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'after', + content: 'inserted', + }); + + expect(result).toContain('inserted'); + }); + }); + + describe('new file creation', () => { + it('creates a new file when it does not exist', () => { + const filePath = join(tmpDir, 'newfile.txt'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 0, + mode: 'before', + content: 'first line', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('first line'); + expect(result).toContain('status=success'); + }); + }); +}); diff --git a/tests/unit/gadgets/fileRemoveContent.test.ts b/tests/unit/gadgets/fileRemoveContent.test.ts new file mode 100644 index 00000000..e71c17f8 --- /dev/null +++ b/tests/unit/gadgets/fileRemoveContent.test.ts @@ -0,0 +1,240 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock readTracking so we don't have to pre-mark files +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + assertFileRead: vi.fn(), // No-op — skip read guard + markFileRead: vi.fn(), + hasReadFile: vi.fn().mockReturnValue(true), + clearReadTracking: vi.fn(), + invalidateFileRead: vi.fn(), + hasListedDirectory: vi.fn().mockReturnValue(false), + markDirectoryListed: vi.fn(), +})); + +// Mock post-edit checks to avoid running tsc/biome +vi.mock('../../../src/gadgets/shared/postEditChecks.js', () => ({ + runPostEditChecks: vi.fn().mockReturnValue(null), +})); + +// Mock diagnosticState to avoid side effects +vi.mock('../../../src/gadgets/shared/diagnosticState.js', () => ({ + updateDiagnosticState: vi.fn(), + formatDiagnosticStatus: vi + .fn() + .mockReturnValue('## Diagnostic Status\n\n✅ All edited files pass type checking'), + runDiagnosticsWithTracking: vi.fn().mockReturnValue(null), + clearDiagnosticState: vi.fn(), + trackModifiedFile: vi.fn(), + getModifiedFiles: vi.fn().mockReturnValue([]), + clearModifiedFiles: vi.fn(), + recordEditFailure: vi.fn().mockReturnValue(1), + clearEditFailure: vi.fn(), + clearEditFailures: vi.fn(), + recordDiagnosticLoop: vi.fn().mockReturnValue(1), + clearDiagnosticLoop: vi.fn(), + getDiagnosticLoopFiles: vi.fn().mockReturnValue(new Map()), + hasAnyDiagnosticErrors: vi.fn().mockReturnValue(false), + getFilesWithErrors: vi.fn().mockReturnValue([]), +})); + +import { FileRemoveContent } from '../../../src/gadgets/FileRemoveContent.js'; + +let tmpDir: string; +let gadget: FileRemoveContent; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cascade-test-remove-')); + gadget = new FileRemoveContent(); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function createFile(name: string, content: string): string { + const filePath = join(tmpDir, name); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +describe('FileRemoveContent', () => { + describe('remove single line', () => { + it('removes a single line from the file', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline3\n'); + expect(result).toContain('Removed 1 line (line 2)'); + }); + + it('removes the first line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line2\nline3\n'); + }); + + it('removes the last line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 3, + endLine: 3, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline2'); + }); + }); + + describe('remove range of lines', () => { + it('removes a range of lines', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\nline4\nline5\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 4, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline5\n'); + expect(result).toContain('Removed 3 lines (lines 2-4)'); + }); + + it('removes all lines when range covers entire file', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 2, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe(''); + }); + + it('clamps endLine to file length when exceeding', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 100, + }); + + const written = readFileSync(filePath, 'utf-8'); + // After removing lines 2+ from 'line1\nline2\nline3\n', only line1 remains + expect(written).toContain('line1'); + expect(written).not.toContain('line2'); + expect(result).toContain('Removed'); + }); + }); + + describe('output format', () => { + it('output includes BEFORE section', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + expect(result).toContain('--- BEFORE ---'); + }); + + it('output includes AFTER section', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + expect(result).toContain('--- AFTER ---'); + }); + + it('output includes file path and status', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }); + + expect(result).toContain(`path=${filePath}`); + expect(result).toContain('status=success'); + }); + }); + + describe('error cases', () => { + it('throws when startLine > endLine', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 5, + endLine: 2, + }), + ).toThrow('Invalid line range'); + }); + + it('throws when startLine is beyond end of file', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 10, + endLine: 12, + }), + ).toThrow('beyond end of file'); + }); + + it('throws when file does not exist', () => { + const filePath = join(tmpDir, 'nonexistent.txt'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }), + ).toThrow('File not found'); + }); + }); +}); diff --git a/tests/unit/gadgets/shared/diagnosticState.test.ts b/tests/unit/gadgets/shared/diagnosticState.test.ts new file mode 100644 index 00000000..138f66c3 --- /dev/null +++ b/tests/unit/gadgets/shared/diagnosticState.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock the core diagnostics module to avoid running actual tsc/biome +vi.mock('../../../../src/gadgets/shared/diagnostics.js', () => ({ + runDiagnostics: vi.fn(), + shouldRunDiagnostics: vi.fn(), +})); + +import { + clearDiagnosticState, + formatDiagnosticStatus, + runDiagnosticsWithTracking, + updateDiagnosticState, +} from '../../../../src/gadgets/shared/diagnosticState.js'; +import { + runDiagnostics, + shouldRunDiagnostics, +} from '../../../../src/gadgets/shared/diagnostics.js'; + +const mockRunDiagnostics = vi.mocked(runDiagnostics); +const mockShouldRunDiagnostics = vi.mocked(shouldRunDiagnostics); + +afterEach(() => { + clearDiagnosticState(); + vi.clearAllMocks(); +}); + +describe('updateDiagnosticState', () => { + describe('TypeScript error parsing', () => { + it('parses TypeScript errors from raw output', () => { + const tsOutput = '/workspace/src/file.ts(10,5): error TS2345: Argument of type string'; + updateDiagnosticState( + '/workspace/src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('TS2345'); + expect(status).toContain('Argument of type string'); + }); + + it('parses line number from TypeScript output', () => { + const tsOutput = '/workspace/src/file.ts(42,3): error TS1234: Some error'; + updateDiagnosticState( + '/workspace/src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('line 42'); + }); + + it('falls back to generic message when no pattern matches', () => { + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + // No raw output — should use generic fallback + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Type errors detected'); + }); + + it('ignores TS error lines not matching the file path', () => { + const tsOutput = '/workspace/src/other.ts(10,5): error TS2345: Some error'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + // Falls back to generic since the error is in other.ts + expect(status).toContain('Type errors detected'); + }); + }); + + describe('Biome parse error parsing', () => { + it('parses biome parse errors', () => { + const biomeOutput = 'src/file.ts:5:10 parse error something went wrong'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: true, hasLintErrors: false }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Parse'); + expect(status).toContain('parse error'); + }); + + it('falls back to generic parse error message', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Parse error detected'); + }); + }); + + describe('Biome lint error parsing', () => { + it('parses lint rule names from biome output', () => { + const biomeOutput = ` +src/file.ts:3:5 lint/suspicious/noDoubleEquals + 3 │ if (x == y) {} +`; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('noDoubleEquals'); + }); + + it('deduplicates repeated lint rules to single error entry', () => { + const biomeOutput = ` +src/file.ts:3:5 lint/suspicious/noDoubleEquals +src/file.ts:7:5 lint/suspicious/noDoubleEquals +src/file.ts:9:5 lint/suspicious/noBannedTypes +`; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + // Both distinct rule names should appear + expect(status).toContain('noDoubleEquals'); + expect(status).toContain('noBannedTypes'); + // The file should show 2 errors (one per unique rule) + expect(status).toContain('2 errors'); + }); + + it('falls back to generic lint error message when no rules found', () => { + const biomeOutput = 'src/file.ts:5:10 some non-lint error'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('lint error'); + }); + + it('falls back to generic lint error when no raw output', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: true, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Lint error(s) detected'); + }); + }); + + describe('state management', () => { + it('removes file from tracking when all errors are resolved', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + let status = formatDiagnosticStatus(); + expect(status).toContain('⚠️'); + + // Fix the errors + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + status = formatDiagnosticStatus(); + expect(status).toContain('✅ All edited files pass type checking'); + }); + + it('tracks multiple files independently', () => { + updateDiagnosticState('src/file1.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + updateDiagnosticState('src/file2.ts', { + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('src/file1.ts'); + expect(status).toContain('src/file2.ts'); + expect(status).toContain('2 file(s)'); + }); + }); +}); + +describe('formatDiagnosticStatus', () => { + it('returns success message when no errors', () => { + const status = formatDiagnosticStatus(); + expect(status).toBe('## Diagnostic Status [v2]\n\n✅ All edited files pass type checking'); + }); + + it('shows error count per file', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('2 errors'); + }); + + it('shows singular "error" for exactly 1 error', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toMatch(/1 error\b/); + }); + + it('includes file path in output', () => { + updateDiagnosticState('src/myModule.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('src/myModule.ts'); + }); +}); + +describe('runDiagnosticsWithTracking', () => { + it('returns null when diagnostics should not run for file type', () => { + mockShouldRunDiagnostics.mockReturnValue(false); + + const result = runDiagnosticsWithTracking('config.json', '/abs/config.json'); + expect(result).toBeNull(); + expect(mockRunDiagnostics).not.toHaveBeenCalled(); + }); + + it('returns hasErrors=false when no issues found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result).not.toBeNull(); + expect(result?.hasErrors).toBe(false); + expect(result?.statusMessage).toBe('✓ No issues'); + }); + + it('returns hasErrors=true when TypeScript errors found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + rawTypescript: '/abs/src/file.ts(5,3): error TS2345: error TS2345: Something is wrong', + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result?.hasErrors).toBe(true); + expect(result?.statusMessage).toContain('diagnostic issue'); + }); + + it('returns hasErrors=true when parse errors found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result?.hasErrors).toBe(true); + }); + + it('updates diagnostic state after running', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + + const status = formatDiagnosticStatus(); + expect(status).toContain('⚠️'); + expect(status).toContain('src/file.ts'); + }); + + it('calls runDiagnostics with the validated path', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + runDiagnosticsWithTracking('src/file.ts', '/abs/workspace/src/file.ts'); + + expect(mockRunDiagnostics).toHaveBeenCalledWith('/abs/workspace/src/file.ts'); + }); +}); diff --git a/tests/unit/github/personas.test.ts b/tests/unit/github/personas.test.ts index be712673..89010cfd 100644 --- a/tests/unit/github/personas.test.ts +++ b/tests/unit/github/personas.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies before importing vi.mock('../../../src/config/provider.js', () => ({ - getAgentCredential: vi.fn(), + getIntegrationCredential: vi.fn(), })); vi.mock('../../../src/github/client.js', () => ({ @@ -18,13 +18,12 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -import { getAgentCredential } from '../../../src/config/provider.js'; +import { getIntegrationCredential } from '../../../src/config/provider.js'; import { getGitHubUserForToken } from '../../../src/github/client.js'; import { getPersonaForAgentType, getPersonaForLogin, getPersonaToken, - getTokenKeyForPersona, isCascadeBot, resolvePersonaIdentities, } from '../../../src/github/personas.js'; @@ -61,90 +60,56 @@ describe('personas', () => { }); }); - // ======================================================================== - // getTokenKeyForPersona - // ======================================================================== - - describe('getTokenKeyForPersona', () => { - it('returns GITHUB_TOKEN_IMPLEMENTER for implementer', () => { - expect(getTokenKeyForPersona('implementer')).toBe('GITHUB_TOKEN_IMPLEMENTER'); - }); - - it('returns GITHUB_TOKEN_REVIEWER for reviewer', () => { - expect(getTokenKeyForPersona('reviewer')).toBe('GITHUB_TOKEN_REVIEWER'); - }); - }); - // ======================================================================== // getPersonaToken // ======================================================================== describe('getPersonaToken', () => { it('resolves implementer token for implementation agent', async () => { - vi.mocked(getAgentCredential).mockResolvedValue('ghp_impl_token'); + vi.mocked(getIntegrationCredential).mockResolvedValue('ghp_impl_token'); const token = await getPersonaToken('project1', 'implementation'); expect(token).toBe('ghp_impl_token'); - expect(getAgentCredential).toHaveBeenCalledWith( - 'project1', - 'implementation', - 'GITHUB_TOKEN_IMPLEMENTER', - ); + expect(getIntegrationCredential).toHaveBeenCalledWith('project1', 'scm', 'implementer_token'); }); it('resolves reviewer token for review agent', async () => { - vi.mocked(getAgentCredential).mockResolvedValue('ghp_review_token'); + vi.mocked(getIntegrationCredential).mockResolvedValue('ghp_review_token'); const token = await getPersonaToken('project1', 'review'); expect(token).toBe('ghp_review_token'); - expect(getAgentCredential).toHaveBeenCalledWith( - 'project1', - 'review', - 'GITHUB_TOKEN_REVIEWER', - ); + expect(getIntegrationCredential).toHaveBeenCalledWith('project1', 'scm', 'reviewer_token'); }); it('resolves implementer token for respond-to-review agent', async () => { - vi.mocked(getAgentCredential).mockResolvedValue('ghp_impl_token'); + vi.mocked(getIntegrationCredential).mockResolvedValue('ghp_impl_token'); const token = await getPersonaToken('project1', 'respond-to-review'); expect(token).toBe('ghp_impl_token'); - expect(getAgentCredential).toHaveBeenCalledWith( - 'project1', - 'respond-to-review', - 'GITHUB_TOKEN_IMPLEMENTER', - ); + expect(getIntegrationCredential).toHaveBeenCalledWith('project1', 'scm', 'implementer_token'); }); it('throws when no token is found', async () => { - vi.mocked(getAgentCredential).mockResolvedValue(null); - - await expect(getPersonaToken('project1', 'implementation')).rejects.toThrow( - 'Missing GITHUB_TOKEN_IMPLEMENTER', + vi.mocked(getIntegrationCredential).mockRejectedValue( + new Error( + "Integration credential 'scm/implementer_token' not found for project 'project1'", + ), ); - }); - it('throws with descriptive error including project, agent, and persona', async () => { - vi.mocked(getAgentCredential).mockResolvedValue(null); - - await expect(getPersonaToken('my-project', 'review')).rejects.toThrow( - "Missing GITHUB_TOKEN_REVIEWER for project 'my-project' (agent: review, persona: reviewer)", + await expect(getPersonaToken('project1', 'implementation')).rejects.toThrow( + "Integration credential 'scm/implementer_token' not found", ); }); it('defaults unknown agent types to implementer persona', async () => { - vi.mocked(getAgentCredential).mockResolvedValue('ghp_token'); + vi.mocked(getIntegrationCredential).mockResolvedValue('ghp_token'); await getPersonaToken('project1', 'some-new-agent'); - expect(getAgentCredential).toHaveBeenCalledWith( - 'project1', - 'some-new-agent', - 'GITHUB_TOKEN_IMPLEMENTER', - ); + expect(getIntegrationCredential).toHaveBeenCalledWith('project1', 'scm', 'implementer_token'); }); }); @@ -154,7 +119,7 @@ describe('personas', () => { describe('resolvePersonaIdentities', () => { it('resolves both persona identities', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl') // implementer token .mockResolvedValueOnce('ghp_review'); // reviewer token vi.mocked(getGitHubUserForToken) @@ -170,7 +135,7 @@ describe('personas', () => { }); it('fetches fresh data on each call', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl') .mockResolvedValueOnce('ghp_review') .mockResolvedValueOnce('ghp_impl') @@ -185,11 +150,11 @@ describe('personas', () => { const second = await resolvePersonaIdentities('project1'); expect(first).toEqual(second); - expect(getAgentCredential).toHaveBeenCalledTimes(4); // Called on every invocation + expect(getIntegrationCredential).toHaveBeenCalledTimes(4); }); it('resolves separately for different projects', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl_1') .mockResolvedValueOnce('ghp_review_1') .mockResolvedValueOnce('ghp_impl_2') @@ -208,25 +173,31 @@ describe('personas', () => { }); it('throws when implementer token is missing', async () => { - vi.mocked(getAgentCredential).mockResolvedValue(null); + vi.mocked(getIntegrationCredential).mockRejectedValue( + new Error( + "Integration credential 'scm/implementer_token' not found for project 'project1'", + ), + ); await expect(resolvePersonaIdentities('project1')).rejects.toThrow( - "Missing GITHUB_TOKEN_IMPLEMENTER for project 'project1'", + "Integration credential 'scm/implementer_token' not found", ); }); it('throws when reviewer token is missing', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl') // implementer OK - .mockResolvedValueOnce(null); // reviewer missing + .mockRejectedValueOnce( + new Error("Integration credential 'scm/reviewer_token' not found for project 'project1'"), + ); await expect(resolvePersonaIdentities('project1')).rejects.toThrow( - "Missing GITHUB_TOKEN_REVIEWER for project 'project1'", + "Integration credential 'scm/reviewer_token' not found", ); }); it('throws when implementer identity cannot be resolved', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl') .mockResolvedValueOnce('ghp_review'); vi.mocked(getGitHubUserForToken) @@ -234,12 +205,12 @@ describe('personas', () => { .mockResolvedValueOnce('review-bot'); await expect(resolvePersonaIdentities('project1')).rejects.toThrow( - 'Failed to resolve GitHub identity for GITHUB_TOKEN_IMPLEMENTER', + 'Failed to resolve GitHub identity for implementer token', ); }); it('throws when reviewer identity cannot be resolved', async () => { - vi.mocked(getAgentCredential) + vi.mocked(getIntegrationCredential) .mockResolvedValueOnce('ghp_impl') .mockResolvedValueOnce('ghp_review'); vi.mocked(getGitHubUserForToken) @@ -247,7 +218,7 @@ describe('personas', () => { .mockResolvedValueOnce(null); // reviewer resolution fails await expect(resolvePersonaIdentities('project1')).rejects.toThrow( - 'Failed to resolve GitHub identity for GITHUB_TOKEN_REVIEWER', + 'Failed to resolve GitHub identity for reviewer token', ); }); }); diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index ec572234..d72c5aa8 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -8,12 +8,48 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -// Mock jira.js Version3Client (for other methods, not needed for raw fetch methods) +// Use vi.hoisted to create mock objects before vi.mock factories run +const { mockIssues, mockIssueComments, mockIssueSearch, mockIssueAttachments, mockMyself } = + vi.hoisted(() => ({ + mockIssues: { + getIssue: vi.fn(), + editIssue: vi.fn(), + createIssue: vi.fn(), + doTransition: vi.fn(), + getTransitions: vi.fn(), + }, + mockIssueComments: { + getComments: vi.fn(), + addComment: vi.fn(), + updateComment: vi.fn(), + }, + mockIssueSearch: { + searchForIssuesUsingJql: vi.fn(), + }, + mockIssueAttachments: { + addAttachment: vi.fn(), + }, + mockMyself: { + getCurrentUser: vi.fn(), + }, + })); + vi.mock('jira.js', () => ({ - Version3Client: vi.fn().mockImplementation(() => ({})), + Version3Client: vi.fn().mockImplementation(() => ({ + issues: mockIssues, + issueComments: mockIssueComments, + issueSearch: mockIssueSearch, + issueAttachments: mockIssueAttachments, + myself: mockMyself, + })), })); -import { _resetCloudIdCache, jiraClient, withJiraCredentials } from '../../../src/jira/client.js'; +import { + _resetCloudIdCache, + getJiraCredentials, + jiraClient, + withJiraCredentials, +} from '../../../src/jira/client.js'; describe('jiraClient', () => { const creds = { @@ -24,12 +60,26 @@ describe('jiraClient', () => { const expectedAuth = `Basic ${Buffer.from('bot@example.com:jira-token').toString('base64')}`; beforeEach(() => { - vi.clearAllMocks(); + // Reset only the call history of mock client methods, not their implementations + mockIssues.getIssue.mockReset(); + mockIssues.editIssue.mockReset(); + mockIssues.createIssue.mockReset(); + mockIssues.doTransition.mockReset(); + mockIssues.getTransitions.mockReset(); + mockIssueComments.getComments.mockReset(); + mockIssueComments.addComment.mockReset(); + mockIssueComments.updateComment.mockReset(); + mockIssueSearch.searchForIssuesUsingJql.mockReset(); + mockIssueAttachments.addAttachment.mockReset(); + mockMyself.getCurrentUser.mockReset(); _resetCloudIdCache(); }); afterEach(() => { - vi.restoreAllMocks(); + // Note: We don't call vi.restoreAllMocks() here because it would reset + // the Version3Client mock implementation from vi.mock(), breaking subsequent tests. + // Instead we clear only the fetch spy manually. + vi.clearAllMocks(); }); describe('getCloudId', () => { @@ -165,4 +215,248 @@ describe('jiraClient', () => { ).rejects.toThrow('No JIRA credentials in scope'); }); }); + + describe('getIssue', () => { + it('calls getIssue with the issue key and required fields', async () => { + const issueData = { key: 'TEST-1', fields: { summary: 'Test Issue' } }; + mockIssues.getIssue.mockResolvedValue(issueData); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssue('TEST-1')); + + expect(result).toEqual(issueData); + expect(mockIssues.getIssue).toHaveBeenCalledWith( + expect.objectContaining({ issueIdOrKey: 'TEST-1' }), + ); + }); + + it('throws when called outside scope', async () => { + await expect(jiraClient.getIssue('TEST-1')).rejects.toThrow('No JIRA credentials in scope'); + }); + }); + + describe('updateIssue', () => { + it('calls editIssue with summary', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => + jiraClient.updateIssue('TEST-1', { summary: 'New Title' }), + ); + + expect(mockIssues.editIssue).toHaveBeenCalledWith( + expect.objectContaining({ + issueIdOrKey: 'TEST-1', + fields: expect.objectContaining({ summary: 'New Title' }), + }), + ); + }); + + it('calls editIssue with description', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + const desc = { type: 'doc', version: 1, content: [] }; + + await withJiraCredentials(creds, () => + jiraClient.updateIssue('TEST-1', { description: desc }), + ); + + expect(mockIssues.editIssue).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.objectContaining({ description: desc }), + }), + ); + }); + }); + + describe('addComment', () => { + it('returns comment id', async () => { + mockIssueComments.addComment.mockResolvedValue({ id: 'comment-123' }); + + const id = await withJiraCredentials(creds, () => + jiraClient.addComment('TEST-1', { type: 'doc' }), + ); + + expect(id).toBe('comment-123'); + expect(mockIssueComments.addComment).toHaveBeenCalledWith( + expect.objectContaining({ issueIdOrKey: 'TEST-1' }), + ); + }); + + it('returns empty string when id is missing', async () => { + mockIssueComments.addComment.mockResolvedValue({}); + + const id = await withJiraCredentials(creds, () => + jiraClient.addComment('TEST-1', { type: 'doc' }), + ); + + expect(id).toBe(''); + }); + }); + + describe('createIssue', () => { + it('calls createIssue with the provided fields', async () => { + const newIssue = { id: '10001', key: 'TEST-2' }; + mockIssues.createIssue.mockResolvedValue(newIssue); + + const result = await withJiraCredentials(creds, () => + jiraClient.createIssue({ + project: { key: 'TEST' }, + summary: 'New Issue', + issuetype: { name: 'Task' }, + }), + ); + + expect(result).toEqual(newIssue); + expect(mockIssues.createIssue).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.objectContaining({ project: { key: 'TEST' } }), + }), + ); + }); + }); + + describe('transitionIssue', () => { + it('calls doTransition with issue key and transition id', async () => { + mockIssues.doTransition.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => jiraClient.transitionIssue('TEST-1', 'transition-31')); + + expect(mockIssues.doTransition).toHaveBeenCalledWith({ + issueIdOrKey: 'TEST-1', + transition: { id: 'transition-31' }, + }); + }); + }); + + describe('getTransitions', () => { + it('returns transitions array', async () => { + const transitions = [ + { id: '31', name: 'Done' }, + { id: '11', name: 'In Progress' }, + ]; + mockIssues.getTransitions.mockResolvedValue({ transitions }); + + const result = await withJiraCredentials(creds, () => jiraClient.getTransitions('TEST-1')); + + expect(result).toEqual(transitions); + }); + + it('returns empty array when transitions is missing', async () => { + mockIssues.getTransitions.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.getTransitions('TEST-1')); + + expect(result).toEqual([]); + }); + }); + + describe('updateLabels', () => { + it('calls editIssue with labels array', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => jiraClient.updateLabels('TEST-1', ['bug', 'urgent'])); + + expect(mockIssues.editIssue).toHaveBeenCalledWith({ + issueIdOrKey: 'TEST-1', + fields: { labels: ['bug', 'urgent'] }, + }); + }); + }); + + describe('searchIssues', () => { + it('returns issues from JQL search', async () => { + const issues = [ + { id: '1', key: 'TEST-1' }, + { id: '2', key: 'TEST-2' }, + ]; + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({ issues }); + + const result = await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST AND status = "In Progress"'), + ); + + expect(result).toEqual(issues); + expect(mockIssueSearch.searchForIssuesUsingJql).toHaveBeenCalledWith( + expect.objectContaining({ + jql: 'project = TEST AND status = "In Progress"', + }), + ); + }); + + it('returns empty array when issues is missing', async () => { + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST'), + ); + + expect(result).toEqual([]); + }); + + it('uses custom fields when provided', async () => { + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({ issues: [] }); + + await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST', ['summary', 'status', 'priority']), + ); + + expect(mockIssueSearch.searchForIssuesUsingJql).toHaveBeenCalledWith( + expect.objectContaining({ + fields: ['summary', 'status', 'priority'], + }), + ); + }); + }); + + describe('addAttachmentFile', () => { + it('calls addAttachment with buffer and filename', async () => { + mockIssueAttachments.addAttachment.mockResolvedValue(undefined); + const buf = Buffer.from('file content'); + + await withJiraCredentials(creds, () => + jiraClient.addAttachmentFile('TEST-1', buf, 'session.zip'), + ); + + expect(mockIssueAttachments.addAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + issueIdOrKey: 'TEST-1', + attachment: expect.objectContaining({ + filename: 'session.zip', + file: buf, + }), + }), + ); + }); + }); + + describe('getIssueComments', () => { + it('returns comments array', async () => { + const comments = [{ id: 'c1', body: 'First comment' }]; + mockIssueComments.getComments.mockResolvedValue({ comments }); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssueComments('TEST-1')); + + expect(result).toEqual(comments); + }); + + it('returns empty array when comments is missing', async () => { + mockIssueComments.getComments.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssueComments('TEST-1')); + + expect(result).toEqual([]); + }); + }); + + describe('getJiraCredentials', () => { + it('throws when called outside scope', () => { + expect(() => getJiraCredentials()).toThrow('No JIRA credentials in scope'); + }); + + it('returns credentials when inside withJiraCredentials scope', async () => { + let captured: ReturnType | undefined; + await withJiraCredentials(creds, async () => { + captured = getJiraCredentials(); + }); + expect(captured).toEqual(creds); + }); + }); }); diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts new file mode 100644 index 00000000..2bc1f72e --- /dev/null +++ b/tests/unit/router/config.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config provider to avoid DB connections +vi.mock('../../../src/config/provider.js', () => ({ + loadConfig: vi.fn(), +})); +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + setSecrets: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { loadConfig } from '../../../src/config/provider.js'; +import { getProjectConfig, loadProjectConfig, routerConfig } from '../../../src/router/config.js'; + +const mockLoadConfig = vi.mocked(loadConfig); + +// Helper to reset the module-level cache between tests +async function resetProjectConfig(): Promise { + // Re-import the module fresh to reset its state + vi.resetModules(); +} + +describe('routerConfig', () => { + it('has default Redis URL', () => { + expect(routerConfig.redisUrl).toBe('redis://localhost:6379'); + }); + + it('has default maxWorkers', () => { + expect(routerConfig.maxWorkers).toBe(3); + }); + + it('has default workerMemoryMb', () => { + expect(routerConfig.workerMemoryMb).toBe(4096); + }); + + it('has default dockerNetwork', () => { + expect(routerConfig.dockerNetwork).toBe('services_default'); + }); + + it('has default workerTimeoutMs of 30 minutes', () => { + expect(routerConfig.workerTimeoutMs).toBe(30 * 60 * 1000); + }); +}); + +describe('getProjectConfig', () => { + it('throws when config has not been loaded yet', async () => { + // We need a fresh module state without cached config + // Use a dynamic import with a reset module + vi.resetModules(); + + // Re-mock after resetModules + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: vi.fn(), + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + + const { getProjectConfig: freshGetProjectConfig } = await import( + '../../../src/router/config.js' + ); + expect(() => freshGetProjectConfig()).toThrow( + '[Router] Config not loaded yet. Call loadProjectConfig() first.', + ); + + vi.resetModules(); + }); +}); + +describe('loadProjectConfig', () => { + beforeEach(() => { + vi.resetModules(); + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: mockLoadConfig, + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + }); + + it('maps trello project config correctly', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p1', + name: 'Project 1', + repo: 'owner/repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + pm: { type: 'trello' }, + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toMatchObject({ + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }); + }); + + it('maps jira project config correctly', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p2', + name: 'JIRA Project', + repo: 'owner/jira-repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + pm: { type: 'jira' }, + jira: { + projectKey: 'MYPROJ', + baseUrl: 'https://mycompany.atlassian.net', + }, + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toMatchObject({ + id: 'p2', + repo: 'owner/jira-repo', + pmType: 'jira', + jira: { + projectKey: 'MYPROJ', + baseUrl: 'https://mycompany.atlassian.net', + }, + }); + }); + + it('defaults pmType to trello when pm.type is not set', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p3', + name: 'No PM type', + repo: 'owner/repo3', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + // No pm field + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects[0].pmType).toBe('trello'); + }); + + it('returns cached result on subsequent calls', async () => { + const innerMock = vi.fn().mockResolvedValue({ + projects: [ + { + id: 'p4', + name: 'Cached', + repo: 'owner/cached', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + }, + ], + }); + + vi.resetModules(); + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: innerMock, + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + await freshLoad(); + await freshLoad(); // Second call — should use cache + + expect(innerMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/router/index.test.ts b/tests/unit/router/index.test.ts new file mode 100644 index 00000000..1434072a --- /dev/null +++ b/tests/unit/router/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports that cause side effects +vi.mock('../../../src/router/queue.js', () => ({ + addJob: vi.fn(), + getQueueStats: vi.fn(), +})); +vi.mock('../../../src/router/worker-manager.js', () => ({ + getActiveWorkerCount: vi.fn(), + getActiveWorkers: vi.fn(), + startWorkerProcessor: vi.fn(), + stopWorkerProcessor: vi.fn(), +})); +vi.mock('@hono/node-server', () => ({ + serve: vi.fn(), +})); +vi.mock('../../../src/utils/webhookLogger.js', () => ({ + logWebhookCall: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); +vi.mock('../../../src/router/pre-actions.js', () => ({ + addEyesReactionToPR: vi.fn(), +})); +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn().mockResolvedValue({ projects: [] }), + getProjectConfig: vi.fn().mockReturnValue({ projects: [] }), +})); + +// Import the functions we want to test - they are module-private so we test through exports +// We'll use a re-export approach by importing the raw module +// Since these functions aren't exported, we test them via the Hono app behavior instead + +import { getProjectConfig } from '../../../src/router/config.js'; + +describe('router config integration', () => { + it('getProjectConfig returns cached projects', () => { + vi.mocked(getProjectConfig).mockReturnValue({ + projects: [ + { + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3', debug: 'list4' }, + labels: { readyToProcess: 'label1' }, + }, + }, + ], + }); + const config = getProjectConfig(); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].id).toBe('p1'); + }); +}); diff --git a/tests/unit/router/notifications.test.ts b/tests/unit/router/notifications.test.ts index 3271f4da..a0f4573b 100644 --- a/tests/unit/router/notifications.test.ts +++ b/tests/unit/router/notifications.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock config provider for DB secret resolution vi.mock('../../../src/config/provider.js', () => ({ - getProjectSecret: vi.fn(), + getIntegrationCredential: vi.fn(), findProjectByRepo: vi.fn(), + findProjectById: vi.fn(), })); // Mock getProjectGitHubToken (uses GITHUB_TOKEN_IMPLEMENTER with legacy fallback) @@ -27,7 +28,11 @@ vi.mock('../../../src/config/configCache.js', () => ({ })); import { getProjectGitHubToken } from '../../../src/config/projects.js'; -import { findProjectByRepo, getProjectSecret } from '../../../src/config/provider.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../../../src/config/provider.js'; import { extractPRNumber, formatDuration, @@ -35,9 +40,17 @@ import { } from '../../../src/router/notifications.js'; import type { CascadeJob, GitHubJob, JiraJob, TrelloJob } from '../../../src/router/queue.js'; -const mockGetProjectSecret = vi.mocked(getProjectSecret); +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); const mockFindProjectByRepo = vi.mocked(findProjectByRepo); +const mockFindProjectById = vi.mocked(findProjectById); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'test-trello-key', + 'pm/token': 'test-trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'test-jira-token', +}; // Mock global fetch const mockFetch = vi.fn(); @@ -135,11 +148,11 @@ describe('notifyTimeout', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); - // Default: DB returns secrets - mockGetProjectSecret.mockImplementation(async (_projectId, key) => { - if (key === 'TRELLO_API_KEY') return 'test-trello-key'; - if (key === 'TRELLO_TOKEN') return 'test-trello-token'; - throw new Error(`Secret '${key}' not found`); + // Default: DB returns credentials + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); }); mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); mockFindProjectByRepo.mockResolvedValue({ @@ -150,6 +163,15 @@ describe('notifyTimeout', () => { branchPrefix: 'feature/', trello: { boardId: 'b1', lists: {}, labels: {} }, }); + mockFindProjectById.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + jira: { baseUrl: 'https://test.atlassian.net', projectKey: 'DAM', statuses: {}, labels: {} }, + }); }); afterEach(() => { @@ -185,7 +207,7 @@ describe('notifyTimeout', () => { }); it('skips notification when Trello credentials are missing in DB', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); await notifyTimeout(trelloJob, defaultInfo); @@ -303,17 +325,6 @@ describe('notifyTimeout', () => { receivedAt: '2026-02-14T10:00:00.000Z', }; - beforeEach(() => { - mockGetProjectSecret.mockImplementation(async (_projectId, key) => { - if (key === 'TRELLO_API_KEY') return 'test-trello-key'; - if (key === 'TRELLO_TOKEN') return 'test-trello-token'; - if (key === 'JIRA_EMAIL') return 'bot@example.com'; - if (key === 'JIRA_API_TOKEN') return 'test-jira-token'; - if (key === 'JIRA_BASE_URL') return 'https://test.atlassian.net'; - throw new Error(`Secret '${key}' not found`); - }); - }); - it('posts a comment to the JIRA issue', async () => { mockFetch.mockResolvedValueOnce({ ok: true }); @@ -328,7 +339,7 @@ describe('notifyTimeout', () => { }); it('skips notification when JIRA credentials are missing in DB', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); await notifyTimeout(jiraJob, defaultInfo); diff --git a/tests/unit/router/pre-actions.test.ts b/tests/unit/router/pre-actions.test.ts index 8386197b..77c50b2f 100644 --- a/tests/unit/router/pre-actions.test.ts +++ b/tests/unit/router/pre-actions.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock config provider for DB secret resolution vi.mock('../../../src/config/provider.js', () => ({ - getProjectSecret: vi.fn(), + getIntegrationCredential: vi.fn(), findProjectByRepo: vi.fn(), })); @@ -21,14 +21,14 @@ vi.mock('../../../src/config/configCache.js', () => ({ }, })); -import { findProjectByRepo, getProjectSecret } from '../../../src/config/provider.js'; +import { findProjectByRepo, getIntegrationCredential } from '../../../src/config/provider.js'; import { _clearReviewerUsernameCache, addEyesReactionToPR, } from '../../../src/router/pre-actions.js'; import type { GitHubJob } from '../../../src/router/queue.js'; -const mockGetProjectSecret = vi.mocked(getProjectSecret); +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); const mockFindProjectByRepo = vi.mocked(findProjectByRepo); // Mock global fetch @@ -73,7 +73,7 @@ describe('addEyesReactionToPR', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockFindProjectByRepo.mockResolvedValue(mockProject); - mockGetProjectSecret.mockResolvedValue('test-reviewer-token'); + mockGetIntegrationCredential.mockResolvedValue('test-reviewer-token'); // Default fetch responses: // 1. GET /user -> reviewer username @@ -248,7 +248,7 @@ describe('addEyesReactionToPR', () => { }); it('skips when reviewer token is missing', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); const job = makeCheckSuiteJob(); await addEyesReactionToPR(job); diff --git a/tests/unit/router/reactions.test.ts b/tests/unit/router/reactions.test.ts index 2e3a33ec..ef4e37e5 100644 --- a/tests/unit/router/reactions.test.ts +++ b/tests/unit/router/reactions.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock config provider vi.mock('../../../src/config/provider.js', () => ({ - getProjectSecret: vi.fn(), + getIntegrationCredential: vi.fn(), findProjectByRepo: vi.fn(), + findProjectById: vi.fn(), })); // Mock getProjectGitHubToken @@ -26,13 +27,29 @@ vi.mock('../../../src/config/configCache.js', () => ({ }, })); +// Mock trello client +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(async (_creds: unknown, fn: () => Promise) => fn()), + trelloClient: { + addActionReaction: vi.fn(), + }, +})); + import { getProjectGitHubToken } from '../../../src/config/projects.js'; -import { findProjectByRepo, getProjectSecret } from '../../../src/config/provider.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../../../src/config/provider.js'; import { _resetJiraCloudIdCache, sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import { trelloClient, withTrelloCredentials } from '../../../src/trello/client.js'; -const mockGetProjectSecret = vi.mocked(getProjectSecret); +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); const mockFindProjectByRepo = vi.mocked(findProjectByRepo); +const mockFindProjectById = vi.mocked(findProjectById); +const mockAddActionReaction = vi.mocked(trelloClient.addActionReaction); +const mockWithTrelloCredentials = vi.mocked(withTrelloCredentials); // Mock global fetch const mockFetch = vi.fn(); @@ -41,6 +58,13 @@ vi.stubGlobal('fetch', mockFetch); const PROJECT_ID = 'test-project'; const REPO_FULL_NAME = 'owner/repo'; +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'test-trello-key', + 'pm/token': 'test-trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'test-jira-token', +}; + const TRELLO_COMMENT_PAYLOAD = { model: { id: 'board123', name: 'Test Board' }, action: { @@ -93,19 +117,29 @@ const JIRA_COMMENT_PAYLOAD = { describe('sendAcknowledgeReaction', () => { beforeEach(() => { mockFetch.mockReset(); + mockAddActionReaction.mockReset(); + mockWithTrelloCredentials.mockReset(); + mockWithTrelloCredentials.mockImplementation(async (_creds, fn) => fn()); _resetJiraCloudIdCache(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); // Default credential mocks - mockGetProjectSecret.mockImplementation(async (_projectId, key) => { - if (key === 'TRELLO_API_KEY') return 'test-trello-key'; - if (key === 'TRELLO_TOKEN') return 'test-trello-token'; - if (key === 'JIRA_EMAIL') return 'bot@example.com'; - if (key === 'JIRA_API_TOKEN') return 'test-jira-token'; - if (key === 'JIRA_BASE_URL') return 'https://test.atlassian.net'; - throw new Error(`Secret '${key}' not found`); + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + + mockFindProjectById.mockResolvedValue({ + id: PROJECT_ID, + name: 'Test', + repo: REPO_FULL_NAME, + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + jira: { baseUrl: 'https://test.atlassian.net', projectKey: 'PROJ', statuses: {}, labels: {} }, }); mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); @@ -129,20 +163,17 @@ describe('sendAcknowledgeReaction', () => { // ------------------------------------------------------------------------- describe('Trello reactions', () => { - it('sends 💭 reaction for commentCard action', async () => { - mockFetch.mockResolvedValueOnce({ ok: true }); + it('sends 👀 reaction for commentCard action', async () => { + mockAddActionReaction.mockResolvedValueOnce(undefined); await sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain('https://api.trello.com/1/actions/action123/reactions'); - expect(url).toContain('key=test-trello-key'); - expect(url).toContain('token=test-trello-token'); - expect(options.method).toBe('POST'); - const body = JSON.parse(options.body); - expect(body.shortName).toBe('thought_balloon'); - expect(body.native).toBe('💭'); + expect(mockAddActionReaction).toHaveBeenCalledOnce(); + expect(mockAddActionReaction).toHaveBeenCalledWith('action123', { + shortName: 'eyes', + native: '👀', + unified: '1f440', + }); }); it('skips reaction for non-commentCard Trello action', async () => { @@ -153,26 +184,24 @@ describe('sendAcknowledgeReaction', () => { await sendAcknowledgeReaction('trello', PROJECT_ID, payload); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockAddActionReaction).not.toHaveBeenCalled(); }); it('skips reaction when Trello credentials are missing', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); await sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockAddActionReaction).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('Missing Trello credentials'), ); }); it('logs warning on Trello API error but does not throw', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - text: async () => 'Unauthorized', - }); + mockAddActionReaction.mockRejectedValueOnce( + new Error('Failed to add reaction to action: 401'), + ); await expect( sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD), @@ -180,8 +209,7 @@ describe('sendAcknowledgeReaction', () => { expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('Trello reaction failed'), - 401, - 'Unauthorized', + expect.stringContaining('401'), ); }); @@ -193,8 +221,8 @@ describe('sendAcknowledgeReaction', () => { await sendAcknowledgeReaction('trello', PROJECT_ID, payload); - expect(mockFetch).not.toHaveBeenCalled(); - expect(mockGetProjectSecret).not.toHaveBeenCalled(); + expect(mockAddActionReaction).not.toHaveBeenCalled(); + expect(mockGetIntegrationCredential).not.toHaveBeenCalled(); }); }); @@ -388,7 +416,7 @@ describe('sendAcknowledgeReaction', () => { }); it('skips reaction when JIRA credentials are missing', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); @@ -399,7 +427,7 @@ describe('sendAcknowledgeReaction', () => { }); it('does not throw when credentials are missing', async () => { - mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); await expect( sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD), @@ -413,8 +441,7 @@ describe('sendAcknowledgeReaction', () => { describe('error handling', () => { it('catches unexpected errors without throwing', async () => { - // Make getProjectSecret throw unexpectedly inside the inner try block - mockGetProjectSecret.mockImplementation(() => { + mockGetIntegrationCredential.mockImplementation(() => { throw new Error('Unexpected sync error'); }); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index 92395d4d..cf67cdcd 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -8,53 +8,74 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -const { mockAddCardComment } = vi.hoisted(() => ({ - mockAddCardComment: vi.fn(), +// Use vi.hoisted to create all mock objects before factory functions run +const { mockCards, mockChecklists, mockLists } = vi.hoisted(() => ({ + mockCards: { + addCardComment: vi.fn(), + getCard: vi.fn(), + getCardActions: vi.fn(), + updateCard: vi.fn(), + addCardLabel: vi.fn(), + deleteCardLabel: vi.fn(), + createCardAttachment: vi.fn(), + createCardChecklist: vi.fn(), + getCardChecklists: vi.fn(), + updateCardCheckItem: vi.fn(), + createCard: vi.fn(), + }, + mockChecklists: { + createChecklistCheckItems: vi.fn(), + }, + mockLists: { + getListCards: vi.fn(), + }, })); // Mock trello.js client vi.mock('trello.js', () => ({ TrelloClient: vi.fn().mockImplementation(() => ({ - cards: { - addCardComment: mockAddCardComment, - }, + cards: mockCards, + checklists: mockChecklists, + lists: mockLists, })), })); import { TrelloClient } from 'trello.js'; -import { trelloClient, withTrelloCredentials } from '../../../src/trello/client.js'; - -const MockedTrelloClient = vi.mocked(TrelloClient); +import { + getTrelloCredentials, + trelloClient, + withTrelloCredentials, +} from '../../../src/trello/client.js'; describe('trelloClient', () => { const creds = { apiKey: 'test-key', token: 'test-token' }; beforeEach(() => { - vi.clearAllMocks(); - // Re-initialize the TrelloClient mock implementation after clearAllMocks - MockedTrelloClient.mockImplementation( - () => ({ cards: { addCardComment: mockAddCardComment } }) as unknown as TrelloClient, - ); + // Reset individual mock functions without clearing implementations + for (const fn of Object.values(mockCards)) fn.mockReset(); + for (const fn of Object.values(mockChecklists)) fn.mockReset(); + for (const fn of Object.values(mockLists)) fn.mockReset(); }); afterEach(() => { - vi.restoreAllMocks(); + // Don't call restoreAllMocks() as it would clear the Version3Client mock impl + vi.clearAllMocks(); }); describe('addComment', () => { it('returns the comment action ID from API response', async () => { - mockAddCardComment.mockResolvedValue({ id: 'action-abc123' }); + mockCards.addCardComment.mockResolvedValue({ id: 'action-abc123' }); const id = await withTrelloCredentials(creds, () => trelloClient.addComment('card-1', 'Hello world'), ); - expect(mockAddCardComment).toHaveBeenCalledWith({ id: 'card-1', text: 'Hello world' }); + expect(mockCards.addCardComment).toHaveBeenCalledWith({ id: 'card-1', text: 'Hello world' }); expect(id).toBe('action-abc123'); }); it('returns empty string when API response has no id', async () => { - mockAddCardComment.mockResolvedValue({}); + mockCards.addCardComment.mockResolvedValue({}); const id = await withTrelloCredentials(creds, () => trelloClient.addComment('card-1', 'Hello'), @@ -139,4 +160,207 @@ describe('trelloClient', () => { ); }); }); + + describe('getTrelloCredentials', () => { + it('throws when called outside scope', () => { + expect(() => getTrelloCredentials()).toThrow('No Trello credentials in scope'); + }); + + it('returns credentials when inside scope', async () => { + let captured: ReturnType | undefined; + await withTrelloCredentials(creds, async () => { + captured = getTrelloCredentials(); + }); + expect(captured).toEqual(creds); + }); + }); + + describe('getCard', () => { + it('returns a card with normalized fields', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + name: 'My Card', + desc: 'Card description', + url: 'https://trello.com/c/abc123', + shortUrl: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [{ id: 'label-1', name: 'Bug', color: 'red' }], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result).toEqual({ + id: 'card-1', + name: 'My Card', + desc: 'Card description', + url: 'https://trello.com/c/abc123', + shortUrl: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [{ id: 'label-1', name: 'Bug', color: 'red' }], + }); + expect(mockCards.getCard).toHaveBeenCalledWith({ id: 'card-1' }); + }); + + it('normalizes missing optional fields to empty strings', async () => { + mockCards.getCard.mockResolvedValue({ id: 'card-2' }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-2')); + + expect(result.name).toBe(''); + expect(result.desc).toBe(''); + expect(result.url).toBe(''); + expect(result.idList).toBe(''); + expect(result.labels).toEqual([]); + }); + + it('throws when called outside scope', async () => { + await expect(trelloClient.getCard('card-1')).rejects.toThrow( + 'No Trello credentials in scope', + ); + }); + }); + + describe('getCardComments', () => { + it('returns comments with mapped fields', async () => { + mockCards.getCardActions.mockResolvedValue([ + { + id: 'action-1', + date: '2026-01-01T00:00:00.000Z', + data: { text: 'Hello world' }, + memberCreator: { id: 'member-1', fullName: 'Alice', username: 'alice' }, + }, + ]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardComments('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'action-1', + date: '2026-01-01T00:00:00.000Z', + data: { text: 'Hello world' }, + memberCreator: { id: 'member-1', fullName: 'Alice', username: 'alice' }, + }); + }); + + it('returns empty array when no comments', async () => { + mockCards.getCardActions.mockResolvedValue([]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardComments('card-1'), + ); + + expect(result).toEqual([]); + }); + }); + + describe('updateCard', () => { + it('calls updateCard with name and desc', async () => { + mockCards.updateCard.mockResolvedValue({}); + + await withTrelloCredentials(creds, () => + trelloClient.updateCard('card-1', { name: 'New Title', desc: 'New desc' }), + ); + + expect(mockCards.updateCard).toHaveBeenCalledWith( + expect.objectContaining({ id: 'card-1', name: 'New Title', desc: 'New desc' }), + ); + }); + }); + + describe('createCard', () => { + it('returns a created card with normalized fields', async () => { + mockCards.createCard.mockResolvedValue({ + id: 'new-card', + name: 'New Feature', + desc: 'Description', + url: 'https://trello.com/c/new', + shortUrl: 'https://trello.com/c/new-short', + idList: 'list-todo', + labels: [], + }); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createCard('list-todo', { name: 'New Feature', desc: 'Description' }), + ); + + expect(result.id).toBe('new-card'); + expect(result.name).toBe('New Feature'); + expect(mockCards.createCard).toHaveBeenCalledWith( + expect.objectContaining({ idList: 'list-todo', name: 'New Feature' }), + ); + }); + }); + + describe('getCardChecklists', () => { + it('returns checklists with check items', async () => { + mockCards.getCardChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + idCard: 'card-1', + checkItems: [ + { id: 'item-1', name: 'Step 1', state: 'complete' }, + { id: 'item-2', name: 'Step 2', state: 'incomplete' }, + ], + }, + ]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardChecklists('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Implementation Steps'); + expect(result[0].checkItems[0].state).toBe('complete'); + expect(result[0].checkItems[1].state).toBe('incomplete'); + }); + }); + + describe('getCardAttachments', () => { + it('returns attachments via fetch', async () => { + const attachments = [ + { + id: 'att-1', + name: 'session.zip', + url: 'https://trello.com/attachments/att-1', + mimeType: 'application/zip', + bytes: 1024, + date: '2026-01-01T00:00:00.000Z', + }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(attachments), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardAttachments('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'att-1', + name: 'session.zip', + url: 'https://trello.com/attachments/att-1', + mimeType: 'application/zip', + bytes: 1024, + date: '2026-01-01T00:00:00.000Z', + }); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/cards/card-1/attachments'); + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Unauthorized', { status: 401 }), + ); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getCardAttachments('card-1')), + ).rejects.toThrow('Failed to get attachments: 401'); + }); + }); }); diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index 5bcdbc36..b860f03c 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -22,12 +22,20 @@ describe('PROpenedTrigger', () => { }, }; + /** Project with prOpened trigger explicitly enabled via github.triggers config */ + const mockProjectWithPrOpenedEnabled = { + ...mockProject, + github: { + triggers: { prOpened: true }, + }, + }; + beforeEach(() => { vi.clearAllMocks(); }); describe('matches', () => { - it('matches when action is opened and not draft', () => { + it('does not match by default (opt-in trigger, disabled without config)', () => { const ctx: TriggerContext = { project: mockProject, source: 'github', @@ -50,12 +58,38 @@ describe('PROpenedTrigger', () => { }, }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('matches when action is opened and not draft with prOpened enabled', () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'Test PR', + body: 'https://trello.com/c/abc123', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/test', sha: 'abc123' }, + base: { ref: 'main' }, + user: { login: 'author' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'author' }, + }, + }; + expect(trigger.matches(ctx)).toBe(true); }); it('does not match when source is not github', () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'trello', payload: {}, }; @@ -65,7 +99,7 @@ describe('PROpenedTrigger', () => { it('does not match when action is not opened', () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'closed', @@ -91,7 +125,7 @@ describe('PROpenedTrigger', () => { it('does not match draft PRs', () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'opened', @@ -117,7 +151,7 @@ describe('PROpenedTrigger', () => { it('does not match non-PR payloads', () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'opened', @@ -132,7 +166,7 @@ describe('PROpenedTrigger', () => { describe('handle', () => { it('returns result when PR body has Trello URL', async () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'opened', @@ -173,7 +207,7 @@ describe('PROpenedTrigger', () => { it('returns null when PR body has no Trello URL', async () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'opened', @@ -201,7 +235,7 @@ describe('PROpenedTrigger', () => { it('handles null PR body', async () => { const ctx: TriggerContext = { - project: mockProject, + project: mockProjectWithPrOpenedEnabled, source: 'github', payload: { action: 'opened', diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts new file mode 100644 index 00000000..99ddb952 --- /dev/null +++ b/tests/unit/triggers/review-requested.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { ReviewRequestedTrigger } from '../../../src/triggers/github/review-requested.js'; +import type { TriggerContext } from '../../../src/triggers/types.js'; + +describe('ReviewRequestedTrigger', () => { + const trigger = new ReviewRequestedTrigger(); + + const mockProject = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: { + briefing: 'briefing-list-id', + planning: 'planning-list-id', + todo: 'todo-list-id', + }, + labels: {}, + }, + // Review-requested is opt-in, default disabled + }; + + /** Project with reviewRequested trigger explicitly enabled */ + const mockProjectWithReviewRequested = { + ...mockProject, + github: { + triggers: { reviewRequested: true }, + }, + }; + + const mockPersonaIdentities = { + implementer: 'cascade-impl', + reviewer: 'cascade-reviewer', + }; + + const makeReviewRequestedPayload = (reviewerLogin = 'cascade-reviewer') => ({ + action: 'review_requested', + number: 42, + pull_request: { + number: 42, + title: 'Test PR', + body: 'Implements https://trello.com/c/abc123/card-name', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/test', sha: 'abc123' }, + base: { ref: 'main' }, + user: { login: 'author' }, + }, + requested_reviewer: { login: reviewerLogin }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'author' }, + }); + + describe('matches', () => { + it('does not match by default (opt-in trigger, disabled without config)', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('matches when review_requested and trigger is enabled', () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match when source is not github', () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'trello', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match on non-review_requested actions', () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: { + ...makeReviewRequestedPayload(), + action: 'opened', + }, + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when payload is not a PR payload', () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: { action: 'review_requested', something: 'else' }, + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + describe('handle', () => { + it('returns null when no persona identities', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload(), + // no personaIdentities + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when requested reviewer is not a CASCADE persona', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload('human-reviewer'), + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no requested reviewer in payload', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: { + ...makeReviewRequestedPayload(), + requested_reviewer: undefined, + }, + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when PR body has no Trello card URL', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: { + ...makeReviewRequestedPayload(), + pull_request: { + ...makeReviewRequestedPayload().pull_request, + body: 'No card URL here', + }, + }, + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('triggers review agent when reviewer persona is requested', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload('cascade-reviewer'), + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + expect(result?.prNumber).toBe(42); + expect(result?.cardId).toBe('abc123'); + expect(result?.agentInput).toMatchObject({ + prNumber: 42, + repoFullName: 'owner/repo', + triggerType: 'review-requested', + cardId: 'abc123', + }); + }); + + it('triggers review agent when implementer persona is requested', async () => { + const ctx: TriggerContext = { + project: mockProjectWithReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload('cascade-impl'), + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + }); +}); diff --git a/tests/unit/utils/envScrub.test.ts b/tests/unit/utils/envScrub.test.ts new file mode 100644 index 00000000..be1c1480 --- /dev/null +++ b/tests/unit/utils/envScrub.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { scrubSensitiveEnv } from '../../../src/utils/envScrub.js'; + +describe('scrubSensitiveEnv', () => { + let savedEnv: Record; + + beforeEach(() => { + // Save original env values for restoration + savedEnv = { + CREDENTIAL_MASTER_KEY: process.env.CREDENTIAL_MASTER_KEY, + DATABASE_URL: process.env.DATABASE_URL, + DATABASE_SSL: process.env.DATABASE_SSL, + REDIS_URL: process.env.REDIS_URL, + CASCADE_CREDENTIALS: process.env.CASCADE_CREDENTIALS, + CASCADE_CREDENTIALS_PROJECT_ID: process.env.CASCADE_CREDENTIALS_PROJECT_ID, + }; + }); + + afterEach(() => { + // Restore original env values + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('removes CREDENTIAL_MASTER_KEY from process.env', () => { + process.env.CREDENTIAL_MASTER_KEY = 'super-secret-key-abc123'; + scrubSensitiveEnv(); + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + }); + + it('removes DATABASE_URL from process.env', () => { + process.env.DATABASE_URL = 'postgresql://user:pass@host:5432/db'; + scrubSensitiveEnv(); + expect(process.env.DATABASE_URL).toBeUndefined(); + }); + + it('removes DATABASE_SSL from process.env', () => { + process.env.DATABASE_SSL = 'false'; + scrubSensitiveEnv(); + expect(process.env.DATABASE_SSL).toBeUndefined(); + }); + + it('removes REDIS_URL from process.env', () => { + process.env.REDIS_URL = 'redis://localhost:6379'; + scrubSensitiveEnv(); + expect(process.env.REDIS_URL).toBeUndefined(); + }); + + it('removes CASCADE_CREDENTIALS from process.env', () => { + process.env.CASCADE_CREDENTIALS = 'eyJzb21lIjoianNvbiJ9'; + scrubSensitiveEnv(); + expect(process.env.CASCADE_CREDENTIALS).toBeUndefined(); + }); + + it('removes CASCADE_CREDENTIALS_PROJECT_ID from process.env', () => { + process.env.CASCADE_CREDENTIALS_PROJECT_ID = 'my-project-id'; + scrubSensitiveEnv(); + expect(process.env.CASCADE_CREDENTIALS_PROJECT_ID).toBeUndefined(); + }); + + it('removes all sensitive keys in a single call', () => { + process.env.CREDENTIAL_MASTER_KEY = 'key1'; + process.env.DATABASE_URL = 'postgres://...'; + process.env.DATABASE_SSL = 'true'; + process.env.REDIS_URL = 'redis://...'; + process.env.CASCADE_CREDENTIALS = 'creds'; + process.env.CASCADE_CREDENTIALS_PROJECT_ID = 'proj-id'; + + scrubSensitiveEnv(); + + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + expect(process.env.DATABASE_URL).toBeUndefined(); + expect(process.env.DATABASE_SSL).toBeUndefined(); + expect(process.env.REDIS_URL).toBeUndefined(); + expect(process.env.CASCADE_CREDENTIALS).toBeUndefined(); + expect(process.env.CASCADE_CREDENTIALS_PROJECT_ID).toBeUndefined(); + }); + + it('does not remove non-sensitive environment variables', () => { + process.env.MY_APP_API_KEY = 'should-remain'; + process.env.PORT = '3000'; + + scrubSensitiveEnv(); + + expect(process.env.MY_APP_API_KEY).toBe('should-remain'); + expect(process.env.PORT).toBe('3000'); + + // Clean up test-specific vars + process.env.MY_APP_API_KEY = undefined; + process.env.PORT = undefined; + }); + + it('handles keys that were never set (undefined)', () => { + // Ensure they are undefined to start + process.env.CREDENTIAL_MASTER_KEY = undefined; + process.env.DATABASE_URL = undefined; + + // Should not throw + expect(() => scrubSensitiveEnv()).not.toThrow(); + + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + expect(process.env.DATABASE_URL).toBeUndefined(); + }); + + it('scrubbing is idempotent — calling twice does not throw', () => { + process.env.DATABASE_URL = 'postgres://...'; + scrubSensitiveEnv(); + expect(() => scrubSensitiveEnv()).not.toThrow(); + }); +}); diff --git a/tools/debug-run.ts b/tools/debug-run.ts index 3f87a0a2..c5f7b854 100644 --- a/tools/debug-run.ts +++ b/tools/debug-run.ts @@ -8,7 +8,7 @@ * Requires DATABASE_URL to be set. */ -import { getProjectSecrets } from '../src/config/provider.js'; +import { getAllProjectCredentials } from '../src/config/provider.js'; import { closeDb } from '../src/db/client.js'; import { findProjectByIdFromDb, @@ -52,7 +52,7 @@ async function main() { } // Scope Trello credentials for the debug analysis - const secrets = await getProjectSecrets(run.projectId); + const secrets = await getAllProjectCredentials(run.projectId); const trelloApiKey = secrets.TRELLO_API_KEY; const trelloToken = secrets.TRELLO_TOKEN; diff --git a/tools/manage-secrets.ts b/tools/manage-secrets.ts index f42adfc1..74ba6833 100644 --- a/tools/manage-secrets.ts +++ b/tools/manage-secrets.ts @@ -1,15 +1,16 @@ #!/usr/bin/env tsx /** - * Manage org-scoped credentials and per-project overrides. + * Manage org-scoped credentials. * * Usage: * npx tsx tools/manage-secrets.ts create [--name "..."] [--default] * npx tsx tools/manage-secrets.ts list * npx tsx tools/manage-secrets.ts delete - * npx tsx tools/manage-secrets.ts set-override [--agent-type ] - * npx tsx tools/manage-secrets.ts remove-override [--agent-type ] * npx tsx tools/manage-secrets.ts resolve * + * Note: Per-project credential overrides have been replaced by integration credentials. + * Use `cascade projects integration-credential-set` to link credentials to integrations. + * * Requires DATABASE_URL to be set. */ @@ -19,12 +20,8 @@ import { createCredential, deleteCredential, listOrgCredentials, - listProjectOverrides, - removeAgentCredentialOverride, - removeProjectCredentialOverride, - resolveAllCredentials, - setAgentCredentialOverride, - setProjectCredentialOverride, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, } from '../src/db/repositories/credentialsRepository.js'; function printUsage(): void { @@ -34,12 +31,6 @@ function printUsage(): void { ); console.log(' npx tsx tools/manage-secrets.ts list '); console.log(' npx tsx tools/manage-secrets.ts delete '); - console.log( - ' npx tsx tools/manage-secrets.ts set-override [--agent-type ]', - ); - console.log( - ' npx tsx tools/manage-secrets.ts remove-override [--agent-type ]', - ); console.log(' npx tsx tools/manage-secrets.ts resolve '); } @@ -109,54 +100,6 @@ async function handleDelete(args: string[]): Promise { console.log(`Deleted credential #${credId}`); } -function parseCredentialId(str: string): number { - const credId = Number.parseInt(str, 10); - if (Number.isNaN(credId)) { - console.error('Error: credential-id must be a number'); - process.exit(1); - } - return credId; -} - -async function handleSetOverride(args: string[]): Promise { - const [, projectId, envVarKey, credIdStr] = args; - if (!projectId || !envVarKey || !credIdStr) { - console.error('Error: set-override requires '); - printUsage(); - process.exit(1); - } - const credId = parseCredentialId(credIdStr); - const agentType = parseFlag(args, '--agent-type'); - if (agentType) { - await setAgentCredentialOverride(projectId, envVarKey, agentType, credId); - console.log( - `Set agent override: project ${projectId} → ${envVarKey} → credential #${credId} (agent: ${agentType})`, - ); - } else { - await setProjectCredentialOverride(projectId, envVarKey, credId); - console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); - } -} - -async function handleRemoveOverride(args: string[]): Promise { - const [, projectId, envVarKey] = args; - if (!projectId || !envVarKey) { - console.error('Error: remove-override requires '); - printUsage(); - process.exit(1); - } - const agentType = parseFlag(args, '--agent-type'); - if (agentType) { - await removeAgentCredentialOverride(projectId, envVarKey, agentType); - console.log( - `Removed agent override: project ${projectId} → ${envVarKey} (agent: ${agentType})`, - ); - } else { - await removeProjectCredentialOverride(projectId, envVarKey); - console.log(`Removed override: project ${projectId} → ${envVarKey}`); - } -} - async function handleResolve(args: string[]): Promise { const projectId = args[1]; if (!projectId) { @@ -169,26 +112,30 @@ async function handleResolve(args: string[]): Promise { console.error(`Project '${projectId}' not found`); process.exit(1); } - const resolved = await resolveAllCredentials(projectId, project.orgId); - const overrides = await listProjectOverrides(projectId); - const projectOverrideKeys = new Set( - overrides.filter((o) => !o.agentType).map((o) => o.envVarKey), - ); - const agentOverrides = overrides.filter((o) => o.agentType); - if (Object.keys(resolved).length === 0 && agentOverrides.length === 0) { + // Resolve org-level credentials + const orgCreds = await resolveAllOrgCredentials(project.orgId); + // Resolve integration credentials + const integrationCreds = await resolveAllIntegrationCredentials(projectId); + + if (Object.keys(orgCreds).length === 0 && integrationCreds.length === 0) { console.log(`No credentials resolved for project ${projectId}`); return; } + console.log(`Resolved credentials for project ${projectId} (org: ${project.orgId}):`); - for (const [key, value] of Object.entries(resolved)) { - const source = projectOverrideKeys.has(key) ? 'override' : 'org-default'; - console.log(` ${key}: ${maskValue(value)} [${source}]`); + + if (Object.keys(orgCreds).length > 0) { + console.log(' Org defaults:'); + for (const [key, value] of Object.entries(orgCreds)) { + console.log(` ${key}: ${maskValue(value)}`); + } } - if (agentOverrides.length > 0) { - console.log(' Agent-scoped overrides:'); - for (const o of agentOverrides) { - console.log(` ${o.envVarKey} → ${o.credentialName} (agent: ${o.agentType})`); + + if (integrationCreds.length > 0) { + console.log(' Integration credentials:'); + for (const c of integrationCreds) { + console.log(` ${c.category}/${c.provider} [${c.role}]: ${maskValue(c.value)}`); } } } @@ -197,8 +144,6 @@ const commandHandlers: Record Promise> = { create: handleCreate, list: handleList, delete: handleDelete, - 'set-override': handleSetOverride, - 'remove-override': handleRemoveOverride, resolve: handleResolve, }; diff --git a/tools/resolve-config.ts b/tools/resolve-config.ts index f637e289..7aff8b6c 100644 --- a/tools/resolve-config.ts +++ b/tools/resolve-config.ts @@ -8,7 +8,7 @@ * 3. Org-level agent_configs (org_id set, project_id IS NULL) * 4. Project-level agent_configs (project_id set) * 5. Project row overrides (model, cardBudgetUsd, agentBackend) - * 6. Resolved credentials (org defaults + project overrides) + * 6. Resolved credentials (integration credentials + org defaults) * * Usage: * npx tsx tools/resolve-config.ts @@ -18,10 +18,14 @@ */ import { and, eq, isNull } from 'drizzle-orm'; +import { + type IntegrationProvider, + PROVIDER_CREDENTIAL_ROLES, +} from '../src/config/integrationRoles.js'; import { closeDb, getDb } from '../src/db/client.js'; import { - listProjectOverrides, - resolveAllCredentials, + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, } from '../src/db/repositories/credentialsRepository.js'; import { agentConfigs, @@ -68,7 +72,7 @@ interface EffectiveConfig { }; trello: TrelloIntegrationConfig | null; credentials: Record; - credentialOverrides: { envVarKey: string; credentialId: number; credentialName: string }[]; + integrationCredentials: { category: string; provider: string; role: string; value: string }[]; } function toInfo(ac: typeof agentConfigs.$inferSelect | null | undefined): AgentConfigInfo | null { @@ -98,6 +102,22 @@ function resolveBackend( ); } +function buildCredentialMap( + integrationCreds: { provider: string; role: string; value: string }[], + orgCreds: Record, +): Record { + const credentials: Record = { ...orgCreds }; + for (const cred of integrationCreds) { + const roles = PROVIDER_CREDENTIAL_ROLES[cred.provider as IntegrationProvider]; + if (!roles) continue; + const roleDef = roles.find((r) => r.role === cred.role); + if (roleDef) { + credentials[roleDef.envVarKey] = cred.value; + } + } + return credentials; +} + async function resolveEffectiveConfig( projectId: string, agentType: string | null, @@ -109,35 +129,30 @@ async function resolveEffectiveConfig( const orgId = projectRow.orgId; - const [ - defaultsRow, - globalAcs, - orgAcs, - projectAcs, - integrations, - credentials, - credentialOverrides, - ] = await Promise.all([ - db - .select() - .from(cascadeDefaults) - .where(eq(cascadeDefaults.orgId, orgId)) - .then((r) => r[0]), - db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))), - db - .select() - .from(agentConfigs) - .where(and(eq(agentConfigs.orgId, orgId), isNull(agentConfigs.projectId))), - db.select().from(agentConfigs).where(eq(agentConfigs.projectId, projectId)), - db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)), - resolveAllCredentials(projectId, orgId), - listProjectOverrides(projectId), - ]); + const [defaultsRow, globalAcs, orgAcs, projectAcs, integrations, integrationCreds, orgCreds] = + await Promise.all([ + db + .select() + .from(cascadeDefaults) + .where(eq(cascadeDefaults.orgId, orgId)) + .then((r) => r[0]), + db + .select() + .from(agentConfigs) + .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))), + db + .select() + .from(agentConfigs) + .where(and(eq(agentConfigs.orgId, orgId), isNull(agentConfigs.projectId))), + db.select().from(agentConfigs).where(eq(agentConfigs.projectId, projectId)), + db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)), + resolveAllIntegrationCredentials(projectId), + resolveAllOrgCredentials(orgId), + ]); - const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as + const credentials = buildCredentialMap(integrationCreds, orgCreds); + + const trelloConfig = integrations.find((i) => i.provider === 'trello')?.config as | TrelloIntegrationConfig | undefined; @@ -180,9 +195,7 @@ async function resolveEffectiveConfig( maxIterations: defaultsRow?.maxIterations ?? null, agentBackend: defaultsRow?.agentBackend ?? null, cardBudgetUsd: defaultsRow?.cardBudgetUsd ?? null, - freshMachineTimeoutMs: defaultsRow?.freshMachineTimeoutMs ?? null, watchdogTimeoutMs: defaultsRow?.watchdogTimeoutMs ?? null, - postJobGracePeriodMs: defaultsRow?.postJobGracePeriodMs ?? null, progressModel: defaultsRow?.progressModel ?? null, progressIntervalMinutes: defaultsRow?.progressIntervalMinutes ?? null, }, @@ -197,7 +210,7 @@ async function resolveEffectiveConfig( agentConfigLayers: { global: globalAc, org: orgAc, project: projectAc }, trello: trelloConfig ?? null, credentials, - credentialOverrides, + integrationCredentials: integrationCreds, }; } @@ -255,21 +268,33 @@ function printTrello(trello: TrelloIntegrationConfig | null): void { } function printCredentials(config: EffectiveConfig): void { - console.log('\n--- Credentials ---'); - const credEntries = Object.entries(config.credentials); - if (credEntries.length === 0) { - console.log(' (no credentials resolved)'); - return; - } - const overrideKeys = new Set(config.credentialOverrides.map((o) => o.envVarKey)); - for (const [key, value] of credEntries) { - const source = overrideKeys.has(key) ? 'project-override' : 'org-default'; - console.log(` ${key}: ${maskValue(value)} [${source}]`); + console.log('\n--- Integration Credentials ---'); + if (config.integrationCredentials.length === 0) { + console.log(' (no integration credentials configured)'); + } else { + for (const ic of config.integrationCredentials) { + console.log(` ${ic.category}/${ic.role} → ${maskValue(ic.value)} [${ic.provider}]`); + } } - if (config.credentialOverrides.length > 0) { - console.log('\n Credential Overrides:'); - for (const o of config.credentialOverrides) { - console.log(` ${o.envVarKey} → credential #${o.credentialId} (${o.credentialName})`); + + // Org-default credentials (non-integration secrets like LLM API keys) + const integrationEnvKeys = new Set( + config.integrationCredentials.flatMap((ic) => { + const roles = PROVIDER_CREDENTIAL_ROLES[ic.provider as IntegrationProvider]; + if (!roles) return []; + const roleDef = roles.find((r) => r.role === ic.role); + return roleDef ? [roleDef.envVarKey] : []; + }), + ); + const orgOnlyEntries = Object.entries(config.credentials).filter( + ([key]) => !integrationEnvKeys.has(key), + ); + console.log('\n--- Org-Default Credentials ---'); + if (orgOnlyEntries.length === 0) { + console.log(' (no org-default credentials)'); + } else { + for (const [key, value] of orgOnlyEntries) { + console.log(` ${key}: ${maskValue(value)}`); } } } diff --git a/tools/setup-webhooks.ts b/tools/setup-webhooks.ts index 5f226f49..b0e658f8 100644 --- a/tools/setup-webhooks.ts +++ b/tools/setup-webhooks.ts @@ -15,9 +15,14 @@ */ import { Octokit } from '@octokit/rest'; +import { PROVIDER_CREDENTIAL_ROLES } from '../src/config/integrationRoles.js'; +import type { IntegrationProvider } from '../src/config/integrationRoles.js'; import { closeDb } from '../src/db/client.js'; import { findProjectByIdFromDb } from '../src/db/repositories/configRepository.js'; -import { resolveAllCredentials } from '../src/db/repositories/credentialsRepository.js'; +import { + resolveAllIntegrationCredentials, + resolveAllOrgCredentials, +} from '../src/db/repositories/credentialsRepository.js'; const GITHUB_WEBHOOK_EVENTS = [ 'pull_request', @@ -66,11 +71,23 @@ async function resolveProjectContext(projectId: string): Promise process.exit(1); } - const creds = await resolveAllCredentials(projectId, project.orgId); + // Build credential map from integration credentials + org defaults + const integrationCreds = await resolveAllIntegrationCredentials(projectId); + const orgCreds = await resolveAllOrgCredentials(project.orgId); + + const credMap: Record = { ...orgCreds }; + for (const cred of integrationCreds) { + const roles = PROVIDER_CREDENTIAL_ROLES[cred.provider as IntegrationProvider]; + if (!roles) continue; + const roleDef = roles.find((r) => r.role === cred.role); + if (roleDef) { + credMap[roleDef.envVarKey] = cred.value; + } + } - const trelloApiKey = creds.TRELLO_API_KEY; - const trelloToken = creds.TRELLO_TOKEN; - const githubToken = creds.GITHUB_TOKEN; + const trelloApiKey = credMap.TRELLO_API_KEY; + const trelloToken = credMap.TRELLO_TOKEN; + const githubToken = credMap.GITHUB_TOKEN_IMPLEMENTER ?? credMap.GITHUB_TOKEN; if (!trelloApiKey || !trelloToken) { console.warn( diff --git a/web/src/components/projects/credential-overrides.tsx b/web/src/components/projects/credential-overrides.tsx deleted file mode 100644 index 14a09156..00000000 --- a/web/src/components/projects/credential-overrides.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { Badge } from '@/components/ui/badge.js'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2 } from 'lucide-react'; -import { useState } from 'react'; - -export function CredentialOverrides({ projectId }: { projectId: string }) { - const queryClient = useQueryClient(); - const overridesQuery = useQuery( - trpc.projects.credentialOverrides.list.queryOptions({ projectId }), - ); - const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); - - const [addOpen, setAddOpen] = useState(false); - const [envVarKey, setEnvVarKey] = useState(''); - const [credentialId, setCredentialId] = useState(''); - const [agentType, setAgentType] = useState(''); - - const queryKey = trpc.projects.credentialOverrides.list.queryOptions({ projectId }).queryKey; - - const setMutation = useMutation({ - mutationFn: () => { - if (agentType) { - return trpcClient.projects.credentialOverrides.setAgent.mutate({ - projectId, - envVarKey, - agentType, - credentialId: Number(credentialId), - }); - } - return trpcClient.projects.credentialOverrides.set.mutate({ - projectId, - envVarKey, - credentialId: Number(credentialId), - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - setAddOpen(false); - setEnvVarKey(''); - setCredentialId(''); - setAgentType(''); - }, - }); - - const removeMutation = useMutation({ - mutationFn: (params: { envVarKey: string; agentType: string | null }) => { - if (params.agentType) { - return trpcClient.projects.credentialOverrides.removeAgent.mutate({ - projectId, - envVarKey: params.envVarKey, - agentType: params.agentType, - }); - } - return trpcClient.projects.credentialOverrides.remove.mutate({ - projectId, - envVarKey: params.envVarKey, - }); - }, - onSuccess: () => queryClient.invalidateQueries({ queryKey }), - }); - - if (overridesQuery.isLoading) { - return
Loading credential overrides...
; - } - - const overrides = overridesQuery.data ?? []; - const credentials = credentialsQuery.data ?? []; - - return ( -
-
-

- Override org-level credentials for this project. -

- -
- -
- - - - Env Var Key - Credential - Scope - - - - - {overrides.length === 0 && ( - - - No overrides configured — using org defaults - - - )} - {overrides.map((o) => ( - - {o.envVarKey} - {o.credentialName} - - {o.agentType ? ( - {o.agentType} - ) : ( - Project-wide - )} - - - - - - ))} - -
-
- - - - - Add Credential Override - -
{ - e.preventDefault(); - setMutation.mutate(); - }} - className="space-y-4" - > -
- - setEnvVarKey(e.target.value)} - placeholder="GITHUB_TOKEN" - required - /> -
-
- - -
-
- - setAgentType(e.target.value)} - placeholder="Leave empty for project-wide" - /> -
-
- - -
- {setMutation.isError && ( -

{setMutation.error.message}

- )} -
-
-
-
- ); -} diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 3ae52db7..e3a1803f 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -82,214 +82,53 @@ function fromKVPairs(pairs: KVPair[]): Record { return result; } -type IntegrationType = 'trello' | 'jira' | 'github'; - -function TrelloForm({ - projectId, - initialConfig, -}: { - projectId: string; - initialConfig?: Record; -}) { - const queryClient = useQueryClient(); - - const [boardId, setBoardId] = useState(''); - const [lists, setLists] = useState([]); - const [labels, setLabels] = useState([]); - const [costField, setCostField] = useState(''); - - useEffect(() => { - if (initialConfig) { - setBoardId((initialConfig.boardId as string) ?? ''); - setLists(toKVPairs(initialConfig.lists as Record)); - setLabels(toKVPairs(initialConfig.labels as Record)); - const cf = initialConfig.customFields as Record | undefined; - setCostField(cf?.cost ?? ''); - } - }, [initialConfig]); - - const upsertMutation = useMutation({ - mutationFn: () => - trpcClient.projects.integrations.upsert.mutate({ - projectId, - type: 'trello', - config: { - boardId, - lists: fromKVPairs(lists), - labels: fromKVPairs(labels), - ...(costField ? { customFields: { cost: costField } } : {}), - }, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - return ( -
-
- - setBoardId(e.target.value)} - placeholder="Trello board ID" - /> -
- - - - -
- - setCostField(e.target.value)} - placeholder="Custom field ID for cost tracking" - /> -
- -
- - {upsertMutation.isSuccess && Saved} - {upsertMutation.isError && ( - {upsertMutation.error.message} - )} -
-
- ); +interface TriggerToggleItem { + key: string; + label: string; + description: string; + defaultValue: boolean; } -function JiraForm({ - projectId, - initialConfig, +function TriggerToggles({ + title, + items, + values, + onChange, }: { - projectId: string; - initialConfig?: Record; + title: string; + items: TriggerToggleItem[]; + values: Record; + onChange: (values: Record) => void; }) { - const queryClient = useQueryClient(); - - const [jiraProjectKey, setJiraProjectKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [statuses, setStatuses] = useState([]); - const [issueTypes, setIssueTypes] = useState([]); - const [jiraLabels, setJiraLabels] = useState([ - { key: 'processing', value: 'cascade-processing' }, - { key: 'processed', value: 'cascade-processed' }, - { key: 'error', value: 'cascade-error' }, - { key: 'readyToProcess', value: 'cascade-ready' }, - ]); - const [costField, setCostField] = useState(''); - - useEffect(() => { - if (initialConfig) { - setJiraProjectKey((initialConfig.projectKey as string) ?? ''); - setBaseUrl((initialConfig.baseUrl as string) ?? ''); - setStatuses(toKVPairs(initialConfig.statuses as Record)); - setIssueTypes(toKVPairs(initialConfig.issueTypes as Record)); - const labels = initialConfig.labels as Record | undefined; - if (labels) { - setJiraLabels(toKVPairs(labels)); - } - const cf = initialConfig.customFields as Record | undefined; - setCostField(cf?.cost ?? ''); - } - }, [initialConfig]); - - const upsertMutation = useMutation({ - mutationFn: () => - trpcClient.projects.integrations.upsert.mutate({ - projectId, - type: 'jira', - config: { - projectKey: jiraProjectKey, - baseUrl, - statuses: fromKVPairs(statuses), - ...(issueTypes.length > 0 ? { issueTypes: fromKVPairs(issueTypes) } : {}), - ...(jiraLabels.length > 0 ? { labels: fromKVPairs(jiraLabels) } : {}), - ...(costField ? { customFields: { cost: costField } } : {}), - }, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - return ( -
-
- - setJiraProjectKey(e.target.value)} - placeholder="e.g., PROJ" - /> -
- -
- - setBaseUrl(e.target.value)} - placeholder="https://your-instance.atlassian.net" - /> -
- - -

- Map CASCADE statuses (briefing, planning, todo, inProgress, inReview, done, merged) to JIRA - status names. -

- - - - -

- JIRA label names used by CASCADE. Keys: processing, processed, error, readyToProcess. -

- -
- - setCostField(e.target.value)} - placeholder="e.g., customfield_10042" - /> -
- -
- - {upsertMutation.isSuccess && Saved} - {upsertMutation.isError && ( - {upsertMutation.error.message} - )} -
+
+ + {items.map((item) => { + const value = item.key in values ? values[item.key] : item.defaultValue; + return ( +
+ onChange({ ...values, [item.key]: e.target.checked })} + className="mt-0.5 h-4 w-4 rounded border-input" + /> +
+ +

{item.description}

+
+
+ ); + })}
); } +type IntegrationCategory = 'pm' | 'scm'; + interface CredentialOption { id: number; name: string; @@ -313,10 +152,10 @@ function CredentialSelector({ credentials: CredentialOption[]; selectedId: number | null; onChange: (id: number | null) => void; - verifiedLogin: string | null; - onVerify: () => void; - isVerifying: boolean; - verifyError: string | null; + verifiedLogin?: string | null; + onVerify?: () => void; + isVerifying?: boolean; + verifyError?: string | null; }) { return (
@@ -335,14 +174,16 @@ function CredentialSelector({ ))} - + {onVerify && ( + + )}
{verifiedLogin && (
@@ -360,103 +201,562 @@ function CredentialSelector({ ); } -function GitHubForm({ +// ============================================================================ +// Provider-specific credential role definitions +// ============================================================================ + +interface CredentialRoleDef { + role: string; + label: string; + description: string; + hasVerify?: boolean; +} + +const PM_CREDENTIAL_ROLES: Record = { + trello: [ + { role: 'api_key', label: 'API Key', description: 'Trello API Key for authentication.' }, + { role: 'token', label: 'Token', description: 'Trello token for authorization.' }, + ], + jira: [ + { role: 'email', label: 'Email', description: 'JIRA account email for authentication.' }, + { role: 'api_token', label: 'API Token', description: 'JIRA API token for authorization.' }, + ], +}; + +const SCM_CREDENTIAL_ROLES: Record = { + github: [ + { + role: 'implementer_token', + label: 'Implementer Token', + description: 'GitHub PAT for the bot that writes code, creates PRs, and responds to reviews.', + hasVerify: true, + }, + { + role: 'reviewer_token', + label: 'Reviewer Token', + description: 'GitHub PAT for the bot that reviews PRs. Must be a different account.', + hasVerify: true, + }, + ], +}; + +// ============================================================================ +// Integration credential slot component +// ============================================================================ + +function IntegrationCredentialSlots({ + projectId, + category, + roles, + credentials, + existingCredentials, + onCredentialsChange, +}: { + projectId: string; + category: IntegrationCategory; + roles: CredentialRoleDef[]; + credentials: CredentialOption[]; + existingCredentials: Map; + onCredentialsChange: (role: string, credentialId: number | null) => void; +}) { + const [verifiedLogins, setVerifiedLogins] = useState>({}); + const [verifyErrors, setVerifyErrors] = useState>({}); + const [verifyingRoles, setVerifyingRoles] = useState>({}); + + const handleVerify = async (role: string, credentialId: number) => { + setVerifyingRoles((prev) => ({ ...prev, [role]: true })); + try { + const result = await trpcClient.credentials.verifyGithubIdentity.mutate({ + credentialId, + }); + setVerifiedLogins((prev) => ({ ...prev, [role]: result.login })); + setVerifyErrors((prev) => ({ ...prev, [role]: null })); + } catch (err) { + setVerifiedLogins((prev) => ({ ...prev, [role]: null })); + setVerifyErrors((prev) => ({ + ...prev, + [role]: err instanceof Error ? err.message : String(err), + })); + } finally { + setVerifyingRoles((prev) => ({ ...prev, [role]: false })); + } + }; + + return ( +
+ + {roles.map((roleDef) => ( + { + onCredentialsChange(roleDef.role, id); + setVerifiedLogins((prev) => ({ ...prev, [roleDef.role]: null })); + setVerifyErrors((prev) => ({ ...prev, [roleDef.role]: null })); + }} + verifiedLogin={roleDef.hasVerify ? verifiedLogins[roleDef.role] : undefined} + onVerify={ + roleDef.hasVerify + ? () => { + const credId = existingCredentials.get(roleDef.role); + if (credId) handleVerify(roleDef.role, credId); + } + : undefined + } + isVerifying={roleDef.hasVerify ? verifyingRoles[roleDef.role] : undefined} + verifyError={roleDef.hasVerify ? verifyErrors[roleDef.role] : undefined} + /> + ))} +
+ ); +} + +// ============================================================================ +// PM Tab (Trello / JIRA) +// ============================================================================ + +const TRELLO_TRIGGERS: TriggerToggleItem[] = [ + { + key: 'cardMovedToBriefing', + label: 'Card moved to Briefing', + description: 'Trigger briefing agent when card is moved to the briefing list.', + defaultValue: true, + }, + { + key: 'cardMovedToPlanning', + label: 'Card moved to Planning', + description: 'Trigger planning agent when card is moved to the planning list.', + defaultValue: true, + }, + { + key: 'cardMovedToTodo', + label: 'Card moved to Todo', + description: 'Trigger implementation agent when card is moved to the todo list.', + defaultValue: true, + }, + { + key: 'readyToProcessLabel', + label: 'Ready to Process label', + description: 'Trigger agent when the "Ready to Process" label is added.', + defaultValue: true, + }, + { + key: 'commentMention', + label: 'Comment @mention', + description: 'Trigger respond-to-planning-comment when the bot is @mentioned in a comment.', + defaultValue: true, + }, +]; + +const JIRA_TRIGGERS: TriggerToggleItem[] = [ + { + key: 'issueTransitioned', + label: 'Issue transitioned', + description: 'Trigger agent when an issue transitions to a configured status.', + defaultValue: true, + }, + { + key: 'readyToProcessLabel', + label: 'Ready to Process label', + description: 'Trigger agent when the ready-to-process label is added.', + defaultValue: true, + }, + { + key: 'commentMention', + label: 'Comment @mention', + description: 'Trigger respond-to-planning-comment when the bot is @mentioned in a comment.', + defaultValue: true, + }, +]; + +function PMTab({ projectId, + initialProvider, initialConfig, + initialTriggers, + initialCredentials, }: { projectId: string; + initialProvider: string; initialConfig?: Record; + initialTriggers?: Record; + initialCredentials: Map; }) { const queryClient = useQueryClient(); const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); - const credentials = (credentialsQuery.data ?? []) as CredentialOption[]; + const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; - const [implementerCredId, setImplementerCredId] = useState(null); - const [reviewerCredId, setReviewerCredId] = useState(null); + const [provider, setProvider] = useState(initialProvider || 'trello'); + const [triggers, setTriggers] = useState>(initialTriggers ?? {}); + const [credentialMap, setCredentialMap] = useState>(initialCredentials); - const [implementerLogin, setImplementerLogin] = useState(null); - const [reviewerLogin, setReviewerLogin] = useState(null); + // Trello fields + const [boardId, setBoardId] = useState(''); + const [lists, setLists] = useState([]); + const [labels, setLabels] = useState([]); + const [costField, setCostField] = useState(''); - const [implementerError, setImplementerError] = useState(null); - const [reviewerError, setReviewerError] = useState(null); + // Jira fields + const [jiraProjectKey, setJiraProjectKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [statuses, setStatuses] = useState([]); + const [issueTypes, setIssueTypes] = useState([]); + const [jiraLabels, setJiraLabels] = useState([ + { key: 'processing', value: 'cascade-processing' }, + { key: 'processed', value: 'cascade-processed' }, + { key: 'error', value: 'cascade-error' }, + { key: 'readyToProcess', value: 'cascade-ready' }, + ]); + const [jiraCostField, setJiraCostField] = useState(''); useEffect(() => { - if (initialConfig) { - setImplementerCredId((initialConfig.implementerCredentialId as number) ?? null); - setReviewerCredId((initialConfig.reviewerCredentialId as number) ?? null); + if (initialConfig && initialProvider === 'trello') { + setBoardId((initialConfig.boardId as string) ?? ''); + setLists(toKVPairs(initialConfig.lists as Record)); + setLabels(toKVPairs(initialConfig.labels as Record)); + const cf = initialConfig.customFields as Record | undefined; + setCostField(cf?.cost ?? ''); + } else if (initialConfig && initialProvider === 'jira') { + setJiraProjectKey((initialConfig.projectKey as string) ?? ''); + setBaseUrl((initialConfig.baseUrl as string) ?? ''); + setStatuses(toKVPairs(initialConfig.statuses as Record)); + setIssueTypes(toKVPairs(initialConfig.issueTypes as Record)); + const jl = initialConfig.labels as Record | undefined; + if (jl) setJiraLabels(toKVPairs(jl)); + const cf = initialConfig.customFields as Record | undefined; + setJiraCostField(cf?.cost ?? ''); } - }, [initialConfig]); - - const verifyImplementer = useMutation({ - mutationFn: () => - trpcClient.credentials.verifyGithubIdentity.mutate({ - credentialId: implementerCredId as number, - }), - onSuccess: (data) => { - setImplementerLogin(data.login); - setImplementerError(null); - }, - onError: (err) => { - setImplementerLogin(null); - setImplementerError(err.message); - }, - }); + }, [initialConfig, initialProvider]); - const verifyReviewer = useMutation({ - mutationFn: () => - trpcClient.credentials.verifyGithubIdentity.mutate({ - credentialId: reviewerCredId as number, - }), - onSuccess: (data) => { - setReviewerLogin(data.login); - setReviewerError(null); - }, - onError: (err) => { - setReviewerLogin(null); - setReviewerError(err.message); - }, - }); + useEffect(() => { + setTriggers(initialTriggers ?? {}); + }, [initialTriggers]); + + useEffect(() => { + setCredentialMap(initialCredentials); + }, [initialCredentials]); const saveMutation = useMutation({ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles multiple provider types + credential linking mutationFn: async () => { - if (!implementerCredId || !reviewerCredId) { - throw new Error('Both persona credentials are required'); + let config: Record; + if (provider === 'trello') { + config = { + boardId, + lists: fromKVPairs(lists), + labels: fromKVPairs(labels), + ...(costField ? { customFields: { cost: costField } } : {}), + }; + } else { + config = { + projectKey: jiraProjectKey, + baseUrl, + statuses: fromKVPairs(statuses), + ...(issueTypes.length > 0 ? { issueTypes: fromKVPairs(issueTypes) } : {}), + ...(jiraLabels.length > 0 ? { labels: fromKVPairs(jiraLabels) } : {}), + ...(jiraCostField ? { customFields: { cost: jiraCostField } } : {}), + }; } - // Save as GitHub integration - await trpcClient.projects.integrations.upsert.mutate({ + const result = await trpcClient.projects.integrations.upsert.mutate({ projectId, - type: 'github', - config: { - implementerCredentialId: implementerCredId, - reviewerCredentialId: reviewerCredId, - }, + category: 'pm', + provider, + config, + triggers, }); - // Set project-wide credential overrides for the persona token keys - await trpcClient.projects.credentialOverrides.set.mutate({ - projectId, - envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', - credentialId: implementerCredId, + // Set integration credentials + for (const [role, credentialId] of credentialMap) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'pm', + role, + credentialId, + }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'pm', + }).queryKey, }); + }, + }); - await trpcClient.projects.credentialOverrides.set.mutate({ + const credentialRoles = PM_CREDENTIAL_ROLES[provider] ?? []; + + return ( +
+
+ + +
+ + {provider === 'trello' && ( + <> +
+ + setBoardId(e.target.value)} + placeholder="Trello board ID" + /> +
+ + +
+ + setCostField(e.target.value)} + placeholder="Custom field ID for cost tracking" + /> +
+ + + )} + + {provider === 'jira' && ( + <> +
+ + setJiraProjectKey(e.target.value)} + placeholder="e.g., PROJ" + /> +
+
+ + setBaseUrl(e.target.value)} + placeholder="https://your-instance.atlassian.net" + /> +
+ +

+ Map CASCADE statuses (briefing, planning, todo, inProgress, inReview, done, merged) to + JIRA status names. +

+ + +

+ JIRA label names used by CASCADE. Keys: processing, processed, error, readyToProcess. +

+
+ + setJiraCostField(e.target.value)} + placeholder="e.g., customfield_10042" + /> +
+ + + )} + + { + setCredentialMap((prev) => { + const next = new Map(prev); + if (id) { + next.set(role, id); + } else { + next.delete(role); + } + return next; + }); + }} + /> + +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
+
+ ); +} + +// ============================================================================ +// SCM Tab (GitHub) +// ============================================================================ + +const GITHUB_TRIGGERS: TriggerToggleItem[] = [ + { + key: 'checkSuiteSuccess', + label: 'Check Suite Success', + description: 'Trigger review agent when all CI checks pass.', + defaultValue: true, + }, + { + key: 'checkSuiteFailure', + label: 'Check Suite Failure', + description: 'Trigger respond-to-ci agent when CI checks fail.', + defaultValue: true, + }, + { + key: 'prReviewSubmitted', + label: 'PR Review Submitted', + description: 'Trigger respond-to-review when a review with changes requested is submitted.', + defaultValue: true, + }, + { + key: 'prCommentMention', + label: 'PR Comment @mention', + description: + 'Trigger respond-to-pr-comment when the implementer bot is @mentioned in a comment.', + defaultValue: true, + }, + { + key: 'prReadyToMerge', + label: 'PR Ready to Merge', + description: 'Auto-move card to DONE when PR is approved and checks pass.', + defaultValue: true, + }, + { + key: 'prMerged', + label: 'PR Merged', + description: 'Auto-move card to MERGED when PR is merged.', + defaultValue: true, + }, + { + key: 'reviewRequested', + label: 'Review Requested (opt-in)', + description: + 'Trigger review agent when review is requested from a CASCADE persona. Default disabled.', + defaultValue: false, + }, + { + key: 'prOpened', + label: 'PR Opened (opt-in)', + description: 'Trigger respond-to-review when a new PR is opened. Default disabled.', + defaultValue: false, + }, +]; + +function SCMTab({ + projectId, + initialProvider, + initialTriggers, + initialCredentials, +}: { + projectId: string; + initialProvider: string; + initialTriggers?: Record; + initialCredentials: Map; +}) { + const queryClient = useQueryClient(); + + const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); + const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; + + const [provider] = useState(initialProvider || 'github'); + const [triggers, setTriggers] = useState>(initialTriggers ?? {}); + const [credentialMap, setCredentialMap] = useState>(initialCredentials); + + useEffect(() => { + setTriggers(initialTriggers ?? {}); + }, [initialTriggers]); + + useEffect(() => { + setCredentialMap(initialCredentials); + }, [initialCredentials]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const result = await trpcClient.projects.integrations.upsert.mutate({ projectId, - envVarKey: 'GITHUB_TOKEN_REVIEWER', - credentialId: reviewerCredId, + category: 'scm', + provider, + config: {}, + triggers, }); + + // Set integration credentials + for (const [role, credentialId] of credentialMap) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'scm', + role, + credentialId, + }); + } + + return result; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, }); queryClient.invalidateQueries({ - queryKey: trpc.projects.credentialOverrides.list.queryOptions({ projectId }).queryKey, + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'scm', + }).queryKey, }); }, }); + const credentialRoles = SCM_CREDENTIAL_ROLES[provider] ?? []; + return (

@@ -465,50 +765,37 @@ function GitHubForm({ reviews PRs and can approve or request changes.

- { - setImplementerCredId(id); - setImplementerLogin(null); - setImplementerError(null); + { + setCredentialMap((prev) => { + const next = new Map(prev); + if (id) { + next.set(role, id); + } else { + next.delete(role); + } + return next; + }); }} - verifiedLogin={implementerLogin} - onVerify={() => verifyImplementer.mutate()} - isVerifying={verifyImplementer.isPending} - verifyError={implementerError} /> - { - setReviewerCredId(id); - setReviewerLogin(null); - setReviewerError(null); - }} - verifiedLogin={reviewerLogin} - onVerify={() => verifyReviewer.mutate()} - isVerifying={verifyReviewer.isPending} - verifyError={reviewerError} + - {implementerLogin && reviewerLogin && implementerLogin === reviewerLogin && ( -

- Both tokens resolve to the same GitHub user ({implementerLogin}). They must be different - accounts for loop prevention to work. -

- )} -
-
- {activeTab === 'trello' && ( - } - /> - )} - - {activeTab === 'jira' && ( - } + initialProvider={pmProvider} + initialConfig={pmIntegration?.config as Record} + initialTriggers={pmIntegration?.triggers as Record} + initialCredentials={pmCredMap} /> )} - {activeTab === 'github' && ( - } + initialProvider={scmProvider} + initialTriggers={scmIntegration?.triggers as Record} + initialCredentials={scmCredMap} /> )}
diff --git a/web/src/routes/projects/$projectId.tsx b/web/src/routes/projects/$projectId.tsx index ace51541..1926fcdd 100644 --- a/web/src/routes/projects/$projectId.tsx +++ b/web/src/routes/projects/$projectId.tsx @@ -1,4 +1,3 @@ -import { CredentialOverrides } from '@/components/projects/credential-overrides.js'; import { IntegrationForm } from '@/components/projects/integration-form.js'; import { ProjectAgentConfigs } from '@/components/projects/project-agent-configs.js'; import { ProjectGeneralForm } from '@/components/projects/project-general-form.js'; @@ -10,7 +9,7 @@ import { ArrowLeft } from 'lucide-react'; import { useState } from 'react'; import { rootRoute } from '../__root.js'; -type Tab = 'general' | 'integrations' | 'credentials' | 'agent-configs'; +type Tab = 'general' | 'integrations' | 'agent-configs'; function ProjectDetailPage() { const { projectId } = projectDetailRoute.useParams(); @@ -31,7 +30,6 @@ function ProjectDetailPage() { const tabs: { id: Tab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'integrations', label: 'Integrations' }, - { id: 'credentials', label: 'Credentials' }, { id: 'agent-configs', label: 'Agent Configs' }, ]; @@ -71,7 +69,6 @@ function ProjectDetailPage() { {activeTab === 'general' && } {activeTab === 'integrations' && } - {activeTab === 'credentials' && } {activeTab === 'agent-configs' && }
);