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

Configure Feed

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

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

Mark Bennett
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(