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! :)

Use constellation to get issue state across all collaborator PDSs

getIssueState() now queries constellation.microcosm.blue for all state
records that reference an issue, fetches each one via getRecord, and
sorts by rkey (TID) to find the most recent. This allows collaborators
to close/reopen issues from their own PDS, fixing the old approach that
only ever saw the issue author's state records.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+149 -133
+23 -23
src/lib/issues-api.ts
··· 336 336 // Validate authentication 337 337 await requireAuth(client); 338 338 339 - // Parse issue URI to get author DID 340 - const { did } = parseIssueUri(issueUri); 341 - 342 339 try { 343 - // Query state records for the issue author 344 - const response = await client.getAgent().com.atproto.repo.listRecords({ 345 - repo: did, 346 - collection: 'sh.tangled.repo.issue.state', 347 - limit: 100, 348 - }); 340 + // Query constellation for all state records that reference this issue across all PDSs 341 + const backlinks = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state', '.issue', 100); 349 342 350 - // Filter to find state records for this specific issue 351 - const stateRecords = response.data.records.filter((record) => { 352 - const stateData = record.value as { issue?: string }; 353 - return stateData.issue === issueUri; 354 - }); 355 - 356 - if (stateRecords.length === 0) { 357 - // No state record found - default to open 343 + if (backlinks.records.length === 0) { 358 344 return 'open'; 359 345 } 360 346 361 - // Get the most recent state record (AT Protocol records are sorted by index) 347 + // Fetch each state record in parallel 348 + const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { 349 + const response = await client.getAgent().com.atproto.repo.getRecord({ 350 + repo: did, 351 + collection, 352 + rkey, 353 + }); 354 + return { 355 + rkey, 356 + value: response.data.value as { 357 + state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; 358 + }, 359 + }; 360 + }); 361 + 362 + const stateRecords = await Promise.all(statePromises); 363 + 364 + // Sort by rkey ascending — TID rkeys are time-ordered, so the last is most recent 365 + stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); 362 366 const latestState = stateRecords[stateRecords.length - 1]; 363 - const stateData = latestState.value as { 364 - state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; 365 - }; 366 367 367 - // Return 'open' or 'closed' based on the state type 368 - if (stateData.state === 'sh.tangled.repo.issue.state.closed') { 368 + if (latestState.value.state === 'sh.tangled.repo.issue.state.closed') { 369 369 return 'closed'; 370 370 } 371 371
+126 -110
tests/lib/issues-api.test.ts
··· 174 174 cursor: null, 175 175 }); 176 176 177 - const mockGetRecord = vi.fn() 177 + const mockGetRecord = vi 178 + .fn() 178 179 .mockResolvedValueOnce({ 179 180 data: { 180 181 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', ··· 557 558 }); 558 559 559 560 it('should return open when no state records exist', async () => { 560 - const mockListRecords = vi.fn().mockResolvedValue({ 561 - data: { records: [] }, 562 - }); 563 - 564 - vi.mocked(mockClient.getAgent).mockReturnValue({ 565 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 566 - } as never); 561 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 567 562 568 563 const result = await getIssueState({ 569 564 client: mockClient, ··· 571 566 }); 572 567 573 568 expect(result).toBe('open'); 574 - expect(mockListRecords).toHaveBeenCalledWith({ 575 - repo: 'did:plc:owner', 576 - collection: 'sh.tangled.repo.issue.state', 577 - limit: 100, 578 - }); 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 + ); 579 575 }); 580 576 581 577 it('should return closed when latest state record is closed', async () => { 582 - const mockListRecords = vi.fn().mockResolvedValue({ 583 - data: { 584 - records: [ 585 - { 586 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 587 - cid: 'cid1', 588 - value: { 589 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 590 - state: 'sh.tangled.repo.issue.state.closed', 591 - }, 592 - }, 593 - ], 594 - }, 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, 595 584 }); 596 585 597 586 vi.mocked(mockClient.getAgent).mockReturnValue({ 598 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 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 + }, 599 600 } as never); 600 601 601 602 const result = await getIssueState({ ··· 606 607 expect(result).toBe('closed'); 607 608 }); 608 609 609 - it('should return open when latest state record is open', async () => { 610 - const mockListRecords = vi.fn().mockResolvedValue({ 611 - data: { 612 - records: [ 613 - { 614 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 615 - cid: 'cid1', 616 - value: { 617 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 618 - state: 'sh.tangled.repo.issue.state.closed', 619 - }, 620 - }, 621 - { 622 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', 623 - cid: 'cid2', 624 - value: { 625 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 626 - state: 'sh.tangled.repo.issue.state.open', 627 - }, 628 - }, 629 - ], 630 - }, 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, 631 618 }); 632 619 633 620 vi.mocked(mockClient.getAgent).mockReturnValue({ 634 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 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 + }, 635 643 } as never); 636 644 637 645 const result = await getIssueState({ ··· 642 650 expect(result).toBe('open'); 643 651 }); 644 652 645 - it('should filter state records to only the target issue', async () => { 646 - const mockListRecords = vi.fn().mockResolvedValue({ 647 - data: { 648 - records: [ 649 - { 650 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 651 - cid: 'cid1', 652 - value: { 653 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', 654 - state: 'sh.tangled.repo.issue.state.closed', 655 - }, 656 - }, 657 - ], 658 - }, 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, 659 662 }); 660 663 661 664 vi.mocked(mockClient.getAgent).mockReturnValue({ 662 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 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 + }, 663 687 } as never); 664 688 665 - // The closed state is for a different issue, so this one should be open 666 689 const result = await getIssueState({ 667 690 client: mockClient, 668 691 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 669 692 }); 670 693 671 - expect(result).toBe('open'); 694 + expect(result).toBe('closed'); 672 695 }); 673 696 674 697 it('should throw error when not authenticated', async () => { ··· 776 799 cursor: null, 777 800 }); 778 801 779 - const mockGetRecord = vi.fn() 802 + const mockGetRecord = vi 803 + .fn() 780 804 .mockResolvedValueOnce({ 781 805 data: { 782 806 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', ··· 818 842 it('should return undefined when issue URI not found in list', async () => { 819 843 vi.mocked(getBacklinks).mockResolvedValue({ 820 844 total: 1, 821 - records: [ 822 - { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, 823 - ], 845 + records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }], 824 846 cursor: null, 825 847 }); 826 848 ··· 855 877 let mockClient: TangledApiClient; 856 878 857 879 beforeEach(() => { 880 + vi.clearAllMocks(); 858 881 mockClient = createMockClient(true); 859 882 }); 860 883 861 884 it('should return all fields including fetched state', async () => { 862 - const mockGetRecord = vi.fn().mockResolvedValue({ 863 - data: { 864 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 865 - cid: 'cid1', 866 - value: { 867 - $type: 'sh.tangled.repo.issue', 868 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 869 - title: 'Test Issue', 870 - body: 'Test body', 871 - createdAt: '2024-01-01T00:00:00.000Z', 872 - }, 873 - }, 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, 874 889 }); 875 890 876 - // getIssueState uses listRecords on the state collection 877 - const mockListRecords = vi.fn().mockResolvedValue({ 878 - data: { 879 - records: [ 880 - { 881 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 882 - cid: 'scid1', 883 - value: { 884 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 885 - state: 'sh.tangled.repo.issue.state.closed', 886 - }, 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', 887 903 }, 888 - ], 889 - }, 890 - }); 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 + }); 891 913 892 914 vi.mocked(mockClient.getAgent).mockReturnValue({ 893 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 915 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 894 916 } as never); 895 917 896 918 const result = await getCompleteIssueData( 897 919 mockClient, 898 920 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 899 - '#1', // fast-path for number — no listRecords call for issues 921 + '#1', // fast-path for number 900 922 'at://did:plc:owner/sh.tangled.repo/my-repo' 901 923 ); 902 924 ··· 926 948 }, 927 949 }); 928 950 929 - const mockListRecords = vi.fn(); 930 951 vi.mocked(mockClient.getAgent).mockReturnValue({ 931 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 952 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 932 953 } as never); 933 954 934 955 const result = await getCompleteIssueData( ··· 941 962 942 963 expect(result.number).toBe(2); 943 964 expect(result.state).toBe('closed'); 944 - expect(mockListRecords).not.toHaveBeenCalled(); 965 + expect(getBacklinks).not.toHaveBeenCalled(); 945 966 }); 946 967 947 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 + 948 971 const mockGetRecord = vi.fn().mockResolvedValue({ 949 972 data: { 950 973 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', ··· 959 982 }); 960 983 961 984 vi.mocked(mockClient.getAgent).mockReturnValue({ 962 - com: { 963 - atproto: { 964 - repo: { 965 - getRecord: mockGetRecord, 966 - listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 967 - }, 968 - }, 969 - }, 985 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 970 986 } as never); 971 987 972 988 const result = await getCompleteIssueData(