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

Add constellation.ts client for cross-PDS record indexing #5

merged opened by markbennett.ca targeting main from feature/update-queries-to-use-contstellation

Introduces getBacklinks() which queries constellation.microcosm.blue to find AT Protocol records that reference a given URI, across all PDSs. This is the foundation for fixing multi-collaborator issue queries.

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

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:b2mcbcamkwyznc5fkplwlxbf/sh.tangled.repo.pull/3men2itxsfx22
+453 -297
Diff #0
+1 -2
.claude/settings.json
··· 9 9 "Bash(npm run lint:fix:*)", 10 10 "Bash(npm run format:*)", 11 11 "Bash(git fetch:*)", 12 - "Bash(git checkout:*)", 13 - "Bash(npx vitest:*)" 12 + "Bash(git checkout:*)" 14 13 ] 15 14 } 16 15 }
+58
src/lib/constellation.ts
··· 1 + const CONSTELLATION_BASE = 'https://constellation.microcosm.blue'; 2 + 3 + export interface ConstellationRecord { 4 + did: string; 5 + collection: string; 6 + rkey: string; 7 + } 8 + 9 + export interface GetBacklinksResult { 10 + total: number; 11 + records: ConstellationRecord[]; 12 + cursor: string | null; 13 + } 14 + 15 + /** 16 + * Query the constellation indexer for records that link to a given AT-URI. 17 + * Constellation indexes records across all PDSs, enabling multi-collaborator queries. 18 + * 19 + * @param targetUri - The AT-URI being referenced by the records 20 + * @param collection - Filter results to this collection (e.g. 'sh.tangled.repo.issue') 21 + * @param path - The field path in each record that holds the target URI (e.g. '.repo') 22 + * @param limit - Max records to return (default 100) 23 + * @param cursor - Pagination cursor from a previous call 24 + */ 25 + export async function getBacklinks( 26 + targetUri: string, 27 + collection: string, 28 + path: string, 29 + limit = 100, 30 + cursor?: string 31 + ): Promise<GetBacklinksResult> { 32 + const params = new URLSearchParams({ 33 + target: targetUri, 34 + collection, 35 + path, 36 + limit: String(limit), 37 + }); 38 + if (cursor) { 39 + params.set('cursor', cursor); 40 + } 41 + 42 + const response = await fetch(`${CONSTELLATION_BASE}/links?${params}`); 43 + if (!response.ok) { 44 + throw new Error(`Constellation API error: ${response.status} ${response.statusText}`); 45 + } 46 + 47 + const data = (await response.json()) as { 48 + total: number; 49 + linking_records: ConstellationRecord[]; 50 + cursor: string | null; 51 + }; 52 + 53 + return { 54 + total: data.total, 55 + records: data.linking_records, 56 + cursor: data.cursor, 57 + }; 58 + }
+47 -49
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) { ··· 338 336 // Validate authentication 339 337 await requireAuth(client); 340 338 341 - // Parse issue URI to get author DID 342 - const { did } = parseIssueUri(issueUri); 343 - 344 339 try { 345 - // Query state records for the issue author 346 - const response = await client.getAgent().com.atproto.repo.listRecords({ 347 - repo: did, 348 - collection: 'sh.tangled.repo.issue.state', 349 - limit: 100, 350 - }); 340 + // Query constellation for all state records that reference this issue across all PDSs 341 + const backlinks = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state', '.issue', 100); 351 342 352 - // Filter to find state records for this specific issue 353 - const stateRecords = response.data.records.filter((record) => { 354 - const stateData = record.value as { issue?: string }; 355 - return stateData.issue === issueUri; 356 - }); 357 - 358 - if (stateRecords.length === 0) { 359 - // No state record found - default to open 343 + if (backlinks.records.length === 0) { 360 344 return 'open'; 361 345 } 362 346 363 - // Get the most recent state record (AT Protocol records are sorted by index) 347 + // Fetch each state record in parallel 348 + const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { 349 + const response = await client.getAgent().com.atproto.repo.getRecord({ 350 + repo: did, 351 + collection, 352 + rkey, 353 + }); 354 + return { 355 + rkey, 356 + value: response.data.value as { 357 + state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; 358 + }, 359 + }; 360 + }); 361 + 362 + const stateRecords = await Promise.all(statePromises); 363 + 364 + // Sort by rkey ascending — TID rkeys are time-ordered, so the last is most recent 365 + stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); 364 366 const latestState = stateRecords[stateRecords.length - 1]; 365 - const stateData = latestState.value as { 366 - state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; 367 - }; 368 367 369 - // Return 'open' or 'closed' based on the state type 370 - if (stateData.state === 'sh.tangled.repo.issue.state.closed') { 368 + if (latestState.value.state === 'sh.tangled.repo.issue.state.closed') { 371 369 return 'closed'; 372 370 } 373 371
+117
tests/lib/constellation.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { getBacklinks } from '../../src/lib/constellation.js'; 3 + 4 + const mockFetch = vi.fn(); 5 + 6 + beforeEach(() => { 7 + mockFetch.mockClear(); 8 + vi.stubGlobal('fetch', mockFetch); 9 + }); 10 + 11 + afterEach(() => { 12 + vi.unstubAllGlobals(); 13 + }); 14 + 15 + describe('getBacklinks', () => { 16 + it('should return records from constellation', async () => { 17 + mockFetch.mockResolvedValue({ 18 + ok: true, 19 + json: async () => ({ 20 + total: 2, 21 + linking_records: [ 22 + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, 23 + { did: 'did:plc:def', collection: 'sh.tangled.repo.issue', rkey: 'rkey2' }, 24 + ], 25 + cursor: null, 26 + }), 27 + }); 28 + 29 + const result = await getBacklinks( 30 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 31 + 'sh.tangled.repo.issue', 32 + '.repo' 33 + ); 34 + 35 + expect(result.total).toBe(2); 36 + expect(result.records).toHaveLength(2); 37 + expect(result.records[0]).toEqual({ 38 + did: 'did:plc:abc', 39 + collection: 'sh.tangled.repo.issue', 40 + rkey: 'rkey1', 41 + }); 42 + expect(result.cursor).toBeNull(); 43 + 44 + expect(mockFetch).toHaveBeenCalledWith( 45 + 'https://constellation.microcosm.blue/links?target=at%3A%2F%2Fdid%3Aplc%3Aowner%2Fsh.tangled.repo%2Fmy-repo&collection=sh.tangled.repo.issue&path=.repo&limit=100' 46 + ); 47 + }); 48 + 49 + it('should pass cursor and limit params', async () => { 50 + mockFetch.mockResolvedValue({ 51 + ok: true, 52 + json: async () => ({ total: 0, linking_records: [], cursor: null }), 53 + }); 54 + 55 + await getBacklinks( 56 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 57 + 'sh.tangled.repo.issue', 58 + '.repo', 59 + 50, 60 + 'abc123' 61 + ); 62 + 63 + const calledUrl = mockFetch.mock.calls[0][0] as string; 64 + expect(calledUrl).toContain('limit=50'); 65 + expect(calledUrl).toContain('cursor=abc123'); 66 + }); 67 + 68 + it('should return cursor for pagination', async () => { 69 + mockFetch.mockResolvedValue({ 70 + ok: true, 71 + json: async () => ({ 72 + total: 200, 73 + linking_records: [ 74 + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, 75 + ], 76 + cursor: 'nextpage', 77 + }), 78 + }); 79 + 80 + const result = await getBacklinks( 81 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 82 + 'sh.tangled.repo.issue', 83 + '.repo', 84 + 1 85 + ); 86 + 87 + expect(result.cursor).toBe('nextpage'); 88 + }); 89 + 90 + it('should throw on non-OK response', async () => { 91 + mockFetch.mockResolvedValue({ 92 + ok: false, 93 + status: 503, 94 + statusText: 'Service Unavailable', 95 + }); 96 + 97 + await expect( 98 + getBacklinks('at://did:plc:owner/sh.tangled.repo/my-repo', 'sh.tangled.repo.issue', '.repo') 99 + ).rejects.toThrow('Constellation API error: 503 Service Unavailable'); 100 + }); 101 + 102 + it('should return empty records when none found', async () => { 103 + mockFetch.mockResolvedValue({ 104 + ok: true, 105 + json: async () => ({ total: 0, linking_records: [], cursor: null }), 106 + }); 107 + 108 + const result = await getBacklinks( 109 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 110 + 'sh.tangled.repo.issue', 111 + '.repo' 112 + ); 113 + 114 + expect(result.total).toBe(0); 115 + expect(result.records).toEqual([]); 116 + }); 117 + });
+230 -246
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 178 + .fn() 179 + .mockResolvedValueOnce({ 180 + data: { 181 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 182 + cid: 'cid1', 183 + value: { 184 + $type: 'sh.tangled.repo.issue', 185 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 186 + title: 'Issue 1', 187 + body: 'Description 1', 188 + createdAt: '2024-01-01T00:00:00.000Z', 199 189 }, 200 190 }, 201 - }, 191 + }) 192 + .mockResolvedValueOnce({ 193 + data: { 194 + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 195 + cid: 'cid2', 196 + value: { 197 + $type: 'sh.tangled.repo.issue', 198 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 199 + title: 'Issue 2', 200 + createdAt: '2024-01-02T00:00:00.000Z', 201 + }, 202 + }, 203 + }); 204 + 205 + vi.mocked(mockClient.getAgent).mockReturnValue({ 206 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 202 207 } as never); 203 208 204 209 const result = await listIssues({ ··· 211 216 title: 'Issue 1', 212 217 body: 'Description 1', 213 218 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 219 + author: 'did:plc:owner', 214 220 }); 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 - }, 221 + expect(result.issues[1]).toMatchObject({ 222 + title: 'Issue 2', 223 + uri: 'at://did:plc:collab/sh.tangled.repo.issue/issue2', 224 + author: 'did:plc:collab', 242 225 }); 243 226 244 - vi.mocked(mockClient.getAgent).mockReturnValue({ 245 - com: { 246 - atproto: { 247 - repo: { 248 - listRecords: mockListRecords, 249 - }, 250 - }, 251 - }, 252 - } as never); 227 + expect(getBacklinks).toHaveBeenCalledWith( 228 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 229 + 'sh.tangled.repo.issue', 230 + '.repo', 231 + 50, 232 + undefined 233 + ); 234 + }); 235 + 236 + it('should return empty array when no issues found', async () => { 237 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 253 238 254 239 const result = await listIssues({ 255 240 client: mockClient, 256 241 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 257 242 }); 258 243 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'); 244 + expect(result.issues).toEqual([]); 262 245 }); 263 246 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); 247 + it('should forward cursor from constellation', async () => { 248 + vi.mocked(getBacklinks).mockResolvedValue({ total: 100, records: [], cursor: 'nextpage' }); 281 249 282 250 const result = await listIssues({ 283 251 client: mockClient, 284 252 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 285 253 }); 286 254 287 - expect(result.issues).toEqual([]); 255 + expect(result.cursor).toBe('nextpage'); 288 256 }); 289 257 290 258 it('should throw error when not authenticated', async () => { ··· 296 264 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 297 265 }) 298 266 ).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 267 }); 309 268 }); 310 269 ··· 599 558 }); 600 559 601 560 it('should return open when no state records exist', async () => { 602 - const mockListRecords = vi.fn().mockResolvedValue({ 603 - data: { records: [] }, 604 - }); 605 - 606 - vi.mocked(mockClient.getAgent).mockReturnValue({ 607 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 608 - } as never); 561 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 609 562 610 563 const result = await getIssueState({ 611 564 client: mockClient, ··· 613 566 }); 614 567 615 568 expect(result).toBe('open'); 616 - expect(mockListRecords).toHaveBeenCalledWith({ 617 - repo: 'did:plc:owner', 618 - collection: 'sh.tangled.repo.issue.state', 619 - limit: 100, 620 - }); 569 + expect(getBacklinks).toHaveBeenCalledWith( 570 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 571 + 'sh.tangled.repo.issue.state', 572 + '.issue', 573 + 100 574 + ); 621 575 }); 622 576 623 577 it('should return closed when latest state record is closed', async () => { 624 - const mockListRecords = vi.fn().mockResolvedValue({ 625 - data: { 626 - records: [ 627 - { 628 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 629 - cid: 'cid1', 630 - value: { 631 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 632 - state: 'sh.tangled.repo.issue.state.closed', 633 - }, 634 - }, 635 - ], 636 - }, 578 + vi.mocked(getBacklinks).mockResolvedValue({ 579 + total: 1, 580 + records: [ 581 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'state1' }, 582 + ], 583 + cursor: null, 637 584 }); 638 585 639 586 vi.mocked(mockClient.getAgent).mockReturnValue({ 640 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 587 + com: { 588 + atproto: { 589 + repo: { 590 + getRecord: vi.fn().mockResolvedValue({ 591 + data: { 592 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 593 + cid: 'cid1', 594 + value: { state: 'sh.tangled.repo.issue.state.closed' }, 595 + }, 596 + }), 597 + }, 598 + }, 599 + }, 641 600 } as never); 642 601 643 602 const result = await getIssueState({ ··· 648 607 expect(result).toBe('closed'); 649 608 }); 650 609 651 - it('should return open when latest state record is open', async () => { 652 - const mockListRecords = vi.fn().mockResolvedValue({ 653 - data: { 654 - records: [ 655 - { 656 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 657 - cid: 'cid1', 658 - value: { 659 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 660 - state: 'sh.tangled.repo.issue.state.closed', 661 - }, 662 - }, 663 - { 664 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', 665 - cid: 'cid2', 666 - value: { 667 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 668 - state: 'sh.tangled.repo.issue.state.open', 669 - }, 670 - }, 671 - ], 672 - }, 610 + it('should return open when latest state record (by rkey) is open', async () => { 611 + vi.mocked(getBacklinks).mockResolvedValue({ 612 + total: 2, 613 + records: [ 614 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, 615 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'bbb222' }, 616 + ], 617 + cursor: null, 673 618 }); 674 619 675 620 vi.mocked(mockClient.getAgent).mockReturnValue({ 676 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 621 + com: { 622 + atproto: { 623 + repo: { 624 + getRecord: vi 625 + .fn() 626 + .mockResolvedValueOnce({ 627 + data: { 628 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', 629 + cid: 'cid1', 630 + value: { state: 'sh.tangled.repo.issue.state.closed' }, 631 + }, 632 + }) 633 + .mockResolvedValueOnce({ 634 + data: { 635 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/bbb222', 636 + cid: 'cid2', 637 + value: { state: 'sh.tangled.repo.issue.state.open' }, 638 + }, 639 + }), 640 + }, 641 + }, 642 + }, 677 643 } as never); 678 644 679 645 const result = await getIssueState({ ··· 684 650 expect(result).toBe('open'); 685 651 }); 686 652 687 - it('should filter state records to only the target issue', async () => { 688 - const mockListRecords = vi.fn().mockResolvedValue({ 689 - data: { 690 - records: [ 691 - { 692 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 693 - cid: 'cid1', 694 - value: { 695 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', 696 - state: 'sh.tangled.repo.issue.state.closed', 697 - }, 698 - }, 699 - ], 700 - }, 653 + it('should use rkey sort order to determine most recent state across PDSs', async () => { 654 + // Collaborator's close (rkey 'ccc333') is more recent than owner's open (rkey 'aaa111') 655 + vi.mocked(getBacklinks).mockResolvedValue({ 656 + total: 2, 657 + records: [ 658 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 'aaa111' }, 659 + { did: 'did:plc:collab', collection: 'sh.tangled.repo.issue.state', rkey: 'ccc333' }, 660 + ], 661 + cursor: null, 701 662 }); 702 663 703 664 vi.mocked(mockClient.getAgent).mockReturnValue({ 704 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 665 + com: { 666 + atproto: { 667 + repo: { 668 + getRecord: vi 669 + .fn() 670 + .mockResolvedValueOnce({ 671 + data: { 672 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/aaa111', 673 + cid: 'cid1', 674 + value: { state: 'sh.tangled.repo.issue.state.open' }, 675 + }, 676 + }) 677 + .mockResolvedValueOnce({ 678 + data: { 679 + uri: 'at://did:plc:collab/sh.tangled.repo.issue.state/ccc333', 680 + cid: 'cid2', 681 + value: { state: 'sh.tangled.repo.issue.state.closed' }, 682 + }, 683 + }), 684 + }, 685 + }, 686 + }, 705 687 } as never); 706 688 707 - // The closed state is for a different issue, so this one should be open 708 689 const result = await getIssueState({ 709 690 client: mockClient, 710 691 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 711 692 }); 712 693 713 - expect(result).toBe('open'); 694 + expect(result).toBe('closed'); 714 695 }); 715 696 716 697 it('should throw error when not authenticated', async () => { ··· 809 790 }); 810 791 811 792 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 - }, 793 + vi.mocked(getBacklinks).mockResolvedValue({ 794 + total: 2, 795 + records: [ 796 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }, 797 + { did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-b' }, 798 + ], 799 + cursor: null, 800 + }); 801 + 802 + const mockGetRecord = vi 803 + .fn() 804 + .mockResolvedValueOnce({ 805 + data: { 806 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 807 + cid: 'cid1', 808 + value: { 809 + $type: 'sh.tangled.repo.issue', 810 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 811 + title: 'First', 812 + createdAt: '2024-01-01T00:00:00.000Z', 824 813 }, 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 - }, 814 + }, 815 + }) 816 + .mockResolvedValueOnce({ 817 + data: { 818 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 819 + cid: 'cid2', 820 + value: { 821 + $type: 'sh.tangled.repo.issue', 822 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 823 + title: 'Second', 824 + createdAt: '2024-01-02T00:00:00.000Z', 834 825 }, 835 - ], 836 - cursor: undefined, 837 - }, 838 - }); 826 + }, 827 + }); 839 828 840 829 vi.mocked(mockClient.getAgent).mockReturnValue({ 841 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 830 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 842 831 } as never); 843 832 844 833 const result = await resolveSequentialNumber( ··· 851 840 }); 852 841 853 842 it('should return undefined when issue URI not found in list', async () => { 854 - const mockListRecords = vi.fn().mockResolvedValue({ 843 + vi.mocked(getBacklinks).mockResolvedValue({ 844 + total: 1, 845 + records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue-a' }], 846 + cursor: null, 847 + }); 848 + 849 + const mockGetRecord = vi.fn().mockResolvedValue({ 855 850 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, 851 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 852 + cid: 'cid1', 853 + value: { 854 + $type: 'sh.tangled.repo.issue', 855 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 856 + title: 'First', 857 + createdAt: '2024-01-01T00:00:00.000Z', 858 + }, 869 859 }, 870 860 }); 871 861 872 862 vi.mocked(mockClient.getAgent).mockReturnValue({ 873 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 863 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 874 864 } as never); 875 865 876 866 const result = await resolveSequentialNumber( ··· 887 877 let mockClient: TangledApiClient; 888 878 889 879 beforeEach(() => { 880 + vi.clearAllMocks(); 890 881 mockClient = createMockClient(true); 891 882 }); 892 883 893 884 it('should return all fields including fetched state', async () => { 894 - const mockGetRecord = vi.fn().mockResolvedValue({ 895 - data: { 896 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 897 - cid: 'cid1', 898 - value: { 899 - $type: 'sh.tangled.repo.issue', 900 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 901 - title: 'Test Issue', 902 - body: 'Test body', 903 - createdAt: '2024-01-01T00:00:00.000Z', 904 - }, 905 - }, 885 + vi.mocked(getBacklinks).mockResolvedValue({ 886 + total: 1, 887 + records: [{ did: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', rkey: 's1' }], 888 + cursor: null, 906 889 }); 907 890 908 - // getIssueState uses listRecords on the state collection 909 - const mockListRecords = vi.fn().mockResolvedValue({ 910 - data: { 911 - records: [ 912 - { 913 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 914 - cid: 'scid1', 915 - value: { 916 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 917 - state: 'sh.tangled.repo.issue.state.closed', 918 - }, 891 + const mockGetRecord = vi 892 + .fn() 893 + .mockResolvedValueOnce({ 894 + data: { 895 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 896 + cid: 'cid1', 897 + value: { 898 + $type: 'sh.tangled.repo.issue', 899 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 900 + title: 'Test Issue', 901 + body: 'Test body', 902 + createdAt: '2024-01-01T00:00:00.000Z', 919 903 }, 920 - ], 921 - }, 922 - }); 904 + }, 905 + }) 906 + .mockResolvedValueOnce({ 907 + data: { 908 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 909 + cid: 'scid1', 910 + value: { state: 'sh.tangled.repo.issue.state.closed' }, 911 + }, 912 + }); 923 913 924 914 vi.mocked(mockClient.getAgent).mockReturnValue({ 925 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 915 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 926 916 } as never); 927 917 928 918 const result = await getCompleteIssueData( 929 919 mockClient, 930 920 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 931 - '#1', // fast-path for number — no listRecords call for issues 921 + '#1', // fast-path for number 932 922 'at://did:plc:owner/sh.tangled.repo/my-repo' 933 923 ); 934 924 ··· 958 948 }, 959 949 }); 960 950 961 - const mockListRecords = vi.fn(); 962 951 vi.mocked(mockClient.getAgent).mockReturnValue({ 963 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 952 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 964 953 } as never); 965 954 966 955 const result = await getCompleteIssueData( ··· 973 962 974 963 expect(result.number).toBe(2); 975 964 expect(result.state).toBe('closed'); 976 - expect(mockListRecords).not.toHaveBeenCalled(); 965 + expect(getBacklinks).not.toHaveBeenCalled(); 977 966 }); 978 967 979 968 it('should return undefined body and default open state when issue has no body or state records', async () => { 969 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 970 + 980 971 const mockGetRecord = vi.fn().mockResolvedValue({ 981 972 data: { 982 973 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', ··· 991 982 }); 992 983 993 984 vi.mocked(mockClient.getAgent).mockReturnValue({ 994 - com: { 995 - atproto: { 996 - repo: { 997 - getRecord: mockGetRecord, 998 - listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 999 - }, 1000 - }, 1001 - }, 985 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 1002 986 } as never); 1003 987 1004 988 const result = await getCompleteIssueData(

History

2 rounds 0 comments
sign up or login to add to the discussion
3 commits
expand
Add constellation.ts client for cross-PDS record indexing
Use constellation to list issues across all collaborator PDSs
Use constellation to get issue state across all collaborator PDSs
expand 0 comments
pull request successfully merged
markbennett.ca submitted #0
3 commits
expand
Add constellation.ts client for cross-PDS record indexing
Use constellation to list issues across all collaborator PDSs
Use constellation to get issue state across all collaborator PDSs
expand 0 comments