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'); + }); +});