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
36 changes: 34 additions & 2 deletions src/cli/pm/add-checklist.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
import { Flags } from '@oclif/core';
import { addChecklist } from '../../gadgets/pm/core/addChecklist.js';
import { type ChecklistItemInput, addChecklist } from '../../gadgets/pm/core/addChecklist.js';
import { CredentialScopedCommand } from '../base.js';

/**
* Parses a raw --item flag string into a ChecklistItemInput.
*
* When the Claude Code backend invokes this CLI, it serialises object items
* (with `name` and optional `description`) as JSON strings. This function
* attempts to parse such strings so that JIRA subtask titles contain only the
* clean `name` value rather than the raw JSON blob.
*
* Falls back to the original string for plain-text items and any string that
* is not a JSON object with a `name` property.
*/
export function parseItem(raw: string): ChecklistItemInput {
try {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
typeof parsed.name === 'string'
) {
return {
name: parsed.name,
...(typeof parsed.description === 'string' ? { description: parsed.description } : {}),
};
}
} catch {
// Not JSON — treat as a plain string
}
return raw;
}

export default class AddChecklist extends CredentialScopedCommand {
static override description = 'Add a checklist with items to a work item.';

Expand All @@ -17,10 +48,11 @@ export default class AddChecklist extends CredentialScopedCommand {

async execute(): Promise<void> {
const { flags } = await this.parse(AddChecklist);
const items = flags.item.map(parseItem);
const result = await addChecklist({
workItemId: flags.workItemId,
checklistName: flags.name,
items: flags.item,
items,
});
this.log(JSON.stringify({ success: true, data: result }));
}
Expand Down
243 changes: 243 additions & 0 deletions tests/unit/cli/pm/add-checklist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { createMockPMProvider } from '../../../helpers/mockPMProvider.js';

const mockProvider = createMockPMProvider();

vi.mock('../../../../src/pm/index.js', () => ({
getPMProvider: vi.fn(() => mockProvider),
}));

// Import after mocks so the module picks up the mocked getPMProvider
import { parseItem } from '../../../../src/cli/pm/add-checklist.js';
import { addChecklist } from '../../../../src/gadgets/pm/core/addChecklist.js';

// ---------------------------------------------------------------------------
// Unit tests for parseItem() — the JSON-parsing helper
// ---------------------------------------------------------------------------

describe('parseItem', () => {
it('returns a plain string unchanged', () => {
expect(parseItem('Simple task')).toBe('Simple task');
});

it('returns empty string unchanged', () => {
expect(parseItem('')).toBe('');
});

it('parses JSON with name and description into an object', () => {
const raw = JSON.stringify({ name: 'Extract input types', description: 'Create types file' });
expect(parseItem(raw)).toEqual({
name: 'Extract input types',
description: 'Create types file',
});
});

it('parses JSON with name only (no description)', () => {
const raw = JSON.stringify({ name: 'Write tests' });
expect(parseItem(raw)).toEqual({ name: 'Write tests' });
});

it('keeps raw string for invalid JSON', () => {
const raw = '{not valid json}';
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string when JSON has no name property', () => {
const raw = JSON.stringify({ foo: 'bar' });
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string when JSON name is not a string', () => {
const raw = JSON.stringify({ name: 42 });
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string for a JSON array', () => {
const raw = JSON.stringify(['step 1', 'step 2']);
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string for a JSON primitive (number)', () => {
const raw = '123';
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string for a JSON primitive (boolean)', () => {
const raw = 'true';
expect(parseItem(raw)).toBe(raw);
});

it('keeps raw string when description is not a string (ignores non-string description)', () => {
// description must be string; if it's not, it should be omitted
const raw = JSON.stringify({ name: 'Task', description: 99 });
expect(parseItem(raw)).toEqual({ name: 'Task' });
});

it('parses a string that looks like JSON but is a nested JSON status field unchanged', () => {
// A JSON object without "name" stays as-is
const raw = JSON.stringify({ status: 'pending' });
expect(parseItem(raw)).toBe(raw);
});
});

// ---------------------------------------------------------------------------
// Integration tests via addChecklist() — verifying end-to-end item handling
// ---------------------------------------------------------------------------

describe('addChecklist with JSON --item strings (CLI integration)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('passes parsed JSON items with name+description to addChecklistItem', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Subtasks',
workItemId: 'PROJ-1',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

const rawItems = [
JSON.stringify({
name: 'Extract input types',
description: 'Create types/service-input.types.ts',
}),
JSON.stringify({ name: 'Update imports', description: 'Fix circular deps' }),
];

await addChecklist({
workItemId: 'PROJ-1',
checklistName: 'Subtasks',
items: rawItems.map(parseItem),
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Extract input types',
false,
'Create types/service-input.types.ts',
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Update imports',
false,
'Fix circular deps',
);
});

it('passes plain string items unchanged', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Tasks',
workItemId: 'PROJ-2',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

await addChecklist({
workItemId: 'PROJ-2',
checklistName: 'Tasks',
items: ['Plain task A', 'Plain task B'].map(parseItem),
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Plain task A',
false,
undefined,
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Plain task B',
false,
undefined,
);
});

it('handles mixed plain strings and JSON objects', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Mixed',
workItemId: 'PROJ-3',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

const rawItems = [
'Plain string item',
JSON.stringify({ name: 'JSON item', description: 'Details here' }),
'Another plain item',
];

await addChecklist({
workItemId: 'PROJ-3',
checklistName: 'Mixed',
items: rawItems.map(parseItem),
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Plain string item',
false,
undefined,
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'JSON item',
false,
'Details here',
);
expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'Another plain item',
false,
undefined,
);
});

it('keeps unparseable JSON strings as raw item names (backward compat)', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Tasks',
workItemId: 'PROJ-4',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

const badJson = '{not: valid}';
await addChecklist({
workItemId: 'PROJ-4',
checklistName: 'Tasks',
items: [badJson].map(parseItem),
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledWith(
'cl1',
'{not: valid}',
false,
undefined,
);
});

it('keeps JSON without name property as a raw string', async () => {
mockProvider.createChecklist.mockResolvedValue({
id: 'cl1',
name: 'Tasks',
workItemId: 'PROJ-5',
items: [],
});
mockProvider.addChecklistItem.mockResolvedValue(undefined);

const raw = JSON.stringify({ status: 'pending', priority: 'high' });
await addChecklist({
workItemId: 'PROJ-5',
checklistName: 'Tasks',
items: [raw].map(parseItem),
});

expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', raw, false, undefined);
});
});