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