Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 34 additions & 37 deletions src/agents/definitions/contextSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand All @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 14 additions & 4 deletions src/pm/jira/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,21 @@ export class JiraPMProvider implements PMProvider {
};
}

async listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise<WorkItem[]> {
// containerId is the JIRA project key
let jql = `project = "${containerId}"`;
async listWorkItems(
containerId: string | undefined,
filter?: ListWorkItemsFilter,
): Promise<WorkItem[]> {
// 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);
Expand Down
8 changes: 6 additions & 2 deletions src/pm/linear/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,13 @@ export class LinearPMProvider implements PMProvider {
};
}

async listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise<WorkItem[]> {
// containerId is the Linear team ID
async listWorkItems(
containerId: string | undefined,
filter?: ListWorkItemsFilter,
): Promise<WorkItem[]> {
// 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 } : {}),
Expand Down
20 changes: 18 additions & 2 deletions src/pm/trello/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { trelloClient } from '../../trello/client.js';
import type { TrelloConfig } from '../config.js';
import { extractMarkdownImages } from '../media.js';
import type {
Attachment,
Expand All @@ -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<WorkItem> {
const card = await trelloClient.getCard(id);
const inlineMedia = extractMarkdownImages(card.desc, 'description');
Expand Down Expand Up @@ -100,8 +109,15 @@ export class TrelloPMProvider implements PMProvider {
};
}

async listWorkItems(containerId: string, _filter?: ListWorkItemsFilter): Promise<WorkItem[]> {
const cards = await trelloClient.getListCards(containerId);
async listWorkItems(
containerId: string | undefined,
filter?: ListWorkItemsFilter,
): Promise<WorkItem[]> {
// 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,
Expand Down
13 changes: 11 additions & 2 deletions src/pm/trello/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(projectId: string, fn: () => Promise<T>): Promise<T> {
Expand Down
21 changes: 19 additions & 2 deletions src/pm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -95,7 +103,16 @@ export interface PMProvider {
addComment(id: string, text: string): Promise<string>;
updateComment(id: string, commentId: string, text: string): Promise<void>;
createWorkItem(config: CreateWorkItemConfig): Promise<WorkItem>;
listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise<WorkItem[]>;
/**
* 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<WorkItem[]>;

// Lifecycle
moveWorkItem(id: string, destination: string): Promise<void>;
Expand Down
40 changes: 4 additions & 36 deletions src/triggers/shared/agent-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof provider.listWorkItems>>;
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,
Expand Down
Loading
Loading