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

Configure Feed

Select the types of activity you want to include in your feed.

Add getIssueState and reopenIssue to issues-api

Extends the issues API layer with two new functions needed for full
issue lifecycle management:

- getIssueState: queries sh.tangled.repo.issue.state records to
determine open/closed status, defaulting to 'open' when no records
exist. Filters by issue URI and uses the most recent state record.
- reopenIssue: mirrors closeIssue by creating a state record with
state 'sh.tangled.repo.issue.state.open'

Includes tests covering state filtering, multi-record precedence,
unauthenticated error paths, and correct record creation.

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

+304
+102
src/lib/issues-api.ts
··· 81 81 } 82 82 83 83 /** 84 + * Parameters for getting issue state 85 + */ 86 + export interface GetIssueStateParams { 87 + client: TangledApiClient; 88 + issueUri: string; 89 + } 90 + 91 + /** 92 + * Parameters for reopening an issue 93 + */ 94 + export interface ReopenIssueParams { 95 + client: TangledApiClient; 96 + issueUri: string; 97 + } 98 + 99 + /** 84 100 * Parse and validate an issue AT-URI 85 101 * @throws Error if URI is invalid or missing rkey 86 102 * @returns Parsed URI components ··· 354 370 throw new Error('Failed to delete issue: Unknown error'); 355 371 } 356 372 } 373 + 374 + /** 375 + * Get the state of an issue (open or closed) 376 + * @returns 'open' or 'closed' (defaults to 'open' if no state record exists) 377 + */ 378 + export async function getIssueState(params: GetIssueStateParams): Promise<'open' | 'closed'> { 379 + const { client, issueUri } = params; 380 + 381 + // Validate authentication 382 + await requireAuth(client); 383 + 384 + // Parse issue URI to get author DID 385 + const { did } = parseIssueUri(issueUri); 386 + 387 + try { 388 + // Query state records for the issue author 389 + const response = await client.getAgent().com.atproto.repo.listRecords({ 390 + repo: did, 391 + collection: 'sh.tangled.repo.issue.state', 392 + limit: 100, 393 + }); 394 + 395 + // Filter to find state records for this specific issue 396 + const stateRecords = response.data.records.filter((record) => { 397 + const stateData = record.value as { issue?: string }; 398 + return stateData.issue === issueUri; 399 + }); 400 + 401 + if (stateRecords.length === 0) { 402 + // No state record found - default to open 403 + return 'open'; 404 + } 405 + 406 + // Get the most recent state record (AT Protocol records are sorted by index) 407 + const latestState = stateRecords[stateRecords.length - 1]; 408 + const stateData = latestState.value as { 409 + state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; 410 + }; 411 + 412 + // Return 'open' or 'closed' based on the state type 413 + if (stateData.state === 'sh.tangled.repo.issue.state.closed') { 414 + return 'closed'; 415 + } 416 + 417 + return 'open'; 418 + } catch (error) { 419 + if (error instanceof Error) { 420 + throw new Error(`Failed to get issue state: ${error.message}`); 421 + } 422 + throw new Error('Failed to get issue state: Unknown error'); 423 + } 424 + } 425 + 426 + /** 427 + * Reopen a closed issue by creating an open state record 428 + */ 429 + export async function reopenIssue(params: ReopenIssueParams): Promise<void> { 430 + const { client, issueUri } = params; 431 + 432 + // Validate authentication 433 + const session = await requireAuth(client); 434 + 435 + try { 436 + // Verify issue exists 437 + await getIssue({ client, issueUri }); 438 + 439 + // Create state record with open state 440 + const stateRecord = { 441 + $type: 'sh.tangled.repo.issue.state', 442 + issue: issueUri, 443 + state: 'sh.tangled.repo.issue.state.open', 444 + }; 445 + 446 + // Create state record 447 + await client.getAgent().com.atproto.repo.createRecord({ 448 + repo: session.did, 449 + collection: 'sh.tangled.repo.issue.state', 450 + record: stateRecord, 451 + }); 452 + } catch (error) { 453 + if (error instanceof Error) { 454 + throw new Error(`Failed to reopen issue: ${error.message}`); 455 + } 456 + throw new Error('Failed to reopen issue: Unknown error'); 457 + } 458 + }
+202
tests/lib/issues-api.test.ts
··· 5 5 createIssue, 6 6 deleteIssue, 7 7 getIssue, 8 + getIssueState, 8 9 listIssues, 10 + reopenIssue, 9 11 updateIssue, 10 12 } from '../../src/lib/issues-api.js'; 11 13 ··· 661 663 ).rejects.toThrow('Must be authenticated'); 662 664 }); 663 665 }); 666 + 667 + describe('getIssueState', () => { 668 + let mockClient: TangledApiClient; 669 + 670 + beforeEach(() => { 671 + mockClient = createMockClient(true); 672 + }); 673 + 674 + it('should return open when no state records exist', async () => { 675 + const mockListRecords = vi.fn().mockResolvedValue({ 676 + data: { records: [] }, 677 + }); 678 + 679 + vi.mocked(mockClient.getAgent).mockReturnValue({ 680 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 681 + } as never); 682 + 683 + const result = await getIssueState({ 684 + client: mockClient, 685 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 686 + }); 687 + 688 + expect(result).toBe('open'); 689 + expect(mockListRecords).toHaveBeenCalledWith({ 690 + repo: 'did:plc:owner', 691 + collection: 'sh.tangled.repo.issue.state', 692 + limit: 100, 693 + }); 694 + }); 695 + 696 + it('should return closed when latest state record is closed', async () => { 697 + const mockListRecords = vi.fn().mockResolvedValue({ 698 + data: { 699 + records: [ 700 + { 701 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 702 + cid: 'cid1', 703 + value: { 704 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 705 + state: 'sh.tangled.repo.issue.state.closed', 706 + }, 707 + }, 708 + ], 709 + }, 710 + }); 711 + 712 + vi.mocked(mockClient.getAgent).mockReturnValue({ 713 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 714 + } as never); 715 + 716 + const result = await getIssueState({ 717 + client: mockClient, 718 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 719 + }); 720 + 721 + expect(result).toBe('closed'); 722 + }); 723 + 724 + it('should return open when latest state record is open', async () => { 725 + const mockListRecords = vi.fn().mockResolvedValue({ 726 + data: { 727 + records: [ 728 + { 729 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 730 + cid: 'cid1', 731 + value: { 732 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 733 + state: 'sh.tangled.repo.issue.state.closed', 734 + }, 735 + }, 736 + { 737 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', 738 + cid: 'cid2', 739 + value: { 740 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 741 + state: 'sh.tangled.repo.issue.state.open', 742 + }, 743 + }, 744 + ], 745 + }, 746 + }); 747 + 748 + vi.mocked(mockClient.getAgent).mockReturnValue({ 749 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 750 + } as never); 751 + 752 + const result = await getIssueState({ 753 + client: mockClient, 754 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 755 + }); 756 + 757 + expect(result).toBe('open'); 758 + }); 759 + 760 + it('should filter state records to only the target issue', async () => { 761 + const mockListRecords = vi.fn().mockResolvedValue({ 762 + data: { 763 + records: [ 764 + { 765 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 766 + cid: 'cid1', 767 + value: { 768 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', 769 + state: 'sh.tangled.repo.issue.state.closed', 770 + }, 771 + }, 772 + ], 773 + }, 774 + }); 775 + 776 + vi.mocked(mockClient.getAgent).mockReturnValue({ 777 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 778 + } as never); 779 + 780 + // The closed state is for a different issue, so this one should be open 781 + const result = await getIssueState({ 782 + client: mockClient, 783 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 784 + }); 785 + 786 + expect(result).toBe('open'); 787 + }); 788 + 789 + it('should throw error when not authenticated', async () => { 790 + mockClient = createMockClient(false); 791 + 792 + await expect( 793 + getIssueState({ 794 + client: mockClient, 795 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 796 + }) 797 + ).rejects.toThrow('Must be authenticated'); 798 + }); 799 + }); 800 + 801 + describe('reopenIssue', () => { 802 + let mockClient: TangledApiClient; 803 + 804 + beforeEach(() => { 805 + mockClient = createMockClient(true); 806 + }); 807 + 808 + it('should reopen a closed issue', async () => { 809 + const mockGetRecord = vi.fn().mockResolvedValue({ 810 + data: { 811 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 812 + cid: 'cid1', 813 + value: { 814 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 815 + title: 'Test Issue', 816 + createdAt: '2024-01-01T00:00:00.000Z', 817 + }, 818 + }, 819 + }); 820 + 821 + const mockCreateRecord = vi.fn().mockResolvedValue({ 822 + data: { 823 + uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 824 + cid: 'state-cid', 825 + }, 826 + }); 827 + 828 + vi.mocked(mockClient.getAgent).mockReturnValue({ 829 + com: { 830 + atproto: { 831 + repo: { 832 + getRecord: mockGetRecord, 833 + createRecord: mockCreateRecord, 834 + }, 835 + }, 836 + }, 837 + } as never); 838 + 839 + await reopenIssue({ 840 + client: mockClient, 841 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 842 + }); 843 + 844 + expect(mockCreateRecord).toHaveBeenCalledWith({ 845 + repo: 'did:plc:test123', 846 + collection: 'sh.tangled.repo.issue.state', 847 + record: { 848 + $type: 'sh.tangled.repo.issue.state', 849 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 850 + state: 'sh.tangled.repo.issue.state.open', 851 + }, 852 + }); 853 + }); 854 + 855 + it('should throw error when not authenticated', async () => { 856 + mockClient = createMockClient(false); 857 + 858 + await expect( 859 + reopenIssue({ 860 + client: mockClient, 861 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 862 + }) 863 + ).rejects.toThrow('Must be authenticated'); 864 + }); 865 + });