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 { beforeEach, describe, expect, it, vi } from 'vitest';
2import type { TangledApiClient } from '../../src/lib/api-client.js';
3import {
4 closeIssue,
5 createIssue,
6 getCompleteIssueData,
7 getIssue,
8 getIssueState,
9 listIssues,
10 reopenIssue,
11 resolveSequentialNumber,
12 updateIssue,
13} from '../../src/lib/issues-api.js';
14
15// Mock API client factory
16const createMockClient = (authenticated = true): TangledApiClient => {
17 const mockAgent = {
18 com: {
19 atproto: {
20 repo: {
21 createRecord: vi.fn(),
22 listRecords: vi.fn(),
23 getRecord: vi.fn(),
24 putRecord: vi.fn(),
25 deleteRecord: vi.fn(),
26 },
27 },
28 },
29 };
30
31 return {
32 isAuthenticated: vi.fn(() => authenticated),
33 getSession: vi.fn(() =>
34 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null
35 ),
36 getAgent: vi.fn(() => mockAgent),
37 } as unknown as TangledApiClient;
38};
39
40describe('createIssue', () => {
41 let mockClient: TangledApiClient;
42
43 beforeEach(() => {
44 mockClient = createMockClient(true);
45 });
46
47 it('should create an issue with all fields', async () => {
48 const mockCreateRecord = vi.fn().mockResolvedValue({
49 data: {
50 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123',
51 cid: 'cid123',
52 },
53 });
54
55 vi.mocked(mockClient.getAgent).mockReturnValue({
56 com: {
57 atproto: {
58 repo: {
59 createRecord: mockCreateRecord,
60 },
61 },
62 },
63 } as never);
64
65 const result = await createIssue({
66 client: mockClient,
67 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
68 title: 'Bug: Login fails',
69 body: 'Detailed description of the bug',
70 });
71
72 expect(result).toMatchObject({
73 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
74 title: 'Bug: Login fails',
75 body: 'Detailed description of the bug',
76 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123',
77 cid: 'cid123',
78 author: 'did:plc:test123',
79 });
80
81 expect(mockCreateRecord).toHaveBeenCalledWith({
82 repo: 'did:plc:test123',
83 collection: 'sh.tangled.repo.issue',
84 record: expect.objectContaining({
85 $type: 'sh.tangled.repo.issue',
86 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
87 title: 'Bug: Login fails',
88 body: 'Detailed description of the bug',
89 createdAt: expect.any(String),
90 }),
91 });
92 });
93
94 it('should create an issue without body', async () => {
95 const mockCreateRecord = vi.fn().mockResolvedValue({
96 data: {
97 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123',
98 cid: 'cid123',
99 },
100 });
101
102 vi.mocked(mockClient.getAgent).mockReturnValue({
103 com: {
104 atproto: {
105 repo: {
106 createRecord: mockCreateRecord,
107 },
108 },
109 },
110 } as never);
111
112 const result = await createIssue({
113 client: mockClient,
114 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
115 title: 'Simple issue',
116 });
117
118 expect(result.body).toBeUndefined();
119 expect(mockCreateRecord).toHaveBeenCalled();
120 });
121
122 it('should throw error when not authenticated', async () => {
123 mockClient = createMockClient(false);
124
125 await expect(
126 createIssue({
127 client: mockClient,
128 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
129 title: 'Test',
130 })
131 ).rejects.toThrow('Must be authenticated');
132 });
133
134 it('should throw error on API failure', async () => {
135 const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error'));
136
137 vi.mocked(mockClient.getAgent).mockReturnValue({
138 com: {
139 atproto: {
140 repo: {
141 createRecord: mockCreateRecord,
142 },
143 },
144 },
145 } as never);
146
147 await expect(
148 createIssue({
149 client: mockClient,
150 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
151 title: 'Test',
152 })
153 ).rejects.toThrow('Failed to create issue: API error');
154 });
155});
156
157describe('listIssues', () => {
158 let mockClient: TangledApiClient;
159
160 beforeEach(() => {
161 mockClient = createMockClient(true);
162 });
163
164 it('should list issues for a repository', async () => {
165 const mockListRecords = vi.fn().mockResolvedValue({
166 data: {
167 records: [
168 {
169 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
170 cid: 'cid1',
171 value: {
172 $type: 'sh.tangled.repo.issue',
173 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
174 title: 'Issue 1',
175 body: 'Description 1',
176 createdAt: '2024-01-01T00:00:00.000Z',
177 },
178 },
179 {
180 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2',
181 cid: 'cid2',
182 value: {
183 $type: 'sh.tangled.repo.issue',
184 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
185 title: 'Issue 2',
186 createdAt: '2024-01-02T00:00:00.000Z',
187 },
188 },
189 ],
190 cursor: undefined,
191 },
192 });
193
194 vi.mocked(mockClient.getAgent).mockReturnValue({
195 com: {
196 atproto: {
197 repo: {
198 listRecords: mockListRecords,
199 },
200 },
201 },
202 } as never);
203
204 const result = await listIssues({
205 client: mockClient,
206 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
207 });
208
209 expect(result.issues).toHaveLength(2);
210 expect(result.issues[0]).toMatchObject({
211 title: 'Issue 1',
212 body: 'Description 1',
213 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
214 });
215 });
216
217 it('should filter issues by repository', async () => {
218 const mockListRecords = vi.fn().mockResolvedValue({
219 data: {
220 records: [
221 {
222 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
223 cid: 'cid1',
224 value: {
225 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
226 title: 'Issue 1',
227 createdAt: '2024-01-01T00:00:00.000Z',
228 },
229 },
230 {
231 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2',
232 cid: 'cid2',
233 value: {
234 repo: 'at://did:plc:owner/sh.tangled.repo/other-repo',
235 title: 'Issue 2',
236 createdAt: '2024-01-02T00:00:00.000Z',
237 },
238 },
239 ],
240 cursor: undefined,
241 },
242 });
243
244 vi.mocked(mockClient.getAgent).mockReturnValue({
245 com: {
246 atproto: {
247 repo: {
248 listRecords: mockListRecords,
249 },
250 },
251 },
252 } as never);
253
254 const result = await listIssues({
255 client: mockClient,
256 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
257 });
258
259 // Should only include issue from my-repo, not other-repo
260 expect(result.issues).toHaveLength(1);
261 expect(result.issues[0].title).toBe('Issue 1');
262 });
263
264 it('should return empty array when no issues found', async () => {
265 const mockListRecords = vi.fn().mockResolvedValue({
266 data: {
267 records: [],
268 cursor: undefined,
269 },
270 });
271
272 vi.mocked(mockClient.getAgent).mockReturnValue({
273 com: {
274 atproto: {
275 repo: {
276 listRecords: mockListRecords,
277 },
278 },
279 },
280 } as never);
281
282 const result = await listIssues({
283 client: mockClient,
284 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
285 });
286
287 expect(result.issues).toEqual([]);
288 });
289
290 it('should throw error when not authenticated', async () => {
291 mockClient = createMockClient(false);
292
293 await expect(
294 listIssues({
295 client: mockClient,
296 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo',
297 })
298 ).rejects.toThrow('Must be authenticated');
299 });
300
301 it('should throw error for invalid repo URI', async () => {
302 await expect(
303 listIssues({
304 client: mockClient,
305 repoAtUri: 'invalid-uri',
306 })
307 ).rejects.toThrow('Invalid repository AT-URI');
308 });
309});
310
311describe('getIssue', () => {
312 let mockClient: TangledApiClient;
313
314 beforeEach(() => {
315 mockClient = createMockClient(true);
316 });
317
318 it('should get a specific issue', async () => {
319 const mockGetRecord = vi.fn().mockResolvedValue({
320 data: {
321 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
322 cid: 'cid1',
323 value: {
324 $type: 'sh.tangled.repo.issue',
325 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
326 title: 'Test Issue',
327 body: 'Test Description',
328 createdAt: '2024-01-01T00:00:00.000Z',
329 },
330 },
331 });
332
333 vi.mocked(mockClient.getAgent).mockReturnValue({
334 com: {
335 atproto: {
336 repo: {
337 getRecord: mockGetRecord,
338 },
339 },
340 },
341 } as never);
342
343 const result = await getIssue({
344 client: mockClient,
345 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
346 });
347
348 expect(result).toMatchObject({
349 title: 'Test Issue',
350 body: 'Test Description',
351 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
352 cid: 'cid1',
353 });
354
355 expect(mockGetRecord).toHaveBeenCalledWith({
356 repo: 'did:plc:owner',
357 collection: 'sh.tangled.repo.issue',
358 rkey: 'issue1',
359 });
360 });
361
362 it('should throw error when issue not found', async () => {
363 const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found'));
364
365 vi.mocked(mockClient.getAgent).mockReturnValue({
366 com: {
367 atproto: {
368 repo: {
369 getRecord: mockGetRecord,
370 },
371 },
372 },
373 } as never);
374
375 await expect(
376 getIssue({
377 client: mockClient,
378 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent',
379 })
380 ).rejects.toThrow('Issue not found');
381 });
382
383 it('should throw error for invalid issue URI', async () => {
384 await expect(
385 getIssue({
386 client: mockClient,
387 issueUri: 'invalid-uri',
388 })
389 ).rejects.toThrow('Invalid issue AT-URI');
390 });
391
392 it('should throw error when not authenticated', async () => {
393 mockClient = createMockClient(false);
394
395 await expect(
396 getIssue({
397 client: mockClient,
398 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
399 })
400 ).rejects.toThrow('Must be authenticated');
401 });
402});
403
404describe('updateIssue', () => {
405 let mockClient: TangledApiClient;
406
407 beforeEach(() => {
408 mockClient = createMockClient(true);
409 });
410
411 it('should update issue title', async () => {
412 const mockGetRecord = vi.fn().mockResolvedValue({
413 data: {
414 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
415 cid: 'old-cid',
416 value: {
417 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo',
418 title: 'Old Title',
419 body: 'Original body',
420 createdAt: '2024-01-01T00:00:00.000Z',
421 },
422 },
423 });
424
425 const mockPutRecord = vi.fn().mockResolvedValue({
426 data: {
427 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
428 cid: 'new-cid',
429 },
430 });
431
432 vi.mocked(mockClient.getAgent).mockReturnValue({
433 com: {
434 atproto: {
435 repo: {
436 getRecord: mockGetRecord,
437 putRecord: mockPutRecord,
438 },
439 },
440 },
441 } as never);
442
443 const result = await updateIssue({
444 client: mockClient,
445 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
446 title: 'New Title',
447 });
448
449 expect(result.title).toBe('New Title');
450 expect(result.body).toBe('Original body'); // Body unchanged
451
452 expect(mockPutRecord).toHaveBeenCalledWith({
453 repo: 'did:plc:test123',
454 collection: 'sh.tangled.repo.issue',
455 rkey: 'issue1',
456 record: expect.objectContaining({
457 title: 'New Title',
458 body: 'Original body',
459 }),
460 swapRecord: 'old-cid',
461 });
462 });
463
464 it('should update issue body', async () => {
465 const mockGetRecord = vi.fn().mockResolvedValue({
466 data: {
467 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
468 cid: 'old-cid',
469 value: {
470 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo',
471 title: 'Title',
472 body: 'Old body',
473 createdAt: '2024-01-01T00:00:00.000Z',
474 },
475 },
476 });
477
478 const mockPutRecord = vi.fn().mockResolvedValue({
479 data: {
480 cid: 'new-cid',
481 },
482 });
483
484 vi.mocked(mockClient.getAgent).mockReturnValue({
485 com: {
486 atproto: {
487 repo: {
488 getRecord: mockGetRecord,
489 putRecord: mockPutRecord,
490 },
491 },
492 },
493 } as never);
494
495 const result = await updateIssue({
496 client: mockClient,
497 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
498 body: 'New body',
499 });
500
501 expect(result.title).toBe('Title'); // Title unchanged
502 expect(result.body).toBe('New body');
503 });
504
505 it('should throw error when updating issue not owned by user', async () => {
506 await expect(
507 updateIssue({
508 client: mockClient,
509 issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1',
510 title: 'New Title',
511 })
512 ).rejects.toThrow('Cannot update issue: you are not the author');
513 });
514
515 it('should throw error when not authenticated', async () => {
516 mockClient = createMockClient(false);
517
518 await expect(
519 updateIssue({
520 client: mockClient,
521 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1',
522 title: 'New Title',
523 })
524 ).rejects.toThrow('Must be authenticated');
525 });
526});
527
528describe('closeIssue', () => {
529 let mockClient: TangledApiClient;
530
531 beforeEach(() => {
532 mockClient = createMockClient(true);
533 });
534
535 it('should close an issue', async () => {
536 const mockGetRecord = vi.fn().mockResolvedValue({
537 data: {
538 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
539 cid: 'cid1',
540 value: {
541 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
542 title: 'Test Issue',
543 createdAt: '2024-01-01T00:00:00.000Z',
544 },
545 },
546 });
547
548 const mockCreateRecord = vi.fn().mockResolvedValue({
549 data: {
550 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1',
551 cid: 'state-cid',
552 },
553 });
554
555 vi.mocked(mockClient.getAgent).mockReturnValue({
556 com: {
557 atproto: {
558 repo: {
559 getRecord: mockGetRecord,
560 createRecord: mockCreateRecord,
561 },
562 },
563 },
564 } as never);
565
566 await closeIssue({
567 client: mockClient,
568 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
569 });
570
571 expect(mockCreateRecord).toHaveBeenCalledWith({
572 repo: 'did:plc:test123',
573 collection: 'sh.tangled.repo.issue.state',
574 record: {
575 $type: 'sh.tangled.repo.issue.state',
576 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
577 state: 'sh.tangled.repo.issue.state.closed',
578 },
579 });
580 });
581
582 it('should throw error when not authenticated', async () => {
583 mockClient = createMockClient(false);
584
585 await expect(
586 closeIssue({
587 client: mockClient,
588 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
589 })
590 ).rejects.toThrow('Must be authenticated');
591 });
592});
593
594describe('getIssueState', () => {
595 let mockClient: TangledApiClient;
596
597 beforeEach(() => {
598 mockClient = createMockClient(true);
599 });
600
601 it('should return open when no state records exist', async () => {
602 const mockListRecords = vi.fn().mockResolvedValue({
603 data: { records: [] },
604 });
605
606 vi.mocked(mockClient.getAgent).mockReturnValue({
607 com: { atproto: { repo: { listRecords: mockListRecords } } },
608 } as never);
609
610 const result = await getIssueState({
611 client: mockClient,
612 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
613 });
614
615 expect(result).toBe('open');
616 expect(mockListRecords).toHaveBeenCalledWith({
617 repo: 'did:plc:owner',
618 collection: 'sh.tangled.repo.issue.state',
619 limit: 100,
620 });
621 });
622
623 it('should return closed when latest state record is closed', async () => {
624 const mockListRecords = vi.fn().mockResolvedValue({
625 data: {
626 records: [
627 {
628 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1',
629 cid: 'cid1',
630 value: {
631 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
632 state: 'sh.tangled.repo.issue.state.closed',
633 },
634 },
635 ],
636 },
637 });
638
639 vi.mocked(mockClient.getAgent).mockReturnValue({
640 com: { atproto: { repo: { listRecords: mockListRecords } } },
641 } as never);
642
643 const result = await getIssueState({
644 client: mockClient,
645 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
646 });
647
648 expect(result).toBe('closed');
649 });
650
651 it('should return open when latest state record is open', async () => {
652 const mockListRecords = vi.fn().mockResolvedValue({
653 data: {
654 records: [
655 {
656 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1',
657 cid: 'cid1',
658 value: {
659 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
660 state: 'sh.tangled.repo.issue.state.closed',
661 },
662 },
663 {
664 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2',
665 cid: 'cid2',
666 value: {
667 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
668 state: 'sh.tangled.repo.issue.state.open',
669 },
670 },
671 ],
672 },
673 });
674
675 vi.mocked(mockClient.getAgent).mockReturnValue({
676 com: { atproto: { repo: { listRecords: mockListRecords } } },
677 } as never);
678
679 const result = await getIssueState({
680 client: mockClient,
681 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
682 });
683
684 expect(result).toBe('open');
685 });
686
687 it('should filter state records to only the target issue', async () => {
688 const mockListRecords = vi.fn().mockResolvedValue({
689 data: {
690 records: [
691 {
692 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1',
693 cid: 'cid1',
694 value: {
695 issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue',
696 state: 'sh.tangled.repo.issue.state.closed',
697 },
698 },
699 ],
700 },
701 });
702
703 vi.mocked(mockClient.getAgent).mockReturnValue({
704 com: { atproto: { repo: { listRecords: mockListRecords } } },
705 } as never);
706
707 // The closed state is for a different issue, so this one should be open
708 const result = await getIssueState({
709 client: mockClient,
710 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
711 });
712
713 expect(result).toBe('open');
714 });
715
716 it('should throw error when not authenticated', async () => {
717 mockClient = createMockClient(false);
718
719 await expect(
720 getIssueState({
721 client: mockClient,
722 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
723 })
724 ).rejects.toThrow('Must be authenticated');
725 });
726});
727
728describe('reopenIssue', () => {
729 let mockClient: TangledApiClient;
730
731 beforeEach(() => {
732 mockClient = createMockClient(true);
733 });
734
735 it('should reopen a closed issue', async () => {
736 const mockGetRecord = vi.fn().mockResolvedValue({
737 data: {
738 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
739 cid: 'cid1',
740 value: {
741 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
742 title: 'Test Issue',
743 createdAt: '2024-01-01T00:00:00.000Z',
744 },
745 },
746 });
747
748 const mockCreateRecord = vi.fn().mockResolvedValue({
749 data: {
750 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1',
751 cid: 'state-cid',
752 },
753 });
754
755 vi.mocked(mockClient.getAgent).mockReturnValue({
756 com: {
757 atproto: {
758 repo: {
759 getRecord: mockGetRecord,
760 createRecord: mockCreateRecord,
761 },
762 },
763 },
764 } as never);
765
766 await reopenIssue({
767 client: mockClient,
768 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
769 });
770
771 expect(mockCreateRecord).toHaveBeenCalledWith({
772 repo: 'did:plc:test123',
773 collection: 'sh.tangled.repo.issue.state',
774 record: {
775 $type: 'sh.tangled.repo.issue.state',
776 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
777 state: 'sh.tangled.repo.issue.state.open',
778 },
779 });
780 });
781
782 it('should throw error when not authenticated', async () => {
783 mockClient = createMockClient(false);
784
785 await expect(
786 reopenIssue({
787 client: mockClient,
788 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
789 })
790 ).rejects.toThrow('Must be authenticated');
791 });
792});
793
794describe('resolveSequentialNumber', () => {
795 let mockClient: TangledApiClient;
796
797 beforeEach(() => {
798 mockClient = createMockClient(true);
799 });
800
801 it('should return number directly for #N displayId without an API call (fast path)', async () => {
802 const result = await resolveSequentialNumber(
803 '#3',
804 'at://did:plc:owner/sh.tangled.repo.issue/issue3',
805 mockClient,
806 'at://did:plc:owner/sh.tangled.repo/my-repo'
807 );
808 expect(result).toBe(3);
809 });
810
811 it('should scan issue list and return 1-based position for rkey displayId', async () => {
812 const mockListRecords = vi.fn().mockResolvedValue({
813 data: {
814 records: [
815 {
816 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a',
817 cid: 'cid1',
818 value: {
819 $type: 'sh.tangled.repo.issue',
820 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
821 title: 'First',
822 createdAt: '2024-01-01T00:00:00.000Z',
823 },
824 },
825 {
826 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b',
827 cid: 'cid2',
828 value: {
829 $type: 'sh.tangled.repo.issue',
830 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
831 title: 'Second',
832 createdAt: '2024-01-02T00:00:00.000Z',
833 },
834 },
835 ],
836 cursor: undefined,
837 },
838 });
839
840 vi.mocked(mockClient.getAgent).mockReturnValue({
841 com: { atproto: { repo: { listRecords: mockListRecords } } },
842 } as never);
843
844 const result = await resolveSequentialNumber(
845 'issue-b',
846 'at://did:plc:owner/sh.tangled.repo.issue/issue-b',
847 mockClient,
848 'at://did:plc:owner/sh.tangled.repo/my-repo'
849 );
850 expect(result).toBe(2);
851 });
852
853 it('should return undefined when issue URI not found in list', async () => {
854 const mockListRecords = vi.fn().mockResolvedValue({
855 data: {
856 records: [
857 {
858 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a',
859 cid: 'cid1',
860 value: {
861 $type: 'sh.tangled.repo.issue',
862 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
863 title: 'First',
864 createdAt: '2024-01-01T00:00:00.000Z',
865 },
866 },
867 ],
868 cursor: undefined,
869 },
870 });
871
872 vi.mocked(mockClient.getAgent).mockReturnValue({
873 com: { atproto: { repo: { listRecords: mockListRecords } } },
874 } as never);
875
876 const result = await resolveSequentialNumber(
877 'nonexistent',
878 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent',
879 mockClient,
880 'at://did:plc:owner/sh.tangled.repo/my-repo'
881 );
882 expect(result).toBeUndefined();
883 });
884});
885
886describe('getCompleteIssueData', () => {
887 let mockClient: TangledApiClient;
888
889 beforeEach(() => {
890 mockClient = createMockClient(true);
891 });
892
893 it('should return all fields including fetched state', async () => {
894 const mockGetRecord = vi.fn().mockResolvedValue({
895 data: {
896 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
897 cid: 'cid1',
898 value: {
899 $type: 'sh.tangled.repo.issue',
900 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
901 title: 'Test Issue',
902 body: 'Test body',
903 createdAt: '2024-01-01T00:00:00.000Z',
904 },
905 },
906 });
907
908 // getIssueState uses listRecords on the state collection
909 const mockListRecords = vi.fn().mockResolvedValue({
910 data: {
911 records: [
912 {
913 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1',
914 cid: 'scid1',
915 value: {
916 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
917 state: 'sh.tangled.repo.issue.state.closed',
918 },
919 },
920 ],
921 },
922 });
923
924 vi.mocked(mockClient.getAgent).mockReturnValue({
925 com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } },
926 } as never);
927
928 const result = await getCompleteIssueData(
929 mockClient,
930 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
931 '#1', // fast-path for number — no listRecords call for issues
932 'at://did:plc:owner/sh.tangled.repo/my-repo'
933 );
934
935 expect(result).toEqual({
936 number: 1,
937 title: 'Test Issue',
938 body: 'Test body',
939 state: 'closed',
940 author: 'did:plc:owner',
941 createdAt: '2024-01-01T00:00:00.000Z',
942 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
943 cid: 'cid1',
944 });
945 });
946
947 it('should use stateOverride and skip the getIssueState network call', async () => {
948 const mockGetRecord = vi.fn().mockResolvedValue({
949 data: {
950 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
951 cid: 'cid1',
952 value: {
953 $type: 'sh.tangled.repo.issue',
954 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
955 title: 'Test Issue',
956 createdAt: '2024-01-01T00:00:00.000Z',
957 },
958 },
959 });
960
961 const mockListRecords = vi.fn();
962 vi.mocked(mockClient.getAgent).mockReturnValue({
963 com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } },
964 } as never);
965
966 const result = await getCompleteIssueData(
967 mockClient,
968 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
969 '#2',
970 'at://did:plc:owner/sh.tangled.repo/my-repo',
971 'closed'
972 );
973
974 expect(result.number).toBe(2);
975 expect(result.state).toBe('closed');
976 expect(mockListRecords).not.toHaveBeenCalled();
977 });
978
979 it('should return undefined body and default open state when issue has no body or state records', async () => {
980 const mockGetRecord = vi.fn().mockResolvedValue({
981 data: {
982 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
983 cid: 'cid1',
984 value: {
985 $type: 'sh.tangled.repo.issue',
986 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo',
987 title: 'No body issue',
988 createdAt: '2024-01-01T00:00:00.000Z',
989 },
990 },
991 });
992
993 vi.mocked(mockClient.getAgent).mockReturnValue({
994 com: {
995 atproto: {
996 repo: {
997 getRecord: mockGetRecord,
998 listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }),
999 },
1000 },
1001 },
1002 } as never);
1003
1004 const result = await getCompleteIssueData(
1005 mockClient,
1006 'at://did:plc:owner/sh.tangled.repo.issue/issue1',
1007 '#1',
1008 'at://did:plc:owner/sh.tangled.repo/my-repo'
1009 );
1010
1011 expect(result.body).toBeUndefined();
1012 expect(result.state).toBe('open');
1013 });
1014});