From 38725ce9c90a85361631bcab5a8ba8b0547eaf82 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 25 Feb 2026 18:25:32 +0000 Subject: [PATCH] fix(cli): parse JSON --item strings in add-checklist CLI command When the Claude Code backend passes checklist items as JSON objects (e.g. --item '{"name":"...","description":"..."}'), the raw JSON string was being used as the JIRA subtask title instead of the name field. Add a parseItem() helper that tries JSON.parse on each --item value and returns a ChecklistItemInput object when the result has a string `name` property. Plain strings and unrecognised JSON fall back to the raw string, preserving full backward compatibility. Co-Authored-By: Claude Opus 4.6 --- src/cli/pm/add-checklist.ts | 36 +++- tests/unit/cli/pm/add-checklist.test.ts | 243 ++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cli/pm/add-checklist.test.ts diff --git a/src/cli/pm/add-checklist.ts b/src/cli/pm/add-checklist.ts index 80ef2470..ebd9e690 100644 --- a/src/cli/pm/add-checklist.ts +++ b/src/cli/pm/add-checklist.ts @@ -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.'; @@ -17,10 +48,11 @@ export default class AddChecklist extends CredentialScopedCommand { async execute(): Promise { 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 })); } diff --git a/tests/unit/cli/pm/add-checklist.test.ts b/tests/unit/cli/pm/add-checklist.test.ts new file mode 100644 index 00000000..f30be96f --- /dev/null +++ b/tests/unit/cli/pm/add-checklist.test.ts @@ -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); + }); +});