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! :)
at feature/issue-4-pr-create-list-view 405 lines 12 kB view raw
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});