WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add issue view, edit, close, reopen, delete commands

Implements the remaining issue management subcommands with full
lifecycle support:

- view: displays issue details including state (open/closed), author,
creation date, body; supports numeric (#1) and rkey identifiers
- edit: updates title and/or body via --title/-t, --body/-b, --body-file/-F
- close/reopen: toggle issue state; idempotent by design
- delete: permanently removes an issue; requires confirmation unless
--force is passed; cancel path uses process.exit(0) outside try/catch
to correctly propagate clean exit

Also:
- resolveIssueUri helper: on-demand numeric resolution by sorting issues
by createdAt (oldest = #1), no persistent cache needed
- list command updated to show numbered issues with state badges
- formatIssueState added to src/utils/formatting.ts
- All existing list tests updated for new output format; new test suites
added for all five commands covering auth, context, numeric/rkey
resolution, option validation, and confirmation flow

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+918 -36
+384 -7
src/commands/issue.ts
··· 1 + import { confirm } from '@inquirer/prompts'; 1 2 import { Command } from 'commander'; 2 3 import { createApiClient } from '../lib/api-client.js'; 4 + import type { TangledApiClient } from '../lib/api-client.js'; 3 5 import { getCurrentRepoContext } from '../lib/context.js'; 4 - import { createIssue, listIssues } from '../lib/issues-api.js'; 6 + import { 7 + closeIssue, 8 + createIssue, 9 + deleteIssue, 10 + getIssue, 11 + getIssueState, 12 + listIssues, 13 + reopenIssue, 14 + updateIssue, 15 + } from '../lib/issues-api.js'; 5 16 import { buildRepoAtUri } from '../utils/at-uri.js'; 17 + import { requireAuth } from '../utils/auth-helpers.js'; 6 18 import { readBodyInput } from '../utils/body-input.js'; 7 - import { formatDate } from '../utils/formatting.js'; 19 + import { formatDate, formatIssueState } from '../utils/formatting.js'; 8 20 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; 9 21 10 22 /** ··· 16 28 } 17 29 18 30 /** 31 + * Resolve issue number or rkey to full AT-URI 32 + * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") 33 + * @param client - API client 34 + * @param repoAtUri - Repository AT-URI 35 + * @returns Object with full issue AT-URI and display identifier 36 + */ 37 + async function resolveIssueUri( 38 + input: string, 39 + client: TangledApiClient, 40 + repoAtUri: string 41 + ): Promise<{ uri: string; displayId: string }> { 42 + // Strip # prefix if present 43 + const normalized = input.startsWith('#') ? input.slice(1) : input; 44 + 45 + // Check if numeric 46 + if (/^\d+$/.test(normalized)) { 47 + const num = Number.parseInt(normalized, 10); 48 + 49 + if (num < 1) { 50 + throw new Error('Issue number must be greater than 0'); 51 + } 52 + 53 + // Query all issues for this repo 54 + const { issues } = await listIssues({ 55 + client, 56 + repoAtUri, 57 + limit: 100, // Adjust if needed for large repos 58 + }); 59 + 60 + // Sort by creation time (oldest first) 61 + const sorted = issues.sort( 62 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 63 + ); 64 + 65 + // Get issue at index (1-based numbering) 66 + const issue = sorted[num - 1]; 67 + if (!issue) { 68 + throw new Error(`Issue #${num} not found`); 69 + } 70 + 71 + return { 72 + uri: issue.uri, 73 + displayId: `#${num}`, 74 + }; 75 + } 76 + 77 + // Treat as rkey - validate and build URI 78 + if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { 79 + throw new Error(`Invalid issue identifier: ${input}`); 80 + } 81 + 82 + const session = await requireAuth(client); 83 + return { 84 + uri: `at://${session.did}/sh.tangled.repo.issue/${normalized}`, 85 + displayId: normalized, 86 + }; 87 + } 88 + 89 + /** 90 + * Issue view subcommand 91 + */ 92 + function createViewCommand(): Command { 93 + return new Command('view') 94 + .description('View details of a specific issue') 95 + .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 96 + .action(async (issueId: string) => { 97 + try { 98 + // 1. Validate auth 99 + const client = createApiClient(); 100 + if (!(await client.resumeSession())) { 101 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 102 + process.exit(1); 103 + } 104 + 105 + // 2. Get repo context 106 + const context = await getCurrentRepoContext(); 107 + if (!context) { 108 + console.error('✗ Not in a Tangled repository'); 109 + console.error('\nTo use this repository with Tangled, add a remote:'); 110 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 111 + process.exit(1); 112 + } 113 + 114 + // 3. Build repo AT-URI 115 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 116 + 117 + // 4. Resolve issue ID to URI 118 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 119 + 120 + // 5. Fetch issue details 121 + const issue = await getIssue({ client, issueUri }); 122 + 123 + // 6. Fetch issue state 124 + const state = await getIssueState({ client, issueUri: issue.uri }); 125 + 126 + // 7. Display issue details 127 + console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 128 + console.log(`Title: ${issue.title}`); 129 + console.log(`Author: ${issue.author}`); 130 + console.log(`Created: ${formatDate(issue.createdAt)}`); 131 + console.log(`Repo: ${context.name}`); 132 + console.log(`URI: ${issue.uri}`); 133 + 134 + if (issue.body) { 135 + console.log('\nBody:'); 136 + console.log(issue.body); 137 + } 138 + 139 + console.log(); // Empty line at end 140 + } catch (error) { 141 + console.error( 142 + `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}` 143 + ); 144 + process.exit(1); 145 + } 146 + }); 147 + } 148 + 149 + /** 150 + * Issue edit subcommand 151 + */ 152 + function createEditCommand(): Command { 153 + return new Command('edit') 154 + .description('Edit an issue title and/or body') 155 + .argument('<issue-id>', 'Issue number or rkey') 156 + .option('-t, --title <string>', 'New issue title') 157 + .option('-b, --body <string>', 'New issue body text') 158 + .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 159 + .action( 160 + async (issueId: string, options: { title?: string; body?: string; bodyFile?: string }) => { 161 + try { 162 + // 1. Validate at least one option provided 163 + if (!options.title && !options.body && !options.bodyFile) { 164 + console.error('✗ At least one of --title, --body, or --body-file must be provided'); 165 + process.exit(1); 166 + } 167 + 168 + // 2. Validate auth 169 + const client = createApiClient(); 170 + if (!(await client.resumeSession())) { 171 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 172 + process.exit(1); 173 + } 174 + 175 + // 3. Get repo context 176 + const context = await getCurrentRepoContext(); 177 + if (!context) { 178 + console.error('✗ Not in a Tangled repository'); 179 + console.error('\nTo use this repository with Tangled, add a remote:'); 180 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 181 + process.exit(1); 182 + } 183 + 184 + // 4. Build repo AT-URI 185 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 186 + 187 + // 5. Resolve issue ID to URI 188 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 189 + 190 + // 6. Handle body input 191 + const body = await readBodyInput(options.body, options.bodyFile); 192 + 193 + // 7. Validate inputs 194 + const validTitle = options.title ? validateIssueTitle(options.title) : undefined; 195 + const validBody = body !== undefined ? validateIssueBody(body) : undefined; 196 + 197 + // 8. Update issue 198 + await updateIssue({ 199 + client, 200 + issueUri, 201 + title: validTitle, 202 + body: validBody, 203 + }); 204 + 205 + // 9. Display success 206 + const updated: string[] = []; 207 + if (validTitle !== undefined) updated.push('title'); 208 + if (validBody !== undefined) updated.push('body'); 209 + 210 + console.log(`✓ Issue ${displayId} updated`); 211 + console.log(` Updated: ${updated.join(', ')}`); 212 + } catch (error) { 213 + console.error( 214 + `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}` 215 + ); 216 + process.exit(1); 217 + } 218 + } 219 + ); 220 + } 221 + 222 + /** 223 + * Issue close subcommand 224 + */ 225 + function createCloseCommand(): Command { 226 + return new Command('close') 227 + .description('Close an issue') 228 + .argument('<issue-id>', 'Issue number or rkey') 229 + .action(async (issueId: string) => { 230 + try { 231 + // 1. Validate auth 232 + const client = createApiClient(); 233 + if (!(await client.resumeSession())) { 234 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 235 + process.exit(1); 236 + } 237 + 238 + // 2. Get repo context 239 + const context = await getCurrentRepoContext(); 240 + if (!context) { 241 + console.error('✗ Not in a Tangled repository'); 242 + console.error('\nTo use this repository with Tangled, add a remote:'); 243 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 244 + process.exit(1); 245 + } 246 + 247 + // 3. Build repo AT-URI 248 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 249 + 250 + // 4. Resolve issue ID to URI 251 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 252 + 253 + // 5. Close issue 254 + await closeIssue({ client, issueUri }); 255 + 256 + // 6. Display success 257 + console.log(`✓ Issue ${displayId} closed`); 258 + } catch (error) { 259 + console.error( 260 + `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` 261 + ); 262 + process.exit(1); 263 + } 264 + }); 265 + } 266 + 267 + /** 268 + * Issue reopen subcommand 269 + */ 270 + function createReopenCommand(): Command { 271 + return new Command('reopen') 272 + .description('Reopen a closed issue') 273 + .argument('<issue-id>', 'Issue number or rkey') 274 + .action(async (issueId: string) => { 275 + try { 276 + // 1. Validate auth 277 + const client = createApiClient(); 278 + if (!(await client.resumeSession())) { 279 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 280 + process.exit(1); 281 + } 282 + 283 + // 2. Get repo context 284 + const context = await getCurrentRepoContext(); 285 + if (!context) { 286 + console.error('✗ Not in a Tangled repository'); 287 + console.error('\nTo use this repository with Tangled, add a remote:'); 288 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 289 + process.exit(1); 290 + } 291 + 292 + // 3. Build repo AT-URI 293 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 294 + 295 + // 4. Resolve issue ID to URI 296 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 297 + 298 + // 5. Reopen issue 299 + await reopenIssue({ client, issueUri }); 300 + 301 + // 6. Display success 302 + console.log(`✓ Issue ${displayId} reopened`); 303 + } catch (error) { 304 + console.error( 305 + `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` 306 + ); 307 + process.exit(1); 308 + } 309 + }); 310 + } 311 + 312 + /** 313 + * Issue delete subcommand 314 + */ 315 + function createDeleteCommand(): Command { 316 + return new Command('delete') 317 + .description('Delete an issue permanently') 318 + .argument('<issue-id>', 'Issue number or rkey') 319 + .option('-f, --force', 'Skip confirmation prompt') 320 + .action(async (issueId: string, options: { force?: boolean }) => { 321 + // 1. Validate auth 322 + const client = createApiClient(); 323 + if (!(await client.resumeSession())) { 324 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 325 + process.exit(1); 326 + } 327 + 328 + // 2. Get repo context 329 + const context = await getCurrentRepoContext(); 330 + if (!context) { 331 + console.error('✗ Not in a Tangled repository'); 332 + console.error('\nTo use this repository with Tangled, add a remote:'); 333 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 334 + process.exit(1); 335 + } 336 + 337 + // 3. Build repo AT-URI and resolve issue ID 338 + let issueUri: string; 339 + let displayId: string; 340 + try { 341 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 342 + ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 343 + } catch (error) { 344 + console.error( 345 + `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 346 + ); 347 + process.exit(1); 348 + } 349 + 350 + // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 351 + if (!options.force) { 352 + const confirmed = await confirm({ 353 + message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 354 + default: false, 355 + }); 356 + 357 + if (!confirmed) { 358 + console.log('Deletion cancelled.'); 359 + process.exit(0); 360 + } 361 + } 362 + 363 + // 5. Delete issue 364 + try { 365 + await deleteIssue({ client, issueUri }); 366 + console.log(`✓ Issue ${displayId} deleted`); 367 + } catch (error) { 368 + console.error( 369 + `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 370 + ); 371 + process.exit(1); 372 + } 373 + }); 374 + } 375 + 376 + /** 19 377 * Create the issue command with all subcommands 20 378 */ 21 379 export function createIssueCommand(): Command { ··· 24 382 25 383 issue.addCommand(createCreateCommand()); 26 384 issue.addCommand(createListCommand()); 385 + issue.addCommand(createViewCommand()); 386 + issue.addCommand(createEditCommand()); 387 + issue.addCommand(createCloseCommand()); 388 + issue.addCommand(createReopenCommand()); 389 + issue.addCommand(createDeleteCommand()); 27 390 28 391 return issue; 29 392 } ··· 137 500 return; 138 501 } 139 502 140 - console.log(`\nFound ${issues.length} issue${issues.length === 1 ? '' : 's'}:\n`); 503 + // Sort issues by creation time (oldest first) for consistent numbering 504 + const sortedIssues = issues.sort( 505 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 506 + ); 141 507 142 - for (const issue of issues) { 143 - const rkey = extractRkey(issue.uri); 508 + console.log( 509 + `\nFound ${sortedIssues.length} issue${sortedIssues.length === 1 ? '' : 's'}:\n` 510 + ); 511 + 512 + // Fetch and display each issue with number and state 513 + for (let i = 0; i < sortedIssues.length; i++) { 514 + const issue = sortedIssues[i]; 515 + const num = i + 1; 144 516 const date = formatDate(issue.createdAt); 145 - console.log(` #${rkey} ${issue.title}`); 146 - console.log(` Created ${date}`); 517 + 518 + // Get issue state 519 + const state = await getIssueState({ client, issueUri: issue.uri }); 520 + const stateBadge = formatIssueState(state); 521 + 522 + console.log(` #${num} ${stateBadge} ${issue.title}`); 523 + console.log(` Created ${date}`); 147 524 console.log(); 148 525 } 149 526 } catch (error) {
+9
src/utils/formatting.ts
··· 16 16 if (days < 365) return `${Math.floor(days / 30)} months ago`; 17 17 return date.toLocaleDateString(); 18 18 } 19 + 20 + /** 21 + * Format issue state for display 22 + * @param state - Issue state ('open' or 'closed') 23 + * @returns Formatted state badge string 24 + */ 25 + export function formatIssueState(state: 'open' | 'closed'): string { 26 + return state === 'open' ? '[OPEN]' : '[CLOSED]'; 27 + }
+525 -29
tests/commands/issue.test.ts
··· 6 6 import * as issuesApi from '../../src/lib/issues-api.js'; 7 7 import type { IssueWithMetadata } from '../../src/lib/issues-api.js'; 8 8 import * as atUri from '../../src/utils/at-uri.js'; 9 + import * as authHelpers from '../../src/utils/auth-helpers.js'; 9 10 import * as bodyInput from '../../src/utils/body-input.js'; 10 11 11 12 // Mock dependencies ··· 14 15 vi.mock('../../src/lib/context.js'); 15 16 vi.mock('../../src/utils/at-uri.js'); 16 17 vi.mock('../../src/utils/body-input.js'); 18 + vi.mock('../../src/utils/auth-helpers.js'); 19 + vi.mock('@inquirer/prompts'); 17 20 18 21 describe('issue create command', () => { 19 22 let mockClient: TangledApiClient; ··· 278 281 }); 279 282 280 283 // Mock AT-URI builder 281 - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 282 - 'at://did:plc:abc123/sh.tangled.repo/xyz789' 283 - ); 284 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 284 285 }); 285 286 286 287 afterEach(() => { ··· 314 315 issues: mockIssues, 315 316 cursor: undefined, 316 317 }); 318 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 317 319 318 320 const command = createIssueCommand(); 319 321 await command.parseAsync(['node', 'test', 'list']); ··· 325 327 }); 326 328 327 329 expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); 328 - expect(consoleLogSpy).toHaveBeenCalledWith(' #issue1 First Issue'); 329 - expect(consoleLogSpy).toHaveBeenCalledWith(' #issue2 Second Issue'); 330 + expect(consoleLogSpy).toHaveBeenCalledWith(' #1 [OPEN] First Issue'); 331 + expect(consoleLogSpy).toHaveBeenCalledWith(' #2 [OPEN] Second Issue'); 330 332 }); 331 333 332 334 it('should handle custom limit', async () => { ··· 364 366 365 367 const command = createIssueCommand(); 366 368 367 - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 368 - 'process.exit(1)' 369 - ); 369 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 370 370 371 371 expect(consoleErrorSpy).toHaveBeenCalledWith( 372 372 '✗ Not authenticated. Run "tangled auth login" first.' ··· 381 381 382 382 const command = createIssueCommand(); 383 383 384 - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 385 - 'process.exit(1)' 386 - ); 384 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 387 385 388 386 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 389 387 expect(processExitSpy).toHaveBeenCalledWith(1); ··· 398 396 'process.exit(1)' 399 397 ); 400 398 401 - expect(consoleErrorSpy).toHaveBeenCalledWith( 402 - '✗ Invalid limit. Must be between 1 and 100.' 403 - ); 399 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 404 400 expect(processExitSpy).toHaveBeenCalledWith(1); 405 401 }); 406 402 407 403 it('should fail with invalid limit (too high)', async () => { 408 404 const command = createIssueCommand(); 409 405 410 - await expect( 411 - command.parseAsync(['node', 'test', 'list', '--limit', '101']) 412 - ).rejects.toThrow('process.exit(1)'); 406 + await expect(command.parseAsync(['node', 'test', 'list', '--limit', '101'])).rejects.toThrow( 407 + 'process.exit(1)' 408 + ); 413 409 414 - expect(consoleErrorSpy).toHaveBeenCalledWith( 415 - '✗ Invalid limit. Must be between 1 and 100.' 416 - ); 410 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 417 411 expect(processExitSpy).toHaveBeenCalledWith(1); 418 412 }); 419 413 420 414 it('should fail with non-numeric limit', async () => { 421 415 const command = createIssueCommand(); 422 416 423 - await expect( 424 - command.parseAsync(['node', 'test', 'list', '--limit', 'abc']) 425 - ).rejects.toThrow('process.exit(1)'); 417 + await expect(command.parseAsync(['node', 'test', 'list', '--limit', 'abc'])).rejects.toThrow( 418 + 'process.exit(1)' 419 + ); 426 420 427 - expect(consoleErrorSpy).toHaveBeenCalledWith( 428 - '✗ Invalid limit. Must be between 1 and 100.' 429 - ); 421 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 430 422 expect(processExitSpy).toHaveBeenCalledWith(1); 431 423 }); 432 424 }); ··· 437 429 438 430 const command = createIssueCommand(); 439 431 440 - await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 441 - 'process.exit(1)' 442 - ); 432 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 443 433 444 434 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); 445 435 expect(processExitSpy).toHaveBeenCalledWith(1); 446 436 }); 447 437 }); 448 438 }); 439 + 440 + describe('issue view command', () => { 441 + let mockClient: TangledApiClient; 442 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 443 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 444 + 445 + const mockIssue: IssueWithMetadata = { 446 + $type: 'sh.tangled.repo.issue', 447 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 448 + title: 'Test Issue', 449 + body: 'Issue body', 450 + createdAt: new Date('2024-01-01').toISOString(), 451 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 452 + cid: 'bafyrei1', 453 + author: 'did:plc:abc123', 454 + }; 455 + 456 + beforeEach(() => { 457 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 458 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 459 + vi.spyOn(process, 'exit').mockImplementation((code) => { 460 + throw new Error(`process.exit(${code})`); 461 + }) as never; 462 + 463 + mockClient = { 464 + resumeSession: vi.fn(async () => true), 465 + } as unknown as TangledApiClient; 466 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 467 + 468 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 469 + owner: 'test.bsky.social', 470 + ownerType: 'handle', 471 + name: 'test-repo', 472 + remoteName: 'origin', 473 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 474 + protocol: 'ssh', 475 + }); 476 + 477 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 478 + 479 + vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 480 + did: 'did:plc:abc123', 481 + handle: 'test.bsky.social', 482 + } as never); 483 + }); 484 + 485 + afterEach(() => { 486 + vi.restoreAllMocks(); 487 + }); 488 + 489 + it('should view issue by number', async () => { 490 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 491 + issues: [mockIssue], 492 + cursor: undefined, 493 + }); 494 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 495 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 496 + 497 + const command = createIssueCommand(); 498 + await command.parseAsync(['node', 'test', 'view', '1']); 499 + 500 + expect(issuesApi.getIssue).toHaveBeenCalledWith({ 501 + client: mockClient, 502 + issueUri: mockIssue.uri, 503 + }); 504 + expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 505 + client: mockClient, 506 + issueUri: mockIssue.uri, 507 + }); 508 + expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 509 + expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 510 + expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); 511 + expect(consoleLogSpy).toHaveBeenCalledWith('Issue body'); 512 + }); 513 + 514 + it('should view issue by rkey', async () => { 515 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 516 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 517 + 518 + const command = createIssueCommand(); 519 + await command.parseAsync(['node', 'test', 'view', 'issue1']); 520 + 521 + expect(issuesApi.getIssue).toHaveBeenCalledWith({ 522 + client: mockClient, 523 + issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 524 + }); 525 + expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 526 + }); 527 + 528 + it('should show issue without body', async () => { 529 + const issueWithoutBody = { ...mockIssue, body: undefined }; 530 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 531 + issues: [issueWithoutBody], 532 + cursor: undefined, 533 + }); 534 + vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 535 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 536 + 537 + const command = createIssueCommand(); 538 + await command.parseAsync(['node', 'test', 'view', '1']); 539 + 540 + const allCalls = consoleLogSpy.mock.calls.map((c) => c[0]); 541 + expect(allCalls).not.toContain('Body:'); 542 + }); 543 + 544 + it('should fail when not authenticated', async () => { 545 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 546 + 547 + const command = createIssueCommand(); 548 + await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 549 + 'process.exit(1)' 550 + ); 551 + 552 + expect(consoleErrorSpy).toHaveBeenCalledWith( 553 + '✗ Not authenticated. Run "tangled auth login" first.' 554 + ); 555 + }); 556 + 557 + it('should fail when not in a Tangled repository', async () => { 558 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 559 + 560 + const command = createIssueCommand(); 561 + await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 562 + 'process.exit(1)' 563 + ); 564 + 565 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 566 + }); 567 + 568 + it('should fail when issue number is out of range', async () => { 569 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 570 + issues: [mockIssue], 571 + cursor: undefined, 572 + }); 573 + 574 + const command = createIssueCommand(); 575 + await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( 576 + 'process.exit(1)' 577 + ); 578 + 579 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); 580 + }); 581 + }); 582 + 583 + describe('issue edit command', () => { 584 + let mockClient: TangledApiClient; 585 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 586 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 587 + 588 + const mockIssue: IssueWithMetadata = { 589 + $type: 'sh.tangled.repo.issue', 590 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 591 + title: 'Original Title', 592 + createdAt: new Date('2024-01-01').toISOString(), 593 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 594 + cid: 'bafyrei1', 595 + author: 'did:plc:abc123', 596 + }; 597 + 598 + beforeEach(() => { 599 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 600 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 601 + vi.spyOn(process, 'exit').mockImplementation((code) => { 602 + throw new Error(`process.exit(${code})`); 603 + }) as never; 604 + 605 + mockClient = { 606 + resumeSession: vi.fn(async () => true), 607 + } as unknown as TangledApiClient; 608 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 609 + 610 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 611 + owner: 'test.bsky.social', 612 + ownerType: 'handle', 613 + name: 'test-repo', 614 + remoteName: 'origin', 615 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 616 + protocol: 'ssh', 617 + }); 618 + 619 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 620 + 621 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 622 + vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 623 + did: 'did:plc:abc123', 624 + handle: 'test.bsky.social', 625 + } as never); 626 + }); 627 + 628 + afterEach(() => { 629 + vi.restoreAllMocks(); 630 + }); 631 + 632 + it('should edit issue title by number', async () => { 633 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 634 + issues: [mockIssue], 635 + cursor: undefined, 636 + }); 637 + vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, title: 'New Title' }); 638 + 639 + const command = createIssueCommand(); 640 + await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title']); 641 + 642 + expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 643 + client: mockClient, 644 + issueUri: mockIssue.uri, 645 + title: 'New Title', 646 + body: undefined, 647 + }); 648 + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 updated'); 649 + expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: title'); 650 + }); 651 + 652 + it('should edit issue body', async () => { 653 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 654 + issues: [mockIssue], 655 + cursor: undefined, 656 + }); 657 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue('New body'); 658 + vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, body: 'New body' }); 659 + 660 + const command = createIssueCommand(); 661 + await command.parseAsync(['node', 'test', 'edit', '1', '--body', 'New body']); 662 + 663 + expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 664 + client: mockClient, 665 + issueUri: mockIssue.uri, 666 + title: undefined, 667 + body: 'New body', 668 + }); 669 + expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: body'); 670 + }); 671 + 672 + it('should fail when no options provided', async () => { 673 + const command = createIssueCommand(); 674 + await expect(command.parseAsync(['node', 'test', 'edit', '1'])).rejects.toThrow( 675 + 'process.exit(1)' 676 + ); 677 + 678 + expect(consoleErrorSpy).toHaveBeenCalledWith( 679 + '✗ At least one of --title, --body, or --body-file must be provided' 680 + ); 681 + }); 682 + 683 + it('should fail when not authenticated', async () => { 684 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 685 + 686 + const command = createIssueCommand(); 687 + await expect( 688 + command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New']) 689 + ).rejects.toThrow('process.exit(1)'); 690 + 691 + expect(consoleErrorSpy).toHaveBeenCalledWith( 692 + '✗ Not authenticated. Run "tangled auth login" first.' 693 + ); 694 + }); 695 + }); 696 + 697 + describe('issue close command', () => { 698 + let mockClient: TangledApiClient; 699 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 700 + 701 + const mockIssue: IssueWithMetadata = { 702 + $type: 'sh.tangled.repo.issue', 703 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 704 + title: 'Test Issue', 705 + createdAt: new Date('2024-01-01').toISOString(), 706 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 707 + cid: 'bafyrei1', 708 + author: 'did:plc:abc123', 709 + }; 710 + 711 + beforeEach(() => { 712 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 713 + vi.spyOn(console, 'error').mockImplementation(() => {}); 714 + vi.spyOn(process, 'exit').mockImplementation((code) => { 715 + throw new Error(`process.exit(${code})`); 716 + }) as never; 717 + 718 + mockClient = { 719 + resumeSession: vi.fn(async () => true), 720 + } as unknown as TangledApiClient; 721 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 722 + 723 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 724 + owner: 'test.bsky.social', 725 + ownerType: 'handle', 726 + name: 'test-repo', 727 + remoteName: 'origin', 728 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 729 + protocol: 'ssh', 730 + }); 731 + 732 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 733 + }); 734 + 735 + afterEach(() => { 736 + vi.restoreAllMocks(); 737 + }); 738 + 739 + it('should close issue by number', async () => { 740 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 741 + issues: [mockIssue], 742 + cursor: undefined, 743 + }); 744 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 745 + 746 + const command = createIssueCommand(); 747 + await command.parseAsync(['node', 'test', 'close', '1']); 748 + 749 + expect(issuesApi.closeIssue).toHaveBeenCalledWith({ 750 + client: mockClient, 751 + issueUri: mockIssue.uri, 752 + }); 753 + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 closed'); 754 + }); 755 + 756 + it('should fail when not authenticated', async () => { 757 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 758 + 759 + const command = createIssueCommand(); 760 + await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( 761 + 'process.exit(1)' 762 + ); 763 + }); 764 + }); 765 + 766 + describe('issue reopen command', () => { 767 + let mockClient: TangledApiClient; 768 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 769 + 770 + const mockIssue: IssueWithMetadata = { 771 + $type: 'sh.tangled.repo.issue', 772 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 773 + title: 'Test Issue', 774 + createdAt: new Date('2024-01-01').toISOString(), 775 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 776 + cid: 'bafyrei1', 777 + author: 'did:plc:abc123', 778 + }; 779 + 780 + beforeEach(() => { 781 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 782 + vi.spyOn(console, 'error').mockImplementation(() => {}); 783 + vi.spyOn(process, 'exit').mockImplementation((code) => { 784 + throw new Error(`process.exit(${code})`); 785 + }) as never; 786 + 787 + mockClient = { 788 + resumeSession: vi.fn(async () => true), 789 + } as unknown as TangledApiClient; 790 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 791 + 792 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 793 + owner: 'test.bsky.social', 794 + ownerType: 'handle', 795 + name: 'test-repo', 796 + remoteName: 'origin', 797 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 798 + protocol: 'ssh', 799 + }); 800 + 801 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 802 + }); 803 + 804 + afterEach(() => { 805 + vi.restoreAllMocks(); 806 + }); 807 + 808 + it('should reopen issue by number', async () => { 809 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 810 + issues: [mockIssue], 811 + cursor: undefined, 812 + }); 813 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 814 + 815 + const command = createIssueCommand(); 816 + await command.parseAsync(['node', 'test', 'reopen', '1']); 817 + 818 + expect(issuesApi.reopenIssue).toHaveBeenCalledWith({ 819 + client: mockClient, 820 + issueUri: mockIssue.uri, 821 + }); 822 + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 reopened'); 823 + }); 824 + 825 + it('should fail when not authenticated', async () => { 826 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 827 + 828 + const command = createIssueCommand(); 829 + await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( 830 + 'process.exit(1)' 831 + ); 832 + }); 833 + }); 834 + 835 + describe('issue delete command', () => { 836 + let mockClient: TangledApiClient; 837 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 838 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 839 + let processExitSpy: ReturnType<typeof vi.spyOn>; 840 + 841 + const mockIssue: IssueWithMetadata = { 842 + $type: 'sh.tangled.repo.issue', 843 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 844 + title: 'Test Issue', 845 + createdAt: new Date('2024-01-01').toISOString(), 846 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 847 + cid: 'bafyrei1', 848 + author: 'did:plc:abc123', 849 + }; 850 + 851 + beforeEach(() => { 852 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 853 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 854 + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 855 + throw new Error(`process.exit(${code})`); 856 + }) as never; 857 + 858 + mockClient = { 859 + resumeSession: vi.fn(async () => true), 860 + } as unknown as TangledApiClient; 861 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 862 + 863 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 864 + owner: 'test.bsky.social', 865 + ownerType: 'handle', 866 + name: 'test-repo', 867 + remoteName: 'origin', 868 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 869 + protocol: 'ssh', 870 + }); 871 + 872 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 873 + }); 874 + 875 + afterEach(() => { 876 + vi.restoreAllMocks(); 877 + }); 878 + 879 + it('should delete issue with --force flag', async () => { 880 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 881 + issues: [mockIssue], 882 + cursor: undefined, 883 + }); 884 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 885 + 886 + const command = createIssueCommand(); 887 + await command.parseAsync(['node', 'test', 'delete', '1', '--force']); 888 + 889 + expect(issuesApi.deleteIssue).toHaveBeenCalledWith({ 890 + client: mockClient, 891 + issueUri: mockIssue.uri, 892 + }); 893 + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 894 + }); 895 + 896 + it('should cancel deletion when user declines confirmation', async () => { 897 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 898 + issues: [mockIssue], 899 + cursor: undefined, 900 + }); 901 + 902 + const { confirm } = await import('@inquirer/prompts'); 903 + vi.mocked(confirm).mockResolvedValue(false); 904 + 905 + const command = createIssueCommand(); 906 + await expect(command.parseAsync(['node', 'test', 'delete', '1'])).rejects.toThrow( 907 + 'process.exit(0)' 908 + ); 909 + 910 + expect(issuesApi.deleteIssue).not.toHaveBeenCalled(); 911 + expect(consoleLogSpy).toHaveBeenCalledWith('Deletion cancelled.'); 912 + expect(processExitSpy).toHaveBeenCalledWith(0); 913 + }); 914 + 915 + it('should delete when user confirms', async () => { 916 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 917 + issues: [mockIssue], 918 + cursor: undefined, 919 + }); 920 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 921 + 922 + const { confirm } = await import('@inquirer/prompts'); 923 + vi.mocked(confirm).mockResolvedValue(true); 924 + 925 + const command = createIssueCommand(); 926 + await command.parseAsync(['node', 'test', 'delete', '1']); 927 + 928 + expect(issuesApi.deleteIssue).toHaveBeenCalled(); 929 + expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 930 + }); 931 + 932 + it('should fail when not authenticated', async () => { 933 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 934 + 935 + const command = createIssueCommand(); 936 + await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( 937 + 'process.exit(1)' 938 + ); 939 + 940 + expect(consoleErrorSpy).toHaveBeenCalledWith( 941 + '✗ Not authenticated. Run "tangled auth login" first.' 942 + ); 943 + }); 944 + });