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