From 1b804d29863e94943971f6caf3a6900ccb0de542 Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Tue, 10 Feb 2026 12:08:37 -0700 Subject: [PATCH] Add --json output with field filtering to issue commands Adds GitHub CLI-style --json [fields] option to issue list, view, create, and edit commands. When --json is passed, outputs machine- readable JSON instead of human-readable text. An optional comma- separated field list filters the output to only the requested fields. Adds outputJson() utility to src/utils/formatting.ts for reuse across future commands. Co-Authored-By: Claude Sonnet 4.5 --- TODO.md | 2 +- src/commands/issue.ts | 212 +++++++++++++++++++++++---------- src/utils/formatting.ts | 44 +++++++ tests/commands/issue.test.ts | 207 ++++++++++++++++++++++++++++++++ tests/utils/formatting.test.ts | 65 +++++++++- 5 files changed, 464 insertions(+), 66 deletions(-) diff --git a/TODO.md b/TODO.md index 43ad853..2d4dcdb 100644 --- a/TODO.md +++ b/TODO.md @@ -39,7 +39,7 @@ This document outlines the development tasks for the Tangled CLI, based on the ` - [x] Implement `tangled issue create "" [--body "<body>" | --body-file <file> | -F -]` command. - [x] Implement `tangled issue list [--json "id,title"]` command. - - [ ] Support `--json` output with field filtering. + - [x] Support `--json` output with field filtering. - [ ] Migrate this TODO list into Tangled issues once issue creation is implemented. (note defects and address blocking features as needed). - [ ] Create phases in this todo list, and then use `- [ ]` tasks in the issue descriptions. - [ ] Remove TODO.md once all tasks are migrated to issues. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index e7d8ff4..167811e 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -16,7 +16,7 @@ import { import { buildRepoAtUri } from '../utils/at-uri.js'; import { requireAuth } from '../utils/auth-helpers.js'; import { readBodyInput } from '../utils/body-input.js'; -import { formatDate, formatIssueState } from '../utils/formatting.js'; +import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; /** @@ -93,7 +93,11 @@ function createViewCommand(): Command { return new Command('view') .description('View details of a specific issue') .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') - .action(async (issueId: string) => { + .option( + '--json [fields]', + 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' + ) + .action(async (issueId: string, options: { json?: string | true }) => { try { // 1. Validate auth const client = createApiClient(); @@ -123,7 +127,21 @@ function createViewCommand(): Command { // 6. Fetch issue state const state = await getIssueState({ client, issueUri: issue.uri }); - // 7. Display issue details + // 7. Output result + if (options.json !== undefined) { + const issueData = { + title: issue.title, + body: issue.body, + state, + author: issue.author, + createdAt: issue.createdAt, + uri: issue.uri, + cid: issue.cid, + }; + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); + return; + } + console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); console.log(`Title: ${issue.title}`); console.log(`Author: ${issue.author}`); @@ -156,8 +174,15 @@ function createEditCommand(): Command { .option('-t, --title <string>', 'New issue title') .option('-b, --body <string>', 'New issue body text') .option('-F, --body-file <path>', 'Read body from file (- for stdin)') + .option( + '--json [fields]', + 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' + ) .action( - async (issueId: string, options: { title?: string; body?: string; bodyFile?: string }) => { + async ( + issueId: string, + options: { title?: string; body?: string; bodyFile?: string; json?: string | true } + ) => { try { // 1. Validate at least one option provided if (!options.title && !options.body && !options.bodyFile) { @@ -195,14 +220,27 @@ function createEditCommand(): Command { const validBody = body !== undefined ? validateIssueBody(body) : undefined; // 8. Update issue - await updateIssue({ + const updatedIssue = await updateIssue({ client, issueUri, title: validTitle, body: validBody, }); - // 9. Display success + // 9. Output result + if (options.json !== undefined) { + const issueData = { + title: updatedIssue.title, + body: updatedIssue.body, + author: updatedIssue.author, + createdAt: updatedIssue.createdAt, + uri: updatedIssue.uri, + cid: updatedIssue.cid, + }; + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); + return; + } + const updated: string[] = []; if (validTitle !== undefined) updated.push('title'); if (validBody !== undefined) updated.push('body'); @@ -400,57 +438,81 @@ function createCreateCommand(): Command { .argument('<title>', 'Issue title') .option('-b, --body <string>', 'Issue body text') .option('-F, --body-file <path>', 'Read body from file (- for stdin)') - .action(async (title: string, options: { body?: string; bodyFile?: string }) => { - try { - // 1. Validate auth - const client = createApiClient(); - if (!(await client.resumeSession())) { - console.error('✗ Not authenticated. Run "tangled auth login" first.'); - process.exit(1); - } + .option( + '--json [fields]', + 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' + ) + .action( + async ( + title: string, + options: { body?: string; bodyFile?: string; json?: string | true } + ) => { + try { + // 1. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } - // 2. Get repo context - const context = await getCurrentRepoContext(); - if (!context) { - console.error('✗ Not in a Tangled repository'); - console.error('\nTo use this repository with Tangled, add a remote:'); - console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); - process.exit(1); - } + // 2. Get repo context + const context = await getCurrentRepoContext(); + if (!context) { + console.error('✗ Not in a Tangled repository'); + console.error('\nTo use this repository with Tangled, add a remote:'); + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); + process.exit(1); + } - // 3. Validate title - const validTitle = validateIssueTitle(title); + // 3. Validate title + const validTitle = validateIssueTitle(title); - // 4. Handle body input - const body = await readBodyInput(options.body, options.bodyFile); - if (body !== undefined) { - validateIssueBody(body); - } + // 4. Handle body input + const body = await readBodyInput(options.body, options.bodyFile); + if (body !== undefined) { + validateIssueBody(body); + } - // 5. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 5. Build repo AT-URI + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); - // 6. Create issue - console.log('Creating issue...'); - const issue = await createIssue({ - client, - repoAtUri, - title: validTitle, - body, - }); + // 6. Create issue (suppress progress message in JSON mode) + if (options.json === undefined) { + console.log('Creating issue...'); + } + const issue = await createIssue({ + client, + repoAtUri, + title: validTitle, + body, + }); - // 7. Display success - const rkey = extractRkey(issue.uri); - console.log(`\n✓ Issue created: #${rkey}`); - console.log(` Title: ${issue.title}`); - console.log(` URI: ${issue.uri}`); - } catch (error) { - console.error( - `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - process.exit(1); + // 7. Output result + if (options.json !== undefined) { + const issueData = { + title: issue.title, + body: issue.body, + author: issue.author, + createdAt: issue.createdAt, + uri: issue.uri, + cid: issue.cid, + }; + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); + return; + } + + const rkey = extractRkey(issue.uri); + console.log(`\n✓ Issue created: #${rkey}`); + console.log(` Title: ${issue.title}`); + console.log(` URI: ${issue.uri}`); + } catch (error) { + console.error( + `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } } - }); + ); } /** @@ -460,7 +522,11 @@ function createListCommand(): Command { return new Command('list') .description('List issues for the current repository') .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') - .action(async (options: { limit: string }) => { + .option( + '--json [fields]', + 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' + ) + .action(async (options: { limit: string; json?: string | true }) => { try { // 1. Validate auth const client = createApiClient(); @@ -494,9 +560,13 @@ function createListCommand(): Command { limit, }); - // 5. Display results + // 5. Handle empty results if (issues.length === 0) { - console.log('No issues found for this repository.'); + if (options.json !== undefined) { + console.log('[]'); + } else { + console.log('No issues found for this repository.'); + } return; } @@ -505,21 +575,35 @@ function createListCommand(): Command { (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); - console.log( - `\nFound ${sortedIssues.length} issue${sortedIssues.length === 1 ? '' : 's'}:\n` + // Build issue data with states (in parallel for performance) + const issueData = await Promise.all( + sortedIssues.map(async (issue, i) => { + const state = await getIssueState({ client, issueUri: issue.uri }); + return { + number: i + 1, + title: issue.title, + body: issue.body, + state, + author: issue.author, + createdAt: issue.createdAt, + uri: issue.uri, + cid: issue.cid, + }; + }) ); - // Fetch and display each issue with number and state - for (let i = 0; i < sortedIssues.length; i++) { - const issue = sortedIssues[i]; - const num = i + 1; - const date = formatDate(issue.createdAt); + // 6. Output results + if (options.json !== undefined) { + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); + return; + } - // Get issue state - const state = await getIssueState({ client, issueUri: issue.uri }); - const stateBadge = formatIssueState(state); + console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`); - console.log(` #${num} ${stateBadge} ${issue.title}`); + for (const item of issueData) { + const stateBadge = formatIssueState(item.state); + const date = formatDate(item.createdAt); + console.log(` #${item.number} ${stateBadge} ${item.title}`); console.log(` Created ${date}`); console.log(); } diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 14db2c1..769949e 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -25,3 +25,47 @@ export function formatDate(dateString: string): string { export function formatIssueState(state: 'open' | 'closed'): string { return state === 'open' ? '[OPEN]' : '[CLOSED]'; } + +/** + * Pick specific fields from an object, omitting fields not present in the object + */ +function pickFields(obj: Record<string, unknown>, fields: string[]): Record<string, unknown> { + const result: Record<string, unknown> = {}; + for (const field of fields) { + if (field in obj) { + result[field] = obj[field]; + } + } + return result; +} + +/** + * Output data as JSON to stdout, following GitHub CLI conventions. + * + * @param data - The data to output (object or array of objects) + * @param fields - Comma-separated field names to include; omit for all fields + */ +export function outputJson( + data: Record<string, unknown> | Record<string, unknown>[], + fields?: string +): void { + if (fields) { + const fieldList = fields + .split(',') + .map((f) => f.trim()) + .filter(Boolean); + if (Array.isArray(data)) { + console.log( + JSON.stringify( + data.map((item) => pickFields(item, fieldList)), + null, + 2 + ) + ); + } else { + console.log(JSON.stringify(pickFields(data, fieldList), null, 2)); + } + } else { + console.log(JSON.stringify(data, null, 2)); + } +} diff --git a/tests/commands/issue.test.ts b/tests/commands/issue.test.ts index 254816e..29c4826 100644 --- a/tests/commands/issue.test.ts +++ b/tests/commands/issue.test.ts @@ -248,6 +248,53 @@ describe('issue create command', () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); }); + + describe('JSON output', () => { + const mockIssue: IssueWithMetadata = { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', + title: 'Test Issue', + body: 'Test body', + createdAt: '2024-01-01T00:00:00.000Z', + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', + cid: 'bafyreiabc123', + author: 'did:plc:abc123', + }; + + it('should output JSON of created issue when --json is passed', async () => { + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); + + // Should NOT print human-readable messages + expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...'); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toMatchObject({ + title: 'Test Issue', + body: 'Test body', + author: 'did:plc:abc123', + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', + cid: 'bafyreiabc123', + }); + }); + + it('should output filtered JSON when --json with fields is passed', async () => { + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toEqual({ + title: 'Test Issue', + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', + }); + expect(jsonOutput).not.toHaveProperty('body'); + expect(jsonOutput).not.toHaveProperty('author'); + }); + }); }); describe('issue list command', () => { @@ -435,6 +482,73 @@ describe('issue list command', () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); }); + + describe('JSON output', () => { + const mockIssues: IssueWithMetadata[] = [ + { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + title: 'First Issue', + body: 'First body', + createdAt: new Date('2024-01-01').toISOString(), + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', + cid: 'bafyrei1', + author: 'did:plc:abc123', + }, + { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + title: 'Second Issue', + createdAt: new Date('2024-01-02').toISOString(), + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', + cid: 'bafyrei2', + author: 'did:plc:abc123', + }, + ]; + + beforeEach(() => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: mockIssues, + cursor: undefined, + }); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); + }); + + it('should output JSON array when --json is passed', async () => { + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'list', '--json']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(Array.isArray(jsonOutput)).toBe(true); + expect(jsonOutput).toHaveLength(2); + expect(jsonOutput[0]).toMatchObject({ + number: 1, + title: 'First Issue', + state: 'open', + author: 'did:plc:abc123', + }); + expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' }); + }); + + it('should output filtered JSON when --json with fields is passed', async () => { + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' }); + expect(jsonOutput[0]).not.toHaveProperty('author'); + expect(jsonOutput[0]).not.toHaveProperty('uri'); + }); + + it('should output empty JSON array when no issues exist', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'list', '--json']); + + expect(consoleLogSpy).toHaveBeenCalledWith('[]'); + }); + }); }); describe('issue view command', () => { @@ -578,6 +692,47 @@ describe('issue view command', () => { expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); }); + + describe('JSON output', () => { + it('should output JSON when --json is passed', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'view', '1', '--json']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toMatchObject({ + title: 'Test Issue', + body: 'Issue body', + state: 'open', + author: 'did:plc:abc123', + uri: mockIssue.uri, + cid: mockIssue.cid, + }); + }); + + it('should output filtered JSON when --json with fields is passed', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' }); + expect(jsonOutput).not.toHaveProperty('body'); + expect(jsonOutput).not.toHaveProperty('author'); + }); + }); }); describe('issue edit command', () => { @@ -692,6 +847,58 @@ describe('issue edit command', () => { '✗ Not authenticated. Run "tangled auth login" first.' ); }); + + describe('JSON output', () => { + it('should output JSON of updated issue when --json is passed', async () => { + const updatedIssue = { ...mockIssue, title: 'New Title' }; + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toMatchObject({ + title: 'New Title', + author: 'did:plc:abc123', + uri: mockIssue.uri, + cid: mockIssue.cid, + }); + // Human-readable messages should NOT appear + expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated'); + }); + + it('should output filtered JSON when --json with fields is passed', async () => { + const updatedIssue = { ...mockIssue, title: 'New Title' }; + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); + + const command = createIssueCommand(); + await command.parseAsync([ + 'node', + 'test', + 'edit', + '1', + '--title', + 'New Title', + '--json', + 'title,uri', + ]); + + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(jsonOutput).toEqual({ + title: 'New Title', + uri: mockIssue.uri, + }); + expect(jsonOutput).not.toHaveProperty('author'); + }); + }); }); describe('issue close command', () => { diff --git a/tests/utils/formatting.test.ts b/tests/utils/formatting.test.ts index fdad7be..d9844fa 100644 --- a/tests/utils/formatting.test.ts +++ b/tests/utils/formatting.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { formatDate } from '../../src/utils/formatting.js'; +import { formatDate, outputJson } from '../../src/utils/formatting.js'; describe('formatDate', () => { beforeEach(() => { @@ -81,3 +81,66 @@ describe('formatDate', () => { expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); }); }); + +describe('outputJson', () => { + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should output all fields of an object when no fields specified', () => { + const data = { title: 'Test', state: 'open', author: 'did:plc:abc' }; + outputJson(data); + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); + }); + + it('should output only specified fields of an object', () => { + const data = { title: 'Test', state: 'open', author: 'did:plc:abc', cid: 'bafyrei1' }; + outputJson(data, 'title,state'); + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(output).toEqual({ title: 'Test', state: 'open' }); + expect(output).not.toHaveProperty('author'); + expect(output).not.toHaveProperty('cid'); + }); + + it('should output all fields of an array when no fields specified', () => { + const data = [ + { title: 'First', state: 'open' }, + { title: 'Second', state: 'closed' }, + ]; + outputJson(data); + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); + }); + + it('should output only specified fields of an array', () => { + const data = [ + { title: 'First', state: 'open', author: 'did:plc:abc' }, + { title: 'Second', state: 'closed', author: 'did:plc:xyz' }, + ]; + outputJson(data, 'title,state'); + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(output).toEqual([ + { title: 'First', state: 'open' }, + { title: 'Second', state: 'closed' }, + ]); + }); + + it('should silently omit fields not present in the object', () => { + const data = { title: 'Test', state: 'open' }; + outputJson(data, 'title,nonexistent'); + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(output).toEqual({ title: 'Test' }); + }); + + it('should trim whitespace from field names', () => { + const data = { title: 'Test', state: 'open' }; + outputJson(data, ' title , state '); + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); + expect(output).toEqual({ title: 'Test', state: 'open' }); + }); +}); -- 2.51.2