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

refactor: add resolveSequentialNumber and getCompleteIssueData to issues-api

Move resolveSequentialNumber out of the commands layer into issues-api.ts
where it belongs alongside the other issue data functions. Add IssueData
interface as the canonical JSON shape for a single issue, and
getCompleteIssueData which fetches issue record, sequential number, and
state in one call (with optional state override for mutation commands that
already know the new state). Add tests for both new exports.

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

authored by markbennett.ca

Claude Sonnet 4.5 and committed by tangled.org 8ce6e5cb 85afb1f0

+290
+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 + });