From 9d33ee95d00bcc9306b4936f6270170e87de6372 Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Wed, 11 Feb 2026 20:00:20 -0700 Subject: [PATCH] Use constellation to get issue state across all collaborator PDSs getIssueState() now queries constellation.microcosm.blue for all state records that reference an issue, fetches each one via getRecord, and sorts by rkey (TID) to find the most recent. This allows collaborators to close/reopen issues from their own PDS, fixing the old approach that only ever saw the issue author's state records. Co-Authored-By: Claude Sonnet 4.5 --- src/lib/issues-api.ts | 46 +++---- tests/lib/issues-api.test.ts | 236 +++++++++++++++++++---------------- 2 files changed, 149 insertions(+), 133 deletions(-) diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 41dc403..8e0fd7f 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -336,36 +336,36 @@ export async function getIssueState(params: GetIssueStateParams): Promise<'open' // Validate authentication await requireAuth(client); - // Parse issue URI to get author DID - const { did } = parseIssueUri(issueUri); - try { - // Query state records for the issue author - const response = await client.getAgent().com.atproto.repo.listRecords({ - repo: did, - collection: 'sh.tangled.repo.issue.state', - limit: 100, - }); + // Query constellation for all state records that reference this issue across all PDSs + const backlinks = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state', '.issue', 100); - // Filter to find state records for this specific issue - const stateRecords = response.data.records.filter((record) => { - const stateData = record.value as { issue?: string }; - return stateData.issue === issueUri; - }); - - if (stateRecords.length === 0) { - // No state record found - default to open + if (backlinks.records.length === 0) { return 'open'; } - // Get the most recent state record (AT Protocol records are sorted by index) + // Fetch each state record in parallel + const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + return { + rkey, + value: response.data.value as { + state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; + }, + }; + }); + + const stateRecords = await Promise.all(statePromises); + + // Sort by rkey ascending — TID rkeys are time-ordered, so the last is most recent + stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); const latestState = stateRecords[stateRecords.length - 1]; - const stateData = latestState.value as { - state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; - }; - // Return 'open' or 'closed' based on the state type - if (stateData.state === 'sh.tangled.repo.issue.state.closed') { + if (latestState.value.state === 'sh.tangled.repo.issue.state.closed') { return 'closed'; } diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index 9e33e6d..4f6d66b 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -174,7 +174,8 @@ describe('listIssues', () => { cursor: null, }); - const mockGetRecord = vi.fn() + const mockGetRecord = vi + .fn() .mockResolvedValueOnce({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', @@ -557,13 +558,7 @@ describe('getIssueState', () => { }); it('should return open when no state records exist', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { records: [] }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, - } as never); + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); const result = await getIssueState({ client: mockClient, @@ -571,31 +566,37 @@ describe('getIssueState', () => { }); expect(result).toBe('open'); - expect(mockListRecords).toHaveBeenCalledWith({ - repo: 'did:plc:owner', - collection: 'sh.tangled.repo.issue.state', - limit: 100, - }); + expect(getBacklinks).toHaveBeenCalledWith( + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + 'sh.tangled.repo.issue.state', + '.issue', + 100 + ); }); it('should return closed when latest state record is closed', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', - cid: 'cid1', - value: { - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - state: 'sh.tangled.repo.issue.state.closed', - }, - }, - ], - }, + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'state1' }, + ], + cursor: null, }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, + com: { + atproto: { + repo: { + getRecord: vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', + cid: 'cid1', + value: { state: 'sh.tangled.repo.issue.state.closed' }, + }, + }), + }, + }, + }, } as never); const result = await getIssueState({ @@ -606,32 +607,39 @@ describe('getIssueState', () => { expect(result).toBe('closed'); }); - it('should return open when latest state record is open', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', - cid: 'cid1', - value: { - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - state: 'sh.tangled.repo.issue.state.closed', - }, - }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', - cid: 'cid2', - value: { - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - state: 'sh.tangled.repo.issue.state.open', - }, - }, - ], - }, + it('should return open when latest state record (by rkey) is open', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'bbb222' }, + ], + cursor: null, }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, + com: { + atproto: { + repo: { + getRecord: vi + .fn() + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', + cid: 'cid1', + value: { state: 'sh.tangled.repo.issue.state.closed' }, + }, + }) + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/bbb222', + cid: 'cid2', + value: { state: 'sh.tangled.repo.issue.state.open' }, + }, + }), + }, + }, + }, } as never); const result = await getIssueState({ @@ -642,33 +650,48 @@ describe('getIssueState', () => { expect(result).toBe('open'); }); - it('should filter state records to only the target issue', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', - cid: 'cid1', - value: { - issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', - state: 'sh.tangled.repo.issue.state.closed', - }, - }, - ], - }, + it('should use rkey sort order to determine most recent state across PDSs', async () => { + // Collaborator's close (rkey 'ccc333') is more recent than owner's open (rkey 'aaa111') + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, + { did: 'did:plc:collab', collection: 'sh.tangled.repo.issue.state', rkey: 'ccc333' }, + ], + cursor: null, }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { listRecords: mockListRecords } } }, + com: { + atproto: { + repo: { + getRecord: vi + .fn() + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', + cid: 'cid1', + value: { state: 'sh.tangled.repo.issue.state.open' }, + }, + }) + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:collab/sh.tangled.repo.issue.state/ccc333', + cid: 'cid2', + value: { state: 'sh.tangled.repo.issue.state.closed' }, + }, + }), + }, + }, + }, } as never); - // The closed state is for a different issue, so this one should be open const result = await getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }); - expect(result).toBe('open'); + expect(result).toBe('closed'); }); it('should throw error when not authenticated', async () => { @@ -776,7 +799,8 @@ describe('resolveSequentialNumber', () => { cursor: null, }); - const mockGetRecord = vi.fn() + const mockGetRecord = vi + .fn() .mockResolvedValueOnce({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', @@ -818,9 +842,7 @@ describe('resolveSequentialNumber', () => { it('should return undefined when issue URI not found in list', async () => { vi.mocked(getBacklinks).mockResolvedValue({ total: 1, - records: [ - { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, - ], + records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }], cursor: null, }); @@ -855,48 +877,48 @@ describe('getCompleteIssueData', () => { let mockClient: TangledApiClient; beforeEach(() => { + vi.clearAllMocks(); mockClient = createMockClient(true); }); it('should return all fields including fetched state', async () => { - const mockGetRecord = vi.fn().mockResolvedValue({ - 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: 'Test Issue', - body: 'Test body', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 's1' }], + cursor: null, }); - // getIssueState uses listRecords on the state collection - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', - cid: 'scid1', - value: { - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - state: 'sh.tangled.repo.issue.state.closed', - }, + 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: 'Test Issue', + body: 'Test body', + createdAt: '2024-01-01T00:00:00.000Z', }, - ], - }, - }); + }, + }) + .mockResolvedValueOnce({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', + cid: 'scid1', + value: { state: 'sh.tangled.repo.issue.state.closed' }, + }, + }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await getCompleteIssueData( mockClient, 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - '#1', // fast-path for number — no listRecords call for issues + '#1', // fast-path for number 'at://did:plc:owner/sh.tangled.repo/my-repo' ); @@ -926,9 +948,8 @@ describe('getCompleteIssueData', () => { }, }); - const mockListRecords = vi.fn(); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await getCompleteIssueData( @@ -941,10 +962,12 @@ describe('getCompleteIssueData', () => { expect(result.number).toBe(2); expect(result.state).toBe('closed'); - expect(mockListRecords).not.toHaveBeenCalled(); + expect(getBacklinks).not.toHaveBeenCalled(); }); it('should return undefined body and default open state when issue has no body or state records', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); + const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', @@ -959,14 +982,7 @@ describe('getCompleteIssueData', () => { }); vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), - }, - }, - }, + com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); const result = await getCompleteIssueData( -- 2.51.2