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! :)
at pull-request-format-testing 1151 lines 40 kB view raw
1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2import { createIssueCommand } from '../../src/commands/issue.js'; 3import * as apiClient from '../../src/lib/api-client.js'; 4import type { TangledApiClient } from '../../src/lib/api-client.js'; 5import * as context from '../../src/lib/context.js'; 6import * as issuesApi from '../../src/lib/issues-api.js'; 7import type { IssueWithMetadata } from '../../src/lib/issues-api.js'; 8import * as atUri from '../../src/utils/at-uri.js'; 9import * as authHelpers from '../../src/utils/auth-helpers.js'; 10import * as bodyInput from '../../src/utils/body-input.js'; 11 12// Mock dependencies 13vi.mock('../../src/lib/api-client.js'); 14vi.mock('../../src/lib/issues-api.js'); 15vi.mock('../../src/lib/context.js'); 16vi.mock('../../src/utils/at-uri.js'); 17vi.mock('../../src/utils/body-input.js'); 18vi.mock('../../src/utils/auth-helpers.js'); 19vi.mock('@inquirer/prompts'); 20 21describe('issue create command', () => { 22 let mockClient: TangledApiClient; 23 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 24 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 25 let processExitSpy: ReturnType<typeof vi.spyOn>; 26 27 beforeEach(() => { 28 // Mock console methods 29 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 30 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 31 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 32 throw new Error(`process.exit(${code})`); 33 }) as never; 34 35 // Mock API client 36 mockClient = { 37 resumeSession: vi.fn(async () => true), 38 } as unknown as TangledApiClient; 39 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 40 41 // Mock context 42 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 43 owner: 'test.bsky.social', 44 ownerType: 'handle', 45 name: 'test-repo', 46 remoteName: 'origin', 47 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 48 protocol: 'ssh', 49 }); 50 51 // Mock AT-URI builder 52 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 53 'at://did:plc:abc123/sh.tangled.repo/test-repo' 54 ); 55 56 // Mock body input 57 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 58 }); 59 60 afterEach(() => { 61 vi.restoreAllMocks(); 62 }); 63 64 describe('create with --body flag', () => { 65 it('should create issue with body text', async () => { 66 const mockIssue: IssueWithMetadata = { 67 $type: 'sh.tangled.repo.issue', 68 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 69 title: 'Test Issue', 70 body: 'Test body', 71 createdAt: new Date().toISOString(), 72 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 73 cid: 'bafyreiabc123', 74 author: 'did:plc:abc123', 75 }; 76 77 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 78 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 79 80 const command = createIssueCommand(); 81 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); 82 83 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 84 client: mockClient, 85 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 86 title: 'Test Issue', 87 body: 'Test body', 88 }); 89 90 expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 91 expect(consoleLogSpy).toHaveBeenCalledWith('\n✓ Issue created: #abc123'); 92 }); 93 }); 94 95 describe('create with --body-file flag', () => { 96 it('should create issue with body from file', async () => { 97 const mockIssue: IssueWithMetadata = { 98 $type: 'sh.tangled.repo.issue', 99 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 100 title: 'Test Issue', 101 body: 'Body from file', 102 createdAt: new Date().toISOString(), 103 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789', 104 cid: 'bafyreixyz789', 105 author: 'did:plc:abc123', 106 }; 107 108 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 109 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 110 111 const command = createIssueCommand(); 112 await command.parseAsync([ 113 'node', 114 'test', 115 'create', 116 'Test Issue', 117 '--body-file', 118 '/tmp/body.txt', 119 ]); 120 121 expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); 122 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 123 client: mockClient, 124 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 125 title: 'Test Issue', 126 body: 'Body from file', 127 }); 128 }); 129 }); 130 131 describe('create without body', () => { 132 it('should create issue without body', async () => { 133 const mockIssue: IssueWithMetadata = { 134 $type: 'sh.tangled.repo.issue', 135 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 136 title: 'Test Issue', 137 createdAt: new Date().toISOString(), 138 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/test123', 139 cid: 'bafyreitest123', 140 author: 'did:plc:abc123', 141 }; 142 143 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 144 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 145 146 const command = createIssueCommand(); 147 await command.parseAsync(['node', 'test', 'create', 'Test Issue']); 148 149 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 150 client: mockClient, 151 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 152 title: 'Test Issue', 153 body: undefined, 154 }); 155 }); 156 }); 157 158 describe('authentication required', () => { 159 it('should fail when not authenticated', async () => { 160 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 161 162 const command = createIssueCommand(); 163 164 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 165 'process.exit(1)' 166 ); 167 168 expect(consoleErrorSpy).toHaveBeenCalledWith( 169 '✗ Not authenticated. Run "tangled auth login" first.' 170 ); 171 expect(processExitSpy).toHaveBeenCalledWith(1); 172 }); 173 }); 174 175 describe('context required', () => { 176 it('should fail when not in a Tangled repository', async () => { 177 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 178 179 const command = createIssueCommand(); 180 181 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 182 'process.exit(1)' 183 ); 184 185 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 186 expect(processExitSpy).toHaveBeenCalledWith(1); 187 }); 188 }); 189 190 describe('validation errors', () => { 191 it('should fail with empty title', async () => { 192 const command = createIssueCommand(); 193 194 await expect(command.parseAsync(['node', 'test', 'create', ''])).rejects.toThrow( 195 'process.exit(1)' 196 ); 197 198 expect(consoleErrorSpy).toHaveBeenCalledWith( 199 expect.stringContaining('Issue title cannot be empty') 200 ); 201 expect(processExitSpy).toHaveBeenCalledWith(1); 202 }); 203 204 it('should fail with title over 256 characters', async () => { 205 const longTitle = 'A'.repeat(257); 206 207 const command = createIssueCommand(); 208 209 await expect(command.parseAsync(['node', 'test', 'create', longTitle])).rejects.toThrow( 210 'process.exit(1)' 211 ); 212 213 expect(consoleErrorSpy).toHaveBeenCalledWith( 214 expect.stringContaining('Issue title must be 256 characters or less') 215 ); 216 expect(processExitSpy).toHaveBeenCalledWith(1); 217 }); 218 219 it('should fail with body over 50,000 characters', async () => { 220 const longBody = 'A'.repeat(50001); 221 222 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(longBody); 223 224 const command = createIssueCommand(); 225 226 await expect( 227 command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', longBody]) 228 ).rejects.toThrow('process.exit(1)'); 229 230 expect(consoleErrorSpy).toHaveBeenCalledWith( 231 expect.stringContaining('Issue body must be 50,000 characters or less') 232 ); 233 expect(processExitSpy).toHaveBeenCalledWith(1); 234 }); 235 }); 236 237 describe('API errors', () => { 238 it('should handle API errors gracefully', async () => { 239 vi.mocked(issuesApi.createIssue).mockRejectedValue(new Error('Network error')); 240 241 const command = createIssueCommand(); 242 243 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 244 'process.exit(1)' 245 ); 246 247 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to create issue: Network error'); 248 expect(processExitSpy).toHaveBeenCalledWith(1); 249 }); 250 }); 251 252 describe('JSON output', () => { 253 const mockIssue: IssueWithMetadata = { 254 $type: 'sh.tangled.repo.issue', 255 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 256 title: 'Test Issue', 257 body: 'Test body', 258 createdAt: '2024-01-01T00:00:00.000Z', 259 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 260 cid: 'bafyreiabc123', 261 author: 'did:plc:abc123', 262 }; 263 264 it('should output JSON of created issue when --json is passed', async () => { 265 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 266 267 const command = createIssueCommand(); 268 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); 269 270 // Should NOT print human-readable messages 271 expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...'); 272 273 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 274 expect(jsonOutput).toMatchObject({ 275 title: 'Test Issue', 276 body: 'Test body', 277 author: 'did:plc:abc123', 278 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 279 cid: 'bafyreiabc123', 280 }); 281 }); 282 283 it('should output filtered JSON when --json with fields is passed', async () => { 284 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 285 286 const command = createIssueCommand(); 287 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); 288 289 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 290 expect(jsonOutput).toEqual({ 291 title: 'Test Issue', 292 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 293 }); 294 expect(jsonOutput).not.toHaveProperty('body'); 295 expect(jsonOutput).not.toHaveProperty('author'); 296 }); 297 }); 298}); 299 300describe('issue list command', () => { 301 let mockClient: TangledApiClient; 302 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 303 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 304 let processExitSpy: ReturnType<typeof vi.spyOn>; 305 306 beforeEach(() => { 307 // Mock console methods 308 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 309 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 310 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 311 throw new Error(`process.exit(${code})`); 312 }) as never; 313 314 // Mock API client 315 mockClient = { 316 resumeSession: vi.fn(async () => true), 317 } as unknown as TangledApiClient; 318 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 319 320 // Mock context 321 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 322 owner: 'test.bsky.social', 323 ownerType: 'handle', 324 name: 'test-repo', 325 remoteName: 'origin', 326 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 327 protocol: 'ssh', 328 }); 329 330 // Mock AT-URI builder 331 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 332 }); 333 334 afterEach(() => { 335 vi.restoreAllMocks(); 336 }); 337 338 describe('list issues', () => { 339 it('should list issues successfully', async () => { 340 const mockIssues: IssueWithMetadata[] = [ 341 { 342 $type: 'sh.tangled.repo.issue', 343 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 344 title: 'First Issue', 345 createdAt: new Date('2024-01-01').toISOString(), 346 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 347 cid: 'bafyrei1', 348 author: 'did:plc:abc123', 349 }, 350 { 351 $type: 'sh.tangled.repo.issue', 352 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 353 title: 'Second Issue', 354 createdAt: new Date('2024-01-02').toISOString(), 355 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 356 cid: 'bafyrei2', 357 author: 'did:plc:abc123', 358 }, 359 ]; 360 361 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 362 issues: mockIssues, 363 cursor: undefined, 364 }); 365 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 366 367 const command = createIssueCommand(); 368 await command.parseAsync(['node', 'test', 'list']); 369 370 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 371 client: mockClient, 372 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 373 limit: 50, 374 }); 375 376 expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); 377 expect(consoleLogSpy).toHaveBeenCalledWith(' #1 [OPEN] First Issue'); 378 expect(consoleLogSpy).toHaveBeenCalledWith(' #2 [OPEN] Second Issue'); 379 }); 380 381 it('should handle custom limit', async () => { 382 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 383 issues: [], 384 cursor: undefined, 385 }); 386 387 const command = createIssueCommand(); 388 await command.parseAsync(['node', 'test', 'list', '--limit', '25']); 389 390 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 391 client: mockClient, 392 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 393 limit: 25, 394 }); 395 }); 396 397 it('should handle empty issue list', async () => { 398 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 399 issues: [], 400 cursor: undefined, 401 }); 402 403 const command = createIssueCommand(); 404 await command.parseAsync(['node', 'test', 'list']); 405 406 expect(consoleLogSpy).toHaveBeenCalledWith('No issues found for this repository.'); 407 }); 408 }); 409 410 describe('authentication required', () => { 411 it('should fail when not authenticated', async () => { 412 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 413 414 const command = createIssueCommand(); 415 416 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 417 418 expect(consoleErrorSpy).toHaveBeenCalledWith( 419 '✗ Not authenticated. Run "tangled auth login" first.' 420 ); 421 expect(processExitSpy).toHaveBeenCalledWith(1); 422 }); 423 }); 424 425 describe('context required', () => { 426 it('should fail when not in a Tangled repository', async () => { 427 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 428 429 const command = createIssueCommand(); 430 431 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 432 433 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 434 expect(processExitSpy).toHaveBeenCalledWith(1); 435 }); 436 }); 437 438 describe('validation errors', () => { 439 it('should fail with invalid limit (too low)', async () => { 440 const command = createIssueCommand(); 441 442 await expect(command.parseAsync(['node', 'test', 'list', '--limit', '0'])).rejects.toThrow( 443 'process.exit(1)' 444 ); 445 446 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 447 expect(processExitSpy).toHaveBeenCalledWith(1); 448 }); 449 450 it('should fail with invalid limit (too high)', async () => { 451 const command = createIssueCommand(); 452 453 await expect(command.parseAsync(['node', 'test', 'list', '--limit', '101'])).rejects.toThrow( 454 'process.exit(1)' 455 ); 456 457 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 458 expect(processExitSpy).toHaveBeenCalledWith(1); 459 }); 460 461 it('should fail with non-numeric limit', async () => { 462 const command = createIssueCommand(); 463 464 await expect(command.parseAsync(['node', 'test', 'list', '--limit', 'abc'])).rejects.toThrow( 465 'process.exit(1)' 466 ); 467 468 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 469 expect(processExitSpy).toHaveBeenCalledWith(1); 470 }); 471 }); 472 473 describe('API errors', () => { 474 it('should handle API errors gracefully', async () => { 475 vi.mocked(issuesApi.listIssues).mockRejectedValue(new Error('Network error')); 476 477 const command = createIssueCommand(); 478 479 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 480 481 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); 482 expect(processExitSpy).toHaveBeenCalledWith(1); 483 }); 484 }); 485 486 describe('JSON output', () => { 487 const mockIssues: IssueWithMetadata[] = [ 488 { 489 $type: 'sh.tangled.repo.issue', 490 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 491 title: 'First Issue', 492 body: 'First body', 493 createdAt: new Date('2024-01-01').toISOString(), 494 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 495 cid: 'bafyrei1', 496 author: 'did:plc:abc123', 497 }, 498 { 499 $type: 'sh.tangled.repo.issue', 500 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 501 title: 'Second Issue', 502 createdAt: new Date('2024-01-02').toISOString(), 503 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 504 cid: 'bafyrei2', 505 author: 'did:plc:abc123', 506 }, 507 ]; 508 509 beforeEach(() => { 510 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 511 issues: mockIssues, 512 cursor: undefined, 513 }); 514 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 515 }); 516 517 it('should output JSON array when --json is passed', async () => { 518 const command = createIssueCommand(); 519 await command.parseAsync(['node', 'test', 'list', '--json']); 520 521 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 522 expect(Array.isArray(jsonOutput)).toBe(true); 523 expect(jsonOutput).toHaveLength(2); 524 expect(jsonOutput[0]).toMatchObject({ 525 number: 1, 526 title: 'First Issue', 527 state: 'open', 528 author: 'did:plc:abc123', 529 }); 530 expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' }); 531 }); 532 533 it('should output filtered JSON when --json with fields is passed', async () => { 534 const command = createIssueCommand(); 535 await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']); 536 537 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 538 expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' }); 539 expect(jsonOutput[0]).not.toHaveProperty('author'); 540 expect(jsonOutput[0]).not.toHaveProperty('uri'); 541 }); 542 543 it('should output empty JSON array when no issues exist', async () => { 544 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 545 546 const command = createIssueCommand(); 547 await command.parseAsync(['node', 'test', 'list', '--json']); 548 549 expect(consoleLogSpy).toHaveBeenCalledWith('[]'); 550 }); 551 }); 552}); 553 554describe('issue view command', () => { 555 let mockClient: TangledApiClient; 556 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 557 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 558 559 const mockIssue: IssueWithMetadata = { 560 $type: 'sh.tangled.repo.issue', 561 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 562 title: 'Test Issue', 563 body: 'Issue body', 564 createdAt: new Date('2024-01-01').toISOString(), 565 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 566 cid: 'bafyrei1', 567 author: 'did:plc:abc123', 568 }; 569 570 beforeEach(() => { 571 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 572 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 573 vi.spyOn(process, 'exit').mockImplementation((code) => { 574 throw new Error(`process.exit(${code})`); 575 }) as never; 576 577 mockClient = { 578 resumeSession: vi.fn(async () => true), 579 } as unknown as TangledApiClient; 580 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 581 582 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 583 owner: 'test.bsky.social', 584 ownerType: 'handle', 585 name: 'test-repo', 586 remoteName: 'origin', 587 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 588 protocol: 'ssh', 589 }); 590 591 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 592 593 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 594 did: 'did:plc:abc123', 595 handle: 'test.bsky.social', 596 } as never); 597 }); 598 599 afterEach(() => { 600 vi.restoreAllMocks(); 601 }); 602 603 it('should view issue by number', async () => { 604 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 605 issues: [mockIssue], 606 cursor: undefined, 607 }); 608 vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 609 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 610 611 const command = createIssueCommand(); 612 await command.parseAsync(['node', 'test', 'view', '1']); 613 614 expect(issuesApi.getIssue).toHaveBeenCalledWith({ 615 client: mockClient, 616 issueUri: mockIssue.uri, 617 }); 618 expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 619 client: mockClient, 620 issueUri: mockIssue.uri, 621 }); 622 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 623 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 624 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); 625 expect(consoleLogSpy).toHaveBeenCalledWith('Issue body'); 626 }); 627 628 it('should view issue by rkey', async () => { 629 vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 630 vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 631 632 const command = createIssueCommand(); 633 await command.parseAsync(['node', 'test', 'view', 'issue1']); 634 635 expect(issuesApi.getIssue).toHaveBeenCalledWith({ 636 client: mockClient, 637 issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 638 }); 639 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 640 }); 641 642 it('should show issue without body', async () => { 643 const issueWithoutBody = { ...mockIssue, body: undefined }; 644 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 645 issues: [issueWithoutBody], 646 cursor: undefined, 647 }); 648 vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 649 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 650 651 const command = createIssueCommand(); 652 await command.parseAsync(['node', 'test', 'view', '1']); 653 654 const allCalls = consoleLogSpy.mock.calls.map((c) => c[0]); 655 expect(allCalls).not.toContain('Body:'); 656 }); 657 658 it('should fail when not authenticated', async () => { 659 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 660 661 const command = createIssueCommand(); 662 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 663 'process.exit(1)' 664 ); 665 666 expect(consoleErrorSpy).toHaveBeenCalledWith( 667 '✗ Not authenticated. Run "tangled auth login" first.' 668 ); 669 }); 670 671 it('should fail when not in a Tangled repository', async () => { 672 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 673 674 const command = createIssueCommand(); 675 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 676 'process.exit(1)' 677 ); 678 679 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 680 }); 681 682 it('should fail when issue number is out of range', async () => { 683 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 684 issues: [mockIssue], 685 cursor: undefined, 686 }); 687 688 const command = createIssueCommand(); 689 await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( 690 'process.exit(1)' 691 ); 692 693 expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); 694 }); 695 696 describe('JSON output', () => { 697 it('should output JSON when --json is passed', async () => { 698 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 699 issues: [mockIssue], 700 cursor: undefined, 701 }); 702 vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 703 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 704 705 const command = createIssueCommand(); 706 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 707 708 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 709 expect(jsonOutput).toMatchObject({ 710 title: 'Test Issue', 711 body: 'Issue body', 712 state: 'open', 713 author: 'did:plc:abc123', 714 uri: mockIssue.uri, 715 cid: mockIssue.cid, 716 }); 717 }); 718 719 it('should output filtered JSON when --json with fields is passed', async () => { 720 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 721 issues: [mockIssue], 722 cursor: undefined, 723 }); 724 vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 725 vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 726 727 const command = createIssueCommand(); 728 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); 729 730 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 731 expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' }); 732 expect(jsonOutput).not.toHaveProperty('body'); 733 expect(jsonOutput).not.toHaveProperty('author'); 734 }); 735 }); 736}); 737 738describe('issue edit command', () => { 739 let mockClient: TangledApiClient; 740 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 741 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 742 743 const mockIssue: IssueWithMetadata = { 744 $type: 'sh.tangled.repo.issue', 745 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 746 title: 'Original Title', 747 createdAt: new Date('2024-01-01').toISOString(), 748 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 749 cid: 'bafyrei1', 750 author: 'did:plc:abc123', 751 }; 752 753 beforeEach(() => { 754 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 755 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 756 vi.spyOn(process, 'exit').mockImplementation((code) => { 757 throw new Error(`process.exit(${code})`); 758 }) as never; 759 760 mockClient = { 761 resumeSession: vi.fn(async () => true), 762 } as unknown as TangledApiClient; 763 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 764 765 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 766 owner: 'test.bsky.social', 767 ownerType: 'handle', 768 name: 'test-repo', 769 remoteName: 'origin', 770 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 771 protocol: 'ssh', 772 }); 773 774 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 775 776 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 777 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 778 did: 'did:plc:abc123', 779 handle: 'test.bsky.social', 780 } as never); 781 }); 782 783 afterEach(() => { 784 vi.restoreAllMocks(); 785 }); 786 787 it('should edit issue title by number', async () => { 788 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 789 issues: [mockIssue], 790 cursor: undefined, 791 }); 792 vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, title: 'New Title' }); 793 794 const command = createIssueCommand(); 795 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title']); 796 797 expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 798 client: mockClient, 799 issueUri: mockIssue.uri, 800 title: 'New Title', 801 body: undefined, 802 }); 803 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 updated'); 804 expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: title'); 805 }); 806 807 it('should edit issue body', async () => { 808 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 809 issues: [mockIssue], 810 cursor: undefined, 811 }); 812 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('New body'); 813 vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, body: 'New body' }); 814 815 const command = createIssueCommand(); 816 await command.parseAsync(['node', 'test', 'edit', '1', '--body', 'New body']); 817 818 expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 819 client: mockClient, 820 issueUri: mockIssue.uri, 821 title: undefined, 822 body: 'New body', 823 }); 824 expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: body'); 825 }); 826 827 it('should fail when no options provided', async () => { 828 const command = createIssueCommand(); 829 await expect(command.parseAsync(['node', 'test', 'edit', '1'])).rejects.toThrow( 830 'process.exit(1)' 831 ); 832 833 expect(consoleErrorSpy).toHaveBeenCalledWith( 834 '✗ At least one of --title, --body, or --body-file must be provided' 835 ); 836 }); 837 838 it('should fail when not authenticated', async () => { 839 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 840 841 const command = createIssueCommand(); 842 await expect( 843 command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New']) 844 ).rejects.toThrow('process.exit(1)'); 845 846 expect(consoleErrorSpy).toHaveBeenCalledWith( 847 '✗ Not authenticated. Run "tangled auth login" first.' 848 ); 849 }); 850 851 describe('JSON output', () => { 852 it('should output JSON of updated issue when --json is passed', async () => { 853 const updatedIssue = { ...mockIssue, title: 'New Title' }; 854 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 855 issues: [mockIssue], 856 cursor: undefined, 857 }); 858 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 859 860 const command = createIssueCommand(); 861 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 862 863 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 864 expect(jsonOutput).toMatchObject({ 865 title: 'New Title', 866 author: 'did:plc:abc123', 867 uri: mockIssue.uri, 868 cid: mockIssue.cid, 869 }); 870 // Human-readable messages should NOT appear 871 expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated'); 872 }); 873 874 it('should output filtered JSON when --json with fields is passed', async () => { 875 const updatedIssue = { ...mockIssue, title: 'New Title' }; 876 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 877 issues: [mockIssue], 878 cursor: undefined, 879 }); 880 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 881 882 const command = createIssueCommand(); 883 await command.parseAsync([ 884 'node', 885 'test', 886 'edit', 887 '1', 888 '--title', 889 'New Title', 890 '--json', 891 'title,uri', 892 ]); 893 894 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 895 expect(jsonOutput).toEqual({ 896 title: 'New Title', 897 uri: mockIssue.uri, 898 }); 899 expect(jsonOutput).not.toHaveProperty('author'); 900 }); 901 }); 902}); 903 904describe('issue close command', () => { 905 let mockClient: TangledApiClient; 906 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 907 908 const mockIssue: IssueWithMetadata = { 909 $type: 'sh.tangled.repo.issue', 910 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 911 title: 'Test Issue', 912 createdAt: new Date('2024-01-01').toISOString(), 913 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 914 cid: 'bafyrei1', 915 author: 'did:plc:abc123', 916 }; 917 918 beforeEach(() => { 919 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 920 vi.spyOn(console, 'error').mockImplementation(() => {}); 921 vi.spyOn(process, 'exit').mockImplementation((code) => { 922 throw new Error(`process.exit(${code})`); 923 }) as never; 924 925 mockClient = { 926 resumeSession: vi.fn(async () => true), 927 } as unknown as TangledApiClient; 928 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 929 930 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 931 owner: 'test.bsky.social', 932 ownerType: 'handle', 933 name: 'test-repo', 934 remoteName: 'origin', 935 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 936 protocol: 'ssh', 937 }); 938 939 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 940 }); 941 942 afterEach(() => { 943 vi.restoreAllMocks(); 944 }); 945 946 it('should close issue by number', async () => { 947 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 948 issues: [mockIssue], 949 cursor: undefined, 950 }); 951 vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 952 953 const command = createIssueCommand(); 954 await command.parseAsync(['node', 'test', 'close', '1']); 955 956 expect(issuesApi.closeIssue).toHaveBeenCalledWith({ 957 client: mockClient, 958 issueUri: mockIssue.uri, 959 }); 960 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 closed'); 961 }); 962 963 it('should fail when not authenticated', async () => { 964 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 965 966 const command = createIssueCommand(); 967 await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( 968 'process.exit(1)' 969 ); 970 }); 971}); 972 973describe('issue reopen command', () => { 974 let mockClient: TangledApiClient; 975 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 976 977 const mockIssue: IssueWithMetadata = { 978 $type: 'sh.tangled.repo.issue', 979 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 980 title: 'Test Issue', 981 createdAt: new Date('2024-01-01').toISOString(), 982 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 983 cid: 'bafyrei1', 984 author: 'did:plc:abc123', 985 }; 986 987 beforeEach(() => { 988 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 989 vi.spyOn(console, 'error').mockImplementation(() => {}); 990 vi.spyOn(process, 'exit').mockImplementation((code) => { 991 throw new Error(`process.exit(${code})`); 992 }) as never; 993 994 mockClient = { 995 resumeSession: vi.fn(async () => true), 996 } as unknown as TangledApiClient; 997 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 998 999 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 1000 owner: 'test.bsky.social', 1001 ownerType: 'handle', 1002 name: 'test-repo', 1003 remoteName: 'origin', 1004 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 1005 protocol: 'ssh', 1006 }); 1007 1008 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1009 }); 1010 1011 afterEach(() => { 1012 vi.restoreAllMocks(); 1013 }); 1014 1015 it('should reopen issue by number', async () => { 1016 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1017 issues: [mockIssue], 1018 cursor: undefined, 1019 }); 1020 vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1021 1022 const command = createIssueCommand(); 1023 await command.parseAsync(['node', 'test', 'reopen', '1']); 1024 1025 expect(issuesApi.reopenIssue).toHaveBeenCalledWith({ 1026 client: mockClient, 1027 issueUri: mockIssue.uri, 1028 }); 1029 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 reopened'); 1030 }); 1031 1032 it('should fail when not authenticated', async () => { 1033 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1034 1035 const command = createIssueCommand(); 1036 await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( 1037 'process.exit(1)' 1038 ); 1039 }); 1040}); 1041 1042describe('issue delete command', () => { 1043 let mockClient: TangledApiClient; 1044 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 1045 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 1046 let processExitSpy: ReturnType<typeof vi.spyOn>; 1047 1048 const mockIssue: IssueWithMetadata = { 1049 $type: 'sh.tangled.repo.issue', 1050 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 1051 title: 'Test Issue', 1052 createdAt: new Date('2024-01-01').toISOString(), 1053 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 1054 cid: 'bafyrei1', 1055 author: 'did:plc:abc123', 1056 }; 1057 1058 beforeEach(() => { 1059 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 1060 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 1061 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 1062 throw new Error(`process.exit(${code})`); 1063 }) as never; 1064 1065 mockClient = { 1066 resumeSession: vi.fn(async () => true), 1067 } as unknown as TangledApiClient; 1068 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 1069 1070 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 1071 owner: 'test.bsky.social', 1072 ownerType: 'handle', 1073 name: 'test-repo', 1074 remoteName: 'origin', 1075 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 1076 protocol: 'ssh', 1077 }); 1078 1079 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1080 }); 1081 1082 afterEach(() => { 1083 vi.restoreAllMocks(); 1084 }); 1085 1086 it('should delete issue with --force flag', async () => { 1087 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1088 issues: [mockIssue], 1089 cursor: undefined, 1090 }); 1091 vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1092 1093 const command = createIssueCommand(); 1094 await command.parseAsync(['node', 'test', 'delete', '1', '--force']); 1095 1096 expect(issuesApi.deleteIssue).toHaveBeenCalledWith({ 1097 client: mockClient, 1098 issueUri: mockIssue.uri, 1099 }); 1100 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 1101 }); 1102 1103 it('should cancel deletion when user declines confirmation', async () => { 1104 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1105 issues: [mockIssue], 1106 cursor: undefined, 1107 }); 1108 1109 const { confirm } = await import('@inquirer/prompts'); 1110 vi.mocked(confirm).mockResolvedValue(false); 1111 1112 const command = createIssueCommand(); 1113 await expect(command.parseAsync(['node', 'test', 'delete', '1'])).rejects.toThrow( 1114 'process.exit(0)' 1115 ); 1116 1117 expect(issuesApi.deleteIssue).not.toHaveBeenCalled(); 1118 expect(consoleLogSpy).toHaveBeenCalledWith('Deletion cancelled.'); 1119 expect(processExitSpy).toHaveBeenCalledWith(0); 1120 }); 1121 1122 it('should delete when user confirms', async () => { 1123 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1124 issues: [mockIssue], 1125 cursor: undefined, 1126 }); 1127 vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1128 1129 const { confirm } = await import('@inquirer/prompts'); 1130 vi.mocked(confirm).mockResolvedValue(true); 1131 1132 const command = createIssueCommand(); 1133 await command.parseAsync(['node', 'test', 'delete', '1']); 1134 1135 expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1136 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 1137 }); 1138 1139 it('should fail when not authenticated', async () => { 1140 vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1141 1142 const command = createIssueCommand(); 1143 await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( 1144 'process.exit(1)' 1145 ); 1146 1147 expect(consoleErrorSpy).toHaveBeenCalledWith( 1148 '✗ Not authenticated. Run "tangled auth login" first.' 1149 ); 1150 }); 1151});