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
5 changes: 3 additions & 2 deletions src/agents/prompts/templates/planning.eta
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ Update the <%= it.workItemNoun || 'card' %> description with **emoji section hea

**IMPORTANT:**
- After updating the <%= it.workItemNoun || 'card' %>, ALWAYS call `AddChecklist` to create an interactive "📋 Implementation Steps" checklist with each step as an item.
- When referencing other <%= it.workItemNounPlural || 'cards' %> (related stories, dependencies), ALWAYS use markdown links: `[<%= it.workItemNounCap || 'Card' %> Title](URL)`
<% if (it.pmType === 'jira') { %>- When calling `AddChecklist`, pass items as objects with `name` and `description`. The description should include the files to modify, specific changes, and testing notes from the corresponding Implementation Step section. This becomes the JIRA subtask description.
<% } %>- When referencing other <%= it.workItemNounPlural || 'cards' %> (related stories, dependencies), ALWAYS use markdown links: `[<%= it.workItemNounCap || 'Card' %> Title](URL)`

## Comment Format

Expand All @@ -124,7 +125,7 @@ Review the updated description and move to TODO when ready to implement!
**<%= it.pmName || 'Trello' %> (for outputting your plan):**
- `ReadWorkItem` - Read <%= it.workItemNoun || 'card' %> details (title, description, comments, labels)
- `UpdateWorkItem` - Update <%= it.workItemNoun || 'card' %> title/description
- `AddChecklist` - Add an interactive checklist to a <%= it.workItemNoun || 'card' %> (use for implementation steps)
- `AddChecklist` - Add an interactive checklist to a <%= it.workItemNoun || 'card' %> (use for implementation steps)<% if (it.pmType === 'jira') { %>. Each item can include a `description` with files-to-modify, specific changes, and testing notes — these become subtask descriptions.<% } %>
- `PostComment` - Post a comment on a <%= it.workItemNoun || 'card' %>

**Codebase exploration (READ-ONLY):**
Expand Down
37 changes: 31 additions & 6 deletions src/gadgets/pm/AddChecklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,45 @@ export class AddChecklist extends Gadget({
checklistName: z
.string()
.describe('Name of the checklist (e.g., "Acceptance Criteria" or "Implementation Steps")'),
items: z.array(z.string()).min(1).describe('List of checklist items to add'),
items: z
.array(
z.union([
z.string(),
z.object({
name: z.string().describe('Checklist item name / subtask title'),
description: z
.string()
.optional()
.describe(
'Detailed description (used as JIRA subtask description, ignored for Trello)',
),
}),
]),
)
.min(1)
.describe(
'List of checklist items to add. Use objects with name+description for richer subtasks.',
),
}),
examples: [
{
params: {
workItemId: 'abc123',
workItemId: 'PROJ-42',
checklistName: 'Implementation Steps',
items: [
'Add reset password endpoint to API',
'Create email template for reset link',
'Add password validation logic',
{
name: 'Add reset password endpoint to API',
description:
'**Files:** `src/api/auth.ts`\n- Add POST /auth/reset-password route\n- Validate email format and lookup user\n- Generate time-limited reset token',
},
{
name: 'Create email template for reset link',
description:
'**Files:** `src/templates/reset-password.html`\n- Create responsive HTML email template\n- Include reset link with token parameter',
},
],
},
comment: 'Add implementation steps checklist to a work item',
comment: 'Add implementation steps with descriptions to a JIRA issue',
},
],
}) {
Expand Down
8 changes: 6 additions & 2 deletions src/gadgets/pm/core/addChecklist.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { getPMProvider } from '../../../pm/index.js';

export type ChecklistItemInput = string | { name: string; description?: string };

export interface AddChecklistParams {
workItemId: string;
checklistName: string;
items: string[];
items: ChecklistItemInput[];
}

export async function addChecklist(params: AddChecklistParams): Promise<string> {
const provider = getPMProvider();
const checklist = await provider.createChecklist(params.workItemId, params.checklistName);

for (const item of params.items) {
await provider.addChecklistItem(checklist.id, item);
const name = typeof item === 'string' ? item : item.name;
const description = typeof item === 'string' ? undefined : item.description;
await provider.addChecklistItem(checklist.id, name, false, description);
}

return `Checklist "${params.checklistName}" created with ${params.items.length} items on work item ${params.workItemId}`;
Expand Down
8 changes: 7 additions & 1 deletion src/pm/jira/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,12 @@ export class JiraPMProvider implements PMProvider {
};
}

async addChecklistItem(_checklistId: string, name: string, _checked = false): Promise<void> {
async addChecklistItem(
_checklistId: string,
name: string,
_checked = false,
description?: string,
): Promise<void> {
// Extract parent issue key from checklistId format: "checklist-PROJ-123-timestamp"
// or "subtasks-PROJ-123"
// Use \d{10,} to only strip timestamps (10+ digits), not issue numbers like PROJ-5
Expand All @@ -258,6 +263,7 @@ export class JiraPMProvider implements PMProvider {
parent: { key: parentKey },
summary: name,
issuetype: { name: issueType },
...(description ? { description: markdownToAdf(description) } : {}),
});
}

Expand Down
7 changes: 6 additions & 1 deletion src/pm/trello/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,12 @@ export class TrelloPMProvider implements PMProvider {
};
}

