WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

Update issue command output to include numbers and work with JSON #2

merged opened by markbennett.ca targeting main from fix/issue-output-improvements

Addresses issue #8, #13, and #14

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:b2mcbcamkwyznc5fkplwlxbf/sh.tangled.repo.pull/3mejvkhdbfd22
+221 -19
Diff #0
+98 -16
src/commands/issue.ts
··· 86 86 }; 87 87 } 88 88 89 + /** 90 + * Resolve a sequential issue number from a displayId or by scanning the issue list. 91 + * Fast path: if displayId is "#N", return N directly. 92 + * Fallback: fetch all issues, sort oldest-first, return 1-based position. 93 + */ 94 + async function resolveSequentialNumber( 95 + displayId: string, 96 + issueUri: string, 97 + client: TangledApiClient, 98 + repoAtUri: string 99 + ): Promise<number | undefined> { 100 + const match = displayId.match(/^#(\d+)$/); 101 + if (match) return Number.parseInt(match[1], 10); 102 + 103 + const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); 104 + const sorted = issues.sort( 105 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 106 + ); 107 + const idx = sorted.findIndex((i) => i.uri === issueUri); 108 + return idx >= 0 ? idx + 1 : undefined; 109 + } 110 + 89 111 /** 90 112 * Issue view subcommand 91 113 */ ··· 264 286 return new Command('close') 265 287 .description('Close an issue') 266 288 .argument('<issue-id>', 'Issue number or rkey') 267 - .action(async (issueId: string) => { 289 + .option( 290 + '--json [fields]', 291 + 'Output JSON; optionally specify comma-separated fields (number, title, uri, state, cid)' 292 + ) 293 + .action(async (issueId: string, options: { json?: string | true }) => { 268 294 try { 269 295 // 1. Validate auth 270 296 const client = createApiClient(); ··· 288 314 // 4. Resolve issue ID to URI 289 315 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 290 316 291 - // 5. Close issue 317 + // 5. Fetch issue details and sequential number 318 + const issue = await getIssue({ client, issueUri }); 319 + const number = await resolveSequentialNumber(displayId, issueUri, client, repoAtUri); 320 + 321 + // 6. Close issue 292 322 await closeIssue({ client, issueUri }); 293 323 294 - // 6. Display success 295 - console.log(`โœ“ Issue ${displayId} closed`); 324 + // 7. Display success 325 + if (options.json !== undefined) { 326 + outputJson( 327 + { number, title: issue.title, uri: issueUri, state: 'closed', cid: issue.cid }, 328 + typeof options.json === 'string' ? options.json : undefined 329 + ); 330 + } else { 331 + console.log(`โœ“ Issue ${displayId} closed`); 332 + console.log(` Title: ${issue.title}`); 333 + } 296 334 } catch (error) { 297 335 console.error( 298 336 `โœ— Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 309 347 return new Command('reopen') 310 348 .description('Reopen a closed issue') 311 349 .argument('<issue-id>', 'Issue number or rkey') 312 - .action(async (issueId: string) => { 350 + .option( 351 + '--json [fields]', 352 + 'Output JSON; optionally specify comma-separated fields (number, title, uri, state, cid)' 353 + ) 354 + .action(async (issueId: string, options: { json?: string | true }) => { 313 355 try { 314 356 // 1. Validate auth 315 357 const client = createApiClient(); ··· 333 375 // 4. Resolve issue ID to URI 334 376 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 335 377 336 - // 5. Reopen issue 378 + // 5. Fetch issue details and sequential number 379 + const issue = await getIssue({ client, issueUri }); 380 + const number = await resolveSequentialNumber(displayId, issueUri, client, repoAtUri); 381 + 382 + // 6. Reopen issue 337 383 await reopenIssue({ client, issueUri }); 338 384 339 - // 6. Display success 340 - console.log(`โœ“ Issue ${displayId} reopened`); 385 + // 7. Display success 386 + if (options.json !== undefined) { 387 + outputJson( 388 + { number, title: issue.title, uri: issueUri, state: 'open', cid: issue.cid }, 389 + typeof options.json === 'string' ? options.json : undefined 390 + ); 391 + } else { 392 + console.log(`โœ“ Issue ${displayId} reopened`); 393 + console.log(` Title: ${issue.title}`); 394 + } 341 395 } catch (error) { 342 396 console.error( 343 397 `โœ— Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 355 409 .description('Delete an issue permanently') 356 410 .argument('<issue-id>', 'Issue number or rkey') 357 411 .option('-f, --force', 'Skip confirmation prompt') 358 - .action(async (issueId: string, options: { force?: boolean }) => { 412 + .option( 413 + '--json [fields]', 414 + 'Output JSON; optionally specify comma-separated fields (number, title, uri, cid)' 415 + ) 416 + .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 359 417 // 1. Validate auth 360 418 const client = createApiClient(); 361 419 if (!(await client.resumeSession())) { ··· 372 430 process.exit(1); 373 431 } 374 432 375 - // 3. Build repo AT-URI and resolve issue ID 433 + // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 376 434 let issueUri: string; 377 435 let displayId: string; 436 + let issueTitle: string; 437 + let issueCid: string; 438 + let issueNumber: number | undefined; 378 439 try { 379 440 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 380 441 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 442 + const issue = await getIssue({ client, issueUri }); 443 + issueTitle = issue.title; 444 + issueCid = issue.cid; 445 + issueNumber = await resolveSequentialNumber(displayId, issueUri, client, repoAtUri); 381 446 } catch (error) { 382 447 console.error( 383 448 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 388 453 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 389 454 if (!options.force) { 390 455 const confirmed = await confirm({ 391 - message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 456 + message: `Are you sure you want to delete issue ${displayId} "${issueTitle}"? This cannot be undone.`, 392 457 default: false, 393 458 }); 394 459 ··· 401 466 // 5. Delete issue 402 467 try { 403 468 await deleteIssue({ client, issueUri }); 404 - console.log(`โœ“ Issue ${displayId} deleted`); 469 + if (options.json !== undefined) { 470 + outputJson( 471 + { number: issueNumber, title: issueTitle, uri: issueUri, cid: issueCid }, 472 + typeof options.json === 'string' ? options.json : undefined 473 + ); 474 + } else { 475 + console.log(`โœ“ Issue ${displayId} deleted`); 476 + console.log(` Title: ${issueTitle}`); 477 + } 405 478 } catch (error) { 406 479 console.error( 407 480 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 440 513 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 441 514 .option( 442 515 '--json [fields]', 443 - 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 516 + 'Output JSON; optionally specify comma-separated fields (number, title, body, author, createdAt, uri, cid)' 444 517 ) 445 518 .action( 446 519 async ( ··· 487 560 body, 488 561 }); 489 562 490 - // 7. Output result 563 + // 7. Compute sequential number 564 + const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); 565 + const sortedAll = allIssues.sort( 566 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 567 + ); 568 + const idx = sortedAll.findIndex((i) => i.uri === issue.uri); 569 + const number = idx >= 0 ? idx + 1 : undefined; 570 + 571 + // 8. Output result 491 572 if (options.json !== undefined) { 492 573 const issueData = { 574 + number, 493 575 title: issue.title, 494 576 body: issue.body, 495 577 author: issue.author, ··· 501 583 return; 502 584 } 503 585 504 - const rkey = extractRkey(issue.uri); 505 - console.log(`\nโœ“ Issue created: #${rkey}`); 586 + const displayNumber = number !== undefined ? `#${number}` : extractRkey(issue.uri); 587 + console.log(`\nโœ“ Issue ${displayNumber} created`); 506 588 console.log(` Title: ${issue.title}`); 507 589 console.log(` URI: ${issue.uri}`); 508 590 } catch (error) {
+123 -3
tests/commands/issue.test.ts
··· 55 55 56 56 // Mock body input 57 57 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 58 + 59 + // Default listIssues mock (needed for sequential number computation after create) 60 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 58 61 }); 59 62 60 63 afterEach(() => { ··· 76 79 77 80 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 78 81 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 82 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 79 83 80 84 const command = createIssueCommand(); 81 85 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); ··· 88 92 }); 89 93 90 94 expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 91 - expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue created: #abc123'); 95 + expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue #1 created'); 92 96 }); 93 97 }); 94 98 ··· 107 111 108 112 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 109 113 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 114 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 110 115 111 116 const command = createIssueCommand(); 112 117 await command.parseAsync([ ··· 142 147 143 148 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 144 149 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 150 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 145 151 146 152 const command = createIssueCommand(); 147 153 await command.parseAsync(['node', 'test', 'create', 'Test Issue']); ··· 263 269 264 270 it('should output JSON of created issue when --json is passed', async () => { 265 271 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 272 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 266 273 267 274 const command = createIssueCommand(); 268 275 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); ··· 272 279 273 280 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 274 281 expect(jsonOutput).toMatchObject({ 282 + number: 1, 275 283 title: 'Test Issue', 276 284 body: 'Test body', 277 285 author: 'did:plc:abc123', ··· 282 290 283 291 it('should output filtered JSON when --json with fields is passed', async () => { 284 292 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 293 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 285 294 286 295 const command = createIssueCommand(); 287 - await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); 296 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'number,uri']); 288 297 289 298 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 290 299 expect(jsonOutput).toEqual({ 291 - title: 'Test Issue', 300 + number: 1, 292 301 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 293 302 }); 303 + expect(jsonOutput).not.toHaveProperty('title'); 294 304 expect(jsonOutput).not.toHaveProperty('body'); 295 305 expect(jsonOutput).not.toHaveProperty('author'); 296 306 }); ··· 937 947 }); 938 948 939 949 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 950 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 940 951 }); 941 952 942 953 afterEach(() => { ··· 958 969 issueUri: mockIssue.uri, 959 970 }); 960 971 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 closed'); 972 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 961 973 }); 962 974 963 975 it('should fail when not authenticated', async () => { ··· 968 980 'process.exit(1)' 969 981 ); 970 982 }); 983 + 984 + describe('JSON output', () => { 985 + it('should output JSON when --json is passed', async () => { 986 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 987 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 988 + 989 + const command = createIssueCommand(); 990 + await command.parseAsync(['node', 'test', 'close', '1', '--json']); 991 + 992 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 993 + expect(jsonOutput).toEqual({ 994 + number: 1, 995 + title: 'Test Issue', 996 + uri: mockIssue.uri, 997 + state: 'closed', 998 + cid: mockIssue.cid, 999 + }); 1000 + }); 1001 + 1002 + it('should output filtered JSON when --json with fields is passed', async () => { 1003 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1004 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1005 + 1006 + const command = createIssueCommand(); 1007 + await command.parseAsync(['node', 'test', 'close', '1', '--json', 'number,state']); 1008 + 1009 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1010 + expect(jsonOutput).toEqual({ number: 1, state: 'closed' }); 1011 + expect(jsonOutput).not.toHaveProperty('title'); 1012 + expect(jsonOutput).not.toHaveProperty('uri'); 1013 + }); 1014 + }); 971 1015 }); 972 1016 973 1017 describe('issue reopen command', () => { ··· 1006 1050 }); 1007 1051 1008 1052 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1053 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 1009 1054 }); 1010 1055 1011 1056 afterEach(() => { ··· 1027 1072 issueUri: mockIssue.uri, 1028 1073 }); 1029 1074 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 reopened'); 1075 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1030 1076 }); 1031 1077 1032 1078 it('should fail when not authenticated', async () => { ··· 1037 1083 'process.exit(1)' 1038 1084 ); 1039 1085 }); 1086 + 1087 + describe('JSON output', () => { 1088 + it('should output JSON when --json is passed', async () => { 1089 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1090 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1091 + 1092 + const command = createIssueCommand(); 1093 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json']); 1094 + 1095 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1096 + expect(jsonOutput).toEqual({ 1097 + number: 1, 1098 + title: 'Test Issue', 1099 + uri: mockIssue.uri, 1100 + state: 'open', 1101 + cid: mockIssue.cid, 1102 + }); 1103 + }); 1104 + 1105 + it('should output filtered JSON when --json with fields is passed', async () => { 1106 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1107 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1108 + 1109 + const command = createIssueCommand(); 1110 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json', 'number,state']); 1111 + 1112 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1113 + expect(jsonOutput).toEqual({ number: 1, state: 'open' }); 1114 + expect(jsonOutput).not.toHaveProperty('title'); 1115 + }); 1116 + }); 1040 1117 }); 1041 1118 1042 1119 describe('issue delete command', () => { ··· 1077 1154 }); 1078 1155 1079 1156 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1157 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 1080 1158 }); 1081 1159 1082 1160 afterEach(() => { ··· 1098 1176 issueUri: mockIssue.uri, 1099 1177 }); 1100 1178 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1179 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1101 1180 }); 1102 1181 1103 1182 it('should cancel deletion when user declines confirmation', async () => { ··· 1134 1213 1135 1214 expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1136 1215 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1216 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1137 1217 }); 1138 1218 1139 1219 it('should fail when not authenticated', async () => { ··· 1148 1228 'โœ— Not authenticated. Run "tangled auth login" first.' 1149 1229 ); 1150 1230 }); 1231 + 1232 + describe('JSON output', () => { 1233 + it('should output JSON when --json is passed', async () => { 1234 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1235 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1236 + 1237 + const command = createIssueCommand(); 1238 + await command.parseAsync(['node', 'test', 'delete', '1', '--force', '--json']); 1239 + 1240 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1241 + expect(jsonOutput).toEqual({ 1242 + number: 1, 1243 + title: 'Test Issue', 1244 + uri: mockIssue.uri, 1245 + cid: mockIssue.cid, 1246 + }); 1247 + expect(jsonOutput).not.toHaveProperty('state'); 1248 + }); 1249 + 1250 + it('should output filtered JSON when --json with fields is passed', async () => { 1251 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1252 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1253 + 1254 + const command = createIssueCommand(); 1255 + await command.parseAsync([ 1256 + 'node', 1257 + 'test', 1258 + 'delete', 1259 + '1', 1260 + '--force', 1261 + '--json', 1262 + 'number,title', 1263 + ]); 1264 + 1265 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1266 + expect(jsonOutput).toEqual({ number: 1, title: 'Test Issue' }); 1267 + expect(jsonOutput).not.toHaveProperty('uri'); 1268 + expect(jsonOutput).not.toHaveProperty('cid'); 1269 + }); 1270 + }); 1151 1271 });

History

2 rounds 1 comment
sign up or login to add to the discussion
3 commits
expand
Fix issue output to show sequential numbers and add --json to write commands
refactor: add resolveSequentialNumber and getCompleteIssueData to issues-api
fix: normalize JSON output fields across all issue commands
expand 1 comment

Changes look good.

pull request successfully merged
markbennett.ca submitted #0
1 commit
expand
Fix issue output to show sequential numbers and add --json to write commands
expand 0 comments