diff --git a/src/commands/issue.ts b/src/commands/issue.ts index c89d20f..e7d8ff4 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,10 +1,22 @@ +import { confirm } from '@inquirer/prompts'; import { Command } from 'commander'; import { createApiClient } from '../lib/api-client.js'; +import type { TangledApiClient } from '../lib/api-client.js'; import { getCurrentRepoContext } from '../lib/context.js'; -import { createIssue, listIssues } from '../lib/issues-api.js'; +import { + closeIssue, + createIssue, + deleteIssue, + getIssue, + getIssueState, + listIssues, + reopenIssue, + updateIssue, +} from '../lib/issues-api.js'; import { buildRepoAtUri } from '../utils/at-uri.js'; +import { requireAuth } from '../utils/auth-helpers.js'; import { readBodyInput } from '../utils/body-input.js'; -import { formatDate } from '../utils/formatting.js'; +import { formatDate, formatIssueState } from '../utils/formatting.js'; import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; /** @@ -15,6 +27,352 @@ function extractRkey(uri: string): string { return parts[parts.length - 1] || 'unknown'; } +/** + * Resolve issue number or rkey to full AT-URI + * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") + * @param client - API client + * @param repoAtUri - Repository AT-URI + * @returns Object with full issue AT-URI and display identifier + */ +async function resolveIssueUri( + input: string, + client: TangledApiClient, + repoAtUri: string +): Promise<{ uri: string; displayId: string }> { + // Strip # prefix if present + const normalized = input.startsWith('#') ? input.slice(1) : input; + + // Check if numeric + if (/^\d+$/.test(normalized)) { + const num = Number.parseInt(normalized, 10); + + if (num < 1) { + throw new Error('Issue number must be greater than 0'); + } + + // Query all issues for this repo + const { issues } = await listIssues({ + client, + repoAtUri, + limit: 100, // Adjust if needed for large repos + }); + + // Sort by creation time (oldest first) + const sorted = issues.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + // Get issue at index (1-based numbering) + const issue = sorted[num - 1]; + if (!issue) { + throw new Error(`Issue #${num} not found`); + } + + return { + uri: issue.uri, + displayId: `#${num}`, + }; + } + + // Treat as rkey - validate and build URI + if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { + throw new Error(`Invalid issue identifier: ${input}`); + } + + const session = await requireAuth(client); + return { + uri: `at://${session.did}/sh.tangled.repo.issue/${normalized}`, + displayId: normalized, + }; +} + +/** + * Issue view subcommand + */ +function createViewCommand(): Command { + return new Command('view') + .description('View details of a specific issue') + .argument('', 'Issue number (e.g., 1, #2) or rkey') + .action(async (issueId: string) => { + try { + // 1. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } + + // 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 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 4. Resolve issue ID to URI + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + + // 5. Fetch issue details + const issue = await getIssue({ client, issueUri }); + + // 6. Fetch issue state + const state = await getIssueState({ client, issueUri: issue.uri }); + + // 7. Display issue details + console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); + console.log(`Title: ${issue.title}`); + console.log(`Author: ${issue.author}`); + console.log(`Created: ${formatDate(issue.createdAt)}`); + console.log(`Repo: ${context.name}`); + console.log(`URI: ${issue.uri}`); + + if (issue.body) { + console.log('\nBody:'); + console.log(issue.body); + } + + console.log(); // Empty line at end + } catch (error) { + console.error( + `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + +/** + * Issue edit subcommand + */ +function createEditCommand(): Command { + return new Command('edit') + .description('Edit an issue title and/or body') + .argument('', 'Issue number or rkey') + .option('-t, --title ', 'New issue title') + .option('-b, --body ', 'New issue body text') + .option('-F, --body-file ', 'Read body from file (- for stdin)') + .action( + async (issueId: string, options: { title?: string; body?: string; bodyFile?: string }) => { + try { + // 1. Validate at least one option provided + if (!options.title && !options.body && !options.bodyFile) { + console.error('✗ At least one of --title, --body, or --body-file must be provided'); + process.exit(1); + } + + // 2. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } + + // 3. 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); + } + + // 4. Build repo AT-URI + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 5. Resolve issue ID to URI + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + + // 6. Handle body input + const body = await readBodyInput(options.body, options.bodyFile); + + // 7. Validate inputs + const validTitle = options.title ? validateIssueTitle(options.title) : undefined; + const validBody = body !== undefined ? validateIssueBody(body) : undefined; + + // 8. Update issue + await updateIssue({ + client, + issueUri, + title: validTitle, + body: validBody, + }); + + // 9. Display success + const updated: string[] = []; + if (validTitle !== undefined) updated.push('title'); + if (validBody !== undefined) updated.push('body'); + + console.log(`✓ Issue ${displayId} updated`); + console.log(` Updated: ${updated.join(', ')}`); + } catch (error) { + console.error( + `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + } + ); +} + +/** + * Issue close subcommand + */ +function createCloseCommand(): Command { + return new Command('close') + .description('Close an issue') + .argument('', 'Issue number or rkey') + .action(async (issueId: string) => { + try { + // 1. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } + + // 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 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 4. Resolve issue ID to URI + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + + // 5. Close issue + await closeIssue({ client, issueUri }); + + // 6. Display success + console.log(`✓ Issue ${displayId} closed`); + } catch (error) { + console.error( + `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + +/** + * Issue reopen subcommand + */ +function createReopenCommand(): Command { + return new Command('reopen') + .description('Reopen a closed issue') + .argument('', 'Issue number or rkey') + .action(async (issueId: string) => { + try { + // 1. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } + + // 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 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 4. Resolve issue ID to URI + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + + // 5. Reopen issue + await reopenIssue({ client, issueUri }); + + // 6. Display success + console.log(`✓ Issue ${displayId} reopened`); + } catch (error) { + console.error( + `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + +/** + * Issue delete subcommand + */ +function createDeleteCommand(): Command { + return new Command('delete') + .description('Delete an issue permanently') + .argument('', 'Issue number or rkey') + .option('-f, --force', 'Skip confirmation prompt') + .action(async (issueId: string, options: { force?: boolean }) => { + // 1. Validate auth + const client = createApiClient(); + if (!(await client.resumeSession())) { + console.error('✗ Not authenticated. Run "tangled auth login" first.'); + process.exit(1); + } + + // 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 and resolve issue ID + let issueUri: string; + let displayId: string; + try { + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, 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}? This cannot be undone.`, + default: false, + }); + + if (!confirmed) { + console.log('Deletion cancelled.'); + process.exit(0); + } + } + + // 5. Delete issue + try { + await deleteIssue({ client, issueUri }); + console.log(`✓ Issue ${displayId} deleted`); + } 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 */ @@ -24,6 +382,11 @@ export function createIssueCommand(): Command { issue.addCommand(createCreateCommand()); issue.addCommand(createListCommand()); + issue.addCommand(createViewCommand()); + issue.addCommand(createEditCommand()); + issue.addCommand(createCloseCommand()); + issue.addCommand(createReopenCommand()); + issue.addCommand(createDeleteCommand()); return issue; } @@ -137,13 +500,27 @@ function createListCommand(): Command { return; } - console.log(`\nFound ${issues.length} issue${issues.length === 1 ? '' : 's'}:\n`); + // Sort issues by creation time (oldest first) for consistent numbering + const sortedIssues = issues.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + console.log( + `\nFound ${sortedIssues.length} issue${sortedIssues.length === 1 ? '' : 's'}:\n` + ); - for (const issue of issues) { - const rkey = extractRkey(issue.uri); + // Fetch and display each issue with number and state + for (let i = 0; i < sortedIssues.length; i++) { + const issue = sortedIssues[i]; + const num = i + 1; const date = formatDate(issue.createdAt); - console.log(` #${rkey} ${issue.title}`); - console.log(` Created ${date}`); + + // Get issue state + const state = await getIssueState({ client, issueUri: issue.uri }); + const stateBadge = formatIssueState(state); + + console.log(` #${num} ${stateBadge} ${issue.title}`); + console.log(` Created ${date}`); console.log(); } } catch (error) { diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index cc331f6..14db2c1 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -16,3 +16,12 @@ export function formatDate(dateString: string): string { if (days < 365) return `${Math.floor(days / 30)} months ago`; return date.toLocaleDateString(); } + +/** + * Format issue state for display + * @param state - Issue state ('open' or 'closed') + * @returns Formatted state badge string + */ +export function formatIssueState(state: 'open' | 'closed'): string { + return state === 'open' ? '[OPEN]' : '[CLOSED]'; +} diff --git a/tests/commands/issue.test.ts b/tests/commands/issue.test.ts index a8d027c..254816e 100644 --- a/tests/commands/issue.test.ts +++ b/tests/commands/issue.test.ts @@ -6,6 +6,7 @@ import * as context from '../../src/lib/context.js'; import * as issuesApi from '../../src/lib/issues-api.js'; import type { IssueWithMetadata } from '../../src/lib/issues-api.js'; import * as atUri from '../../src/utils/at-uri.js'; +import * as authHelpers from '../../src/utils/auth-helpers.js'; import * as bodyInput from '../../src/utils/body-input.js'; // Mock dependencies @@ -14,6 +15,8 @@ vi.mock('../../src/lib/issues-api.js'); vi.mock('../../src/lib/context.js'); vi.mock('../../src/utils/at-uri.js'); vi.mock('../../src/utils/body-input.js'); +vi.mock('../../src/utils/auth-helpers.js'); +vi.mock('@inquirer/prompts'); describe('issue create command', () => { let mockClient: TangledApiClient; @@ -278,9 +281,7 @@ describe('issue list command', () => { }); // Mock AT-URI builder - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( - 'at://did:plc:abc123/sh.tangled.repo/xyz789' - ); + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); }); afterEach(() => { @@ -314,6 +315,7 @@ describe('issue list command', () => { issues: mockIssues, cursor: undefined, }); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list']); @@ -325,8 +327,8 @@ describe('issue list command', () => { }); expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); - expect(consoleLogSpy).toHaveBeenCalledWith(' #issue1 First Issue'); - expect(consoleLogSpy).toHaveBeenCalledWith(' #issue2 Second Issue'); + expect(consoleLogSpy).toHaveBeenCalledWith(' #1 [OPEN] First Issue'); + expect(consoleLogSpy).toHaveBeenCalledWith(' #2 [OPEN] Second Issue'); }); it('should handle custom limit', async () => { @@ -364,9 +366,7 @@ describe('issue list command', () => { const command = createIssueCommand(); - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' @@ -381,9 +381,7 @@ describe('issue list command', () => { const command = createIssueCommand(); - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); expect(processExitSpy).toHaveBeenCalledWith(1); @@ -398,35 +396,29 @@ describe('issue list command', () => { 'process.exit(1)' ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '✗ Invalid limit. Must be between 1 and 100.' - ); + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); }); it('should fail with invalid limit (too high)', async () => { const command = createIssueCommand(); - await expect( - command.parseAsync(['node', 'test', 'list', '--limit', '101']) - ).rejects.toThrow('process.exit(1)'); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '✗ Invalid limit. Must be between 1 and 100.' + await expect(command.parseAsync(['node', 'test', 'list', '--limit', '101'])).rejects.toThrow( + 'process.exit(1)' ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); }); it('should fail with non-numeric limit', async () => { const command = createIssueCommand(); - await expect( - command.parseAsync(['node', 'test', 'list', '--limit', 'abc']) - ).rejects.toThrow('process.exit(1)'); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '✗ Invalid limit. Must be between 1 and 100.' + await expect(command.parseAsync(['node', 'test', 'list', '--limit', 'abc'])).rejects.toThrow( + 'process.exit(1)' ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); }); }); @@ -437,12 +429,516 @@ describe('issue list command', () => { const command = createIssueCommand(); - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); expect(processExitSpy).toHaveBeenCalledWith(1); }); }); }); + +describe('issue view command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + const mockIssue: IssueWithMetadata = { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + title: 'Test Issue', + body: 'Issue body', + 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; + 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(authHelpers.requireAuth).mockResolvedValue({ + did: 'did:plc:abc123', + handle: 'test.bsky.social', + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should view issue by number', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'view', '1']); + + expect(issuesApi.getIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + }); + expect(issuesApi.getIssueState).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + }); + expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); + expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); + expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); + expect(consoleLogSpy).toHaveBeenCalledWith('Issue body'); + }); + + it('should view issue by rkey', async () => { + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'view', 'issue1']); + + expect(issuesApi.getIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', + }); + expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); + }); + + it('should show issue without body', async () => { + const issueWithoutBody = { ...mockIssue, body: undefined }; + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [issueWithoutBody], + cursor: undefined, + }); + vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'view', '1']); + + const allCalls = consoleLogSpy.mock.calls.map((c) => c[0]); + expect(allCalls).not.toContain('Body:'); + }); + + it('should fail when not authenticated', async () => { + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); + + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( + 'process.exit(1)' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '✗ Not authenticated. Run "tangled auth login" first.' + ); + }); + + it('should fail when not in a Tangled repository', async () => { + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); + + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( + 'process.exit(1)' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); + }); + + it('should fail when issue number is out of range', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( + 'process.exit(1)' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); + }); +}); + +describe('issue edit command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + const mockIssue: IssueWithMetadata = { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + title: 'Original Title', + 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; + 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(bodyInput.readBodyInput).mockResolvedValue(undefined); + vi.mocked(authHelpers.requireAuth).mockResolvedValue({ + did: 'did:plc:abc123', + handle: 'test.bsky.social', + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should edit issue title by number', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, title: 'New Title' }); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title']); + + expect(issuesApi.updateIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + title: 'New Title', + body: undefined, + }); + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 updated'); + expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: title'); + }); + + it('should edit issue body', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(bodyInput.readBodyInput).mockResolvedValue('New body'); + vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, body: 'New body' }); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'edit', '1', '--body', 'New body']); + + expect(issuesApi.updateIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + title: undefined, + body: 'New body', + }); + expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: body'); + }); + + it('should fail when no options provided', async () => { + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'edit', '1'])).rejects.toThrow( + 'process.exit(1)' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '✗ At least one of --title, --body, or --body-file must be provided' + ); + }); + + it('should fail when not authenticated', async () => { + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); + + const command = createIssueCommand(); + await expect( + command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New']) + ).rejects.toThrow('process.exit(1)'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '✗ Not authenticated. Run "tangled auth login" first.' + ); + }); +}); + +describe('issue close command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: 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; + vi.spyOn(console, 'error').mockImplementation(() => {}); + 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'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should close issue by number', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'close', '1']); + + expect(issuesApi.closeIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + }); + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 closed'); + }); + + it('should fail when not authenticated', async () => { + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); + + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( + 'process.exit(1)' + ); + }); +}); + +describe('issue reopen command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: 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; + vi.spyOn(console, 'error').mockImplementation(() => {}); + 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'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should reopen issue by number', async () => { + vi.mocked(issuesApi.listIssues).mockResolvedValue({ + issues: [mockIssue], + cursor: undefined, + }); + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); + + const command = createIssueCommand(); + await command.parseAsync(['node', 'test', 'reopen', '1']); + + expect(issuesApi.reopenIssue).toHaveBeenCalledWith({ + client: mockClient, + issueUri: mockIssue.uri, + }); + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 reopened'); + }); + + it('should fail when not authenticated', async () => { + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); + + const command = createIssueCommand(); + await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( + 'process.exit(1)' + ); + }); +}); + +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'); + }); + + 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'); + }); + + 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'); + }); + + it('should fail when not authenticated', async () => { + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); + + 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.' + ); + }); +});