diff --git a/src/commands/issue.ts b/src/commands/issue.ts index fa92138..db82339 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,4 +1,3 @@ -import { confirm } from '@inquirer/prompts'; import { Command } from 'commander'; import type { TangledApiClient } from '../lib/api-client.js'; import { createApiClient } from '../lib/api-client.js'; @@ -7,7 +6,6 @@ import type { IssueData } from '../lib/issues-api.js'; import { closeIssue, createIssue, - deleteIssue, getCompleteIssueData, getIssueState, listIssues, @@ -367,75 +365,6 @@ function createReopenCommand(): Command { }); } -/** - * Issue delete subcommand - */ -function createDeleteCommand(): Command { - return new IssueCommand('delete') - .description('Delete an issue permanently') - .argument('', 'Issue number or rkey') - .option('-f, --force', 'Skip confirmation prompt') - .addIssueJsonOption() - .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { - // 1. Validate auth - const client = createApiClient(); - await ensureAuthenticated(client); - - // 2. Get repo context - const context = await getCurrentRepoContext(); - if (!context) { - console.error('✗ Not in a Tangled repository'); - console.error('\nTo use this repository with Tangled, add a remote:'); - console.error(' git remote add origin git@tangled.org:/.git'); - process.exit(1); - } - - // 3. Build repo AT-URI, resolve issue ID, and fetch issue details - let issueUri: string; - let displayId: string; - let issueData: IssueData; - try { - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); - ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); - issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); - } catch (error) { - console.error( - `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - process.exit(1); - } - - // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) - if (!options.force) { - const confirmed = await confirm({ - message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, - default: false, - }); - - if (!confirmed) { - console.log('Deletion cancelled.'); - process.exit(0); - } - } - - // 5. Delete issue - try { - await deleteIssue({ client, issueUri }); - if (options.json !== undefined) { - outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); - } else { - console.log(`✓ Issue ${displayId} deleted`); - console.log(` Title: ${issueData.title}`); - } - } catch (error) { - console.error( - `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - process.exit(1); - } - }); -} - /** * Create the issue command with all subcommands */ @@ -449,7 +378,6 @@ export function createIssueCommand(): Command { issue.addCommand(createEditCommand()); issue.addCommand(createCloseCommand()); issue.addCommand(createReopenCommand()); - issue.addCommand(createDeleteCommand()); return issue; } diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 788b8b8..349b79c 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -72,14 +72,6 @@ export interface CloseIssueParams { issueUri: string; } -/** - * Parameters for deleting an issue - */ -export interface DeleteIssueParams { - client: TangledApiClient; - issueUri: string; -} - /** * Parameters for getting issue state */ @@ -336,41 +328,6 @@ export async function closeIssue(params: CloseIssueParams): Promise { } } -/** - * Delete an issue - */ -export async function deleteIssue(params: DeleteIssueParams): Promise { - const { client, issueUri } = params; - - // Validate authentication - const session = await requireAuth(client); - - // Parse issue URI - const { did, collection, rkey } = parseIssueUri(issueUri); - - // Verify user owns the issue - if (did !== session.did) { - throw new Error('Cannot delete issue: you are not the author'); - } - - try { - // Delete record via AT Protocol - await client.getAgent().com.atproto.repo.deleteRecord({ - repo: did, - collection, - rkey, - }); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('not found')) { - throw new Error(`Issue not found: ${issueUri}`); - } - throw new Error(`Failed to delete issue: ${error.message}`); - } - 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) diff --git a/tests/commands/issue.test.ts b/tests/commands/issue.test.ts index d3e5cdd..c305428 100644 --- a/tests/commands/issue.test.ts +++ b/tests/commands/issue.test.ts @@ -1201,171 +1201,3 @@ describe('issue reopen command', () => { }); }); }); - -describe('issue delete command', () => { - let mockClient: TangledApiClient; - let consoleLogSpy: ReturnType; - let consoleErrorSpy: ReturnType; - let processExitSpy: ReturnType; - - const mockIssue: IssueWithMetadata = { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', - title: 'Test Issue', - createdAt: new Date('2024-01-01').toISOString(), - uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', - cid: 'bafyrei1', - author: 'did:plc:abc123', - }; - - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; - processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`); - }) as never; - - mockClient = { - resumeSession: vi.fn(async () => true), - } as unknown as TangledApiClient; - vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); - - vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ - owner: 'test.bsky.social', - ownerType: 'handle', - name: 'test-repo', - remoteName: 'origin', - remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', - protocol: 'ssh', - }); - - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ - number: 1, - title: mockIssue.title, - body: undefined, - state: 'open', - author: mockIssue.author, - createdAt: mockIssue.createdAt, - uri: mockIssue.uri, - cid: mockIssue.cid, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should delete issue with --force flag', async () => { - vi.mocked(issuesApi.listIssues).mockResolvedValue({ - issues: [mockIssue], - cursor: undefined, - }); - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); - - const command = createIssueCommand(); - await command.parseAsync(['node', 'test', 'delete', '1', '--force']); - - expect(issuesApi.deleteIssue).toHaveBeenCalledWith({ - client: mockClient, - issueUri: mockIssue.uri, - }); - expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); - }); - - it('should cancel deletion when user declines confirmation', async () => { - vi.mocked(issuesApi.listIssues).mockResolvedValue({ - issues: [mockIssue], - cursor: undefined, - }); - - const { confirm } = await import('@inquirer/prompts'); - vi.mocked(confirm).mockResolvedValue(false); - - const command = createIssueCommand(); - await expect(command.parseAsync(['node', 'test', 'delete', '1'])).rejects.toThrow( - 'process.exit(0)' - ); - - expect(issuesApi.deleteIssue).not.toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith('Deletion cancelled.'); - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - it('should delete when user confirms', async () => { - vi.mocked(issuesApi.listIssues).mockResolvedValue({ - issues: [mockIssue], - cursor: undefined, - }); - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); - - const { confirm } = await import('@inquirer/prompts'); - vi.mocked(confirm).mockResolvedValue(true); - - const command = createIssueCommand(); - await command.parseAsync(['node', 'test', 'delete', '1']); - - expect(issuesApi.deleteIssue).toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); - }); - - it('should fail when not authenticated', async () => { - vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { - console.error('✗ Not authenticated. Run "tangled auth login" first.'); - process.exit(1); - }); - - const command = createIssueCommand(); - await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( - 'process.exit(1)' - ); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '✗ Not authenticated. Run "tangled auth login" first.' - ); - }); - - describe('JSON output', () => { - it('should output JSON when --json is passed', async () => { - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); - - const command = createIssueCommand(); - await command.parseAsync(['node', 'test', 'delete', '1', '--force', '--json']); - - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); - expect(jsonOutput).toEqual({ - number: 1, - title: 'Test Issue', - state: 'open', - author: mockIssue.author, - createdAt: mockIssue.createdAt, - uri: mockIssue.uri, - cid: mockIssue.cid, - }); - }); - - it('should output filtered JSON when --json with fields is passed', async () => { - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); - - const command = createIssueCommand(); - await command.parseAsync([ - 'node', - 'test', - 'delete', - '1', - '--force', - '--json', - 'number,title', - ]); - - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); - expect(jsonOutput).toEqual({ number: 1, title: 'Test Issue' }); - expect(jsonOutput).not.toHaveProperty('uri'); - expect(jsonOutput).not.toHaveProperty('cid'); - }); - }); -}); diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index aa37af9..adff1ce 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -3,7 +3,6 @@ import type { TangledApiClient } from '../../src/lib/api-client.js'; import { closeIssue, createIssue, - deleteIssue, getCompleteIssueData, getIssue, getIssueState, @@ -592,80 +591,6 @@ describe('closeIssue', () => { }); }); -describe('deleteIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should delete an issue', async () => { - const mockDeleteRecord = vi.fn().mockResolvedValue({}); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - deleteRecord: mockDeleteRecord, - }, - }, - }, - } as never); - - await deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - }); - - expect(mockDeleteRecord).toHaveBeenCalledWith({ - repo: 'did:plc:test123', - collection: 'sh.tangled.repo.issue', - rkey: 'issue1', - }); - }); - - it('should throw error when deleting issue not owned by user', async () => { - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', - }) - ).rejects.toThrow('Cannot delete issue: you are not the author'); - }); - - it('should throw error when issue not found', async () => { - const mockDeleteRecord = vi.fn().mockRejectedValue(new Error('Record not found')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - deleteRecord: mockDeleteRecord, - }, - }, - }, - } as never); - - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', - }) - ).rejects.toThrow('Issue not found'); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - }) - ).rejects.toThrow('Must be authenticated'); - }); -}); - describe('getIssueState', () => { let mockClient: TangledApiClient; diff --git a/tests/utils/auth-helpers.test.ts b/tests/utils/auth-helpers.test.ts index a37d8fc..28718b5 100644 --- a/tests/utils/auth-helpers.test.ts +++ b/tests/utils/auth-helpers.test.ts @@ -84,20 +84,23 @@ describe('ensureAuthenticated', () => { expect(mockExit).toHaveBeenCalledWith(1); }); - it.skipIf(process.platform !== 'darwin')('should unlock keychain and retry when KeychainAccessError is thrown', async () => { - const mockClient = { - resumeSession: vi - .fn() - .mockRejectedValueOnce(new KeychainAccessError('locked')) - .mockResolvedValueOnce(true), - } as unknown as TangledApiClient; - - vi.mocked(execSync).mockReturnValue(Buffer.from('')); - - await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); - expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); - expect(mockExit).not.toHaveBeenCalled(); - }); + it.skipIf(process.platform !== 'darwin')( + 'should unlock keychain and retry when KeychainAccessError is thrown', + async () => { + const mockClient = { + resumeSession: vi + .fn() + .mockRejectedValueOnce(new KeychainAccessError('locked')) + .mockResolvedValueOnce(true), + } as unknown as TangledApiClient; + + vi.mocked(execSync).mockReturnValue(Buffer.from('')); + + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); + expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); + expect(mockExit).not.toHaveBeenCalled(); + } + ); it('should exit with keychain error when unlock fails', async () => { const mockClient = {