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
+623 -90
Diff #1
+107 -63
src/commands/issue.ts
··· 7 7 closeIssue, 8 8 createIssue, 9 9 deleteIssue, 10 - getIssue, 10 + getCompleteIssueData, 11 11 getIssueState, 12 12 listIssues, 13 13 reopenIssue, 14 + resolveSequentialNumber, 14 15 updateIssue, 15 16 } from '../lib/issues-api.js'; 17 + import type { IssueData } from '../lib/issues-api.js'; 16 18 import { buildRepoAtUri } from '../utils/at-uri.js'; 17 19 import { requireAuth } from '../utils/auth-helpers.js'; 18 20 import { readBodyInput } from '../utils/body-input.js'; ··· 87 89 } 88 90 89 91 /** 92 + * A custom subclass of Command with support for adding the common issue JSON flag. 93 + */ 94 + class IssueCommand extends Command { 95 + addIssueJsonOption() { 96 + return this.option( 97 + '--json [fields]', 98 + 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 99 + ); 100 + } 101 + } 102 + 103 + /** 90 104 * Issue view subcommand 91 105 */ 92 106 function createViewCommand(): Command { 93 - return new Command('view') 107 + return new IssueCommand('view') 94 108 .description('View details of a specific issue') 95 109 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 96 - .option( 97 - '--json [fields]', 98 - 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 99 - ) 110 + .addIssueJsonOption() 100 111 .action(async (issueId: string, options: { json?: string | true }) => { 101 112 try { 102 113 // 1. Validate auth ··· 121 132 // 4. Resolve issue ID to URI 122 133 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 123 134 124 - // 5. Fetch issue details 125 - const issue = await getIssue({ client, issueUri }); 126 - 127 - // 6. Fetch issue state 128 - const state = await getIssueState({ client, issueUri: issue.uri }); 135 + // 5. Fetch complete issue data (record, sequential number, state) 136 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 129 137 130 - // 7. Output result 138 + // 6. Output result 131 139 if (options.json !== undefined) { 132 - const issueData = { 133 - title: issue.title, 134 - body: issue.body, 135 - state, 136 - author: issue.author, 137 - createdAt: issue.createdAt, 138 - uri: issue.uri, 139 - cid: issue.cid, 140 - }; 141 140 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 142 141 return; 143 142 } 144 143 145 - console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 146 - console.log(`Title: ${issue.title}`); 147 - console.log(`Author: ${issue.author}`); 148 - console.log(`Created: ${formatDate(issue.createdAt)}`); 144 + console.log(`\nIssue ${displayId} ${formatIssueState(issueData.state)}`); 145 + console.log(`Title: ${issueData.title}`); 146 + console.log(`Author: ${issueData.author}`); 147 + console.log(`Created: ${formatDate(issueData.createdAt)}`); 149 148 console.log(`Repo: ${context.name}`); 150 - console.log(`URI: ${issue.uri}`); 149 + console.log(`URI: ${issueData.uri}`); 151 150 152 - if (issue.body) { 151 + if (issueData.body) { 153 152 console.log('\nBody:'); 154 - console.log(issue.body); 153 + console.log(issueData.body); 155 154 } 156 155 157 156 console.log(); // Empty line at end ··· 168 167 * Issue edit subcommand 169 168 */ 170 169 function createEditCommand(): Command { 171 - return new Command('edit') 170 + return new IssueCommand('edit') 172 171 .description('Edit an issue title and/or body') 173 172 .argument('<issue-id>', 'Issue number or rkey') 174 173 .option('-t, --title <string>', 'New issue title') 175 174 .option('-b, --body <string>', 'New issue body text') 176 175 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 177 - .option( 178 - '--json [fields]', 179 - 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 180 - ) 176 + .addIssueJsonOption() 181 177 .action( 182 178 async ( 183 179 issueId: string, ··· 229 225 230 226 // 9. Output result 231 227 if (options.json !== undefined) { 232 - const issueData = { 228 + const [number, state] = await Promise.all([ 229 + resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri), 230 + getIssueState({ client, issueUri: updatedIssue.uri }), 231 + ]); 232 + const issueData: IssueData = { 233 + number, 233 234 title: updatedIssue.title, 234 235 body: updatedIssue.body, 236 + state, 235 237 author: updatedIssue.author, 236 238 createdAt: updatedIssue.createdAt, 237 239 uri: updatedIssue.uri, ··· 261 263 * Issue close subcommand 262 264 */ 263 265 function createCloseCommand(): Command { 264 - return new Command('close') 266 + return new IssueCommand('close') 265 267 .description('Close an issue') 266 268 .argument('<issue-id>', 'Issue number or rkey') 267 - .action(async (issueId: string) => { 269 + .addIssueJsonOption() 270 + .action(async (issueId: string, options: { json?: string | true }) => { 268 271 try { 269 272 // 1. Validate auth 270 273 const client = createApiClient(); ··· 288 291 // 4. Resolve issue ID to URI 289 292 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 290 293 291 - // 5. Close issue 294 + // 5. Fetch complete issue data (state will be 'closed' after operation) 295 + const issueData = await getCompleteIssueData( 296 + client, 297 + issueUri, 298 + displayId, 299 + repoAtUri, 300 + 'closed' 301 + ); 302 + 303 + // 6. Close issue 292 304 await closeIssue({ client, issueUri }); 293 305 294 - // 6. Display success 295 - console.log(`โœ“ Issue ${displayId} closed`); 306 + // 7. Display success 307 + if (options.json !== undefined) { 308 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 309 + } else { 310 + console.log(`โœ“ Issue ${displayId} closed`); 311 + console.log(` Title: ${issueData.title}`); 312 + } 296 313 } catch (error) { 297 314 console.error( 298 315 `โœ— Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 306 323 * Issue reopen subcommand 307 324 */ 308 325 function createReopenCommand(): Command { 309 - return new Command('reopen') 326 + return new IssueCommand('reopen') 310 327 .description('Reopen a closed issue') 311 328 .argument('<issue-id>', 'Issue number or rkey') 312 - .action(async (issueId: string) => { 329 + .addIssueJsonOption() 330 + .action(async (issueId: string, options: { json?: string | true }) => { 313 331 try { 314 332 // 1. Validate auth 315 333 const client = createApiClient(); ··· 333 351 // 4. Resolve issue ID to URI 334 352 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 335 353 336 - // 5. Reopen issue 354 + // 5. Fetch complete issue data (state will be 'open' after operation) 355 + const issueData = await getCompleteIssueData( 356 + client, 357 + issueUri, 358 + displayId, 359 + repoAtUri, 360 + 'open' 361 + ); 362 + 363 + // 6. Reopen issue 337 364 await reopenIssue({ client, issueUri }); 338 365 339 - // 6. Display success 340 - console.log(`โœ“ Issue ${displayId} reopened`); 366 + // 7. Display success 367 + if (options.json !== undefined) { 368 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 369 + } else { 370 + console.log(`โœ“ Issue ${displayId} reopened`); 371 + console.log(` Title: ${issueData.title}`); 372 + } 341 373 } catch (error) { 342 374 console.error( 343 375 `โœ— Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 351 383 * Issue delete subcommand 352 384 */ 353 385 function createDeleteCommand(): Command { 354 - return new Command('delete') 386 + return new IssueCommand('delete') 355 387 .description('Delete an issue permanently') 356 388 .argument('<issue-id>', 'Issue number or rkey') 357 389 .option('-f, --force', 'Skip confirmation prompt') 358 - .action(async (issueId: string, options: { force?: boolean }) => { 390 + .addIssueJsonOption() 391 + .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 359 392 // 1. Validate auth 360 393 const client = createApiClient(); 361 394 if (!(await client.resumeSession())) { ··· 372 405 process.exit(1); 373 406 } 374 407 375 - // 3. Build repo AT-URI and resolve issue ID 408 + // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 376 409 let issueUri: string; 377 410 let displayId: string; 411 + let issueData: IssueData; 378 412 try { 379 413 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 380 414 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 415 + issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 381 416 } catch (error) { 382 417 console.error( 383 418 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 388 423 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 389 424 if (!options.force) { 390 425 const confirmed = await confirm({ 391 - message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 426 + message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, 392 427 default: false, 393 428 }); 394 429 ··· 401 436 // 5. Delete issue 402 437 try { 403 438 await deleteIssue({ client, issueUri }); 404 - console.log(`โœ“ Issue ${displayId} deleted`); 439 + if (options.json !== undefined) { 440 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 441 + } else { 442 + console.log(`โœ“ Issue ${displayId} deleted`); 443 + console.log(` Title: ${issueData.title}`); 444 + } 405 445 } catch (error) { 406 446 console.error( 407 447 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 433 473 * Issue create subcommand 434 474 */ 435 475 function createCreateCommand(): Command { 436 - return new Command('create') 476 + return new IssueCommand('create') 437 477 .description('Create a new issue') 438 478 .argument('<title>', 'Issue title') 439 479 .option('-b, --body <string>', 'Issue body text') 440 480 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 441 - .option( 442 - '--json [fields]', 443 - 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 444 - ) 481 + .addIssueJsonOption() 445 482 .action( 446 483 async ( 447 484 title: string, ··· 487 524 body, 488 525 }); 489 526 490 - // 7. Output result 527 + // 7. Compute sequential number 528 + const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); 529 + const sortedAll = allIssues.sort( 530 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 531 + ); 532 + const idx = sortedAll.findIndex((i) => i.uri === issue.uri); 533 + const number = idx >= 0 ? idx + 1 : undefined; 534 + 535 + // 8. Output result 491 536 if (options.json !== undefined) { 492 - const issueData = { 537 + const issueData: IssueData = { 538 + number, 493 539 title: issue.title, 494 540 body: issue.body, 541 + state: 'open', 495 542 author: issue.author, 496 543 createdAt: issue.createdAt, 497 544 uri: issue.uri, ··· 501 548 return; 502 549 } 503 550 504 - const rkey = extractRkey(issue.uri); 505 - console.log(`\nโœ“ Issue created: #${rkey}`); 551 + const displayNumber = number !== undefined ? `#${number}` : extractRkey(issue.uri); 552 + console.log(`\nโœ“ Issue ${displayNumber} created`); 506 553 console.log(` Title: ${issue.title}`); 507 554 console.log(` URI: ${issue.uri}`); 508 555 } catch (error) { ··· 519 566 * Issue list subcommand 520 567 */ 521 568 function createListCommand(): Command { 522 - return new Command('list') 569 + return new IssueCommand('list') 523 570 .description('List issues for the current repository') 524 571 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 525 - .option( 526 - '--json [fields]', 527 - 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 528 - ) 572 + .addIssueJsonOption() 529 573 .action(async (options: { limit: string; json?: string | true }) => { 530 574 try { 531 575 // 1. Validate auth
+66
src/lib/issues-api.ts
··· 424 424 } 425 425 426 426 /** 427 + * Resolve a sequential issue number from a displayId or by scanning the issue list. 428 + * Fast path: if displayId is "#N", return N directly. 429 + * Fallback: fetch all issues, sort oldest-first, return 1-based position. 430 + */ 431 + export async function resolveSequentialNumber( 432 + displayId: string, 433 + issueUri: string, 434 + client: TangledApiClient, 435 + repoAtUri: string 436 + ): Promise<number | undefined> { 437 + const match = displayId.match(/^#(\d+)$/); 438 + if (match) return Number.parseInt(match[1], 10); 439 + 440 + const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); 441 + const sorted = issues.sort( 442 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 443 + ); 444 + const idx = sorted.findIndex((i) => i.uri === issueUri); 445 + return idx >= 0 ? idx + 1 : undefined; 446 + } 447 + 448 + /** 449 + * Canonical JSON shape for a single issue, used by all issue commands. 450 + */ 451 + export interface IssueData { 452 + number: number | undefined; 453 + title: string; 454 + body?: string; 455 + state: 'open' | 'closed'; 456 + author: string; 457 + createdAt: string; 458 + uri: string; 459 + cid: string; 460 + } 461 + 462 + /** 463 + * Fetch a complete IssueData object ready for JSON output. 464 + * Fetches the issue record and sequential number in parallel. 465 + * If stateOverride is supplied (e.g. 'closed' after a close operation), 466 + * getIssueState is skipped; otherwise the current state is fetched. 467 + */ 468 + export async function getCompleteIssueData( 469 + client: TangledApiClient, 470 + issueUri: string, 471 + displayId: string, 472 + repoAtUri: string, 473 + stateOverride?: 'open' | 'closed' 474 + ): Promise<IssueData> { 475 + const [issue, number] = await Promise.all([ 476 + getIssue({ client, issueUri }), 477 + resolveSequentialNumber(displayId, issueUri, client, repoAtUri), 478 + ]); 479 + const state = stateOverride ?? (await getIssueState({ client, issueUri })); 480 + return { 481 + number, 482 + title: issue.title, 483 + body: issue.body, 484 + state, 485 + author: issue.author, 486 + createdAt: issue.createdAt, 487 + uri: issue.uri, 488 + cid: issue.cid, 489 + }; 490 + } 491 + 492 + /** 427 493 * Reopen a closed issue by creating an open state record 428 494 */ 429 495 export async function reopenIssue(params: ReopenIssueParams): Promise<void> {
+226 -27
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 }); ··· 605 615 issues: [mockIssue], 606 616 cursor: undefined, 607 617 }); 608 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 609 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 618 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 619 + number: 1, 620 + title: mockIssue.title, 621 + body: mockIssue.body, 622 + state: 'open', 623 + author: mockIssue.author, 624 + createdAt: mockIssue.createdAt, 625 + uri: mockIssue.uri, 626 + cid: mockIssue.cid, 627 + }); 610 628 611 629 const command = createIssueCommand(); 612 630 await command.parseAsync(['node', 'test', 'view', '1']); 613 631 614 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 615 - client: mockClient, 616 - issueUri: mockIssue.uri, 617 - }); 618 - expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 619 - client: mockClient, 620 - issueUri: mockIssue.uri, 621 - }); 632 + expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 633 + mockClient, 634 + mockIssue.uri, 635 + '#1', 636 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 637 + ); 622 638 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 623 639 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 624 640 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); ··· 626 642 }); 627 643 628 644 it('should view issue by rkey', async () => { 629 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 630 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 645 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 646 + number: undefined, 647 + title: mockIssue.title, 648 + body: mockIssue.body, 649 + state: 'closed', 650 + author: mockIssue.author, 651 + createdAt: mockIssue.createdAt, 652 + uri: mockIssue.uri, 653 + cid: mockIssue.cid, 654 + }); 631 655 632 656 const command = createIssueCommand(); 633 657 await command.parseAsync(['node', 'test', 'view', 'issue1']); 634 658 635 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 636 - client: mockClient, 637 - issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 638 - }); 659 + expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 660 + mockClient, 661 + 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 662 + 'issue1', 663 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 664 + ); 639 665 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 640 666 }); 641 667 642 668 it('should show issue without body', async () => { 643 - const issueWithoutBody = { ...mockIssue, body: undefined }; 644 669 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 645 - issues: [issueWithoutBody], 670 + issues: [mockIssue], 646 671 cursor: undefined, 647 672 }); 648 - vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 649 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 673 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 674 + number: 1, 675 + title: mockIssue.title, 676 + body: undefined, 677 + state: 'open', 678 + author: mockIssue.author, 679 + createdAt: mockIssue.createdAt, 680 + uri: mockIssue.uri, 681 + cid: mockIssue.cid, 682 + }); 650 683 651 684 const command = createIssueCommand(); 652 685 await command.parseAsync(['node', 'test', 'view', '1']); ··· 699 732 issues: [mockIssue], 700 733 cursor: undefined, 701 734 }); 702 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 703 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 735 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 736 + number: 1, 737 + title: mockIssue.title, 738 + body: mockIssue.body, 739 + state: 'open', 740 + author: mockIssue.author, 741 + createdAt: mockIssue.createdAt, 742 + uri: mockIssue.uri, 743 + cid: mockIssue.cid, 744 + }); 704 745 705 746 const command = createIssueCommand(); 706 747 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 707 748 708 749 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 709 750 expect(jsonOutput).toMatchObject({ 751 + number: 1, 710 752 title: 'Test Issue', 711 753 body: 'Issue body', 712 754 state: 'open', ··· 721 763 issues: [mockIssue], 722 764 cursor: undefined, 723 765 }); 724 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 725 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 766 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 767 + number: 1, 768 + title: mockIssue.title, 769 + body: mockIssue.body, 770 + state: 'closed', 771 + author: mockIssue.author, 772 + createdAt: mockIssue.createdAt, 773 + uri: mockIssue.uri, 774 + cid: mockIssue.cid, 775 + }); 726 776 727 777 const command = createIssueCommand(); 728 778 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); ··· 856 906 cursor: undefined, 857 907 }); 858 908 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 909 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 910 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 859 911 860 912 const command = createIssueCommand(); 861 913 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 862 914 863 915 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 864 916 expect(jsonOutput).toMatchObject({ 917 + number: 1, 865 918 title: 'New Title', 919 + state: 'open', 866 920 author: 'did:plc:abc123', 867 921 uri: mockIssue.uri, 868 922 cid: mockIssue.cid, ··· 878 932 cursor: undefined, 879 933 }); 880 934 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 935 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 936 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 881 937 882 938 const command = createIssueCommand(); 883 939 await command.parseAsync([ ··· 937 993 }); 938 994 939 995 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 996 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 997 + number: 1, 998 + title: mockIssue.title, 999 + body: undefined, 1000 + state: 'closed', 1001 + author: mockIssue.author, 1002 + createdAt: mockIssue.createdAt, 1003 + uri: mockIssue.uri, 1004 + cid: mockIssue.cid, 1005 + }); 940 1006 }); 941 1007 942 1008 afterEach(() => { ··· 958 1024 issueUri: mockIssue.uri, 959 1025 }); 960 1026 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 closed'); 1027 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 961 1028 }); 962 1029 963 1030 it('should fail when not authenticated', async () => { ··· 968 1035 'process.exit(1)' 969 1036 ); 970 1037 }); 1038 + 1039 + describe('JSON output', () => { 1040 + it('should output JSON when --json is passed', async () => { 1041 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1042 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1043 + 1044 + const command = createIssueCommand(); 1045 + await command.parseAsync(['node', 'test', 'close', '1', '--json']); 1046 + 1047 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1048 + expect(jsonOutput).toEqual({ 1049 + number: 1, 1050 + title: 'Test Issue', 1051 + state: 'closed', 1052 + author: mockIssue.author, 1053 + createdAt: mockIssue.createdAt, 1054 + uri: mockIssue.uri, 1055 + cid: mockIssue.cid, 1056 + }); 1057 + }); 1058 + 1059 + it('should output filtered JSON when --json with fields is passed', async () => { 1060 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1061 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1062 + 1063 + const command = createIssueCommand(); 1064 + await command.parseAsync(['node', 'test', 'close', '1', '--json', 'number,state']); 1065 + 1066 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1067 + expect(jsonOutput).toEqual({ number: 1, state: 'closed' }); 1068 + expect(jsonOutput).not.toHaveProperty('title'); 1069 + expect(jsonOutput).not.toHaveProperty('uri'); 1070 + }); 1071 + }); 971 1072 }); 972 1073 973 1074 describe('issue reopen command', () => { ··· 1006 1107 }); 1007 1108 1008 1109 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1110 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1111 + number: 1, 1112 + title: mockIssue.title, 1113 + body: undefined, 1114 + state: 'open', 1115 + author: mockIssue.author, 1116 + createdAt: mockIssue.createdAt, 1117 + uri: mockIssue.uri, 1118 + cid: mockIssue.cid, 1119 + }); 1009 1120 }); 1010 1121 1011 1122 afterEach(() => { ··· 1027 1138 issueUri: mockIssue.uri, 1028 1139 }); 1029 1140 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 reopened'); 1141 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1030 1142 }); 1031 1143 1032 1144 it('should fail when not authenticated', async () => { ··· 1037 1149 'process.exit(1)' 1038 1150 ); 1039 1151 }); 1152 + 1153 + describe('JSON output', () => { 1154 + it('should output JSON when --json is passed', async () => { 1155 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1156 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1157 + 1158 + const command = createIssueCommand(); 1159 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json']); 1160 + 1161 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1162 + expect(jsonOutput).toEqual({ 1163 + number: 1, 1164 + title: 'Test Issue', 1165 + state: 'open', 1166 + author: mockIssue.author, 1167 + createdAt: mockIssue.createdAt, 1168 + uri: mockIssue.uri, 1169 + cid: mockIssue.cid, 1170 + }); 1171 + }); 1172 + 1173 + it('should output filtered JSON when --json with fields is passed', async () => { 1174 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1175 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1176 + 1177 + const command = createIssueCommand(); 1178 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json', 'number,state']); 1179 + 1180 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1181 + expect(jsonOutput).toEqual({ number: 1, state: 'open' }); 1182 + expect(jsonOutput).not.toHaveProperty('title'); 1183 + }); 1184 + }); 1040 1185 }); 1041 1186 1042 1187 describe('issue delete command', () => { ··· 1077 1222 }); 1078 1223 1079 1224 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1225 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1226 + number: 1, 1227 + title: mockIssue.title, 1228 + body: undefined, 1229 + state: 'open', 1230 + author: mockIssue.author, 1231 + createdAt: mockIssue.createdAt, 1232 + uri: mockIssue.uri, 1233 + cid: mockIssue.cid, 1234 + }); 1080 1235 }); 1081 1236 1082 1237 afterEach(() => { ··· 1098 1253 issueUri: mockIssue.uri, 1099 1254 }); 1100 1255 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1256 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1101 1257 }); 1102 1258 1103 1259 it('should cancel deletion when user declines confirmation', async () => { ··· 1134 1290 1135 1291 expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1136 1292 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1293 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1137 1294 }); 1138 1295 1139 1296 it('should fail when not authenticated', async () => { ··· 1147 1304 expect(consoleErrorSpy).toHaveBeenCalledWith( 1148 1305 'โœ— Not authenticated. Run "tangled auth login" first.' 1149 1306 ); 1307 + }); 1308 + 1309 + describe('JSON output', () => { 1310 + it('should output JSON when --json is passed', async () => { 1311 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1312 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1313 + 1314 + const command = createIssueCommand(); 1315 + await command.parseAsync(['node', 'test', 'delete', '1', '--force', '--json']); 1316 + 1317 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1318 + expect(jsonOutput).toEqual({ 1319 + number: 1, 1320 + title: 'Test Issue', 1321 + state: 'open', 1322 + author: mockIssue.author, 1323 + createdAt: mockIssue.createdAt, 1324 + uri: mockIssue.uri, 1325 + cid: mockIssue.cid, 1326 + }); 1327 + }); 1328 + 1329 + it('should output filtered JSON when --json with fields is passed', async () => { 1330 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1331 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1332 + 1333 + const command = createIssueCommand(); 1334 + await command.parseAsync([ 1335 + 'node', 1336 + 'test', 1337 + 'delete', 1338 + '1', 1339 + '--force', 1340 + '--json', 1341 + 'number,title', 1342 + ]); 1343 + 1344 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1345 + expect(jsonOutput).toEqual({ number: 1, title: 'Test Issue' }); 1346 + expect(jsonOutput).not.toHaveProperty('uri'); 1347 + expect(jsonOutput).not.toHaveProperty('cid'); 1348 + }); 1150 1349 }); 1151 1350 });
+224
tests/lib/issues-api.test.ts
··· 4 4 closeIssue, 5 5 createIssue, 6 6 deleteIssue, 7 + getCompleteIssueData, 7 8 getIssue, 8 9 getIssueState, 9 10 listIssues, 10 11 reopenIssue, 12 + resolveSequentialNumber, 11 13 updateIssue, 12 14 } from '../../src/lib/issues-api.js'; 13 15 ··· 863 865 ).rejects.toThrow('Must be authenticated'); 864 866 }); 865 867 }); 868 + 869 + describe('resolveSequentialNumber', () => { 870 + let mockClient: TangledApiClient; 871 + 872 + beforeEach(() => { 873 + mockClient = createMockClient(true); 874 + }); 875 + 876 + it('should return number directly for #N displayId without an API call (fast path)', async () => { 877 + const result = await resolveSequentialNumber( 878 + '#3', 879 + 'at://did:plc:owner/sh.tangled.repo.issue/issue3', 880 + mockClient, 881 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 882 + ); 883 + expect(result).toBe(3); 884 + }); 885 + 886 + it('should scan issue list and return 1-based position for rkey displayId', async () => { 887 + const mockListRecords = vi.fn().mockResolvedValue({ 888 + data: { 889 + records: [ 890 + { 891 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 892 + cid: 'cid1', 893 + value: { 894 + $type: 'sh.tangled.repo.issue', 895 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 896 + title: 'First', 897 + createdAt: '2024-01-01T00:00:00.000Z', 898 + }, 899 + }, 900 + { 901 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 902 + cid: 'cid2', 903 + value: { 904 + $type: 'sh.tangled.repo.issue', 905 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 906 + title: 'Second', 907 + createdAt: '2024-01-02T00:00:00.000Z', 908 + }, 909 + }, 910 + ], 911 + cursor: undefined, 912 + }, 913 + }); 914 + 915 + vi.mocked(mockClient.getAgent).mockReturnValue({ 916 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 917 + } as never); 918 + 919 + const result = await resolveSequentialNumber( 920 + 'issue-b', 921 + 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 922 + mockClient, 923 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 924 + ); 925 + expect(result).toBe(2); 926 + }); 927 + 928 + it('should return undefined when issue URI not found in list', async () => { 929 + const mockListRecords = vi.fn().mockResolvedValue({ 930 + data: { 931 + records: [ 932 + { 933 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 934 + cid: 'cid1', 935 + value: { 936 + $type: 'sh.tangled.repo.issue', 937 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 938 + title: 'First', 939 + createdAt: '2024-01-01T00:00:00.000Z', 940 + }, 941 + }, 942 + ], 943 + cursor: undefined, 944 + }, 945 + }); 946 + 947 + vi.mocked(mockClient.getAgent).mockReturnValue({ 948 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 949 + } as never); 950 + 951 + const result = await resolveSequentialNumber( 952 + 'nonexistent', 953 + 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 954 + mockClient, 955 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 956 + ); 957 + expect(result).toBeUndefined(); 958 + }); 959 + }); 960 + 961 + describe('getCompleteIssueData', () => { 962 + let mockClient: TangledApiClient; 963 + 964 + beforeEach(() => { 965 + mockClient = createMockClient(true); 966 + }); 967 + 968 + it('should return all fields including fetched state', async () => { 969 + const mockGetRecord = vi.fn().mockResolvedValue({ 970 + data: { 971 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 972 + cid: 'cid1', 973 + value: { 974 + $type: 'sh.tangled.repo.issue', 975 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 976 + title: 'Test Issue', 977 + body: 'Test body', 978 + createdAt: '2024-01-01T00:00:00.000Z', 979 + }, 980 + }, 981 + }); 982 + 983 + // getIssueState uses listRecords on the state collection 984 + const mockListRecords = vi.fn().mockResolvedValue({ 985 + data: { 986 + records: [ 987 + { 988 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 989 + cid: 'scid1', 990 + value: { 991 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 992 + state: 'sh.tangled.repo.issue.state.closed', 993 + }, 994 + }, 995 + ], 996 + }, 997 + }); 998 + 999 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1000 + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1001 + } as never); 1002 + 1003 + const result = await getCompleteIssueData( 1004 + mockClient, 1005 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1006 + '#1', // fast-path for number โ€” no listRecords call for issues 1007 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 1008 + ); 1009 + 1010 + expect(result).toEqual({ 1011 + number: 1, 1012 + title: 'Test Issue', 1013 + body: 'Test body', 1014 + state: 'closed', 1015 + author: 'did:plc:owner', 1016 + createdAt: '2024-01-01T00:00:00.000Z', 1017 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1018 + cid: 'cid1', 1019 + }); 1020 + }); 1021 + 1022 + it('should use stateOverride and skip the getIssueState network call', async () => { 1023 + const mockGetRecord = vi.fn().mockResolvedValue({ 1024 + data: { 1025 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1026 + cid: 'cid1', 1027 + value: { 1028 + $type: 'sh.tangled.repo.issue', 1029 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1030 + title: 'Test Issue', 1031 + createdAt: '2024-01-01T00:00:00.000Z', 1032 + }, 1033 + }, 1034 + }); 1035 + 1036 + const mockListRecords = vi.fn(); 1037 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1038 + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1039 + } as never); 1040 + 1041 + const result = await getCompleteIssueData( 1042 + mockClient, 1043 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1044 + '#2', 1045 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 1046 + 'closed' 1047 + ); 1048 + 1049 + expect(result.number).toBe(2); 1050 + expect(result.state).toBe('closed'); 1051 + expect(mockListRecords).not.toHaveBeenCalled(); 1052 + }); 1053 + 1054 + it('should return undefined body and default open state when issue has no body or state records', async () => { 1055 + const mockGetRecord = vi.fn().mockResolvedValue({ 1056 + data: { 1057 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1058 + cid: 'cid1', 1059 + value: { 1060 + $type: 'sh.tangled.repo.issue', 1061 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1062 + title: 'No body issue', 1063 + createdAt: '2024-01-01T00:00:00.000Z', 1064 + }, 1065 + }, 1066 + }); 1067 + 1068 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1069 + com: { 1070 + atproto: { 1071 + repo: { 1072 + getRecord: mockGetRecord, 1073 + listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 1074 + }, 1075 + }, 1076 + }, 1077 + } as never); 1078 + 1079 + const result = await getCompleteIssueData( 1080 + mockClient, 1081 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1082 + '#1', 1083 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 1084 + ); 1085 + 1086 + expect(result.body).toBeUndefined(); 1087 + expect(result.state).toBe('open'); 1088 + }); 1089 + });

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
1 commit
expand
Fix issue output to show sequential numbers and add --json to write commands
expand 0 comments