diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index 25746534..a5d283db 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -356,10 +356,15 @@ export async function prepopulateTodosStep( /** * Named list entries used in the pipeline snapshot. + * + * `statusKey` is the CASCADE-canonical status (`'backlog'`, `'todo'`, ...) that + * gets passed to `provider.listWorkItems(undefined, { status: statusKey })`. + * Each provider self-resolves its native identifier (Trello list ID, JIRA + * status name, Linear state UUID) from its own config. */ interface PipelineList { name: string; - id: string; + statusKey: string; } interface PipelineListResult { @@ -377,42 +382,28 @@ function buildPipelineLists(project: ProjectConfig): PipelineList[] { const trelloConfig = getTrelloConfig(project); const jiraConfig = getJiraConfig(project); const linearConfig = getLinearConfig(project); - const lists: PipelineList[] = []; - const addList = (name: string, id: string | undefined): void => { - if (id) lists.push({ name, id }); + const STATUS_KEYS = ['backlog', 'todo', 'inProgress', 'inReview', 'done', 'merged'] as const; + const NAME_BY_KEY: Record<(typeof STATUS_KEYS)[number], string> = { + backlog: 'BACKLOG', + todo: 'TODO', + inProgress: 'IN_PROGRESS', + inReview: 'IN_REVIEW', + done: 'DONE', + merged: 'MERGED', }; - addList( - 'BACKLOG', - trelloConfig?.lists?.backlog ?? - jiraConfig?.statuses?.backlog ?? - linearConfig?.statuses?.backlog, - ); - addList( - 'TODO', - trelloConfig?.lists?.todo ?? jiraConfig?.statuses?.todo ?? linearConfig?.statuses?.todo, - ); - addList( - 'IN_PROGRESS', - trelloConfig?.lists?.inProgress ?? - jiraConfig?.statuses?.inProgress ?? - linearConfig?.statuses?.inProgress, - ); - addList( - 'IN_REVIEW', - trelloConfig?.lists?.inReview ?? - jiraConfig?.statuses?.inReview ?? - linearConfig?.statuses?.inReview, - ); - addList( - 'DONE', - trelloConfig?.lists?.done ?? jiraConfig?.statuses?.done ?? linearConfig?.statuses?.done, - ); - addList( - 'MERGED', - trelloConfig?.lists?.merged ?? jiraConfig?.statuses?.merged ?? linearConfig?.statuses?.merged, - ); + const lists: PipelineList[] = []; + for (const statusKey of STATUS_KEYS) { + // Skip statuses that no provider has configured — provider self-resolves + // the actual native ID at fetch time. + const hasMapping = Boolean( + trelloConfig?.lists?.[statusKey] ?? + jiraConfig?.statuses?.[statusKey] ?? + linearConfig?.statuses?.[statusKey], + ); + if (hasMapping) lists.push({ name: NAME_BY_KEY[statusKey], statusKey }); + } return lists; } @@ -425,12 +416,18 @@ async function fetchPipelineLists( return Promise.all( lists.map(async (list) => { try { - const items = await provider.listWorkItems(list.id); + // Pass `undefined` as containerId so each provider self-resolves + // the natural scope from its own config. The `status` filter is + // the CASCADE status key — provider maps it to its native + // identifier internally. This unified call shape works for all + // providers; passing `list.id` (a status identifier) directly as + // containerId silently returned [] for JIRA and Linear. + const items = await provider.listWorkItems(undefined, { status: list.statusKey }); return { list, items, error: null }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logWriter('WARN', `fetchPipelineSnapshotStep: Failed to fetch list ${list.name}`, { - listId: list.id, + statusKey: list.statusKey, error: message, }); return { list, items: null, error: message }; @@ -480,7 +477,7 @@ function appendPipelineSection( ): void { const { list, items, error } = listResult; - sections.push(`## ${list.name} (list ID: ${list.id})`); + sections.push(`## ${list.name} (status: ${list.statusKey})`); sections.push(''); if (error) { diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index 0c61ca64..08434e89 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -184,11 +184,21 @@ export class JiraPMProvider implements PMProvider { }; } - async listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise { - // containerId is the JIRA project key - let jql = `project = "${containerId}"`; + async listWorkItems( + containerId: string | undefined, + filter?: ListWorkItemsFilter, + ): Promise { + // containerId is the JIRA project key — defaults to config.projectKey. + const projectKey = containerId ?? this.config.projectKey; + if (!projectKey) return []; + let jql = `project = "${projectKey}"`; if (filter?.status) { - jql += ` AND status = "${filter.status}"`; + // Map CASCADE status key (e.g. 'todo') to native JIRA status name + // via config.statuses. Falls through to the literal value when no + // mapping exists, preserving backwards compat with callers that + // pass status names directly. + const native = this.config.statuses?.[filter.status] ?? filter.status; + jql += ` AND status = "${native}"`; } jql += ' ORDER BY created DESC'; const issues = await jiraClient.searchIssues(jql); diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index b630317b..49cc307c 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -134,9 +134,13 @@ export class LinearPMProvider implements PMProvider { }; } - async listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise { - // containerId is the Linear team ID + async listWorkItems( + containerId: string | undefined, + filter?: ListWorkItemsFilter, + ): Promise { + // containerId is the Linear team ID — defaults to config.teamId. const teamId = containerId || this.config.teamId; + if (!teamId) return []; const issues = await linearClient.listIssues({ teamId, ...(this.config.projectId ? { projectId: this.config.projectId } : {}), diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts index f1d38d55..4aecfc65 100644 --- a/src/pm/trello/adapter.ts +++ b/src/pm/trello/adapter.ts @@ -7,6 +7,7 @@ */ import { trelloClient } from '../../trello/client.js'; +import type { TrelloConfig } from '../config.js'; import { extractMarkdownImages } from '../media.js'; import type { Attachment, @@ -23,6 +24,14 @@ import type { export class TrelloPMProvider implements PMProvider { readonly type = 'trello' as const; + /** + * `config` is required — `listWorkItems` self-resolution looks up + * `config.lists[filter.status]` when no containerId is passed. The + * single production caller (`TrelloIntegration.createProvider`) always + * has a `TrelloConfig` available; tests must provide one too. + */ + constructor(private readonly config: TrelloConfig) {} + async getWorkItem(id: string): Promise { const card = await trelloClient.getCard(id); const inlineMedia = extractMarkdownImages(card.desc, 'description'); @@ -100,8 +109,15 @@ export class TrelloPMProvider implements PMProvider { }; } - async listWorkItems(containerId: string, _filter?: ListWorkItemsFilter): Promise { - const cards = await trelloClient.getListCards(containerId); + async listWorkItems( + containerId: string | undefined, + filter?: ListWorkItemsFilter, + ): Promise { + // Self-resolve list ID from config when caller doesn't pass one. Trello + // lists ARE the statuses, so `config.lists[filter.status]` IS the list ID. + const listId = containerId ?? (filter?.status ? this.config.lists?.[filter.status] : undefined); + if (!listId) return []; + const cards = await trelloClient.getListCards(listId); return cards.map((card) => ({ id: card.id, title: card.name, diff --git a/src/pm/trello/integration.ts b/src/pm/trello/integration.ts index d68f996f..1ac791fe 100644 --- a/src/pm/trello/integration.ts +++ b/src/pm/trello/integration.ts @@ -48,8 +48,17 @@ export class TrelloIntegration implements PMIntegration { return values.every((v) => v !== null); } - createProvider(_project: ProjectConfig): PMProvider { - return new TrelloPMProvider(); + createProvider(project: ProjectConfig): PMProvider { + // Pass the project's TrelloConfig so listWorkItems can self-resolve list + // IDs from CASCADE status keys (used by the snapshot loader and capacity + // check). When the project doesn't carry a TrelloConfig — the CLI's + // CredentialScopedCommand synthesises a `{ pm: { type: 'trello' } }` + // shell with no `trello` field for gadget-scope purposes (gadgets pass + // containerId explicitly) — fall back to an empty config so the + // adapter still constructs cleanly. listWorkItems' self-resolution + // then returns [] which is fine for that path. + const config = getTrelloConfig(project) ?? { boardId: '', lists: {}, labels: {} }; + return new TrelloPMProvider(config); } async withCredentials(projectId: string, fn: () => Promise): Promise { diff --git a/src/pm/types.ts b/src/pm/types.ts index bc4e5308..f5613db6 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -81,7 +81,15 @@ export interface CreateWorkItemConfig { /** Optional filters for listWorkItems to enable server-side filtering */ export interface ListWorkItemsFilter { - /** Filter by status name (JIRA: adds status filter to JQL; Trello: ignored since lists are status-scoped) */ + /** + * CASCADE-canonical status key (e.g. `'backlog'`, `'todo'`, `'inProgress'`). + * Each provider maps this through its own config: + * - Trello: looks up `config.lists[status]` to find the list ID. + * - JIRA: looks up `config.statuses[status]` for the status name in JQL. + * - Linear: looks up `config.statuses[status]` for the state UUID. + * + * Falls through to literal value when no mapping exists (backwards compat). + */ status?: string; } @@ -95,7 +103,16 @@ export interface PMProvider { addComment(id: string, text: string): Promise; updateComment(id: string, commentId: string, text: string): Promise; createWorkItem(config: CreateWorkItemConfig): Promise; - listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise; + /** + * List work items in a container (Trello list / JIRA project / Linear team). + * + * Pass `undefined` for `containerId` to fetch by status — each provider + * self-resolves the natural scope from its config: Trello looks up + * `lists[filter.status]`, JIRA defaults to `projectKey`, Linear defaults + * to `teamId`. Returns `[]` when neither containerId nor a resolvable + * scope is available. + */ + listWorkItems(containerId: string | undefined, filter?: ListWorkItemsFilter): Promise; // Lifecycle moveWorkItem(id: string, destination: string): Promise; diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index e3261617..e6703d7c 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -3,7 +3,6 @@ import type { LifecycleHooks } from '../../agents/definitions/schema.js'; import { runAgent } from '../../agents/registry.js'; import { createWorkItem, linkPRToWorkItem } from '../../db/repositories/prWorkItemsRepository.js'; import { updateRunPRNumber } from '../../db/repositories/runsRepository.js'; -import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; import { getPMProvider } from '../../pm/context.js'; import { createPMProvider, @@ -558,43 +557,12 @@ async function propagateAutoLabelAfterSplitting( const autoLabelId = pmConfig.labels.auto; if (!autoLabelId) return null; - // List all backlog items and add auto label + // List backlog items via the unified call shape — provider self-resolves + // scope (Trello list / JIRA project / Linear team) and maps the CASCADE + // status key to its native identifier from its own config. let backlogItems: Awaited>; try { - if (provider.type === 'trello') { - // Trello: containerId is the list ID - const backlogListId = getTrelloConfig(project)?.lists?.backlog; - if (!backlogListId) { - logger.warn( - 'propagateAutoLabelAfterSplitting: no backlog list configured for Trello, skipping', - { workItemId }, - ); - return null; - } - backlogItems = await provider.listWorkItems(backlogListId); - } else if (provider.type === 'jira') { - // JIRA: use server-side JQL filtering by status to avoid fetching all project issues - const jiraConfig = getJiraConfig(project); - const backlogStatus = jiraConfig?.statuses?.backlog; - const projectKey = jiraConfig?.projectKey; - if (!backlogStatus || !projectKey) { - logger.warn( - 'propagateAutoLabelAfterSplitting: no backlog status or projectKey configured for JIRA, skipping', - { workItemId }, - ); - return null; - } - backlogItems = await provider.listWorkItems(projectKey, { status: backlogStatus }); - logger.info('JIRA backlog items fetched for auto-label propagation', { - backlogCount: backlogItems.length, - projectKey, - }); - } else { - logger.warn('propagateAutoLabelAfterSplitting: unsupported PM provider type', { - providerType: provider.type, - }); - return null; - } + backlogItems = await provider.listWorkItems(undefined, { status: 'backlog' }); } catch (err) { logger.warn('propagateAutoLabelAfterSplitting: failed to list backlog items', { workItemId, diff --git a/src/triggers/shared/backlog-check.ts b/src/triggers/shared/backlog-check.ts index e9b1df1b..074c3c7b 100644 --- a/src/triggers/shared/backlog-check.ts +++ b/src/triggers/shared/backlog-check.ts @@ -11,7 +11,7 @@ * still runs normally. */ -import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; +import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js'; import type { PMProvider } from '../../pm/types.js'; import type { ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -57,130 +57,88 @@ export interface PipelineCapacityResult { * @param project - Resolved project configuration * @param provider - An initialised PM provider instance */ -export async function isPipelineAtCapacity( - project: ProjectConfig, - provider: PMProvider, -): Promise { - const limit = project.maxInFlightItems ?? 1; +/** + * Compile-time exhaustiveness guard. The `default` branch of the switch in + * `isProviderMisconfigured` calls this with `provider.type` narrowed to + * `never` — TypeScript only allows that when every `PMType` member has its + * own case. Adding a 4th provider without a matching case becomes a compile + * error, not a silent runtime "misconfigured". + */ +function assertNeverPMType(t: never): never { + throw new Error(`Unhandled PMType in isProviderMisconfigured: ${String(t)}`); +} - try { - if (provider.type === 'trello') { - return await checkTrelloCapacity(project, provider, limit); +/** + * Detect missing/incomplete provider config so we can return `'misconfigured'` + * (conservative fallback: agent runs anyway) instead of silently treating it as + * an empty backlog (which would skip the agent run). This is the *only* part of + * isPipelineAtCapacity that needs per-provider awareness — the actual queries + * go through the unified `provider.listWorkItems(undefined, { status })` path. + */ +function isProviderMisconfigured(project: ProjectConfig, provider: PMProvider): boolean { + switch (provider.type) { + case 'trello': + return !getTrelloConfig(project)?.lists?.backlog; + case 'jira': { + const jira = getJiraConfig(project); + return !jira?.projectKey || !jira.statuses?.backlog; } - - if (provider.type === 'jira') { - return await checkJiraCapacity(project, provider, limit); + case 'linear': { + const linear = getLinearConfig(project); + return !linear?.teamId || !linear.statuses?.backlog; } - - logger.warn('isPipelineAtCapacity: unsupported PM provider type', { - providerType: provider.type, - projectId: project.id, - }); - return { atCapacity: false, reason: 'misconfigured' }; - } catch (err) { - logger.warn('isPipelineAtCapacity: failed to check capacity, assuming not at capacity', { - projectId: project.id, - error: String(err), - }); - return { atCapacity: false, reason: 'error' }; + default: + return assertNeverPMType(provider.type); } } -async function checkTrelloCapacity( +export async function isPipelineAtCapacity( project: ProjectConfig, provider: PMProvider, - limit: number, ): Promise { - const trelloConfig = getTrelloConfig(project); - if (!trelloConfig) { - logger.warn('isPipelineAtCapacity: no Trello config for project', { - projectId: project.id, - }); - return { atCapacity: false, reason: 'misconfigured' }; - } - - const { lists } = trelloConfig; + const limit = project.maxInFlightItems ?? 1; - // Step 1: Check if backlog is empty — no work to pull in - const backlogListId = lists.backlog; - if (!backlogListId) { - logger.warn('isPipelineAtCapacity: no backlog list configured for Trello project', { + if (isProviderMisconfigured(project, provider)) { + logger.warn('isPipelineAtCapacity: provider config incomplete for backlog check', { + providerType: provider.type, projectId: project.id, }); return { atCapacity: false, reason: 'misconfigured' }; } - const backlogItems = await provider.listWorkItems(backlogListId); - if (backlogItems.length === 0) { - logger.info('isPipelineAtCapacity: backlog is empty', { projectId: project.id }); - return { atCapacity: true, reason: 'backlog-empty', inFlightCount: 0, limit }; - } - - // Step 2: Count in-flight items (TODO + IN_PROGRESS + IN_REVIEW) - const inFlightListIds = [lists.todo, lists.inProgress, lists.inReview].filter( - (id): id is string => Boolean(id), - ); - - const inFlightCounts = await Promise.all( - inFlightListIds.map((listId) => provider.listWorkItems(listId)), - ); - const inFlightCount = inFlightCounts.reduce((sum, items) => sum + items.length, 0); - - if (inFlightCount >= limit) { - logger.info('isPipelineAtCapacity: pipeline at capacity', { - projectId: project.id, - inFlightCount, - limit, - }); - return { atCapacity: true, reason: 'at-capacity', inFlightCount, limit }; - } - - return { atCapacity: false, reason: 'below-capacity', inFlightCount, limit }; -} - -async function checkJiraCapacity( - project: ProjectConfig, - provider: PMProvider, - limit: number, -): Promise { - const jiraConfig = getJiraConfig(project); - const backlogStatus = jiraConfig?.statuses?.backlog; - const projectKey = jiraConfig?.projectKey; + try { + // Unified path: each provider self-resolves the natural scope + // (Trello list / JIRA project / Linear team) from its config when + // containerId is undefined. The status filter is the CASCADE-canonical + // key, mapped to the provider's native identifier internally. + const backlogItems = await provider.listWorkItems(undefined, { status: 'backlog' }); + if (backlogItems.length === 0) { + logger.info('isPipelineAtCapacity: backlog is empty', { projectId: project.id }); + return { atCapacity: true, reason: 'backlog-empty', inFlightCount: 0, limit }; + } - if (!backlogStatus || !projectKey) { - logger.warn( - 'isPipelineAtCapacity: no backlog status or projectKey configured for JIRA project', - { projectId: project.id }, + const inFlightLists = await Promise.all( + (['todo', 'inProgress', 'inReview'] as const).map((status) => + provider.listWorkItems(undefined, { status }), + ), ); - return { atCapacity: false, reason: 'misconfigured' }; - } - - // Step 1: Check if backlog is empty — no work to pull in - const backlogItems = await provider.listWorkItems(projectKey, { status: backlogStatus }); - if (backlogItems.length === 0) { - logger.info('isPipelineAtCapacity: backlog is empty', { projectId: project.id }); - return { atCapacity: true, reason: 'backlog-empty', inFlightCount: 0, limit }; - } - - // Step 2: Count in-flight items across TODO + IN_PROGRESS + IN_REVIEW statuses - const { statuses } = jiraConfig; - const inFlightStatuses = [statuses.todo, statuses.inProgress, statuses.inReview].filter( - (s): s is string => Boolean(s), - ); - - const inFlightCounts = await Promise.all( - inFlightStatuses.map((status) => provider.listWorkItems(projectKey, { status })), - ); - const inFlightCount = inFlightCounts.reduce((sum, items) => sum + items.length, 0); + const inFlightCount = inFlightLists.reduce((sum, items) => sum + items.length, 0); + + if (inFlightCount >= limit) { + logger.info('isPipelineAtCapacity: pipeline at capacity', { + projectId: project.id, + inFlightCount, + limit, + }); + return { atCapacity: true, reason: 'at-capacity', inFlightCount, limit }; + } - if (inFlightCount >= limit) { - logger.info('isPipelineAtCapacity: pipeline at capacity', { + return { atCapacity: false, reason: 'below-capacity', inFlightCount, limit }; + } catch (err) { + logger.warn('isPipelineAtCapacity: failed to check capacity, assuming not at capacity', { projectId: project.id, - inFlightCount, - limit, + error: String(err), }); - return { atCapacity: true, reason: 'at-capacity', inFlightCount, limit }; + return { atCapacity: false, reason: 'error' }; } - - return { atCapacity: false, reason: 'below-capacity', inFlightCount, limit }; } diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index ba822bc0..95fd5059 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -60,6 +60,37 @@ export function createMockJiraProject(overrides?: Partial): Proje } as ProjectConfig; } +/** + * Creates a mock Linear project config. + */ +export function createMockLinearProject(overrides?: Partial): ProjectConfig { + return { + id: 'linear-project', + orgId: 'org-1', + name: 'Linear Project', + repo: 'owner/linear-repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'linear' }, + linear: { + teamId: 'team-1', + statuses: { + backlog: 'state-backlog', + todo: 'state-todo', + inProgress: 'state-inprog', + inReview: 'state-inrev', + }, + labels: { + processing: 'lbl-processing', + processed: 'lbl-processed', + error: 'lbl-error', + readyToProcess: 'lbl-ready', + }, + }, + ...overrides, + } as ProjectConfig; +} + // --------------------------------------------------------------------------- // tRPC factories // --------------------------------------------------------------------------- diff --git a/tests/unit/agents/definitions/pipelineSnapshot.test.ts b/tests/unit/agents/definitions/pipelineSnapshot.test.ts index dcd63e39..bc866f84 100644 --- a/tests/unit/agents/definitions/pipelineSnapshot.test.ts +++ b/tests/unit/agents/definitions/pipelineSnapshot.test.ts @@ -88,7 +88,7 @@ describe('fetchPipelineSnapshotStep', () => { expect(result).toEqual([]); }); - it('builds pipeline lists from Linear statuses', async () => { + it('uses unified provider.listWorkItems(undefined, { status }) for Linear projects', async () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); mockProvider.listWorkItems.mockResolvedValue([]); mockReadWorkItem.mockResolvedValue('# details'); @@ -117,8 +117,11 @@ describe('fetchPipelineSnapshotStep', () => { const result = await fetchPipelineSnapshotStep(makeParams({}, linearProject)); expect(result).toHaveLength(1); - for (const id of ['st-backlog', 'st-todo', 'st-inprog', 'st-inrev', 'st-done', 'st-merged']) { - expect(mockProvider.listWorkItems).toHaveBeenCalledWith(id); + // After the listWorkItems unification fix: the loader passes + // (undefined, { status: cascadeKey }) — NOT the raw state UUID as + // containerId. Each provider self-resolves the scope from its config. + for (const status of ['backlog', 'todo', 'inProgress', 'inReview', 'done', 'merged']) { + expect(mockProvider.listWorkItems).toHaveBeenCalledWith(undefined, { status }); } }); @@ -165,9 +168,9 @@ describe('fetchPipelineSnapshotStep', () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; - mockProvider.listWorkItems.mockImplementation(async (listId: string) => { - if (listId === 'list-backlog') return [card]; - if (listId === 'list-todo') return [card]; + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'backlog') return [card]; + if (filter?.status === 'todo') return [card]; return []; }); mockReadWorkItem.mockResolvedValue('# Test Card\n\nFull details here'); @@ -183,8 +186,8 @@ describe('fetchPipelineSnapshotStep', () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const card = { id: 'card-done', title: 'Done Card', url: 'http://trello.com/c/2', labels: [] }; - mockProvider.listWorkItems.mockImplementation(async (listId: string) => { - if (listId === 'list-done' || listId === 'list-merged') return [card]; + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'done' || filter?.status === 'merged') return [card]; return []; }); mockReadWorkItem.mockResolvedValue('# Done Card\n\nFull details'); @@ -204,8 +207,8 @@ describe('fetchPipelineSnapshotStep', () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const card = { id: 'card-done', title: 'Done Card', url: '', labels: [] }; - mockProvider.listWorkItems.mockImplementation(async (listId: string) => { - if (listId === 'list-done') return [card]; + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'done') return [card]; return []; }); @@ -237,8 +240,8 @@ describe('fetchPipelineSnapshotStep', () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; - mockProvider.listWorkItems.mockImplementation(async (listId: string) => { - if (listId === 'list-backlog') return [card]; + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'backlog') return [card]; return []; }); mockReadWorkItem.mockRejectedValue(new Error('Card read error')); @@ -254,8 +257,8 @@ describe('fetchPipelineSnapshotStep', () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); const card = { id: 'card-1', title: 'Test Card', url: 'http://trello.com/c/1', labels: [] }; - mockProvider.listWorkItems.mockImplementation(async (listId: string) => { - if (listId === 'list-backlog') return [card]; + mockProvider.listWorkItems.mockImplementation(async (_containerId, filter) => { + if (filter?.status === 'backlog') return [card]; return []; }); mockReadWorkItem.mockResolvedValue('# Test Card'); @@ -329,14 +332,17 @@ describe('fetchPipelineSnapshotStep', () => { expect(result[0].description).toContain('2 lists'); }); - it('includes list IDs in section headers', async () => { + it('includes CASCADE status keys in section headers', async () => { mockGetPMProviderOrNull.mockReturnValue(mockProvider as never); mockProvider.listWorkItems.mockResolvedValue([]); const result = await fetchPipelineSnapshotStep(makeParams({}, makeProject())); const output = result[0].result as string; - expect(output).toContain('list ID: list-backlog'); - expect(output).toContain('list ID: list-todo'); + // Headers expose the CASCADE status key (what move-work-item expects), + // not the provider-native ID — that's a Linear UUID for Linear projects + // and useless to the agent. + expect(output).toContain('status: backlog'); + expect(output).toContain('status: todo'); }); }); diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index 8539a8cf..a6d81573 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -449,6 +449,33 @@ describe('JiraPMProvider', () => { status: 'Backlog', }); }); + + describe('self-resolution from config', () => { + it('uses config.projectKey when containerId is omitted', async () => { + mockJiraClient.searchIssues.mockResolvedValue([]); + await provider.listWorkItems(undefined, { status: 'backlog' }); + expect(mockJiraClient.searchIssues).toHaveBeenCalledWith( + expect.stringContaining(`project = "${mockConfig.projectKey}"`), + ); + }); + + it('maps a CASCADE status key (e.g. "todo") through config.statuses to the native status name', async () => { + mockJiraClient.searchIssues.mockResolvedValue([]); + await provider.listWorkItems(undefined, { status: 'todo' }); + const native = mockConfig.statuses.todo; + expect(mockJiraClient.searchIssues).toHaveBeenCalledWith( + expect.stringContaining(`status = "${native}"`), + ); + }); + + it('falls through to literal status when config.statuses has no mapping (backwards compat)', async () => { + mockJiraClient.searchIssues.mockResolvedValue([]); + await provider.listWorkItems(undefined, { status: 'Custom Status' }); + expect(mockJiraClient.searchIssues).toHaveBeenCalledWith( + expect.stringContaining(`status = "Custom Status"`), + ); + }); + }); }); describe('moveWorkItem', () => { diff --git a/tests/unit/pm/linear-adapter.test.ts b/tests/unit/pm/linear-adapter.test.ts index 5f367fb2..9504d6c5 100644 --- a/tests/unit/pm/linear-adapter.test.ts +++ b/tests/unit/pm/linear-adapter.test.ts @@ -65,6 +65,42 @@ describe('LinearPMProvider.listWorkItems — project scope', () => { }); }); +describe('LinearPMProvider.listWorkItems — self-resolution from config', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses config.teamId when containerId is omitted', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider( + configOf({ teamId: 'T-from-config', statuses: { backlog: 'S-BL' } }), + ); + await provider.listWorkItems(undefined, { status: 'backlog' }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'T-from-config', stateId: 'S-BL' }), + ); + }); + + it('uses config.teamId AND config.projectId AND status filter when all set, no containerId', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider( + configOf({ teamId: 'T1', projectId: 'P1', statuses: { todo: 'S-TODO' } }), + ); + await provider.listWorkItems(undefined, { status: 'todo' }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'T1', projectId: 'P1', stateId: 'S-TODO' }), + ); + }); + + it('returns [] when neither containerId nor config.teamId is set', async () => { + const spy = vi.spyOn(linearClient, 'listIssues').mockResolvedValue([]); + const provider = new LinearPMProvider(configOf({ teamId: '' })); + const result = await provider.listWorkItems(undefined, { status: 'backlog' }); + expect(result).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + }); +}); + describe('LinearPMProvider.createWorkItem — project scope', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/tests/unit/pm/trello/adapter.test.ts b/tests/unit/pm/trello/adapter.test.ts index 6476a162..c339777b 100644 --- a/tests/unit/pm/trello/adapter.test.ts +++ b/tests/unit/pm/trello/adapter.test.ts @@ -38,7 +38,9 @@ describe('TrelloPMProvider', () => { beforeEach(() => { vi.resetAllMocks(); - provider = new TrelloPMProvider(); + // Default to an empty Trello config; specific listWorkItems tests below + // instantiate with a populated config when they need self-resolution. + provider = new TrelloPMProvider({ boardId: 'B1', lists: {}, labels: {} }); }); it('has type "trello"', () => { @@ -338,6 +340,51 @@ describe('TrelloPMProvider', () => { }); }); + describe('listWorkItems — self-resolution from config', () => { + it('looks up config.lists[status] when containerId is omitted', async () => { + mockTrelloClient.getListCards.mockResolvedValue([]); + const providerWithConfig = new TrelloPMProvider({ + boardId: 'B1', + lists: { backlog: 'list-BL', todo: 'list-TODO' }, + labels: {}, + }); + await providerWithConfig.listWorkItems(undefined, { status: 'backlog' }); + expect(mockTrelloClient.getListCards).toHaveBeenCalledWith('list-BL'); + }); + + it('returns empty array when status has no list mapping in config', async () => { + mockTrelloClient.getListCards.mockResolvedValue([]); + const providerWithEmptyConfig = new TrelloPMProvider({ + boardId: 'B1', + lists: {}, // no backlog + labels: {}, + }); + const result = await providerWithEmptyConfig.listWorkItems(undefined, { + status: 'backlog', + }); + expect(result).toEqual([]); + expect(mockTrelloClient.getListCards).not.toHaveBeenCalled(); + }); + + it('returns empty array when no config and no containerId', async () => { + mockTrelloClient.getListCards.mockResolvedValue([]); + const result = await provider.listWorkItems(undefined, { status: 'backlog' }); + expect(result).toEqual([]); + expect(mockTrelloClient.getListCards).not.toHaveBeenCalled(); + }); + + it('explicit containerId overrides config lookup (backwards compat)', async () => { + mockTrelloClient.getListCards.mockResolvedValue([]); + const providerWithConfig = new TrelloPMProvider({ + boardId: 'B1', + lists: { backlog: 'list-BL' }, + labels: {}, + }); + await providerWithConfig.listWorkItems('explicit-list', { status: 'backlog' }); + expect(mockTrelloClient.getListCards).toHaveBeenCalledWith('explicit-list'); + }); + }); + describe('moveWorkItem', () => { it('delegates to trelloClient.moveCardToList', async () => { mockTrelloClient.moveCardToList.mockResolvedValue(undefined); diff --git a/tests/unit/triggers/agent-execution.test.ts b/tests/unit/triggers/agent-execution.test.ts index dbefbcf8..50939cec 100644 --- a/tests/unit/triggers/agent-execution.test.ts +++ b/tests/unit/triggers/agent-execution.test.ts @@ -525,7 +525,7 @@ describe('runAgentExecutionPipeline', () => { await runAgentExecutionPipeline(splittingResult, mockProject, mockConfig); - expect(mockProvider.listWorkItems).toHaveBeenCalledWith('backlog-list-id'); + expect(mockProvider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' }); expect(mockProvider.addLabel).toHaveBeenCalledTimes(2); expect(mockProvider.addLabel).toHaveBeenCalledWith('card-1', 'auto-label-id'); expect(mockProvider.addLabel).toHaveBeenCalledWith('card-3', 'auto-label-id'); @@ -580,8 +580,9 @@ describe('runAgentExecutionPipeline', () => { await runAgentExecutionPipeline(splittingResult, mockProject, mockConfig); - // Should use server-side status filtering via the filter parameter - expect(jiraProvider.listWorkItems).toHaveBeenCalledWith('PROJ', { status: 'Backlog' }); + // After listWorkItems unification: provider self-resolves projectKey from + // its own config and maps the CASCADE status key to its native status name. + expect(jiraProvider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' }); // Should only label PROJ-2 (no auto label yet); PROJ-4 already has auto label expect(jiraProvider.addLabel).toHaveBeenCalledTimes(1); expect(jiraProvider.addLabel).toHaveBeenCalledWith('PROJ-2', 'auto-label-id'); @@ -627,10 +628,15 @@ describe('runAgentExecutionPipeline', () => { labels: [{ id: 'auto-label-id', name: 'auto' }], }); - // Non-empty backlog — agent should chain to backlog-manager - mockProvider.listWorkItems.mockResolvedValue([ - { id: 'backlog-card-1', title: 'Item 1', description: '', url: '', labels: [] }, - ]); + // Non-empty backlog — agent should chain to backlog-manager. Mock + // per-status so the in-flight checks (todo/inProgress/inReview) return + // [] and the capacity check below the backlog-empty check sees room. + mockProvider.listWorkItems.mockImplementation(async (_containerId, opts) => { + if (opts?.status === 'backlog') { + return [{ id: 'backlog-card-1', title: 'Item 1', description: '', url: '', labels: [] }]; + } + return []; + }); await runAgentExecutionPipeline(splittingResult, mockProject, mockConfig); @@ -668,12 +674,8 @@ describe('runAgentExecutionPipeline', () => { expect(runAgent).toHaveBeenCalledWith('splitting', expect.any(Object)); }); - it('skips propagation if backlog list/status is not configured', async () => { - vi.mocked(getTrelloConfig).mockReturnValue({ - boardId: 'board123', - lists: {}, // No backlog list - labels: {}, - }); + it('skips chaining to backlog-manager when backlog comes back empty (e.g. provider misconfigured)', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); // chain would happen if backlog were non-empty const splittingResult: TriggerResult = { agentType: 'splitting', @@ -689,9 +691,19 @@ describe('runAgentExecutionPipeline', () => { labels: [{ id: 'auto-label-id', name: 'auto' }], }); + // After listWorkItems unification: misconfigured providers return [] from + // self-resolution rather than the function dispatching on provider type and + // short-circuiting. The backlog-empty check below the propagation block + // catches it the same way. + mockProvider.listWorkItems.mockResolvedValue([]); + await runAgentExecutionPipeline(splittingResult, mockProject, mockConfig); - expect(mockProvider.listWorkItems).not.toHaveBeenCalled(); + // listWorkItems IS called now (the unified path always queries) — but the + // chain still skips because backlog comes back empty. + expect(mockProvider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' }); + expect(runAgent).toHaveBeenCalledTimes(1); + expect(runAgent).toHaveBeenCalledWith('splitting', expect.any(Object)); }); }); }); diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index b4250bd6..69eaca38 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -190,10 +190,19 @@ function mockProvider(overrides: Record = {}) { id: 'parent-card', labels: [{ id: 'label-auto-id', name: 'auto' }], }), - listWorkItems: vi.fn().mockResolvedValue([ - { id: 'backlog-1', labels: [] }, - { id: 'backlog-2', labels: [{ id: 'label-auto-id', name: 'auto' }] }, - ]), + // Per-status impl: backlog has 2 cards, in-flight statuses are empty so the + // chain's capacity check below the propagation block doesn't bail. + listWorkItems: vi + .fn() + .mockImplementation(async (_containerId: string | undefined, opts?: { status?: string }) => { + if (opts?.status === 'backlog') { + return [ + { id: 'backlog-1', labels: [] }, + { id: 'backlog-2', labels: [{ id: 'label-auto-id', name: 'auto' }] }, + ]; + } + return []; + }), addLabel: vi.fn().mockResolvedValue(undefined), ...overrides, }; diff --git a/tests/unit/triggers/shared/backlog-check.test.ts b/tests/unit/triggers/shared/backlog-check.test.ts index 89eff0a6..95943ac4 100644 --- a/tests/unit/triggers/shared/backlog-check.test.ts +++ b/tests/unit/triggers/shared/backlog-check.test.ts @@ -4,20 +4,24 @@ import { describe, expect, it, vi } from 'vitest'; // Hoisted mocks // --------------------------------------------------------------------------- -const { mockGetTrelloConfig, mockGetJiraConfig, mockLogger } = vi.hoisted(() => ({ - mockGetTrelloConfig: vi.fn(), - mockGetJiraConfig: vi.fn(), - mockLogger: { - info: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - }, -})); +const { mockGetTrelloConfig, mockGetJiraConfig, mockGetLinearConfig, mockLogger } = vi.hoisted( + () => ({ + mockGetTrelloConfig: vi.fn(), + mockGetJiraConfig: vi.fn(), + mockGetLinearConfig: vi.fn(), + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + }), +); vi.mock('../../../../src/pm/config.js', () => ({ getTrelloConfig: mockGetTrelloConfig, getJiraConfig: mockGetJiraConfig, + getLinearConfig: mockGetLinearConfig, })); vi.mock('../../../../src/utils/logging.js', () => ({ @@ -25,24 +29,37 @@ vi.mock('../../../../src/utils/logging.js', () => ({ })); import { isPipelineAtCapacity } from '../../../../src/triggers/shared/backlog-check.js'; -import { createMockJiraProject, createMockProject } from '../../../helpers/factories.js'; +import { + createMockJiraProject, + createMockLinearProject, + createMockProject, +} from '../../../helpers/factories.js'; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- -function makeProvider(type: 'trello' | 'jira', itemsByList: Record = {}) { +/** + * Build a mock PMProvider whose `listWorkItems(undefined, { status: })` + * resolves to `itemsByStatus[key]`. Keys MUST be CASCADE-canonical statuses + * (`'backlog'`, `'todo'`, `'inProgress'`, `'inReview'`) — same shape that + * `isPipelineAtCapacity` and the snapshot loader use. + */ +function makeProvider( + type: 'trello' | 'jira' | 'linear', + itemsByStatus: Record = {}, +) { return { type, - listWorkItems: vi.fn().mockImplementation((listIdOrKey: string, opts?: { status?: string }) => { - // For JIRA: look up by status value; for Trello: look up by list ID - const key = opts?.status ?? listIdOrKey; - return Promise.resolve(itemsByList[key] ?? []); - }), + listWorkItems: vi + .fn() + .mockImplementation((_containerId: string | undefined, opts?: { status?: string }) => + Promise.resolve(opts?.status ? (itemsByStatus[opts.status] ?? []) : []), + ), } as unknown as Parameters[1]; } -function makeErrorProvider(type: 'trello' | 'jira') { +function makeErrorProvider(type: 'trello' | 'jira' | 'linear') { return { type, listWorkItems: vi.fn().mockRejectedValue(new Error('network error')), @@ -102,8 +119,8 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], - 'todo-list-id': [{ id: 'card-todo-1' }], + backlog: [{ id: 'card-backlog-1' }], + todo: [{ id: 'card-todo-1' }], }); const result = await isPipelineAtCapacity(trelloProject, provider); @@ -138,9 +155,9 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], - 'todo-list-id': [{ id: 'card-todo-1' }], - 'in-progress-list-id': [{ id: 'card-wip-1' }, { id: 'card-wip-2' }], + backlog: [{ id: 'card-backlog-1' }], + todo: [{ id: 'card-todo-1' }], + inProgress: [{ id: 'card-wip-1' }, { id: 'card-wip-2' }], }); const result = await isPipelineAtCapacity(project, provider); @@ -175,9 +192,9 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], - 'todo-list-id': [{ id: 'card-todo-1' }], - 'in-progress-list-id': [{ id: 'card-wip-1' }], + backlog: [{ id: 'card-backlog-1' }], + todo: [{ id: 'card-todo-1' }], + inProgress: [{ id: 'card-wip-1' }], }); const result = await isPipelineAtCapacity(project, provider); @@ -205,8 +222,8 @@ describe('isPipelineAtCapacity', () => { lists: { backlog: 'backlog-list-id', todo: 'todo-list-id' }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], - 'todo-list-id': [{ id: 'card-todo-1' }], + backlog: [{ id: 'card-backlog-1' }], + todo: [{ id: 'card-todo-1' }], }); const result = await isPipelineAtCapacity(projectNoLimit, provider); @@ -230,7 +247,7 @@ describe('isPipelineAtCapacity', () => { lists: { backlog: 'backlog-list-id', todo: 'todo-list-id' }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], + backlog: [{ id: 'card-backlog-1' }], // todo is empty }); @@ -302,10 +319,10 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('trello', { - 'backlog-list-id': [{ id: 'card-backlog-1' }], - 'todo-list-id': [{ id: 'todo-1' }, { id: 'todo-2' }], - 'in-progress-list-id': [{ id: 'wip-1' }], - 'in-review-list-id': [{ id: 'review-1' }, { id: 'review-2' }, { id: 'review-3' }], + backlog: [{ id: 'card-backlog-1' }], + todo: [{ id: 'todo-1' }, { id: 'todo-2' }], + inProgress: [{ id: 'wip-1' }], + inReview: [{ id: 'review-1' }, { id: 'review-2' }, { id: 'review-3' }], }); const result = await isPipelineAtCapacity(project, provider); @@ -367,8 +384,8 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('jira', { - Backlog: [{ id: 'PROJ-1' }], - 'To Do': [{ id: 'PROJ-2' }], + backlog: [{ id: 'PROJ-1' }], + todo: [{ id: 'PROJ-2' }], }); const result = await isPipelineAtCapacity(jiraProject, provider); @@ -404,9 +421,9 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('jira', { - Backlog: [{ id: 'PROJ-1' }], - 'To Do': [{ id: 'PROJ-2' }], - 'In Progress': [{ id: 'PROJ-3' }], + backlog: [{ id: 'PROJ-1' }], + todo: [{ id: 'PROJ-2' }], + inProgress: [{ id: 'PROJ-3' }], }); const result = await isPipelineAtCapacity(project, provider); @@ -442,10 +459,10 @@ describe('isPipelineAtCapacity', () => { }, }); const provider = makeProvider('jira', { - Backlog: [{ id: 'PROJ-1' }], - 'To Do': [{ id: 'PROJ-2' }], - 'In Progress': [{ id: 'PROJ-3' }], - 'In Review': [{ id: 'PROJ-4' }], + backlog: [{ id: 'PROJ-1' }], + todo: [{ id: 'PROJ-2' }], + inProgress: [{ id: 'PROJ-3' }], + inReview: [{ id: 'PROJ-4' }], }); const result = await isPipelineAtCapacity(project, provider); @@ -474,8 +491,8 @@ describe('isPipelineAtCapacity', () => { statuses: { backlog: 'Backlog', todo: 'To Do' }, }); const provider = makeProvider('jira', { - Backlog: [{ id: 'PROJ-1' }], - 'To Do': [{ id: 'PROJ-2' }], + backlog: [{ id: 'PROJ-1' }], + todo: [{ id: 'PROJ-2' }], }); const result = await isPipelineAtCapacity(projectNoLimit, provider); @@ -500,7 +517,7 @@ describe('isPipelineAtCapacity', () => { statuses: { backlog: 'Backlog', todo: 'To Do' }, }); const provider = makeProvider('jira', { - Backlog: [{ id: 'PROJ-1' }], + backlog: [{ id: 'PROJ-1' }], // To Do is empty }); @@ -567,26 +584,122 @@ describe('isPipelineAtCapacity', () => { }); // ========================================================================= - // Unsupported provider type + // Linear + // ========================================================================= + + describe('Linear', () => { + const linearProject = createMockLinearProject({ + linear: { + teamId: 'T1', + statuses: { + backlog: 'state-backlog', + todo: 'state-todo', + inProgress: 'state-inprog', + inReview: 'state-inrev', + }, + labels: {}, + }, + maxInFlightItems: 1, + }); + + it('returns at-capacity (backlog-empty) when the Linear backlog is empty', async () => { + mockGetLinearConfig.mockReturnValue({ + teamId: 'T1', + statuses: { backlog: 'state-backlog' }, + }); + const provider = makeProvider('linear', {}); + + const result = await isPipelineAtCapacity(linearProject, provider); + + expect(result.atCapacity).toBe(true); + expect(result.reason).toBe('backlog-empty'); + expect(provider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' }); + }); + + it('returns below-capacity when Linear in-flight count is below limit', async () => { + mockGetLinearConfig.mockReturnValue({ + teamId: 'T1', + statuses: { backlog: 'state-backlog' }, + }); + const provider = makeProvider('linear', { + backlog: [{ id: 'MNG-97' }], + todo: [], + inProgress: [], + inReview: [], + }); + + const result = await isPipelineAtCapacity(linearProject, provider); + + expect(result.atCapacity).toBe(false); + expect(result.reason).toBe('below-capacity'); + expect(result.inFlightCount).toBe(0); + expect(result.limit).toBe(1); + }); + + it('returns at-capacity when Linear in-flight count meets the limit', async () => { + mockGetLinearConfig.mockReturnValue({ + teamId: 'T1', + statuses: { backlog: 'state-backlog' }, + }); + const provider = makeProvider('linear', { + backlog: [{ id: 'MNG-97' }], + todo: [{ id: 'MNG-96' }], + }); + + const result = await isPipelineAtCapacity(linearProject, provider); + + expect(result.atCapacity).toBe(true); + expect(result.reason).toBe('at-capacity'); + expect(result.inFlightCount).toBe(1); + }); + + it('returns misconfigured when Linear has no statuses.backlog configured', async () => { + mockGetLinearConfig.mockReturnValue({ + teamId: 'T1', + statuses: {}, // no backlog + }); + const provider = makeProvider('linear'); + + const result = await isPipelineAtCapacity(linearProject, provider); + + expect(result.atCapacity).toBe(false); + expect(result.reason).toBe('misconfigured'); + expect(provider.listWorkItems).not.toHaveBeenCalled(); + }); + + it('returns misconfigured when Linear has no teamId configured', async () => { + mockGetLinearConfig.mockReturnValue({ + teamId: '', + statuses: { backlog: 'state-backlog' }, + }); + const provider = makeProvider('linear'); + + const result = await isPipelineAtCapacity(linearProject, provider); + + expect(result.atCapacity).toBe(false); + expect(result.reason).toBe('misconfigured'); + }); + }); + + // ========================================================================= + // Unsupported provider type — exhaustiveness safety net // ========================================================================= describe('unsupported provider type', () => { - it('returns misconfigured for an unknown provider type', async () => { + it('throws when an unknown provider.type sneaks past TypeScript', async () => { + // In normal use, PMType (`'trello' | 'jira' | 'linear'`) is enforced at + // compile time. The cast here simulates a JS-side path bypassing the + // type system (e.g. the oclif command loader). The exhaustive switch + // in isProviderMisconfigured throws via assertNeverPMType so the bug + // surfaces immediately rather than silently reporting "misconfigured". const project = createMockProject(); const provider = { type: 'unknown-provider' as unknown as 'trello', listWorkItems: vi.fn(), } as unknown as Parameters[1]; - const result = await isPipelineAtCapacity(project, provider); - - expect(result.atCapacity).toBe(false); - expect(result.reason).toBe('misconfigured'); + await expect(isPipelineAtCapacity(project, provider)).rejects.toThrow(/Unhandled PMType/); expect(provider.listWorkItems).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'isPipelineAtCapacity: unsupported PM provider type', - expect.objectContaining({ providerType: 'unknown-provider' }), - ); }); }); });