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 createPull,
6 getPull,
7 getPullState,
8 listPulls,
9 resolveSequentialPullNumber,
10} from '../../src/lib/pulls-api.js';
11
12vi.mock('../../src/lib/constellation.js');
13
14// Mock API client factory
15const createMockClient = (authenticated = true): TangledApiClient => {
16 const mockAgent = {
17 com: {
18 atproto: {
19 repo: {
20 createRecord: vi.fn(),
21 getRecord: vi.fn(),
22 uploadBlob: vi.fn(),
23 },
24 },
25 },
26 };
27
28 return {
29 isAuthenticated: vi.fn(() => authenticated),
30 getSession: vi.fn(() =>
31 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null
32 ),
33 getAgent: vi.fn(() => mockAgent),
34 } as unknown as TangledApiClient;
35};
36
37const REPO_AT_URI = 'at://did:plc:owner/sh.tangled.repo/my-repo';
38const PULL_AT_URI = 'at://did:plc:test123/sh.tangled.repo.pull/abc123';
39
40describe('createPull', () => {
41 let mockClient: TangledApiClient;
42
43 beforeEach(() => {
44 mockClient = createMockClient(true);
45 });
46
47 it('should upload blob and create pull record', async () => {
48 const mockBlob = {
49 $type: 'blob',
50 ref: { $link: 'bafyreiabc123' },
51 mimeType: 'application/gzip',
52 size: 42,
53 };
54 const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } });
55 const mockCreateRecord = vi.fn().mockResolvedValue({
56 data: {
57 uri: PULL_AT_URI,
58 cid: 'cid123',
59 },
60 });
61
62 vi.mocked(mockClient.getAgent).mockReturnValue({
63 com: {
64 atproto: {
65 repo: {
66 uploadBlob: mockUploadBlob,
67 createRecord: mockCreateRecord,
68 },
69 },
70 },
71 } as never);
72
73 const patchBuffer = Buffer.from('fake gzip content');
74 const result = await createPull({
75 client: mockClient,
76 repoAtUri: REPO_AT_URI,
77 title: 'Add new feature',
78 body: 'Description',
79 targetBranch: 'main',
80 sourceBranch: 'feature/new-thing',
81 sourceSha: 'abc123sha',
82 patchBuffer,
83 });
84
85 expect(mockUploadBlob).toHaveBeenCalledWith(patchBuffer, { encoding: 'application/gzip' });
86 expect(mockCreateRecord).toHaveBeenCalledWith({
87 repo: 'did:plc:test123',
88 collection: 'sh.tangled.repo.pull',
89 record: expect.objectContaining({
90 $type: 'sh.tangled.repo.pull',
91 target: { repo: REPO_AT_URI, branch: 'main' },
92 title: 'Add new feature',
93 body: 'Description',
94 patchBlob: mockBlob,
95 source: { branch: 'feature/new-thing', sha: 'abc123sha', repo: REPO_AT_URI },
96 createdAt: expect.any(String),
97 }),
98 });
99
100 expect(result).toMatchObject({
101 uri: PULL_AT_URI,
102 cid: 'cid123',
103 author: 'did:plc:test123',
104 title: 'Add new feature',
105 });
106 });
107
108 it('should create pull without body', async () => {
109 const mockBlob = {
110 $type: 'blob',
111 ref: { $link: 'bafyreiabc123' },
112 mimeType: 'application/gzip',
113 size: 10,
114 };
115 const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } });
116 const mockCreateRecord = vi.fn().mockResolvedValue({
117 data: { uri: PULL_AT_URI, cid: 'cid123' },
118 });
119
120 vi.mocked(mockClient.getAgent).mockReturnValue({
121 com: { atproto: { repo: { uploadBlob: mockUploadBlob, createRecord: mockCreateRecord } } },
122 } as never);
123
124 const result = await createPull({
125 client: mockClient,
126 repoAtUri: REPO_AT_URI,
127 title: 'Fix bug',
128 targetBranch: 'main',
129 sourceBranch: 'fix/bug',
130 sourceSha: 'deadbeef',
131 patchBuffer: Buffer.from('patch'),
132 });
133
134 expect(result.body).toBeUndefined();
135 expect(result.title).toBe('Fix bug');
136 });
137
138 it('should throw when not authenticated', async () => {
139 const unauthClient = createMockClient(false);
140 await expect(
141 createPull({
142 client: unauthClient,
143 repoAtUri: REPO_AT_URI,
144 title: 'Test',
145 targetBranch: 'main',
146 sourceBranch: 'feature',
147 sourceSha: 'abc',
148 patchBuffer: Buffer.from('patch'),
149 })
150 ).rejects.toThrow();
151 });
152});
153
154describe('listPulls', () => {
155 let mockClient: TangledApiClient;
156
157 beforeEach(() => {
158 mockClient = createMockClient(true);
159 vi.mocked(getBacklinks).mockResolvedValue({
160 total: 0,
161 records: [],
162 cursor: null,
163 });
164 });
165
166 it('should return empty list when no pulls exist', async () => {
167 const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI });
168 expect(result.pulls).toHaveLength(0);
169 expect(result.cursor).toBeUndefined();
170 });
171
172 it('should query constellation with correct parameters', async () => {
173 await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI, limit: 25 });
174 expect(getBacklinks).toHaveBeenCalledWith(
175 REPO_AT_URI,
176 'sh.tangled.repo.pull',
177 '.target.repo',
178 25,
179 undefined
180 );
181 });
182
183 it('should fetch records from backlinks and return pulls', async () => {
184 vi.mocked(getBacklinks).mockResolvedValue({
185 total: 1,
186 records: [{ did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'abc123' }],
187 cursor: null,
188 });
189
190 const mockRecord = {
191 $type: 'sh.tangled.repo.pull',
192 target: { repo: REPO_AT_URI, branch: 'main' },
193 title: 'Test PR',
194 patchBlob: {},
195 source: { branch: 'feature', sha: 'abc', repo: REPO_AT_URI },
196 createdAt: '2024-01-01T00:00:00.000Z',
197 };
198
199 const mockGetRecord = vi.fn().mockResolvedValue({
200 data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' },
201 });
202
203 vi.mocked(mockClient.getAgent).mockReturnValue({
204 com: { atproto: { repo: { getRecord: mockGetRecord } } },
205 } as never);
206
207 const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI });
208 expect(result.pulls).toHaveLength(1);
209 expect(result.pulls[0].title).toBe('Test PR');
210 expect(result.pulls[0].uri).toBe(PULL_AT_URI);
211 expect(result.pulls[0].author).toBe('did:plc:test123');
212 });
213});
214
215describe('getPull', () => {
216 let mockClient: TangledApiClient;
217
218 beforeEach(() => {
219 mockClient = createMockClient(true);
220 });
221
222 it('should fetch pull record by AT-URI', async () => {
223 const mockRecord = {
224 $type: 'sh.tangled.repo.pull',
225 target: { repo: REPO_AT_URI, branch: 'main' },
226 title: 'Test PR',
227 patchBlob: {},
228 createdAt: '2024-01-01T00:00:00.000Z',
229 };
230
231 const mockGetRecord = vi.fn().mockResolvedValue({
232 data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' },
233 });
234
235 vi.mocked(mockClient.getAgent).mockReturnValue({
236 com: { atproto: { repo: { getRecord: mockGetRecord } } },
237 } as never);
238
239 const result = await getPull({ client: mockClient, pullUri: PULL_AT_URI });
240 expect(result.title).toBe('Test PR');
241 expect(result.uri).toBe(PULL_AT_URI);
242 expect(result.author).toBe('did:plc:test123');
243 expect(mockGetRecord).toHaveBeenCalledWith({
244 repo: 'did:plc:test123',
245 collection: 'sh.tangled.repo.pull',
246 rkey: 'abc123',
247 });
248 });
249
250 it('should throw for invalid AT-URI', async () => {
251 await expect(getPull({ client: mockClient, pullUri: 'not-a-uri' })).rejects.toThrow(
252 'Invalid pull request AT-URI'
253 );
254 });
255});
256
257describe('getPullState', () => {
258 let mockClient: TangledApiClient;
259
260 beforeEach(() => {
261 mockClient = createMockClient(true);
262 vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null });
263 });
264
265 it('should return open when no state records exist', async () => {
266 const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI });
267 expect(state).toBe('open');
268 expect(getBacklinks).toHaveBeenCalledWith(
269 PULL_AT_URI,
270 'sh.tangled.repo.pull.status',
271 '.pull',
272 100
273 );
274 });
275
276 it('should return closed for closed status', async () => {
277 vi.mocked(getBacklinks).mockResolvedValue({
278 total: 1,
279 records: [
280 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' },
281 ],
282 cursor: null,
283 });
284 const mockGetRecord = vi.fn().mockResolvedValue({
285 data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' },
286 });
287 vi.mocked(mockClient.getAgent).mockReturnValue({
288 com: { atproto: { repo: { getRecord: mockGetRecord } } },
289 } as never);
290
291 const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI });
292 expect(state).toBe('closed');
293 });
294
295 it('should return merged for merged status', async () => {
296 vi.mocked(getBacklinks).mockResolvedValue({
297 total: 1,
298 records: [
299 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' },
300 ],
301 cursor: null,
302 });
303 const mockGetRecord = vi.fn().mockResolvedValue({
304 data: { value: { status: 'sh.tangled.repo.pull.status.merged' }, uri: '', cid: '' },
305 });
306 vi.mocked(mockClient.getAgent).mockReturnValue({
307 com: { atproto: { repo: { getRecord: mockGetRecord } } },
308 } as never);
309
310 const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI });
311 expect(state).toBe('merged');
312 });
313
314 it('should use latest rkey when multiple state records exist', async () => {
315 vi.mocked(getBacklinks).mockResolvedValue({
316 total: 2,
317 records: [
318 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey2' },
319 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' },
320 ],
321 cursor: null,
322 });
323 const mockGetRecord = vi
324 .fn()
325 .mockResolvedValueOnce({
326 data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' },
327 })
328 .mockResolvedValueOnce({
329 data: { value: { status: 'sh.tangled.repo.pull.status.open' }, uri: '', cid: '' },
330 });
331 vi.mocked(mockClient.getAgent).mockReturnValue({
332 com: { atproto: { repo: { getRecord: mockGetRecord } } },
333 } as never);
334
335 // rkey2 > rkey1 alphabetically, so rkey2 (closed) should win
336 const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI });
337 expect(state).toBe('closed');
338 });
339});
340
341describe('resolveSequentialPullNumber', () => {
342 let mockClient: TangledApiClient;
343
344 beforeEach(() => {
345 mockClient = createMockClient(true);
346 vi.mocked(getBacklinks).mockClear();
347 vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null });
348 });
349
350 it('should use fast path for #N displayId', async () => {
351 const num = await resolveSequentialPullNumber('#3', PULL_AT_URI, mockClient, REPO_AT_URI);
352 expect(num).toBe(3);
353 expect(getBacklinks).not.toHaveBeenCalled();
354 });
355
356 it('should scan pulls when displayId is not #N', async () => {
357 const pullUri1 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey1';
358 const pullUri2 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey2';
359
360 vi.mocked(getBacklinks).mockResolvedValue({
361 total: 2,
362 records: [
363 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey1' },
364 { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey2' },
365 ],
366 cursor: null,
367 });
368
369 const mockGetRecord = vi
370 .fn()
371 .mockResolvedValueOnce({
372 data: {
373 value: {
374 $type: 'sh.tangled.repo.pull',
375 target: { repo: REPO_AT_URI, branch: 'main' },
376 title: 'First',
377 patchBlob: {},
378 createdAt: '2024-01-01T00:00:00.000Z',
379 },
380 uri: pullUri1,
381 cid: 'cid1',
382 },
383 })
384 .mockResolvedValueOnce({
385 data: {
386 value: {
387 $type: 'sh.tangled.repo.pull',
388 target: { repo: REPO_AT_URI, branch: 'main' },
389 title: 'Second',
390 patchBlob: {},
391 createdAt: '2024-01-02T00:00:00.000Z',
392 },
393 uri: pullUri2,
394 cid: 'cid2',
395 },
396 });
397
398 vi.mocked(mockClient.getAgent).mockReturnValue({
399 com: { atproto: { repo: { getRecord: mockGetRecord } } },
400 } as never);
401
402 const num = await resolveSequentialPullNumber('rkey2', pullUri2, mockClient, REPO_AT_URI);
403 expect(num).toBe(2);
404 });
405});