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 // Validate authentication 337 await requireAuth(client); 338 339 - // Parse issue URI to get author DID 340 - const { did } = parseIssueUri(issueUri); 341 - 342 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 - }); 349 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 358 return 'open'; 359 } 360 361 - // Get the most recent state record (AT Protocol records are sorted by index) 362 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 - // Return 'open' or 'closed' based on the state type 368 - if (stateData.state === 'sh.tangled.repo.issue.state.closed') { 369 return 'closed'; 370 } 371
··· 336 // Validate authentication 337 await requireAuth(client); 338 339 try { 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); 342 343 + if (backlinks.records.length === 0) { 344 return 'open'; 345 } 346 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)); 366 const latestState = stateRecords[stateRecords.length - 1]; 367 368 + if (latestState.value.state === 'sh.tangled.repo.issue.state.closed') { 369 return 'closed'; 370 } 371
+126 -110
tests/lib/issues-api.test.ts
··· 174 cursor: null, 175 }); 176 177 - const mockGetRecord = vi.fn() 178 .mockResolvedValueOnce({ 179 data: { 180 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', ··· 557 }); 558 559 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); 567 568 const result = await getIssueState({ 569 client: mockClient, ··· 571 }); 572 573 expect(result).toBe('open'); 574 - expect(mockListRecords).toHaveBeenCalledWith({ 575 - repo: 'did:plc:owner', 576 - collection: 'sh.tangled.repo.issue.state', 577 - limit: 100, 578 - }); 579 }); 580 581 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 - }, 595 }); 596 597 vi.mocked(mockClient.getAgent).mockReturnValue({ 598 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 599 } as never); 600 601 const result = await getIssueState({ ··· 606 expect(result).toBe('closed'); 607 }); 608 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 - }, 631 }); 632 633 vi.mocked(mockClient.getAgent).mockReturnValue({ 634 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 635 } as never); 636 637 const result = await getIssueState({ ··· 642 expect(result).toBe('open'); 643 }); 644 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 - }, 659 }); 660 661 vi.mocked(mockClient.getAgent).mockReturnValue({ 662 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 663 } as never); 664 665 - // The closed state is for a different issue, so this one should be open 666 const result = await getIssueState({ 667 client: mockClient, 668 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 669 }); 670 671 - expect(result).toBe('open'); 672 }); 673 674 it('should throw error when not authenticated', async () => { ··· 776 cursor: null, 777 }); 778 779 - const mockGetRecord = vi.fn() 780 .mockResolvedValueOnce({ 781 data: { 782 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', ··· 818 it('should return undefined when issue URI not found in list', async () => { 819 vi.mocked(getBacklinks).mockResolvedValue({ 820 total: 1, 821 - records: [ 822 - { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, 823 - ], 824 cursor: null, 825 }); 826 ··· 855 let mockClient: TangledApiClient; 856 857 beforeEach(() => { 858 mockClient = createMockClient(true); 859 }); 860 861 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 - }, 874 }); 875 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 - }, 887 }, 888 - ], 889 - }, 890 - }); 891 892 vi.mocked(mockClient.getAgent).mockReturnValue({ 893 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 894 } as never); 895 896 const result = await getCompleteIssueData( 897 mockClient, 898 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 899 - '#1', // fast-path for number — no listRecords call for issues 900 'at://did:plc:owner/sh.tangled.repo/my-repo' 901 ); 902 ··· 926 }, 927 }); 928 929 - const mockListRecords = vi.fn(); 930 vi.mocked(mockClient.getAgent).mockReturnValue({ 931 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 932 } as never); 933 934 const result = await getCompleteIssueData( ··· 941 942 expect(result.number).toBe(2); 943 expect(result.state).toBe('closed'); 944 - expect(mockListRecords).not.toHaveBeenCalled(); 945 }); 946 947 it('should return undefined body and default open state when issue has no body or state records', async () => { 948 const mockGetRecord = vi.fn().mockResolvedValue({ 949 data: { 950 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', ··· 959 }); 960 961 vi.mocked(mockClient.getAgent).mockReturnValue({ 962 - com: { 963 - atproto: { 964 - repo: { 965 - getRecord: mockGetRecord, 966 - listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 967 - }, 968 - }, 969 - }, 970 } as never); 971 972 const result = await getCompleteIssueData(
··· 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', ··· 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, ··· 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({ ··· 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({ ··· 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 () => { ··· 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', ··· 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 ··· 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 ··· 948 }, 949 }); 950 951 vi.mocked(mockClient.getAgent).mockReturnValue({ 952 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 953 } as never); 954 955 const result = await getCompleteIssueData( ··· 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', ··· 982 }); 983 984 vi.mocked(mockClient.getAgent).mockReturnValue({ 985 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 986 } as never); 987 988 const result = await getCompleteIssueData(