diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 052fe43..4ee2330 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -80,6 +80,22 @@ export interface DeleteIssueParams { issueUri: string; } +/** + * Parameters for getting issue state + */ +export interface GetIssueStateParams { + client: TangledApiClient; + issueUri: string; +} + +/** + * Parameters for reopening an issue + */ +export interface ReopenIssueParams { + client: TangledApiClient; + issueUri: string; +} + /** * Parse and validate an issue AT-URI * @throws Error if URI is invalid or missing rkey @@ -354,3 +370,89 @@ export async function deleteIssue(params: DeleteIssueParams): Promise { throw new Error('Failed to delete issue: Unknown error'); } } + +/** + * Get the state of an issue (open or closed) + * @returns 'open' or 'closed' (defaults to 'open' if no state record exists) + */ +export async function getIssueState(params: GetIssueStateParams): Promise<'open' | 'closed'> { + const { client, issueUri } = params; + + // 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, + }); + + // 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 + return 'open'; + } + + // Get the most recent state record (AT Protocol records are sorted by index) + 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') { + return 'closed'; + } + + return 'open'; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to get issue state: ${error.message}`); + } + throw new Error('Failed to get issue state: Unknown error'); + } +} + +/** + * Reopen a closed issue by creating an open state record + */ +export async function reopenIssue(params: ReopenIssueParams): Promise { + const { client, issueUri } = params; + + // Validate authentication + const session = await requireAuth(client); + + try { + // Verify issue exists + await getIssue({ client, issueUri }); + + // Create state record with open state + const stateRecord = { + $type: 'sh.tangled.repo.issue.state', + issue: issueUri, + state: 'sh.tangled.repo.issue.state.open', + }; + + // Create state record + await client.getAgent().com.atproto.repo.createRecord({ + repo: session.did, + collection: 'sh.tangled.repo.issue.state', + record: stateRecord, + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to reopen issue: ${error.message}`); + } + throw new Error('Failed to reopen issue: Unknown error'); + } +} diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index c308c02..65bb665 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -5,7 +5,9 @@ import { createIssue, deleteIssue, getIssue, + getIssueState, listIssues, + reopenIssue, updateIssue, } from '../../src/lib/issues-api.js'; @@ -661,3 +663,203 @@ describe('deleteIssue', () => { ).rejects.toThrow('Must be authenticated'); }); }); + +describe('getIssueState', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + 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); + + const result = await getIssueState({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + expect(result).toBe('open'); + expect(mockListRecords).toHaveBeenCalledWith({ + repo: 'did:plc:owner', + collection: 'sh.tangled.repo.issue.state', + limit: 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(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { listRecords: mockListRecords } } }, + } as never); + + const result = await getIssueState({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + 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', + }, + }, + ], + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { listRecords: mockListRecords } } }, + } as never); + + const result = await getIssueState({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + 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', + }, + }, + ], + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { listRecords: mockListRecords } } }, + } 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'); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + getIssueState({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Must be authenticated'); + }); +}); + +describe('reopenIssue', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should reopen a closed issue', async () => { + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Test Issue', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', + cid: 'state-cid', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + await reopenIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + expect(mockCreateRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.issue.state', + record: { + $type: 'sh.tangled.repo.issue.state', + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + state: 'sh.tangled.repo.issue.state.open', + }, + }); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + reopenIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Must be authenticated'); + }); +});