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! :)
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});