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 list issues across all collaborator PDSs

listIssues() now queries constellation.microcosm.blue for all issue
records referencing a repo, then fetches each one via getRecord. This
fixes multi-collaborator scenarios where team members host issues on
different PDSs — the old listRecords approach only saw the repo owner's
issues.

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

authored by markbennett.ca

Claude Sonnet 4.5 and committed by tangled.org 5ec43077 ac6cce98

+133 -167
+24 -26
src/lib/issues-api.ts
··· 1 1 import { parseAtUri } from '../utils/at-uri.js'; 2 2 import { requireAuth } from '../utils/auth-helpers.js'; 3 3 import type { TangledApiClient } from './api-client.js'; 4 + import { getBacklinks } from './constellation.js'; 4 5 5 6 /** 6 7 * Issue record type based on sh.tangled.repo.issue lexicon ··· 162 163 // Validate authentication 163 164 await requireAuth(client); 164 165 165 - // Extract owner DID from repo AT-URI 166 - const parsed = parseAtUri(repoAtUri); 167 - if (!parsed) { 168 - throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); 169 - } 170 - 171 - const ownerDid = parsed.did; 172 - 173 166 try { 174 - // List all issue records for the owner 175 - const response = await client.getAgent().com.atproto.repo.listRecords({ 176 - repo: ownerDid, 177 - collection: 'sh.tangled.repo.issue', 167 + // Query constellation for all issues that reference this repo across all PDSs 168 + const backlinks = await getBacklinks( 169 + repoAtUri, 170 + 'sh.tangled.repo.issue', 171 + '.repo', 178 172 limit, 179 - cursor, 173 + cursor 174 + ); 175 + 176 + // Fetch each issue record individually (constellation only gives us the AT-URI components) 177 + const issuePromises = backlinks.records.map(async ({ did, collection, rkey }) => { 178 + const response = await client.getAgent().com.atproto.repo.getRecord({ 179 + repo: did, 180 + collection, 181 + rkey, 182 + }); 183 + return { 184 + ...(response.data.value as IssueRecord), 185 + uri: response.data.uri, 186 + cid: response.data.cid as string, 187 + author: did, 188 + }; 180 189 }); 181 190 182 - // Filter to only issues for this specific repository 183 - const issues: IssueWithMetadata[] = response.data.records 184 - .filter((record) => { 185 - const issueRecord = record.value as IssueRecord; 186 - return issueRecord.repo === repoAtUri; 187 - }) 188 - .map((record) => ({ 189 - ...(record.value as IssueRecord), 190 - uri: record.uri, 191 - cid: record.cid, 192 - author: ownerDid, 193 - })); 191 + const issues = await Promise.all(issuePromises); 194 192 195 193 return { 196 194 issues, 197 - cursor: response.data.cursor, 195 + cursor: backlinks.cursor ?? undefined, 198 196 }; 199 197 } catch (error) { 200 198 if (error instanceof Error) {
+109 -141
tests/lib/issues-api.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 2 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 + import { getBacklinks } from '../../src/lib/constellation.js'; 3 4 import { 4 5 closeIssue, 5 6 createIssue, ··· 11 12 resolveSequentialNumber, 12 13 updateIssue, 13 14 } from '../../src/lib/issues-api.js'; 15 + 16 + vi.mock('../../src/lib/constellation.js'); 14 17 15 18 // Mock API client factory 16 19 const createMockClient = (authenticated = true): TangledApiClient => { ··· 161 164 mockClient = createMockClient(true); 162 165 }); 163 166 164 - it('should list issues for a repository', async () => { 165 - const mockListRecords = vi.fn().mockResolvedValue({ 166 - data: { 167 - records: [ 168 - { 169 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 170 - cid: 'cid1', 171 - value: { 172 - $type: 'sh.tangled.repo.issue', 173 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 174 - title: 'Issue 1', 175 - body: 'Description 1', 176 - createdAt: '2024-01-01T00:00:00.000Z', 177 - }, 178 - }, 179 - { 180 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 181 - cid: 'cid2', 182 - value: { 183 - $type: 'sh.tangled.repo.issue', 184 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 185 - title: 'Issue 2', 186 - createdAt: '2024-01-02T00:00:00.000Z', 187 - }, 188 - }, 189 - ], 190 - cursor: undefined, 191 - }, 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, 192 175 }); 193 176 194 - vi.mocked(mockClient.getAgent).mockReturnValue({ 195 - com: { 196 - atproto: { 197 - repo: { 198 - listRecords: mockListRecords, 177 + const mockGetRecord = vi.fn() 178 + .mockResolvedValueOnce({ 179 + data: { 180 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 181 + cid: 'cid1', 182 + value: { 183 + $type: 'sh.tangled.repo.issue', 184 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 185 + title: 'Issue 1', 186 + body: 'Description 1', 187 + createdAt: '2024-01-01T00:00:00.000Z', 199 188 }, 200 189 }, 201 - }, 190 + }) 191 + .mockResolvedValueOnce({ 192 + data: { 193 + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 194 + cid: 'cid2', 195 + value: { 196 + $type: 'sh.tangled.repo.issue', 197 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 198 + title: 'Issue 2', 199 + createdAt: '2024-01-02T00:00:00.000Z', 200 + }, 201 + }, 202 + }); 203 + 204 + vi.mocked(mockClient.getAgent).mockReturnValue({ 205 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 202 206 } as never); 203 207 204 208 const result = await listIssues({ ··· 211 215 title: 'Issue 1', 212 216 body: 'Description 1', 213 217 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 218 + author: 'did:plc:owner', 214 219 }); 215 - }); 216 - 217 - it('should filter issues by repository', async () => { 218 - const mockListRecords = vi.fn().mockResolvedValue({ 219 - data: { 220 - records: [ 221 - { 222 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 223 - cid: 'cid1', 224 - value: { 225 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 226 - title: 'Issue 1', 227 - createdAt: '2024-01-01T00:00:00.000Z', 228 - }, 229 - }, 230 - { 231 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 232 - cid: 'cid2', 233 - value: { 234 - repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', 235 - title: 'Issue 2', 236 - createdAt: '2024-01-02T00:00:00.000Z', 237 - }, 238 - }, 239 - ], 240 - cursor: undefined, 241 - }, 220 + expect(result.issues[1]).toMatchObject({ 221 + title: 'Issue 2', 222 + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 223 + author: 'did:plc:collab', 242 224 }); 243 225 244 - vi.mocked(mockClient.getAgent).mockReturnValue({ 245 - com: { 246 - atproto: { 247 - repo: { 248 - listRecords: mockListRecords, 249 - }, 250 - }, 251 - }, 252 - } as never); 226 + expect(getBacklinks).toHaveBeenCalledWith( 227 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 228 + 'sh.tangled.repo.issue', 229 + '.repo', 230 + 50, 231 + undefined 232 + ); 233 + }); 234 + 235 + it('should return empty array when no issues found', async () => { 236 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 253 237 254 238 const result = await listIssues({ 255 239 client: mockClient, 256 240 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 257 241 }); 258 242 259 - // Should only include issue from my-repo, not other-repo 260 - expect(result.issues).toHaveLength(1); 261 - expect(result.issues[0].title).toBe('Issue 1'); 243 + expect(result.issues).toEqual([]); 262 244 }); 263 245 264 - it('should return empty array when no issues found', async () => { 265 - const mockListRecords = vi.fn().mockResolvedValue({ 266 - data: { 267 - records: [], 268 - cursor: undefined, 269 - }, 270 - }); 271 - 272 - vi.mocked(mockClient.getAgent).mockReturnValue({ 273 - com: { 274 - atproto: { 275 - repo: { 276 - listRecords: mockListRecords, 277 - }, 278 - }, 279 - }, 280 - } as never); 246 + it('should forward cursor from constellation', async () => { 247 + vi.mocked(getBacklinks).mockResolvedValue({ total: 100, records: [], cursor: 'nextpage' }); 281 248 282 249 const result = await listIssues({ 283 250 client: mockClient, 284 251 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 285 252 }); 286 253 287 - expect(result.issues).toEqual([]); 254 + expect(result.cursor).toBe('nextpage'); 288 255 }); 289 256 290 257 it('should throw error when not authenticated', async () => { ··· 296 263 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 297 264 }) 298 265 ).rejects.toThrow('Must be authenticated'); 299 - }); 300 - 301 - it('should throw error for invalid repo URI', async () => { 302 - await expect( 303 - listIssues({ 304 - client: mockClient, 305 - repoAtUri: 'invalid-uri', 306 - }) 307 - ).rejects.toThrow('Invalid repository AT-URI'); 308 266 }); 309 267 }); 310 268 ··· 809 767 }); 810 768 811 769 it('should scan issue list and return 1-based position for rkey displayId', async () => { 812 - const mockListRecords = vi.fn().mockResolvedValue({ 813 - data: { 814 - records: [ 815 - { 816 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 817 - cid: 'cid1', 818 - value: { 819 - $type: 'sh.tangled.repo.issue', 820 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 821 - title: 'First', 822 - createdAt: '2024-01-01T00:00:00.000Z', 823 - }, 770 + vi.mocked(getBacklinks).mockResolvedValue({ 771 + total: 2, 772 + records: [ 773 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, 774 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-b' }, 775 + ], 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', 783 + cid: 'cid1', 784 + value: { 785 + $type: 'sh.tangled.repo.issue', 786 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 787 + title: 'First', 788 + createdAt: '2024-01-01T00:00:00.000Z', 824 789 }, 825 - { 826 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 827 - cid: 'cid2', 828 - value: { 829 - $type: 'sh.tangled.repo.issue', 830 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 831 - title: 'Second', 832 - createdAt: '2024-01-02T00:00:00.000Z', 833 - }, 790 + }, 791 + }) 792 + .mockResolvedValueOnce({ 793 + data: { 794 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 795 + cid: 'cid2', 796 + value: { 797 + $type: 'sh.tangled.repo.issue', 798 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 799 + title: 'Second', 800 + createdAt: '2024-01-02T00:00:00.000Z', 834 801 }, 835 - ], 836 - cursor: undefined, 837 - }, 838 - }); 802 + }, 803 + }); 839 804 840 805 vi.mocked(mockClient.getAgent).mockReturnValue({ 841 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 806 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 842 807 } as never); 843 808 844 809 const result = await resolveSequentialNumber( ··· 851 816 }); 852 817 853 818 it('should return undefined when issue URI not found in list', async () => { 854 - const mockListRecords = vi.fn().mockResolvedValue({ 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 + 827 + const mockGetRecord = vi.fn().mockResolvedValue({ 855 828 data: { 856 - records: [ 857 - { 858 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 859 - cid: 'cid1', 860 - value: { 861 - $type: 'sh.tangled.repo.issue', 862 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 863 - title: 'First', 864 - createdAt: '2024-01-01T00:00:00.000Z', 865 - }, 866 - }, 867 - ], 868 - cursor: undefined, 829 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 830 + cid: 'cid1', 831 + value: { 832 + $type: 'sh.tangled.repo.issue', 833 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 834 + title: 'First', 835 + createdAt: '2024-01-01T00:00:00.000Z', 836 + }, 869 837 }, 870 838 }); 871 839 872 840 vi.mocked(mockClient.getAgent).mockReturnValue({ 873 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 841 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 874 842 } as never); 875 843 876 844 const result = await resolveSequentialNumber(