From 62fc7f698c9bed3164a438fb65b796afec22070f Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 24 Feb 2026 18:02:12 +0000 Subject: [PATCH] refactor(config): extract configMapper, unify build paths, DRY gadget escalation --- src/db/repositories/configMapper.ts | 296 +++++++++++++ src/db/repositories/configRepository.ts | 289 ++++--------- src/gadgets/FileMultiEdit.ts | 12 +- src/gadgets/FileSearchAndReplace.ts | 12 +- src/gadgets/shared/editEscalation.ts | 19 + .../unit/db/repositories/configMapper.test.ts | 397 ++++++++++++++++++ .../gadgets/shared/editEscalation.test.ts | 60 +++ 7 files changed, 848 insertions(+), 237 deletions(-) create mode 100644 src/db/repositories/configMapper.ts create mode 100644 src/gadgets/shared/editEscalation.ts create mode 100644 tests/unit/db/repositories/configMapper.test.ts create mode 100644 tests/unit/gadgets/shared/editEscalation.test.ts diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts new file mode 100644 index 00000000..b85ef3d6 --- /dev/null +++ b/src/db/repositories/configMapper.ts @@ -0,0 +1,296 @@ +/** + * Config mapper — pure transformation functions for converting DB rows into + * raw config objects consumed by `validateConfig`. + * + * Extracted from configRepository.ts to separate query concerns from mapping + * concerns and to enable isolated unit testing of the transformation logic. + */ + +// --------------------------------------------------------------------------- +// Integration config interfaces +// --------------------------------------------------------------------------- + +export interface TrelloIntegrationConfig { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; +} + +export interface JiraIntegrationConfig { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; + labels?: Record; +} + +// biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) +export type GitHubIntegrationConfig = {}; + +// --------------------------------------------------------------------------- +// Row interfaces (mirrors DB select shapes) +// --------------------------------------------------------------------------- + +export interface DefaultsRow { + model: string | null; + maxIterations: number | null; + watchdogTimeoutMs: number | null; + cardBudgetUsd: string | null; + agentBackend: string | null; + progressModel: string | null; + progressIntervalMinutes: string | null; +} + +export interface AgentConfigRow { + orgId: string | null; + projectId: string | null; + agentType: string; + model: string | null; + maxIterations: number | null; + agentBackend: string | null; + prompt: string | null; +} + +export interface IntegrationRow { + projectId: string; + category: string; + provider: string; + config: unknown; + triggers: unknown; +} + +// --------------------------------------------------------------------------- +// Structured input for mapProjectRow (replaces 8 positional params) +// --------------------------------------------------------------------------- + +export interface MapProjectInput { + row: ProjectRow; + projectAgentConfigs: AgentConfigRow[]; + trelloConfig?: TrelloIntegrationConfig; + trelloTriggers?: Record; + jiraConfig?: JiraIntegrationConfig; + jiraTriggers?: Record; + githubConfig?: GitHubIntegrationConfig; + githubTriggers?: Record; +} + +// --------------------------------------------------------------------------- +// Typed return interface for mapProjectRow +// --------------------------------------------------------------------------- + +export interface ProjectConfigRaw { + id: string; + orgId: string; + name: string; + repo: string; + baseBranch: string; + branchPrefix: string; + pm: { type: string }; + prompts?: Record; + model?: string; + agentModels?: Record; + cardBudgetUsd?: number; + squintDbUrl?: string; + trello?: { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; + triggers?: Record; + }; + jira?: { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; + labels?: Record; + triggers?: Record; + }; + github?: { triggers: Record }; + agentBackend?: { + default?: string; + overrides: Record; + subscriptionCostZero: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type ProjectRow = { + id: string; + orgId: string; + name: string; + repo: string; + baseBranch: string | null; + branchPrefix: string | null; + model: string | null; + cardBudgetUsd: string | null; + squintDbUrl: string | null; + agentBackend: string | null; + subscriptionCostZero: boolean | null; +}; + +export function buildAgentMaps(configs: AgentConfigRow[]): { + models: Record; + iterations: Record; + prompts: Record; + backends: Record; +} { + const models: Record = {}; + const iterations: Record = {}; + const prompts: Record = {}; + const backends: Record = {}; + for (const ac of configs) { + if (ac.model) models[ac.agentType] = ac.model; + if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; + if (ac.prompt) prompts[ac.agentType] = ac.prompt; + if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; + } + return { models, iterations, prompts, backends }; +} + +export function orUndefined>(obj: T): T | undefined { + return Object.keys(obj).length > 0 ? obj : undefined; +} + +function buildTrelloConfig( + config: TrelloIntegrationConfig, + triggers?: Record, +): ProjectConfigRaw['trello'] { + return { + boardId: config.boardId, + lists: config.lists, + labels: config.labels, + customFields: config.customFields, + ...(triggers && Object.keys(triggers).length > 0 ? { triggers } : {}), + }; +} + +function buildJiraConfig( + config: JiraIntegrationConfig, + triggers?: Record, +): ProjectConfigRaw['jira'] { + return { + projectKey: config.projectKey, + baseUrl: config.baseUrl, + statuses: config.statuses, + issueTypes: config.issueTypes, + customFields: config.customFields, + labels: config.labels, + ...(triggers && Object.keys(triggers).length > 0 ? { triggers } : {}), + }; +} + +function buildAgentBackendConfig( + row: ProjectRow, + backends: Record, +): ProjectConfigRaw['agentBackend'] | undefined { + if (!row.agentBackend && Object.keys(backends).length === 0) return undefined; + return { + default: row.agentBackend ?? undefined, + overrides: backends, + subscriptionCostZero: row.subscriptionCostZero ?? false, + }; +} + +// --------------------------------------------------------------------------- +// Public mapping functions +// --------------------------------------------------------------------------- + +export function mapDefaultsRow( + row: DefaultsRow | undefined, + globalAgentConfigs: AgentConfigRow[], +): Record { + const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); + + return { + model: row?.model ?? undefined, + agentModels: orUndefined(models), + maxIterations: row?.maxIterations ?? undefined, + agentIterations: orUndefined(iterations), + watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, + cardBudgetUsd: row?.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, + agentBackend: row?.agentBackend ?? undefined, + progressModel: row?.progressModel ?? undefined, + progressIntervalMinutes: row?.progressIntervalMinutes + ? Number(row.progressIntervalMinutes) + : undefined, + prompts: orUndefined(prompts), + }; +} + +export function extractIntegrationConfigs(integrations: IntegrationRow[]): { + trelloConfig?: TrelloIntegrationConfig; + trelloTriggers?: Record; + jiraConfig?: JiraIntegrationConfig; + jiraTriggers?: Record; + githubConfig?: GitHubIntegrationConfig; + githubTriggers?: Record; +} { + 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, + }; +} + +export function mapProjectRow({ + row, + projectAgentConfigs, + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubTriggers, +}: MapProjectInput): ProjectConfigRaw { + const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); + + // Derive PM type from integration config + const pmType = jiraConfig ? 'jira' : 'trello'; + + const project: ProjectConfigRaw = { + id: row.id, + orgId: row.orgId, + name: row.name, + repo: row.repo, + baseBranch: row.baseBranch ?? 'main', + branchPrefix: row.branchPrefix ?? 'feature/', + pm: { type: pmType }, + prompts: orUndefined(prompts), + model: row.model ?? undefined, + agentModels: orUndefined(models), + cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, + squintDbUrl: row.squintDbUrl ?? undefined, + }; + + if (trelloConfig) { + project.trello = buildTrelloConfig(trelloConfig, trelloTriggers); + } + + if (jiraConfig) { + project.jira = buildJiraConfig(jiraConfig, jiraTriggers); + } + + if (githubTriggers && Object.keys(githubTriggers).length > 0) { + project.github = { triggers: githubTriggers }; + } + + const agentBackend = buildAgentBackendConfig(row, backends); + if (agentBackend) { + project.agentBackend = agentBackend; + } + + return project; +} diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 7575c2d3..30af354b 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -3,174 +3,67 @@ import { validateConfig } from '../../config/schema.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { getDb } from '../client.js'; import { agentConfigs, cascadeDefaults, projectIntegrations, projects } from '../schema/index.js'; - -interface TrelloIntegrationConfig { - boardId: string; - lists: Record; - labels: Record; - customFields?: { cost?: string }; -} - -interface JiraIntegrationConfig { - projectKey: string; - baseUrl: string; - statuses: Record; - issueTypes?: Record; - customFields?: { cost?: string }; - 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; - watchdogTimeoutMs: number | null; - cardBudgetUsd: string | null; - agentBackend: string | null; - progressModel: string | null; - progressIntervalMinutes: string | null; -} - -interface AgentConfigRow { - orgId: string | null; - projectId: string | null; - agentType: string; - model: string | null; - maxIterations: number | null; - agentBackend: string | null; - prompt: string | null; -} - -function buildAgentMaps(configs: AgentConfigRow[]) { - const models: Record = {}; - const iterations: Record = {}; - const prompts: Record = {}; - const backends: Record = {}; - for (const ac of configs) { - if (ac.model) models[ac.agentType] = ac.model; - if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; - if (ac.prompt) prompts[ac.agentType] = ac.prompt; - if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; - } - return { models, iterations, prompts, backends }; -} - -function orUndefined>(obj: T): T | undefined { - return Object.keys(obj).length > 0 ? obj : undefined; -} - -function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[]) { - const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); - - return { - model: row?.model ?? undefined, - agentModels: orUndefined(models), - maxIterations: row?.maxIterations ?? undefined, - agentIterations: orUndefined(iterations), - watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, - cardBudgetUsd: row?.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, - agentBackend: row?.agentBackend ?? undefined, - progressModel: row?.progressModel ?? undefined, - progressIntervalMinutes: row?.progressIntervalMinutes - ? Number(row.progressIntervalMinutes) - : undefined, - prompts: orUndefined(prompts), - }; -} - -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); - - // Derive PM type from integration config - const pmType = jiraConfig ? 'jira' : 'trello'; - - const project: Record = { - id: row.id, - orgId: row.orgId, - name: row.name, - repo: row.repo, - baseBranch: row.baseBranch ?? 'main', - branchPrefix: row.branchPrefix ?? 'feature/', - pm: { type: pmType }, - prompts: orUndefined(prompts), - model: row.model ?? undefined, - agentModels: orUndefined(models), - cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, - squintDbUrl: row.squintDbUrl ?? undefined, - }; - - if (trelloConfig) { - project.trello = { - boardId: trelloConfig.boardId, - lists: trelloConfig.lists, - labels: trelloConfig.labels, - customFields: trelloConfig.customFields, - ...(trelloTriggers && Object.keys(trelloTriggers).length > 0 - ? { triggers: trelloTriggers } - : {}), - }; - } - - if (jiraConfig) { - project.jira = { - projectKey: jiraConfig.projectKey, - baseUrl: jiraConfig.baseUrl, - statuses: jiraConfig.statuses, - 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 || Object.keys(backends).length > 0) { - project.agentBackend = { - default: row.agentBackend ?? undefined, - overrides: backends, - subscriptionCostZero: row.subscriptionCostZero ?? false, - }; +import { + type AgentConfigRow, + type DefaultsRow, + type IntegrationRow, + extractIntegrationConfigs, + mapDefaultsRow, + mapProjectRow, +} from './configMapper.js'; + +// --------------------------------------------------------------------------- +// Shared config builder — eliminates duplicated extract→split→map→validate +// --------------------------------------------------------------------------- + +interface BuildRawConfigOpts { + defaultsRow: DefaultsRow | undefined; + globalAgentConfigs: AgentConfigRow[]; + projectRows: Array; + /** All integration rows for all projects in projectRows */ + integrationRows: IntegrationRow[]; + /** Per-project agent configs, keyed by project ID */ + projectAgentConfigsMap: Map; +} + +function buildRawConfig({ + defaultsRow, + globalAgentConfigs, + projectRows, + integrationRows, + projectAgentConfigsMap, +}: BuildRawConfigOpts) { + // Index integrations by project ID + const integrationsByProject = new Map(); + for (const row of integrationRows) { + const existing = integrationsByProject.get(row.projectId as string) ?? []; + existing.push(row); + integrationsByProject.set(row.projectId as string, existing); } - 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, + defaults: mapDefaultsRow(defaultsRow, globalAgentConfigs), + projects: projectRows.map((row) => { + const integrations = integrationsByProject.get(row.id) ?? []; + const { + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + } = extractIntegrationConfigs(integrations); + return mapProjectRow({ + row, + projectAgentConfigs: projectAgentConfigsMap.get(row.id) ?? [], + trelloConfig, + trelloTriggers, + jiraConfig, + jiraTriggers, + githubConfig, + githubTriggers, + }); + }), }; } @@ -193,14 +86,6 @@ export async function loadConfigFromDb(): Promise { db.select().from(projectIntegrations), ]); - // Index integrations by project ID - const integrationsByProject = new Map(); - for (const row of integrationRows) { - const existing = integrationsByProject.get(row.projectId) ?? []; - existing.push(row); - integrationsByProject.set(row.projectId, existing); - } - // Split agent configs: global (project_id IS NULL, org_id IS NULL) and per-project // Also collect org-level configs (org_id set, project_id IS NULL) as fallback globals const globalAgentConfigs = allAgentConfigs.filter( @@ -226,30 +111,13 @@ export async function loadConfigFromDb(): Promise { ...(defaultsRow ? (orgAgentConfigsMap.get(defaultsRow.orgId) ?? []) : []), ]; - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs), - projects: projectRows.map((row) => { - 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, - ); - }), - }; + const rawConfig = buildRawConfig({ + defaultsRow, + globalAgentConfigs: mergedGlobalConfigs, + projectRows, + integrationRows: integrationRows as IntegrationRow[], + projectAgentConfigsMap, + }); return validateConfig(rawConfig); } @@ -279,25 +147,16 @@ async function findProjectConfigFromDb( db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, row.id)), ]); - const integrationRows = integrations as IntegrationRow[]; - const { trelloConfig, trelloTriggers, jiraConfig, jiraTriggers, githubConfig, githubTriggers } = - extractIntegrationConfigs(integrationRows); + const projectAgentConfigsMap = new Map([[row.id, projectAcs]]); + + const rawConfig = buildRawConfig({ + defaultsRow, + globalAgentConfigs: [...globalAcs, ...orgAcs], + projectRows: [row], + integrationRows: integrations as IntegrationRow[], + projectAgentConfigsMap, + }); - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [ - mapProjectRow( - row, - projectAcs, - trelloConfig, - trelloTriggers, - jiraConfig, - jiraTriggers, - githubConfig, - githubTriggers, - ), - ], - }; const config = validateConfig(rawConfig); return { project: config.projects[0], config }; } diff --git a/src/gadgets/FileMultiEdit.ts b/src/gadgets/FileMultiEdit.ts index 38075a08..f401e150 100644 --- a/src/gadgets/FileMultiEdit.ts +++ b/src/gadgets/FileMultiEdit.ts @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { Gadget, z } from 'llmist'; import { assertFileRead, markFileRead } from './readTracking.js'; +import { withEscalationHint } from './shared/editEscalation.js'; import { adjustIndentation, applyReplacement, @@ -18,21 +19,10 @@ import { findAllMatches, formatContext, getMatchFailure, - recordEditFailure, runPostEditChecks, validatePath, } from './shared/index.js'; -const ESCALATION_HINT = - '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + - '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + - 'then WriteFile to rewrite the entire file or section.'; - -function withEscalationHint(message: string, filePath: string): string { - const failCount = recordEditFailure(filePath); - return failCount >= 2 ? message + ESCALATION_HINT : message; -} - export class FileMultiEdit extends Gadget({ name: 'FileMultiEdit', description: `Apply multiple search/replace edits to a single file atomically. diff --git a/src/gadgets/FileSearchAndReplace.ts b/src/gadgets/FileSearchAndReplace.ts index d9837d67..72d6d53e 100644 --- a/src/gadgets/FileSearchAndReplace.ts +++ b/src/gadgets/FileSearchAndReplace.ts @@ -10,6 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { Gadget, z } from 'llmist'; import { assertFileRead, markFileRead } from './readTracking.js'; +import { withEscalationHint } from './shared/editEscalation.js'; import { adjustIndentation, applyReplacement, @@ -17,22 +18,11 @@ import { findAllMatches, formatContext, getMatchFailure, - recordEditFailure, runPostEditChecks, validatePath, } from './shared/index.js'; import type { MatchResult } from './shared/types.js'; -const ESCALATION_HINT = - '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + - '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + - 'then WriteFile to rewrite the entire file or section.'; - -function withEscalationHint(message: string, filePath: string): string { - const failCount = recordEditFailure(filePath); - return failCount >= 2 ? message + ESCALATION_HINT : message; -} - export class FileSearchAndReplace extends Gadget({ name: 'FileSearchAndReplace', description: `Search for content in a file and replace it. diff --git a/src/gadgets/shared/editEscalation.ts b/src/gadgets/shared/editEscalation.ts new file mode 100644 index 00000000..51c4fbfe --- /dev/null +++ b/src/gadgets/shared/editEscalation.ts @@ -0,0 +1,19 @@ +/** + * Shared escalation hint utilities for file-editing gadgets. + * + * Extracted from FileSearchAndReplace and FileMultiEdit to eliminate + * byte-for-byte duplication of the ESCALATION_HINT constant and the + * withEscalationHint function. + */ + +import { recordEditFailure } from './diagnosticState.js'; + +export const ESCALATION_HINT = + '\n\nTIP: This file has failed multiple edit attempts. For files with repetitive structure ' + + '(CRUD methods, similar function signatures), use ReadFile to get the current content, ' + + 'then WriteFile to rewrite the entire file or section.'; + +export function withEscalationHint(message: string, filePath: string): string { + const failCount = recordEditFailure(filePath); + return failCount >= 2 ? message + ESCALATION_HINT : message; +} diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts new file mode 100644 index 00000000..d21909d8 --- /dev/null +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from 'vitest'; + +import { + type AgentConfigRow, + type DefaultsRow, + type IntegrationRow, + type MapProjectInput, + buildAgentMaps, + extractIntegrationConfigs, + mapDefaultsRow, + mapProjectRow, + orUndefined, +} from '../../../../src/db/repositories/configMapper.js'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const baseProjectRow = { + id: 'proj1', + orgId: 'org1', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + model: null, + cardBudgetUsd: null, + squintDbUrl: null, + agentBackend: null, + subscriptionCostZero: false, +}; + +const trelloConfig = { + boardId: 'board123', + lists: { todo: 'list-todo', done: 'list-done' }, + labels: { processing: 'label-proc' }, +}; + +const jiraConfig = { + projectKey: 'PROJ', + baseUrl: 'https://test.atlassian.net', + statuses: { briefing: 'Briefing', todo: 'To Do' }, +}; + +const trelloIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'trello', + config: trelloConfig, + triggers: {}, +}; + +const jiraIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'jira', + config: jiraConfig, + triggers: {}, +}; + +const githubIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'scm', + provider: 'github', + config: {}, + triggers: { ownPrsOnly: true }, +}; + +// --------------------------------------------------------------------------- +// orUndefined +// --------------------------------------------------------------------------- + +describe('orUndefined', () => { + it('returns the object when it has keys', () => { + expect(orUndefined({ a: '1' })).toEqual({ a: '1' }); + }); + + it('returns undefined for an empty object', () => { + expect(orUndefined({})).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildAgentMaps +// --------------------------------------------------------------------------- + +describe('buildAgentMaps', () => { + it('returns empty maps for empty input', () => { + const result = buildAgentMaps([]); + expect(result.models).toEqual({}); + expect(result.iterations).toEqual({}); + expect(result.prompts).toEqual({}); + expect(result.backends).toEqual({}); + }); + + it('maps model, iterations, prompt, and backend for each agent type', () => { + const configs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: 'claude-3-7-sonnet', + maxIterations: 30, + agentBackend: 'claude-code', + prompt: 'Write clean code', + }, + { + orgId: null, + projectId: 'proj1', + agentType: 'review', + model: 'claude-3-opus', + maxIterations: null, + agentBackend: null, + prompt: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.models).toEqual({ implementation: 'claude-3-7-sonnet', review: 'claude-3-opus' }); + expect(result.iterations).toEqual({ implementation: 30 }); + expect(result.prompts).toEqual({ implementation: 'Write clean code' }); + expect(result.backends).toEqual({ implementation: 'claude-code' }); + }); + + it('skips null values', () => { + const configs: AgentConfigRow[] = [ + { + orgId: null, + projectId: null, + agentType: 'briefing', + model: null, + maxIterations: null, + agentBackend: null, + prompt: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(Object.keys(result.models)).toHaveLength(0); + expect(Object.keys(result.iterations)).toHaveLength(0); + expect(Object.keys(result.prompts)).toHaveLength(0); + expect(Object.keys(result.backends)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// mapDefaultsRow +// --------------------------------------------------------------------------- + +describe('mapDefaultsRow', () => { + const defaultsRow: DefaultsRow = { + model: 'test-model', + maxIterations: 50, + watchdogTimeoutMs: 1800000, + cardBudgetUsd: '5.00', + agentBackend: 'llmist', + progressModel: 'progress-model', + progressIntervalMinutes: '5', + }; + + it('maps all fields from row', () => { + const result = mapDefaultsRow(defaultsRow, []); + expect(result.model).toBe('test-model'); + expect(result.maxIterations).toBe(50); + expect(result.watchdogTimeoutMs).toBe(1800000); + expect(result.cardBudgetUsd).toBe(5); + expect(result.agentBackend).toBe('llmist'); + expect(result.progressModel).toBe('progress-model'); + expect(result.progressIntervalMinutes).toBe(5); + }); + + it('converts cardBudgetUsd string to number', () => { + const result = mapDefaultsRow({ ...defaultsRow, cardBudgetUsd: '10.50' }, []); + expect(result.cardBudgetUsd).toBe(10.5); + }); + + it('converts progressIntervalMinutes string to number', () => { + const result = mapDefaultsRow({ ...defaultsRow, progressIntervalMinutes: '15' }, []); + expect(result.progressIntervalMinutes).toBe(15); + }); + + it('handles undefined defaults row gracefully', () => { + const result = mapDefaultsRow(undefined, []); + expect(result.model).toBeUndefined(); + expect(result.cardBudgetUsd).toBeUndefined(); + }); + + it('builds agentModels and agentIterations from agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: null, + agentType: 'review', + model: 'review-model', + maxIterations: 20, + agentBackend: null, + prompt: null, + }, + ]; + const result = mapDefaultsRow(defaultsRow, agentConfigs); + expect(result.agentModels).toEqual({ review: 'review-model' }); + expect(result.agentIterations).toEqual({ review: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// extractIntegrationConfigs +// --------------------------------------------------------------------------- + +describe('extractIntegrationConfigs', () => { + it('extracts trello config from integration rows', () => { + const result = extractIntegrationConfigs([trelloIntegrationRow]); + expect(result.trelloConfig).toEqual(trelloConfig); + expect(result.jiraConfig).toBeUndefined(); + expect(result.githubConfig).toBeUndefined(); + }); + + it('extracts jira config from integration rows', () => { + const result = extractIntegrationConfigs([jiraIntegrationRow]); + expect(result.jiraConfig).toEqual(jiraConfig); + expect(result.trelloConfig).toBeUndefined(); + }); + + it('extracts github triggers from integration rows', () => { + const result = extractIntegrationConfigs([githubIntegrationRow]); + expect(result.githubTriggers).toEqual({ ownPrsOnly: true }); + }); + + it('extracts trello triggers', () => { + const withTriggers: IntegrationRow = { + ...trelloIntegrationRow, + triggers: { cardMovedToTodo: true }, + }; + const result = extractIntegrationConfigs([withTriggers]); + expect(result.trelloTriggers).toEqual({ cardMovedToTodo: true }); + }); + + it('handles empty integration list', () => { + const result = extractIntegrationConfigs([]); + expect(result.trelloConfig).toBeUndefined(); + expect(result.jiraConfig).toBeUndefined(); + expect(result.githubConfig).toBeUndefined(); + }); + + it('extracts all providers from mixed integration list', () => { + const rows = [trelloIntegrationRow, githubIntegrationRow]; + const result = extractIntegrationConfigs(rows); + expect(result.trelloConfig).toEqual(trelloConfig); + expect(result.githubTriggers).toEqual({ ownPrsOnly: true }); + expect(result.jiraConfig).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// mapProjectRow +// --------------------------------------------------------------------------- + +describe('mapProjectRow', () => { + function makeInput(overrides: Partial = {}): MapProjectInput { + return { + row: baseProjectRow, + projectAgentConfigs: [], + trelloConfig, + ...overrides, + }; + } + + it('maps base project fields', () => { + const result = mapProjectRow(makeInput()); + expect(result.id).toBe('proj1'); + expect(result.orgId).toBe('org1'); + expect(result.name).toBe('Test Project'); + expect(result.repo).toBe('owner/repo'); + expect(result.baseBranch).toBe('main'); + expect(result.branchPrefix).toBe('feature/'); + }); + + it('defaults baseBranch to main when null', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, baseBranch: null } })); + expect(result.baseBranch).toBe('main'); + }); + + it('defaults branchPrefix to feature/ when null', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, branchPrefix: null } })); + expect(result.branchPrefix).toBe('feature/'); + }); + + it('sets pm.type to trello when trelloConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig })); + expect(result.pm.type).toBe('trello'); + }); + + it('sets pm.type to jira when jiraConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); + expect(result.pm.type).toBe('jira'); + }); + + it('builds trello config with boardId, lists, labels', () => { + const result = mapProjectRow(makeInput()); + expect(result.trello?.boardId).toBe('board123'); + expect(result.trello?.lists).toEqual({ todo: 'list-todo', done: 'list-done' }); + expect(result.trello?.labels).toEqual({ processing: 'label-proc' }); + }); + + it('includes trello triggers when non-empty', () => { + const result = mapProjectRow(makeInput({ trelloTriggers: { cardMovedToTodo: true } })); + expect(result.trello?.triggers).toEqual({ cardMovedToTodo: true }); + }); + + it('omits trello triggers when empty object', () => { + const result = mapProjectRow(makeInput({ trelloTriggers: {} })); + expect(result.trello?.triggers).toBeUndefined(); + }); + + it('builds jira config', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); + expect(result.jira?.projectKey).toBe('PROJ'); + expect(result.jira?.baseUrl).toBe('https://test.atlassian.net'); + expect(result.jira?.statuses).toEqual({ briefing: 'Briefing', todo: 'To Do' }); + }); + + it('includes jira triggers when non-empty', () => { + const result = mapProjectRow( + makeInput({ trelloConfig: undefined, jiraConfig, jiraTriggers: { issueTransitioned: true } }), + ); + expect(result.jira?.triggers).toEqual({ issueTransitioned: true }); + }); + + it('builds github section when githubTriggers is non-empty', () => { + const result = mapProjectRow(makeInput({ githubTriggers: { ownPrsOnly: true } })); + expect(result.github?.triggers).toEqual({ ownPrsOnly: true }); + }); + + it('omits github section when githubTriggers is empty', () => { + const result = mapProjectRow(makeInput({ githubTriggers: {} })); + expect(result.github).toBeUndefined(); + }); + + it('omits agentBackend when neither row.agentBackend nor agent overrides are set', () => { + const result = mapProjectRow(makeInput()); + expect(result.agentBackend).toBeUndefined(); + }); + + it('builds agentBackend from project row', () => { + const result = mapProjectRow( + makeInput({ + row: { ...baseProjectRow, agentBackend: 'claude-code', subscriptionCostZero: true }, + }), + ); + expect(result.agentBackend?.default).toBe('claude-code'); + expect(result.agentBackend?.subscriptionCostZero).toBe(true); + }); + + it('builds agentBackend overrides from project agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: 'impl-model', + maxIterations: null, + agentBackend: 'claude-code', + prompt: null, + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.agentBackend?.overrides).toEqual({ implementation: 'claude-code' }); + }); + + it('converts cardBudgetUsd from string to number', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, cardBudgetUsd: '7.50' } })); + expect(result.cardBudgetUsd).toBe(7.5); + }); + + it('includes squintDbUrl when set', () => { + const result = mapProjectRow( + makeInput({ row: { ...baseProjectRow, squintDbUrl: 'file://.squint.db' } }), + ); + expect(result.squintDbUrl).toBe('file://.squint.db'); + }); + + it('includes prompts from agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentBackend: null, + prompt: 'Write clean code', + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.prompts).toEqual({ implementation: 'Write clean code' }); + }); +}); diff --git a/tests/unit/gadgets/shared/editEscalation.test.ts b/tests/unit/gadgets/shared/editEscalation.test.ts new file mode 100644 index 00000000..fd44a500 --- /dev/null +++ b/tests/unit/gadgets/shared/editEscalation.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { clearDiagnosticState } from '../../../../src/gadgets/shared/diagnosticState.js'; +import { + ESCALATION_HINT, + withEscalationHint, +} from '../../../../src/gadgets/shared/editEscalation.js'; + +describe('editEscalation', () => { + afterEach(() => { + clearDiagnosticState(); + }); + + describe('ESCALATION_HINT', () => { + it('is a non-empty string', () => { + expect(typeof ESCALATION_HINT).toBe('string'); + expect(ESCALATION_HINT.length).toBeGreaterThan(0); + }); + + it('contains guidance about ReadFile/WriteFile', () => { + expect(ESCALATION_HINT).toContain('ReadFile'); + expect(ESCALATION_HINT).toContain('WriteFile'); + }); + }); + + describe('withEscalationHint', () => { + it('returns message unchanged on first failure', () => { + const result = withEscalationHint('Some error', '/path/to/file.ts'); + expect(result).toBe('Some error'); + }); + + it('returns message unchanged on second failure', () => { + withEscalationHint('first failure', '/path/to/file.ts'); + // The second call records failure count 2 — hint kicks in at >= 2 + const result = withEscalationHint('second failure', '/path/to/file.ts'); + expect(result).toBe(`second failure${ESCALATION_HINT}`); + }); + + it('appends escalation hint from the second failure onward', () => { + withEscalationHint('msg', '/path/to/file.ts'); // count = 1 + const second = withEscalationHint('msg', '/path/to/file.ts'); // count = 2 + const third = withEscalationHint('msg', '/path/to/file.ts'); // count = 3 + + expect(second).toContain(ESCALATION_HINT); + expect(third).toContain(ESCALATION_HINT); + }); + + it('tracks failure counts per file independently', () => { + withEscalationHint('msg', '/path/to/file1.ts'); // file1: count = 1 + const resultFile2 = withEscalationHint('msg', '/path/to/file2.ts'); // file2: count = 1 + + // file2 count is only 1, so no hint + expect(resultFile2).toBe('msg'); + + // file1 second failure triggers hint + const resultFile1 = withEscalationHint('msg', '/path/to/file1.ts'); // file1: count = 2 + expect(resultFile1).toContain(ESCALATION_HINT); + }); + }); +});