From 8ce6e5cb90bce966c87088987136822a1ce204a0 Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Wed, 11 Feb 2026 06:57:02 -0700 Subject: [PATCH] refactor: add resolveSequentialNumber and getCompleteIssueData to issues-api Move resolveSequentialNumber out of the commands layer into issues-api.ts where it belongs alongside the other issue data functions. Add IssueData interface as the canonical JSON shape for a single issue, and getCompleteIssueData which fetches issue record, sequential number, and state in one call (with optional state override for mutation commands that already know the new state). Add tests for both new exports. Co-Authored-By: Claude Sonnet 4.5 --- src/lib/issues-api.ts | 66 +++++++++++ tests/lib/issues-api.test.ts | 224 +++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 4ee2330..788b8b8 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -423,6 +423,72 @@ export async function getIssueState(params: GetIssueStateParams): Promise<'open' } } +/** + * Resolve a sequential issue number from a displayId or by scanning the issue list. + * Fast path: if displayId is "#N", return N directly. + * Fallback: fetch all issues, sort oldest-first, return 1-based position. + */ +export async function resolveSequentialNumber( + displayId: string, + issueUri: string, + client: TangledApiClient, + repoAtUri: string +): Promise { + const match = displayId.match(/^#(\d+)$/); + if (match) return Number.parseInt(match[1], 10); + + const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); + const sorted = issues.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + const idx = sorted.findIndex((i) => i.uri === issueUri); + return idx >= 0 ? idx + 1 : undefined; +} + +/** + * Canonical JSON shape for a single issue, used by all issue commands. + */ +export interface IssueData { + number: number | undefined; + title: string; + body?: string; + state: 'open' | 'closed'; + author: string; + createdAt: string; + uri: string; + cid: string; +} + +/** + * Fetch a complete IssueData object ready for JSON output. + * Fetches the issue record and sequential number in parallel. + * If stateOverride is supplied (e.g. 'closed' after a close operation), + * getIssueState is skipped; otherwise the current state is fetched. + */ +export async function getCompleteIssueData( + client: TangledApiClient, + issueUri: string, + displayId: string, + repoAtUri: string, + stateOverride?: 'open' | 'closed' +): Promise { + const [issue, number] = await Promise.all([ + getIssue({ client, issueUri }), + resolveSequentialNumber(displayId, issueUri, client, repoAtUri), + ]); + const state = stateOverride ?? (await getIssueState({ client, issueUri })); + return { + number, + title: issue.title, + body: issue.body, + state, + author: issue.author, + createdAt: issue.createdAt, + uri: issue.uri, + cid: issue.cid, + }; +} + /** * Reopen a closed issue by creating an open state record */ diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index 65bb665..f052e6c 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -4,10 +4,12 @@ import { closeIssue, createIssue, deleteIssue, + getCompleteIssueData, getIssue, getIssueState, listIssues, reopenIssue, + resolveSequentialNumber, updateIssue, } from '../../src/lib/issues-api.js'; @@ -863,3 +865,225 @@ describe('reopenIssue', () => { ).rejects.toThrow('Must be authenticated'); }); }); + +describe('resolveSequentialNumber', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should return number directly for #N displayId without an API call (fast path)', async () => { + const result = await resolveSequentialNumber( + '#3', + 'at://did:plc:owner/sh.tangled.repo.issue/issue3', + mockClient, + 'at://did:plc:owner/sh.tangled.repo/my-repo' + ); + expect(result).toBe(3); + }); + + 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', + }, + }, + { + 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 } } }, + } as never); + + const result = await resolveSequentialNumber( + 'issue-b', + 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', + mockClient, + 'at://did:plc:owner/sh.tangled.repo/my-repo' + ); + expect(result).toBe(2); + }); + + it('should return undefined when issue URI not found in list', 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', + }, + }, + ], + cursor: undefined, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { listRecords: mockListRecords } } }, + } as never); + + const result = await resolveSequentialNumber( + 'nonexistent', + 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', + mockClient, + 'at://did:plc:owner/sh.tangled.repo/my-repo' + ); + expect(result).toBeUndefined(); + }); +}); + +describe('getCompleteIssueData', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + 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', + }, + }, + }); + + // 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', + }, + }, + ], + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, + } 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 + 'at://did:plc:owner/sh.tangled.repo/my-repo' + ); + + expect(result).toEqual({ + number: 1, + title: 'Test Issue', + body: 'Test body', + state: 'closed', + author: 'did:plc:owner', + createdAt: '2024-01-01T00:00:00.000Z', + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + }); + }); + + it('should use stateOverride and skip the getIssueState network call', 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', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + const mockListRecords = vi.fn(); + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, + } as never); + + const result = await getCompleteIssueData( + mockClient, + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + '#2', + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'closed' + ); + + expect(result.number).toBe(2); + expect(result.state).toBe('closed'); + expect(mockListRecords).not.toHaveBeenCalled(); + }); + + it('should return undefined body and default open state when issue has no body or state records', 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: 'No body issue', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), + }, + }, + }, + } as never); + + const result = await getCompleteIssueData( + mockClient, + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + '#1', + 'at://did:plc:owner/sh.tangled.repo/my-repo' + ); + + expect(result.body).toBeUndefined(); + expect(result.state).toBe('open'); + }); +}); -- 2.51.2