diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 349b79c..41dc403 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -1,6 +1,7 @@ import { parseAtUri } from '../utils/at-uri.js'; import { requireAuth } from '../utils/auth-helpers.js'; import type { TangledApiClient } from './api-client.js'; +import { getBacklinks } from './constellation.js'; /** * Issue record type based on sh.tangled.repo.issue lexicon @@ -162,39 +163,36 @@ export async function listIssues(params: ListIssuesParams): Promise<{ // Validate authentication await requireAuth(client); - // Extract owner DID from repo AT-URI - const parsed = parseAtUri(repoAtUri); - if (!parsed) { - throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); - } - - const ownerDid = parsed.did; - try { - // List all issue records for the owner - const response = await client.getAgent().com.atproto.repo.listRecords({ - repo: ownerDid, - collection: 'sh.tangled.repo.issue', + // Query constellation for all issues that reference this repo across all PDSs + const backlinks = await getBacklinks( + repoAtUri, + 'sh.tangled.repo.issue', + '.repo', limit, - cursor, + cursor + ); + + // Fetch each issue record individually (constellation only gives us the AT-URI components) + const issuePromises = backlinks.records.map(async ({ did, collection, rkey }) => { + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + return { + ...(response.data.value as IssueRecord), + uri: response.data.uri, + cid: response.data.cid as string, + author: did, + }; }); - // Filter to only issues for this specific repository - const issues: IssueWithMetadata[] = response.data.records - .filter((record) => { - const issueRecord = record.value as IssueRecord; - return issueRecord.repo === repoAtUri; - }) - .map((record) => ({ - ...(record.value as IssueRecord), - uri: record.uri, - cid: record.cid, - author: ownerDid, - })); + const issues = await Promise.all(issuePromises); return { issues, - cursor: response.data.cursor, + cursor: backlinks.cursor ?? undefined, }; } catch (error) { if (error instanceof Error) { diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index adff1ce..9e33e6d 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TangledApiClient } from '../../src/lib/api-client.js'; +import { getBacklinks } from '../../src/lib/constellation.js'; import { closeIssue, createIssue, @@ -12,6 +13,8 @@ import { updateIssue, } from '../../src/lib/issues-api.js'; +vi.mock('../../src/lib/constellation.js'); + // Mock API client factory const createMockClient = (authenticated = true): TangledApiClient => { const mockAgent = { @@ -161,44 +164,45 @@ describe('listIssues', () => { mockClient = createMockClient(true); }); - it('should list issues for a repository', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 1', - body: 'Description 1', - createdAt: '2024-01-01T00:00:00.000Z', - }, + it('should list issues from multiple PDSs via constellation', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue1' }, + { did: 'did:plc:collab', collection: 'sh.tangled.repo.issue', rkey: 'issue2' }, + ], + cursor: null, + }); + + const mockGetRecord = vi.fn() + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Issue 1', + body: 'Description 1', + createdAt: '2024-01-01T00:00:00.000Z', }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', - cid: 'cid2', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 2', - createdAt: '2024-01-02T00:00:00.000Z', - }, + }, + }) + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', + cid: 'cid2', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Issue 2', + createdAt: '2024-01-02T00:00:00.000Z', }, - ], - cursor: undefined, - }, - }); + }, + }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await listIssues({ @@ -211,80 +215,43 @@ describe('listIssues', () => { title: 'Issue 1', body: 'Description 1', uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + author: 'did:plc:owner', }); - }); - - it('should filter issues by repository', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 1', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', - cid: 'cid2', - value: { - repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', - title: 'Issue 2', - createdAt: '2024-01-02T00:00:00.000Z', - }, - }, - ], - cursor: undefined, - }, + expect(result.issues[1]).toMatchObject({ + title: 'Issue 2', + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', + author: 'did:plc:collab', }); - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, - } as never); + expect(getBacklinks).toHaveBeenCalledWith( + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'sh.tangled.repo.issue', + '.repo', + 50, + undefined + ); + }); + + it('should return empty array when no issues found', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); const result = await listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', }); - // Should only include issue from my-repo, not other-repo - expect(result.issues).toHaveLength(1); - expect(result.issues[0].title).toBe('Issue 1'); + expect(result.issues).toEqual([]); }); - it('should return empty array when no issues found', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [], - cursor: undefined, - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, - } as never); + it('should forward cursor from constellation', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ total: 100, records: [], cursor: 'nextpage' }); const result = await listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', }); - expect(result.issues).toEqual([]); + expect(result.cursor).toBe('nextpage'); }); it('should throw error when not authenticated', async () => { @@ -297,15 +264,6 @@ describe('listIssues', () => { }) ).rejects.toThrow('Must be authenticated'); }); - - it('should throw error for invalid repo URI', async () => { - await expect( - listIssues({ - client: mockClient, - repoAtUri: 'invalid-uri', - }) - ).rejects.toThrow('Invalid repository AT-URI'); - }); }); describe('getIssue', () => { @@ -809,36 +767,43 @@ describe('resolveSequentialNumber', () => { }); it('should scan issue list and return 1-based position for rkey displayId', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', - cid: 'cid1', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'First', - createdAt: '2024-01-01T00:00:00.000Z', - }, + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-b' }, + ], + cursor: null, + }); + + const mockGetRecord = vi.fn() + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', + cid: 'cid1', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'First', + createdAt: '2024-01-01T00:00:00.000Z', }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', - cid: 'cid2', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Second', - createdAt: '2024-01-02T00:00:00.000Z', - }, + }, + }) + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', + cid: 'cid2', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Second', + createdAt: '2024-01-02T00:00:00.000Z', }, - ], - cursor: undefined, - }, - }); + }, + }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await resolveSequentialNumber( @@ -851,26 +816,29 @@ describe('resolveSequentialNumber', () => { }); it('should return undefined when issue URI not found in list', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, + ], + cursor: null, + }); + + const mockGetRecord = vi.fn().mockResolvedValue({ data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', - cid: 'cid1', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'First', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - ], - cursor: undefined, + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', + cid: 'cid1', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'First', + createdAt: '2024-01-01T00:00:00.000Z', + }, }, }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await resolveSequentialNumber(