async addChecklistItem(checklistId: string, name: string, checked = false): Promise<void> {
async addChecklistItem(
checklistId: string,
name: string,
checked = false,
_description?: string,
): Promise<void> {
await trelloClient.addChecklistItem(checklistId, name, checked);
}

Expand Down
7 changes: 6 additions & 1 deletion src/pm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ export interface PMProvider {
// Checklists
getChecklists(workItemId: string): Promise<Checklist[]>;
createChecklist(workItemId: string, name: string): Promise<Checklist>;
addChecklistItem(checklistId: string, name: string, checked?: boolean): Promise<void>;
addChecklistItem(
checklistId: string,
name: string,
checked?: boolean,
description?: string,
): Promise<void>;
updateChecklistItem(workItemId: string, checkItemId: string, complete: boolean): Promise<void>;

// Attachments & custom fields
Expand Down
80 changes: 77 additions & 3 deletions tests/unit/gadgets/pm/core/addChecklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ beforeEach(() => {
});

describe('addChecklist', () => {
it('creates checklist and adds items', async () => {
it('creates checklist and adds string items', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'My Tasks',
Expand All @@ -32,11 +32,85 @@ describe('addChecklist', () => {

expect(mockProvider.createChecklist).toHaveBeenCalledWith('item1', 'My Tasks');
expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A');
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B');
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A', false, undefined);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B', false, undefined);
expect(result).toBe('Checklist "My Tasks" created with 2 items on work item item1');
});

it('creates checklist and adds object items with descriptions', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Steps',
workItemId: 'PROJ-42',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

const result = await addChecklist({
workItemId: 'PROJ-42',
checklistName: 'Steps',
items: [
{ name: 'Add endpoint', description: '**Files:** `src/api.ts`\n- Add POST route' },
{ name: 'Write tests' },
],
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Add endpoint',
false,
'**Files:** `src/api.ts`\n- Add POST route',
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Write tests',
false,
undefined,
);
expect(result).toBe('Checklist "Steps" created with 2 items on work item PROJ-42');
});

it('handles mixed string and object items', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Mixed',
workItemId: 'item1',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

await addChecklist({
workItemId: 'item1',
checklistName: 'Mixed',
items: [
'Simple string item',
{ name: 'Object item', description: 'Detailed description' },
'Another string',
],
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Simple string item',
false,
undefined,
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Object item',
false,
'Detailed description',
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Another string',
false,
undefined,
);
});

it('creates checklist with no items', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/pm/jira/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,40 @@ describe('JiraPMProvider', () => {
expect(mockJiraClient.getIssueTypesForProject).toHaveBeenCalledOnce();
});

it('passes description as ADF to createIssue when provided', async () => {
const adfDoc = { type: 'doc', version: 1, content: [{ type: 'paragraph' }] };
mockMarkdownToAdf.mockReturnValue(adfDoc);
mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-105' });

await provider.addChecklistItem(
'checklist-PROJ-1-1234567890',
'Subtask with description',
false,
'**Files:** `src/api.ts`\n- Add POST route',
);

expect(mockMarkdownToAdf).toHaveBeenCalledWith('**Files:** `src/api.ts`\n- Add POST route');
expect(mockJiraClient.createIssue).toHaveBeenCalledWith(
expect.objectContaining({
project: { key: 'PROJ' },
parent: { key: 'PROJ-1' },
summary: 'Subtask with description',
issuetype: { name: 'Sub-task' },
description: adfDoc,
}),
);
});

it('omits description from createIssue when not provided', async () => {
mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-106' });

await provider.addChecklistItem('checklist-PROJ-1-1234567890', 'No description subtask');

expect(mockJiraClient.createIssue).toHaveBeenCalledWith(
expect.not.objectContaining({ description: expect.anything() }),
);
});

it('falls back to "Subtask" when no subtask type found', async () => {
const providerNoConfig = new JiraPMProvider({
...mockConfig,
Expand Down