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! :)
at main 998 lines 28 kB view raw
1import { beforeEach, describe, expect, it, vi } from 'vitest'; 2import type { TangledApiClient } from '../../src/lib/api-client.js'; 3import { getBacklinks } from '../../src/lib/constellation.js'; 4import { 5 closeIssue, 6 createIssue, 7 getCompleteIssueData, 8 getIssue, 9 getIssueState, 10 listIssues, 11 reopenIssue, 12 resolveSequentialNumber, 13 updateIssue, 14} from '../../src/lib/issues-api.js'; 15 16vi.mock('../../src/lib/constellation.js'); 17 18// Mock API client factory 19const createMockClient = (authenticated = true): TangledApiClient => { 20 const mockAgent = { 21 com: { 22 atproto: { 23 repo: { 24 createRecord: vi.fn(), 25 listRecords: vi.fn(), 26 getRecord: vi.fn(), 27 putRecord: vi.fn(), 28 deleteRecord: vi.fn(), 29 }, 30 }, 31 }, 32 }; 33 34 return { 35 isAuthenticated: vi.fn(() => authenticated), 36 getSession: vi.fn(() => 37 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 38 ), 39 getAgent: vi.fn(() => mockAgent), 40 } as unknown as TangledApiClient; 41}; 42 43describe('createIssue', () => { 44 let mockClient: TangledApiClient; 45 46 beforeEach(() => { 47 mockClient = createMockClient(true); 48 }); 49 50 it('should create an issue with all fields', async () => { 51 const mockCreateRecord = vi.fn().mockResolvedValue({ 52 data: { 53 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 54 cid: 'cid123', 55 }, 56 }); 57 58 vi.mocked(mockClient.getAgent).mockReturnValue({ 59 com: { 60 atproto: { 61 repo: { 62 createRecord: mockCreateRecord, 63 }, 64 }, 65 }, 66 } as never); 67 68 const result = await createIssue({ 69 client: mockClient, 70 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 71 title: 'Bug: Login fails', 72 body: 'Detailed description of the bug', 73 }); 74 75 expect(result).toMatchObject({ 76 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 77 title: 'Bug: Login fails', 78 body: 'Detailed description of the bug', 79 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 80 cid: 'cid123', 81 author: 'did:plc:test123', 82 }); 83 84 expect(mockCreateRecord).toHaveBeenCalledWith({ 85 repo: 'did:plc:test123', 86 collection: 'sh.tangled.repo.issue', 87 record: expect.objectContaining({ 88 $type: 'sh.tangled.repo.issue', 89 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 90 title: 'Bug: Login fails', 91 body: 'Detailed description of the bug', 92 createdAt: expect.any(String), 93 }), 94 }); 95 }); 96 97 it('should create an issue without body', async () => { 98 const mockCreateRecord = vi.fn().mockResolvedValue({ 99 data: { 100 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 101 cid: 'cid123', 102 }, 103 }); 104 105 vi.mocked(mockClient.getAgent).mockReturnValue({ 106 com: { 107 atproto: { 108 repo: { 109 createRecord: mockCreateRecord, 110 }, 111 }, 112 }, 113 } as never); 114 115 const result = await createIssue({ 116 client: mockClient, 117 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 118 title: 'Simple issue', 119 }); 120 121 expect(result.body).toBeUndefined(); 122 expect(mockCreateRecord).toHaveBeenCalled(); 123 }); 124 125 it('should throw error when not authenticated', async () => { 126 mockClient = createMockClient(false); 127 128 await expect( 129 createIssue({ 130 client: mockClient, 131 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 132 title: 'Test', 133 }) 134 ).rejects.toThrow('Must be authenticated'); 135 }); 136 137 it('should throw error on API failure', async () => { 138 const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error')); 139 140 vi.mocked(mockClient.getAgent).mockReturnValue({ 141 com: { 142 atproto: { 143 repo: { 144 createRecord: mockCreateRecord, 145 }, 146 }, 147 }, 148 } as never); 149 150 await expect( 151 createIssue({ 152 client: mockClient, 153 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 154 title: 'Test', 155 }) 156 ).rejects.toThrow('Failed to create issue: API error'); 157 }); 158}); 159 160describe('listIssues', () => { 161 let mockClient: TangledApiClient; 162 163 beforeEach(() => { 164 mockClient = createMockClient(true); 165 }); 166 167 it('should list issues from multiple PDSs via constellation', async () => { 168 vi.mocked(getBacklinks).mockResolvedValue({ 169 total: 2, 170 records: [ 171 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue1' }, 172 { did: 'did:plc:collab', collection: 'sh.tangled.repo.issue', rkey: 'issue2' }, 173 ], 174 cursor: null, 175 }); 176 177 const mockGetRecord = vi 178 .fn() 179 .mockResolvedValueOnce({ 180 data: { 181 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 182 cid: 'cid1', 183 value: { 184 $type: 'sh.tangled.repo.issue', 185 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 186 title: 'Issue 1', 187 body: 'Description 1', 188 createdAt: '2024-01-01T00:00:00.000Z', 189 }, 190 }, 191 }) 192 .mockResolvedValueOnce({ 193 data: { 194 uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 195 cid: 'cid2', 196 value: { 197 $type: 'sh.tangled.repo.issue', 198 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 199 title: 'Issue 2', 200 createdAt: '2024-01-02T00:00:00.000Z', 201 }, 202 }, 203 }); 204 205 vi.mocked(mockClient.getAgent).mockReturnValue({ 206 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 207 } as never); 208 209 const result = await listIssues({ 210 client: mockClient, 211 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 212 }); 213 214 expect(result.issues).toHaveLength(2); 215 expect(result.issues[0]).toMatchObject({ 216 title: 'Issue 1', 217 body: 'Description 1', 218 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 219 author: 'did:plc:owner', 220 }); 221 expect(result.issues[1]).toMatchObject({ 222 title: 'Issue 2', 223 uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 224 author: 'did:plc:collab', 225 }); 226 227 expect(getBacklinks).toHaveBeenCalledWith( 228 'at://did:plc:owner/sh.tangled.repo/my-repo', 229 'sh.tangled.repo.issue', 230 '.repo', 231 50, 232 undefined 233 ); 234 }); 235 236 it('should return empty array when no issues found', async () => { 237 vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 238 239 const result = await listIssues({ 240 client: mockClient, 241 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 242 }); 243 244 expect(result.issues).toEqual([]); 245 }); 246 247 it('should forward cursor from constellation', async () => { 248 vi.mocked(getBacklinks).mockResolvedValue({ total: 100, records: [], cursor: 'nextpage' }); 249 250 const result = await listIssues({ 251 client: mockClient, 252 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 253 }); 254 255 expect(result.cursor).toBe('nextpage'); 256 }); 257 258 it('should throw error when not authenticated', async () => { 259 mockClient = createMockClient(false); 260 261 await expect( 262 listIssues({ 263 client: mockClient, 264 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 265 }) 266 ).rejects.toThrow('Must be authenticated'); 267 }); 268}); 269 270describe('getIssue', () => { 271 let mockClient: TangledApiClient; 272 273 beforeEach(() => { 274 mockClient = createMockClient(true); 275 }); 276 277 it('should get a specific issue', async () => { 278 const mockGetRecord = vi.fn().mockResolvedValue({ 279 data: { 280 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 281 cid: 'cid1', 282 value: { 283 $type: 'sh.tangled.repo.issue', 284 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 285 title: 'Test Issue', 286 body: 'Test Description', 287 createdAt: '2024-01-01T00:00:00.000Z', 288 }, 289 }, 290 }); 291 292 vi.mocked(mockClient.getAgent).mockReturnValue({ 293 com: { 294 atproto: { 295 repo: { 296 getRecord: mockGetRecord, 297 }, 298 }, 299 }, 300 } as never); 301 302 const result = await getIssue({ 303 client: mockClient, 304 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 305 }); 306 307 expect(result).toMatchObject({ 308 title: 'Test Issue', 309 body: 'Test Description', 310 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 311 cid: 'cid1', 312 }); 313 314 expect(mockGetRecord).toHaveBeenCalledWith({ 315 repo: 'did:plc:owner', 316 collection: 'sh.tangled.repo.issue', 317 rkey: 'issue1', 318 }); 319 }); 320 321 it('should throw error when issue not found', async () => { 322 const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found')); 323 324 vi.mocked(mockClient.getAgent).mockReturnValue({ 325 com: { 326 atproto: { 327 repo: { 328 getRecord: mockGetRecord, 329 }, 330 }, 331 }, 332 } as never); 333 334 await expect( 335 getIssue({ 336 client: mockClient, 337 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 338 }) 339 ).rejects.toThrow('Issue not found'); 340 }); 341 342 it('should throw error for invalid issue URI', async () => { 343 await expect( 344 getIssue({ 345 client: mockClient, 346 issueUri: 'invalid-uri', 347 }) 348 ).rejects.toThrow('Invalid issue AT-URI'); 349 }); 350 351 it('should throw error when not authenticated', async () => { 352 mockClient = createMockClient(false); 353 354 await expect( 355 getIssue({ 356 client: mockClient, 357 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 358 }) 359 ).rejects.toThrow('Must be authenticated'); 360 }); 361}); 362 363describe('updateIssue', () => { 364 let mockClient: TangledApiClient; 365 366 beforeEach(() => { 367 mockClient = createMockClient(true); 368 }); 369 370 it('should update issue title', async () => { 371 const mockGetRecord = vi.fn().mockResolvedValue({ 372 data: { 373 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 374 cid: 'old-cid', 375 value: { 376 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 377 title: 'Old Title', 378 body: 'Original body', 379 createdAt: '2024-01-01T00:00:00.000Z', 380 }, 381 }, 382 }); 383 384 const mockPutRecord = vi.fn().mockResolvedValue({ 385 data: { 386 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 387 cid: 'new-cid', 388 }, 389 }); 390 391 vi.mocked(mockClient.getAgent).mockReturnValue({ 392 com: { 393 atproto: { 394 repo: { 395 getRecord: mockGetRecord, 396 putRecord: mockPutRecord, 397 }, 398 }, 399 }, 400 } as never); 401 402 const result = await updateIssue({ 403 client: mockClient, 404 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 405 title: 'New Title', 406 }); 407 408 expect(result.title).toBe('New Title'); 409 expect(result.body).toBe('Original body'); // Body unchanged 410 411 expect(mockPutRecord).toHaveBeenCalledWith({ 412 repo: 'did:plc:test123', 413 collection: 'sh.tangled.repo.issue', 414 rkey: 'issue1', 415 record: expect.objectContaining({ 416 title: 'New Title', 417 body: 'Original body', 418 }), 419 swapRecord: 'old-cid', 420 }); 421 }); 422 423 it('should update issue body', async () => { 424 const mockGetRecord = vi.fn().mockResolvedValue({ 425 data: { 426 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 427 cid: 'old-cid', 428 value: { 429 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 430 title: 'Title', 431 body: 'Old body', 432 createdAt: '2024-01-01T00:00:00.000Z', 433 }, 434 }, 435 }); 436 437 const mockPutRecord = vi.fn().mockResolvedValue({ 438 data: { 439 cid: 'new-cid', 440 }, 441 }); 442 443 vi.mocked(mockClient.getAgent).mockReturnValue({ 444 com: { 445 atproto: { 446 repo: { 447 getRecord: mockGetRecord, 448 putRecord: mockPutRecord, 449 }, 450 }, 451 }, 452 } as never); 453 454 const result = await updateIssue({ 455 client: mockClient, 456 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 457 body: 'New body', 458 }); 459 460 expect(result.title).toBe('Title'); // Title unchanged 461 expect(result.body).toBe('New body'); 462 }); 463 464 it('should throw error when updating issue not owned by user', async () => { 465 await expect( 466 updateIssue({ 467 client: mockClient, 468 issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 469 title: 'New Title', 470 }) 471 ).rejects.toThrow('Cannot update issue: you are not the author'); 472 }); 473 474 it('should throw error when not authenticated', async () => { 475 mockClient = createMockClient(false); 476 477 await expect( 478 updateIssue({ 479 client: mockClient, 480 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 481 title: 'New Title', 482 }) 483 ).rejects.toThrow('Must be authenticated'); 484 }); 485}); 486 487describe('closeIssue', () => { 488 let mockClient: TangledApiClient; 489 490 beforeEach(() => { 491 mockClient = createMockClient(true); 492 }); 493 494 it('should close an issue', async () => { 495 const mockGetRecord = vi.fn().mockResolvedValue({ 496 data: { 497 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 498 cid: 'cid1', 499 value: { 500 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 501 title: 'Test Issue', 502 createdAt: '2024-01-01T00:00:00.000Z', 503 }, 504 }, 505 }); 506 507 const mockCreateRecord = vi.fn().mockResolvedValue({ 508 data: { 509 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 510 cid: 'state-cid', 511 }, 512 }); 513 514 vi.mocked(mockClient.getAgent).mockReturnValue({ 515 com: { 516 atproto: { 517 repo: { 518 getRecord: mockGetRecord, 519 createRecord: mockCreateRecord, 520 }, 521 }, 522 }, 523 } as never); 524 525 await closeIssue({ 526 client: mockClient, 527 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 528 }); 529 530 expect(mockCreateRecord).toHaveBeenCalledWith({ 531 repo: 'did:plc:test123', 532 collection: 'sh.tangled.repo.issue.state', 533 record: { 534 $type: 'sh.tangled.repo.issue.state', 535 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 536 state: 'sh.tangled.repo.issue.state.closed', 537 }, 538 }); 539 }); 540 541 it('should throw error when not authenticated', async () => { 542 mockClient = createMockClient(false); 543 544 await expect( 545 closeIssue({ 546 client: mockClient, 547 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 548 }) 549 ).rejects.toThrow('Must be authenticated'); 550 }); 551}); 552 553describe('getIssueState', () => { 554 let mockClient: TangledApiClient; 555 556 beforeEach(() => { 557 mockClient = createMockClient(true); 558 }); 559 560 it('should return open when no state records exist', async () => { 561 vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 562 563 const result = await getIssueState({ 564 client: mockClient, 565 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 566 }); 567 568 expect(result).toBe('open'); 569 expect(getBacklinks).toHaveBeenCalledWith( 570 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 571 'sh.tangled.repo.issue.state', 572 '.issue', 573 100 574 ); 575 }); 576 577 it('should return closed when latest state record is closed', async () => { 578 vi.mocked(getBacklinks).mockResolvedValue({ 579 total: 1, 580 records: [ 581 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'state1' }, 582 ], 583 cursor: null, 584 }); 585 586 vi.mocked(mockClient.getAgent).mockReturnValue({ 587 com: { 588 atproto: { 589 repo: { 590 getRecord: vi.fn().mockResolvedValue({ 591 data: { 592 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 593 cid: 'cid1', 594 value: { state: 'sh.tangled.repo.issue.state.closed' }, 595 }, 596 }), 597 }, 598 }, 599 }, 600 } as never); 601 602 const result = await getIssueState({ 603 client: mockClient, 604 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 605 }); 606 607 expect(result).toBe('closed'); 608 }); 609 610 it('should return open when latest state record (by rkey) is open', async () => { 611 vi.mocked(getBacklinks).mockResolvedValue({ 612 total: 2, 613 records: [ 614 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, 615 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'bbb222' }, 616 ], 617 cursor: null, 618 }); 619 620 vi.mocked(mockClient.getAgent).mockReturnValue({ 621 com: { 622 atproto: { 623 repo: { 624 getRecord: vi 625 .fn() 626 .mockResolvedValueOnce({ 627 data: { 628 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', 629 cid: 'cid1', 630 value: { state: 'sh.tangled.repo.issue.state.closed' }, 631 }, 632 }) 633 .mockResolvedValueOnce({ 634 data: { 635 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/bbb222', 636 cid: 'cid2', 637 value: { state: 'sh.tangled.repo.issue.state.open' }, 638 }, 639 }), 640 }, 641 }, 642 }, 643 } as never); 644 645 const result = await getIssueState({ 646 client: mockClient, 647 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 648 }); 649 650 expect(result).toBe('open'); 651 }); 652 653 it('should use rkey sort order to determine most recent state across PDSs', async () => { 654 // Collaborator's close (rkey 'ccc333') is more recent than owner's open (rkey 'aaa111') 655 vi.mocked(getBacklinks).mockResolvedValue({ 656 total: 2, 657 records: [ 658 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, 659 { did: 'did:plc:collab', collection: 'sh.tangled.repo.issue.state', rkey: 'ccc333' }, 660 ], 661 cursor: null, 662 }); 663 664 vi.mocked(mockClient.getAgent).mockReturnValue({ 665 com: { 666 atproto: { 667 repo: { 668 getRecord: vi 669 .fn() 670 .mockResolvedValueOnce({ 671 data: { 672 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', 673 cid: 'cid1', 674 value: { state: 'sh.tangled.repo.issue.state.open' }, 675 }, 676 }) 677 .mockResolvedValueOnce({ 678 data: { 679 uri: 'at://did:plc:collab/sh.tangled.repo.issue.state/ccc333', 680 cid: 'cid2', 681 value: { state: 'sh.tangled.repo.issue.state.closed' }, 682 }, 683 }), 684 }, 685 }, 686 }, 687 } as never); 688 689 const result = await getIssueState({ 690 client: mockClient, 691 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 692 }); 693 694 expect(result).toBe('closed'); 695 }); 696 697 it('should throw error when not authenticated', async () => { 698 mockClient = createMockClient(false); 699 700 await expect( 701 getIssueState({ 702 client: mockClient, 703 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 704 }) 705 ).rejects.toThrow('Must be authenticated'); 706 }); 707}); 708 709describe('reopenIssue', () => { 710 let mockClient: TangledApiClient; 711 712 beforeEach(() => { 713 mockClient = createMockClient(true); 714 }); 715 716 it('should reopen a closed issue', async () => { 717 const mockGetRecord = vi.fn().mockResolvedValue({ 718 data: { 719 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 720 cid: 'cid1', 721 value: { 722 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 723 title: 'Test Issue', 724 createdAt: '2024-01-01T00:00:00.000Z', 725 }, 726 }, 727 }); 728 729 const mockCreateRecord = vi.fn().mockResolvedValue({ 730 data: { 731 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 732 cid: 'state-cid', 733 }, 734 }); 735 736 vi.mocked(mockClient.getAgent).mockReturnValue({ 737 com: { 738 atproto: { 739 repo: { 740 getRecord: mockGetRecord, 741 createRecord: mockCreateRecord, 742 }, 743 }, 744 }, 745 } as never); 746 747 await reopenIssue({ 748 client: mockClient, 749 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 750 }); 751 752 expect(mockCreateRecord).toHaveBeenCalledWith({ 753 repo: 'did:plc:test123', 754 collection: 'sh.tangled.repo.issue.state', 755 record: { 756 $type: 'sh.tangled.repo.issue.state', 757 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 758 state: 'sh.tangled.repo.issue.state.open', 759 }, 760 }); 761 }); 762 763 it('should throw error when not authenticated', async () => { 764 mockClient = createMockClient(false); 765 766 await expect( 767 reopenIssue({ 768 client: mockClient, 769 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 770 }) 771 ).rejects.toThrow('Must be authenticated'); 772 }); 773}); 774 775describe('resolveSequentialNumber', () => { 776 let mockClient: TangledApiClient; 777 778 beforeEach(() => { 779 mockClient = createMockClient(true); 780 }); 781 782 it('should return number directly for #N displayId without an API call (fast path)', async () => { 783 const result = await resolveSequentialNumber( 784 '#3', 785 'at://did:plc:owner/sh.tangled.repo.issue/issue3', 786 mockClient, 787 'at://did:plc:owner/sh.tangled.repo/my-repo' 788 ); 789 expect(result).toBe(3); 790 }); 791 792 it('should scan issue list and return 1-based position for rkey displayId', async () => { 793 vi.mocked(getBacklinks).mockResolvedValue({ 794 total: 2, 795 records: [ 796 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, 797 { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-b' }, 798 ], 799 cursor: null, 800 }); 801 802 const mockGetRecord = vi 803 .fn() 804 .mockResolvedValueOnce({ 805 data: { 806 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 807 cid: 'cid1', 808 value: { 809 $type: 'sh.tangled.repo.issue', 810 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 811 title: 'First', 812 createdAt: '2024-01-01T00:00:00.000Z', 813 }, 814 }, 815 }) 816 .mockResolvedValueOnce({ 817 data: { 818 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 819 cid: 'cid2', 820 value: { 821 $type: 'sh.tangled.repo.issue', 822 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 823 title: 'Second', 824 createdAt: '2024-01-02T00:00:00.000Z', 825 }, 826 }, 827 }); 828 829 vi.mocked(mockClient.getAgent).mockReturnValue({ 830 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 831 } as never); 832 833 const result = await resolveSequentialNumber( 834 'issue-b', 835 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 836 mockClient, 837 'at://did:plc:owner/sh.tangled.repo/my-repo' 838 ); 839 expect(result).toBe(2); 840 }); 841 842 it('should return undefined when issue URI not found in list', async () => { 843 vi.mocked(getBacklinks).mockResolvedValue({ 844 total: 1, 845 records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }], 846 cursor: null, 847 }); 848 849 const mockGetRecord = vi.fn().mockResolvedValue({ 850 data: { 851 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 852 cid: 'cid1', 853 value: { 854 $type: 'sh.tangled.repo.issue', 855 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 856 title: 'First', 857 createdAt: '2024-01-01T00:00:00.000Z', 858 }, 859 }, 860 }); 861 862 vi.mocked(mockClient.getAgent).mockReturnValue({ 863 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 864 } as never); 865 866 const result = await resolveSequentialNumber( 867 'nonexistent', 868 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 869 mockClient, 870 'at://did:plc:owner/sh.tangled.repo/my-repo' 871 ); 872 expect(result).toBeUndefined(); 873 }); 874}); 875 876describe('getCompleteIssueData', () => { 877 let mockClient: TangledApiClient; 878 879 beforeEach(() => { 880 vi.clearAllMocks(); 881 mockClient = createMockClient(true); 882 }); 883 884 it('should return all fields including fetched state', async () => { 885 vi.mocked(getBacklinks).mockResolvedValue({ 886 total: 1, 887 records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 's1' }], 888 cursor: null, 889 }); 890 891 const mockGetRecord = vi 892 .fn() 893 .mockResolvedValueOnce({ 894 data: { 895 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 896 cid: 'cid1', 897 value: { 898 $type: 'sh.tangled.repo.issue', 899 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 900 title: 'Test Issue', 901 body: 'Test body', 902 createdAt: '2024-01-01T00:00:00.000Z', 903 }, 904 }, 905 }) 906 .mockResolvedValueOnce({ 907 data: { 908 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 909 cid: 'scid1', 910 value: { state: 'sh.tangled.repo.issue.state.closed' }, 911 }, 912 }); 913 914 vi.mocked(mockClient.getAgent).mockReturnValue({ 915 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 916 } as never); 917 918 const result = await getCompleteIssueData( 919 mockClient, 920 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 921 '#1', // fast-path for number 922 'at://did:plc:owner/sh.tangled.repo/my-repo' 923 ); 924 925 expect(result).toEqual({ 926 number: 1, 927 title: 'Test Issue', 928 body: 'Test body', 929 state: 'closed', 930 author: 'did:plc:owner', 931 createdAt: '2024-01-01T00:00:00.000Z', 932 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 933 cid: 'cid1', 934 }); 935 }); 936 937 it('should use stateOverride and skip the getIssueState network call', async () => { 938 const mockGetRecord = vi.fn().mockResolvedValue({ 939 data: { 940 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 941 cid: 'cid1', 942 value: { 943 $type: 'sh.tangled.repo.issue', 944 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 945 title: 'Test Issue', 946 createdAt: '2024-01-01T00:00:00.000Z', 947 }, 948 }, 949 }); 950 951 vi.mocked(mockClient.getAgent).mockReturnValue({ 952 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 953 } as never); 954 955 const result = await getCompleteIssueData( 956 mockClient, 957 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 958 '#2', 959 'at://did:plc:owner/sh.tangled.repo/my-repo', 960 'closed' 961 ); 962 963 expect(result.number).toBe(2); 964 expect(result.state).toBe('closed'); 965 expect(getBacklinks).not.toHaveBeenCalled(); 966 }); 967 968 it('should return undefined body and default open state when issue has no body or state records', async () => { 969 vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 970 971 const mockGetRecord = vi.fn().mockResolvedValue({ 972 data: { 973 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 974 cid: 'cid1', 975 value: { 976 $type: 'sh.tangled.repo.issue', 977 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 978 title: 'No body issue', 979 createdAt: '2024-01-01T00:00:00.000Z', 980 }, 981 }, 982 }); 983 984 vi.mocked(mockClient.getAgent).mockReturnValue({ 985 com: { atproto: { repo: { getRecord: mockGetRecord } } }, 986 } as never); 987 988 const result = await getCompleteIssueData( 989 mockClient, 990 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 991 '#1', 992 'at://did:plc:owner/sh.tangled.repo/my-repo' 993 ); 994 995 expect(result.body).toBeUndefined(); 996 expect(result.state).toBe('open'); 997 }); 998});