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
+468 -137
Interdiff #0 โ†’ #1
+69 -107
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 /** 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. 92 + * A custom subclass of Command with support for adding the common issue JSON flag. 93 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; 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 + } 109 101 } 110 102 111 103 /** 112 104 * Issue view subcommand 113 105 */ 114 106 function createViewCommand(): Command { 115 - return new Command('view') 107 + return new IssueCommand('view') 116 108 .description('View details of a specific issue') 117 109 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 118 - .option( 119 - '--json [fields]', 120 - 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 121 - ) 110 + .addIssueJsonOption() 122 111 .action(async (issueId: string, options: { json?: string | true }) => { 123 112 try { 124 113 // 1. Validate auth ··· 143 132 // 4. Resolve issue ID to URI 144 133 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 145 134 146 - // 5. Fetch issue details 147 - const issue = await getIssue({ client, issueUri }); 135 + // 5. Fetch complete issue data (record, sequential number, state) 136 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 148 137 149 - // 6. Fetch issue state 150 - const state = await getIssueState({ client, issueUri: issue.uri }); 151 - 152 - // 7. Output result 138 + // 6. Output result 153 139 if (options.json !== undefined) { 154 - const issueData = { 155 - title: issue.title, 156 - body: issue.body, 157 - state, 158 - author: issue.author, 159 - createdAt: issue.createdAt, 160 - uri: issue.uri, 161 - cid: issue.cid, 162 - }; 163 140 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 164 141 return; 165 142 } 166 143 167 - console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 168 - console.log(`Title: ${issue.title}`); 169 - console.log(`Author: ${issue.author}`); 170 - 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)}`); 171 148 console.log(`Repo: ${context.name}`); 172 - console.log(`URI: ${issue.uri}`); 149 + console.log(`URI: ${issueData.uri}`); 173 150 174 - if (issue.body) { 151 + if (issueData.body) { 175 152 console.log('\nBody:'); 176 - console.log(issue.body); 153 + console.log(issueData.body); 177 154 } 178 155 179 156 console.log(); // Empty line at end ··· 190 167 * Issue edit subcommand 191 168 */ 192 169 function createEditCommand(): Command { 193 - return new Command('edit') 170 + return new IssueCommand('edit') 194 171 .description('Edit an issue title and/or body') 195 172 .argument('<issue-id>', 'Issue number or rkey') 196 173 .option('-t, --title <string>', 'New issue title') 197 174 .option('-b, --body <string>', 'New issue body text') 198 175 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 199 - .option( 200 - '--json [fields]', 201 - 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 202 - ) 176 + .addIssueJsonOption() 203 177 .action( 204 178 async ( 205 179 issueId: string, ··· 251 225 252 226 // 9. Output result 253 227 if (options.json !== undefined) { 254 - 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, 255 234 title: updatedIssue.title, 256 235 body: updatedIssue.body, 236 + state, 257 237 author: updatedIssue.author, 258 238 createdAt: updatedIssue.createdAt, 259 239 uri: updatedIssue.uri, ··· 283 263 * Issue close subcommand 284 264 */ 285 265 function createCloseCommand(): Command { 286 - return new Command('close') 266 + return new IssueCommand('close') 287 267 .description('Close an issue') 288 268 .argument('<issue-id>', 'Issue number or rkey') 289 - .option( 290 - '--json [fields]', 291 - 'Output JSON; optionally specify comma-separated fields (number, title, uri, state, cid)' 292 - ) 269 + .addIssueJsonOption() 293 270 .action(async (issueId: string, options: { json?: string | true }) => { 294 271 try { 295 272 // 1. Validate auth ··· 314 291 // 4. Resolve issue ID to URI 315 292 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 316 293 317 - // 5. Fetch issue details and sequential number 318 - const issue = await getIssue({ client, issueUri }); 319 - const number = await resolveSequentialNumber(displayId, issueUri, client, repoAtUri); 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 + ); 320 302 321 303 // 6. Close issue 322 304 await closeIssue({ client, issueUri }); 323 305 324 306 // 7. Display success 325 307 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 - ); 308 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 330 309 } else { 331 310 console.log(`โœ“ Issue ${displayId} closed`); 332 - console.log(` Title: ${issue.title}`); 311 + console.log(` Title: ${issueData.title}`); 333 312 } 334 313 } catch (error) { 335 314 console.error( ··· 344 323 * Issue reopen subcommand 345 324 */ 346 325 function createReopenCommand(): Command { 347 - return new Command('reopen') 326 + return new IssueCommand('reopen') 348 327 .description('Reopen a closed issue') 349 328 .argument('<issue-id>', 'Issue number or rkey') 350 - .option( 351 - '--json [fields]', 352 - 'Output JSON; optionally specify comma-separated fields (number, title, uri, state, cid)' 353 - ) 329 + .addIssueJsonOption() 354 330 .action(async (issueId: string, options: { json?: string | true }) => { 355 331 try { 356 332 // 1. Validate auth ··· 375 351 // 4. Resolve issue ID to URI 376 352 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 377 353 378 - // 5. Fetch issue details and sequential number 379 - const issue = await getIssue({ client, issueUri }); 380 - const number = await resolveSequentialNumber(displayId, issueUri, client, repoAtUri); 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 + ); 381 362 382 363 // 6. Reopen issue 383 364 await reopenIssue({ client, issueUri }); 384 365 385 366 // 7. Display success 386 367 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 - ); 368 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 391 369 } else { 392 370 console.log(`โœ“ Issue ${displayId} reopened`); 393 - console.log(` Title: ${issue.title}`); 371 + console.log(` Title: ${issueData.title}`); 394 372 } 395 373 } catch (error) { 396 374 console.error( ··· 405 383 * Issue delete subcommand 406 384 */ 407 385 function createDeleteCommand(): Command { 408 - return new Command('delete') 386 + return new IssueCommand('delete') 409 387 .description('Delete an issue permanently') 410 388 .argument('<issue-id>', 'Issue number or rkey') 411 389 .option('-f, --force', 'Skip confirmation prompt') 412 - .option( 413 - '--json [fields]', 414 - 'Output JSON; optionally specify comma-separated fields (number, title, uri, cid)' 415 - ) 390 + .addIssueJsonOption() 416 391 .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 417 392 // 1. Validate auth 418 393 const client = createApiClient(); ··· 433 408 // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 434 409 let issueUri: string; 435 410 let displayId: string; 436 - let issueTitle: string; 437 - let issueCid: string; 438 - let issueNumber: number | undefined; 411 + let issueData: IssueData; 439 412 try { 440 413 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 441 414 ({ 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); 415 + issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 446 416 } catch (error) { 447 417 console.error( 448 418 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 453 423 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 454 424 if (!options.force) { 455 425 const confirmed = await confirm({ 456 - message: `Are you sure you want to delete issue ${displayId} "${issueTitle}"? This cannot be undone.`, 426 + message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, 457 427 default: false, 458 428 }); 459 429 ··· 467 437 try { 468 438 await deleteIssue({ client, issueUri }); 469 439 if (options.json !== undefined) { 470 - outputJson( 471 - { number: issueNumber, title: issueTitle, uri: issueUri, cid: issueCid }, 472 - typeof options.json === 'string' ? options.json : undefined 473 - ); 440 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 474 441 } else { 475 442 console.log(`โœ“ Issue ${displayId} deleted`); 476 - console.log(` Title: ${issueTitle}`); 443 + console.log(` Title: ${issueData.title}`); 477 444 } 478 445 } catch (error) { 479 446 console.error( ··· 506 473 * Issue create subcommand 507 474 */ 508 475 function createCreateCommand(): Command { 509 - return new Command('create') 476 + return new IssueCommand('create') 510 477 .description('Create a new issue') 511 478 .argument('<title>', 'Issue title') 512 479 .option('-b, --body <string>', 'Issue body text') 513 480 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 514 - .option( 515 - '--json [fields]', 516 - 'Output JSON; optionally specify comma-separated fields (number, title, body, author, createdAt, uri, cid)' 517 - ) 481 + .addIssueJsonOption() 518 482 .action( 519 483 async ( 520 484 title: string, ··· 570 534 571 535 // 8. Output result 572 536 if (options.json !== undefined) { 573 - const issueData = { 537 + const issueData: IssueData = { 574 538 number, 575 539 title: issue.title, 576 540 body: issue.body, 541 + state: 'open', 577 542 author: issue.author, 578 543 createdAt: issue.createdAt, 579 544 uri: issue.uri, ··· 601 566 * Issue list subcommand 602 567 */ 603 568 function createListCommand(): Command { 604 - return new Command('list') 569 + return new IssueCommand('list') 605 570 .description('List issues for the current repository') 606 571 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 607 - .option( 608 - '--json [fields]', 609 - 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 610 - ) 572 + .addIssueJsonOption() 611 573 .action(async (options: { limit: string; json?: string | true }) => { 612 574 try { 613 575 // 1. Validate auth
+109 -30
tests/commands/issue.test.ts
··· 615 615 issues: [mockIssue], 616 616 cursor: undefined, 617 617 }); 618 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 619 - 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 + }); 620 628 621 629 const command = createIssueCommand(); 622 630 await command.parseAsync(['node', 'test', 'view', '1']); 623 631 624 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 625 - client: mockClient, 626 - issueUri: mockIssue.uri, 627 - }); 628 - expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 629 - client: mockClient, 630 - issueUri: mockIssue.uri, 631 - }); 632 + expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 633 + mockClient, 634 + mockIssue.uri, 635 + '#1', 636 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 637 + ); 632 638 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 633 639 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 634 640 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); ··· 636 642 }); 637 643 638 644 it('should view issue by rkey', async () => { 639 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 640 - 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 + }); 641 655 642 656 const command = createIssueCommand(); 643 657 await command.parseAsync(['node', 'test', 'view', 'issue1']); 644 658 645 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 646 - client: mockClient, 647 - issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 648 - }); 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 + ); 649 665 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 650 666 }); 651 667 652 668 it('should show issue without body', async () => { 653 - const issueWithoutBody = { ...mockIssue, body: undefined }; 654 669 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 655 - issues: [issueWithoutBody], 670 + issues: [mockIssue], 656 671 cursor: undefined, 657 672 }); 658 - vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 659 - 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 + }); 660 683 661 684 const command = createIssueCommand(); 662 685 await command.parseAsync(['node', 'test', 'view', '1']); ··· 709 732 issues: [mockIssue], 710 733 cursor: undefined, 711 734 }); 712 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 713 - 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 + }); 714 745 715 746 const command = createIssueCommand(); 716 747 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 717 748 718 749 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 719 750 expect(jsonOutput).toMatchObject({ 751 + number: 1, 720 752 title: 'Test Issue', 721 753 body: 'Issue body', 722 754 state: 'open', ··· 731 763 issues: [mockIssue], 732 764 cursor: undefined, 733 765 }); 734 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 735 - 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 + }); 736 776 737 777 const command = createIssueCommand(); 738 778 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); ··· 866 906 cursor: undefined, 867 907 }); 868 908 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 909 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 910 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 869 911 870 912 const command = createIssueCommand(); 871 913 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 872 914 873 915 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 874 916 expect(jsonOutput).toMatchObject({ 917 + number: 1, 875 918 title: 'New Title', 919 + state: 'open', 876 920 author: 'did:plc:abc123', 877 921 uri: mockIssue.uri, 878 922 cid: mockIssue.cid, ··· 888 932 cursor: undefined, 889 933 }); 890 934 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 935 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 936 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 891 937 892 938 const command = createIssueCommand(); 893 939 await command.parseAsync([ ··· 947 993 }); 948 994 949 995 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 950 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 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 + }); 951 1006 }); 952 1007 953 1008 afterEach(() => { ··· 993 1048 expect(jsonOutput).toEqual({ 994 1049 number: 1, 995 1050 title: 'Test Issue', 996 - uri: mockIssue.uri, 997 1051 state: 'closed', 1052 + author: mockIssue.author, 1053 + createdAt: mockIssue.createdAt, 1054 + uri: mockIssue.uri, 998 1055 cid: mockIssue.cid, 999 1056 }); 1000 1057 }); ··· 1050 1107 }); 1051 1108 1052 1109 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1053 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 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 + }); 1054 1120 }); 1055 1121 1056 1122 afterEach(() => { ··· 1096 1162 expect(jsonOutput).toEqual({ 1097 1163 number: 1, 1098 1164 title: 'Test Issue', 1099 - uri: mockIssue.uri, 1100 1165 state: 'open', 1166 + author: mockIssue.author, 1167 + createdAt: mockIssue.createdAt, 1168 + uri: mockIssue.uri, 1101 1169 cid: mockIssue.cid, 1102 1170 }); 1103 1171 }); ··· 1154 1222 }); 1155 1223 1156 1224 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1157 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 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 + }); 1158 1235 }); 1159 1236 1160 1237 afterEach(() => { ··· 1241 1318 expect(jsonOutput).toEqual({ 1242 1319 number: 1, 1243 1320 title: 'Test Issue', 1321 + state: 'open', 1322 + author: mockIssue.author, 1323 + createdAt: mockIssue.createdAt, 1244 1324 uri: mockIssue.uri, 1245 1325 cid: mockIssue.cid, 1246 1326 }); 1247 - expect(jsonOutput).not.toHaveProperty('state'); 1248 1327 }); 1249 1328 1250 1329 it('should output filtered JSON when --json with fields is passed', async () => {
+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> {
+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