mirror your GitHub repos to tangled.org automatically
1import crypto from 'node:crypto'
2import { sql } from 'drizzle-orm'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4import { installation, repoMapping } from '../../server/db/schema'
5import { clearDb, setDb, useDb } from '../../server/utils/db'
6import { clearEncryptionKeyCache } from '../../server/utils/encryption'
7import {
8 buildReadOnlyDescription,
9 enrollRepo,
10 mergeRepoRecord,
11 stripReadOnlyMarker,
12 syncRepoMetadata,
13} from '../../server/utils/tangled-repo'
14import { createTestDb } from '../utils/db'
15
16const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
17
18interface GithubRepoLike {
19 id: number
20 full_name: string
21 private: boolean
22 fork: boolean
23 default_branch: string
24 description: string | null
25 homepage: string | null
26 topics: string[]
27}
28
29const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>()
30const getServiceAuthMock = vi.fn<(input: { aud: string, lxm: string, exp: number }) => Promise<{ data: { token: string } }>>()
31const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown>, swapRecord?: string }) => Promise<unknown>>()
32const listRecordsMock = vi.fn<(input: { repo: string, collection: string, limit?: number, cursor?: string }) => Promise<{ data: { records: Array<{ uri: string, cid: string, value: Record<string, unknown> }>, cursor?: string } }>>()
33
34vi.mock('@atproto/api', () => ({
35 Agent: class {
36 com = {
37 atproto: {
38 server: { getServiceAuth: getServiceAuthMock },
39 repo: { putRecord: putRecordMock, listRecords: listRecordsMock },
40 },
41 }
42 },
43}))
44
45vi.mock('../../server/utils/github-app', () => ({
46 installationOctokit: async () => ({
47 request: githubGet,
48 }),
49 clearGitHubAppCache: () => {},
50}))
51
52interface CapturedInit {
53 method?: string
54 headers?: Record<string, string>
55 body?: string
56}
57const fakeFetch = vi.fn<(url: string, init: CapturedInit) => Promise<Response>>()
58const ORIGINAL_FETCH = globalThis.fetch
59
60function fakeOauthSession(did: string) {
61 // eslint-disable-next-line ts/no-unsafe-type-assertion
62 return { did } as unknown as Parameters<typeof enrollRepo>[0]['oauthSession']
63}
64
65function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike {
66 return {
67 id: 9001,
68 full_name: 'alice/my-project',
69 private: false,
70 fork: false,
71 default_branch: 'main',
72 description: 'a cool thing',
73 homepage: 'https://my-project.example',
74 topics: ['cool', 'thing'],
75 ...over,
76 }
77}
78
79describe('enrollRepo', () => {
80 beforeEach(async () => {
81 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
82 clearEncryptionKeyCache()
83
84 setDb(await createTestDb())
85 await useDb().insert(installation).values({
86 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
87 })
88
89 githubGet.mockReset()
90 getServiceAuthMock.mockReset()
91 putRecordMock.mockReset()
92 listRecordsMock.mockReset()
93 fakeFetch.mockReset()
94 // eslint-disable-next-line ts/no-unsafe-type-assertion
95 globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch
96
97 getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } })
98 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } })
99 })
100
101 afterEach(() => {
102 globalThis.fetch = ORIGINAL_FETCH
103 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
104 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
105 clearEncryptionKeyCache()
106 clearDb()
107 })
108
109 it('enrolls a public, non-fork repo end to end', async () => {
110 githubGet.mockResolvedValue({ data: ghRepo() })
111 fakeFetch.mockResolvedValue(new Response(
112 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
113 { status: 200 },
114 ))
115
116 const result = await enrollRepo({
117 oauthSession: fakeOauthSession('did:plc:abc'),
118 installationId: 1,
119 githubRepoId: 9001,
120 })
121 expect(result.status).toBe('enrolled')
122
123 // Service auth requested with the right shape.
124 expect(getServiceAuthMock).toHaveBeenCalledTimes(1)
125 const sa = getServiceAuthMock.mock.calls[0]?.[0]
126 expect(sa?.aud).toBe('did:web:knot1.tangled.sh')
127 expect(sa?.lxm).toBe('sh.tangled.repo.create')
128
129 // Knot procedure invoked with source URL and rkey.
130 expect(fakeFetch).toHaveBeenCalledTimes(1)
131 const fetchCall = fakeFetch.mock.calls[0]
132 const url = fetchCall?.[0]
133 const init = fetchCall?.[1]
134 expect(url).toBe('https://knot1.tangled.sh/xrpc/sh.tangled.repo.create')
135 expect(init?.headers?.authorization).toBe('Bearer service-auth-jwt')
136 if (typeof init?.body !== 'string') throw new TypeError('expected string body')
137 const body: Record<string, unknown> = JSON.parse(init.body)
138 expect(body.name).toBe('my-project')
139 expect(body.source).toBe('https://github.com/alice/my-project')
140 expect(body.defaultBranch).toBe('main')
141 expect(typeof body.rkey).toBe('string')
142
143 // PDS record written with the same rkey, marker, topics, website.
144 expect(putRecordMock).toHaveBeenCalledTimes(1)
145 const put = putRecordMock.mock.calls[0]?.[0]
146 expect(put?.rkey).toBe(body.rkey)
147 expect(put?.record.repoDid).toBe('did:plc:repo-xyz')
148 expect(put?.record.knot).toBe('knot1.tangled.sh')
149 expect(put?.record.description).toBe(
150 '[READ-ONLY] Mirror of https://github.com/alice/my-project. a cool thing',
151 )
152 expect(put?.record.topics).toEqual(['cool', 'thing'])
153 expect(put?.record.website).toBe('https://my-project.example')
154
155 // Mapping persisted.
156 const rows = await useDb().select().from(repoMapping)
157 .where(sql`${repoMapping.installationId} = 1`)
158 expect(rows).toHaveLength(1)
159 expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz')
160 expect(rows[0].knot).toBe('knot1.tangled.sh')
161 expect(rows[0].status).toBe('active')
162 })
163
164 it('skips private repos', async () => {
165 githubGet.mockResolvedValue({ data: ghRepo({ private: true }) })
166
167 const result = await enrollRepo({
168 oauthSession: fakeOauthSession('did:plc:abc'),
169 installationId: 1,
170 githubRepoId: 9001,
171 })
172 expect(result).toEqual({ status: 'skipped', reason: 'private' })
173 expect(fakeFetch).not.toHaveBeenCalled()
174 expect(putRecordMock).not.toHaveBeenCalled()
175 expect(await useDb().select().from(repoMapping)).toHaveLength(0)
176 })
177
178 it('skips forks', async () => {
179 githubGet.mockResolvedValue({ data: ghRepo({ fork: true }) })
180
181 const result = await enrollRepo({
182 oauthSession: fakeOauthSession('did:plc:abc'),
183 installationId: 1,
184 githubRepoId: 9001,
185 })
186 expect(result).toEqual({ status: 'skipped', reason: 'fork' })
187 expect(fakeFetch).not.toHaveBeenCalled()
188 })
189
190 it('no-ops if a mapping already exists', async () => {
191 await useDb().insert(repoMapping).values({
192 installationId: 1,
193 githubRepoId: 9001,
194 githubFullName: 'alice/my-project',
195 status: 'active',
196 })
197
198 const result = await enrollRepo({
199 oauthSession: fakeOauthSession('did:plc:abc'),
200 installationId: 1,
201 githubRepoId: 9001,
202 })
203 expect(result).toEqual({ status: 'already' })
204 expect(githubGet).not.toHaveBeenCalled()
205 })
206
207 it('re-enrolls when force=true and updates the existing mapping in place', async () => {
208 await useDb().insert(repoMapping).values({
209 installationId: 1,
210 githubRepoId: 9001,
211 githubFullName: 'alice/my-project',
212 tangledRepoDid: 'did:plc:old-repo',
213 tangledFullName: 'did:plc:abc/my-project',
214 knot: 'knot1.tangled.sh',
215 status: 'error',
216 lastError: 'previous failure',
217 lastSyncedRefs: { 'refs/heads/main': 'deadbeef' },
218 })
219
220 githubGet.mockResolvedValue({ data: ghRepo() })
221 fakeFetch.mockResolvedValue(new Response(
222 JSON.stringify({ repoDid: 'did:plc:repo-new' }),
223 { status: 200 },
224 ))
225
226 const result = await enrollRepo({
227 oauthSession: fakeOauthSession('did:plc:abc'),
228 installationId: 1,
229 githubRepoId: 9001,
230 force: true,
231 })
232 expect(result.status).toBe('enrolled')
233
234 const rows = await useDb().select().from(repoMapping)
235 .where(sql`${repoMapping.installationId} = 1`)
236 expect(rows).toHaveLength(1)
237 expect(rows[0].tangledRepoDid).toBe('did:plc:repo-new')
238 expect(rows[0].status).toBe('active')
239 expect(rows[0].lastError).toBeNull()
240 // lastSyncedRefs preserved across the resync so the push worker keeps
241 // dedupe state.
242 expect(rows[0].lastSyncedRefs).toEqual({ 'refs/heads/main': 'deadbeef' })
243 })
244
245 it('throws and writes nothing if the knot rejects the procedure', async () => {
246 githubGet.mockResolvedValue({ data: ghRepo() })
247 fakeFetch.mockResolvedValue(new Response('nope', { status: 500 }))
248
249 await expect(enrollRepo({
250 oauthSession: fakeOauthSession('did:plc:abc'),
251 installationId: 1,
252 githubRepoId: 9001,
253 })).rejects.toThrow(/knot1\.tangled\.sh returned 500/)
254
255 expect(putRecordMock).not.toHaveBeenCalled()
256 expect(await useDb().select().from(repoMapping)).toHaveLength(0)
257 })
258
259 it('omits website when GitHub homepage is empty', async () => {
260 githubGet.mockResolvedValue({ data: ghRepo({ homepage: '' }) })
261 fakeFetch.mockResolvedValue(new Response(
262 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
263 { status: 200 },
264 ))
265
266 await enrollRepo({
267 oauthSession: fakeOauthSession('did:plc:abc'),
268 installationId: 1,
269 githubRepoId: 9001,
270 })
271
272 const put = putRecordMock.mock.calls[0]?.[0]
273 expect(put?.record).not.toHaveProperty('website')
274 })
275
276 it('emits a marker-only description when GitHub description is null', async () => {
277 githubGet.mockResolvedValue({ data: ghRepo({ description: null }) })
278 fakeFetch.mockResolvedValue(new Response(
279 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
280 { status: 200 },
281 ))
282
283 await enrollRepo({
284 oauthSession: fakeOauthSession('did:plc:abc'),
285 installationId: 1,
286 githubRepoId: 9001,
287 })
288
289 const put = putRecordMock.mock.calls[0]?.[0]
290 expect(put?.record.description).toBe(
291 '[READ-ONLY] Mirror of https://github.com/alice/my-project.',
292 )
293 })
294})
295
296describe('stripReadOnlyMarker', () => {
297 it('returns an empty string for null / undefined / empty input', () => {
298 expect(stripReadOnlyMarker(null)).toBe('')
299 expect(stripReadOnlyMarker(undefined)).toBe('')
300 expect(stripReadOnlyMarker('')).toBe('')
301 })
302
303 it('passes through values without the marker', () => {
304 expect(stripReadOnlyMarker('plain description')).toBe('plain description')
305 })
306
307 it('strips a single marker', () => {
308 expect(stripReadOnlyMarker('[READ-ONLY] Mirror of https://github.com/alice/proj. real text'))
309 .toBe('real text')
310 })
311
312 it('strips repeated markers (defence against historical accumulation)', () => {
313 const doubled = '[READ-ONLY] Mirror of https://github.com/alice/proj. [READ-ONLY] Mirror of https://github.com/alice/proj. real text'
314 expect(stripReadOnlyMarker(doubled)).toBe('real text')
315 })
316})
317
318describe('buildReadOnlyDescription', () => {
319 it('prepends the marker to a non-empty description', () => {
320 expect(buildReadOnlyDescription('alice/proj', 'hello world'))
321 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. hello world')
322 })
323
324 it('emits a bare marker when the GitHub description is empty / null', () => {
325 expect(buildReadOnlyDescription('alice/proj', null))
326 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.')
327 expect(buildReadOnlyDescription('alice/proj', ' '))
328 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.')
329 })
330
331 it('is idempotent: re-applying produces the same string', () => {
332 const once = buildReadOnlyDescription('alice/proj', 'hello')
333 const twice = buildReadOnlyDescription('alice/proj', once)
334 expect(twice).toBe(once)
335 const thrice = buildReadOnlyDescription('alice/proj', twice)
336 expect(thrice).toBe(once)
337 })
338})
339
340describe('mergeRepoRecord', () => {
341 const base = { name: 'proj', knot: 'knot1.tangled.sh', repoDid: 'did:plc:r', createdAt: '2025-01-01T00:00:00Z' }
342
343 it('preserves $type, name, knot, repoDid and unmanaged fields on existing records', () => {
344 const existing = {
345 $type: 'sh.tangled.repo',
346 name: 'proj',
347 knot: 'knot1.tangled.sh',
348 repoDid: 'did:plc:r',
349 createdAt: '2024-06-01T00:00:00Z',
350 customField: 'untouched',
351 }
352 const merged = mergeRepoRecord(existing, base, {
353 full_name: 'alice/proj',
354 description: 'new',
355 homepage: 'https://x.example',
356 topics: ['a'],
357 })
358 expect(merged.$type).toBe('sh.tangled.repo')
359 expect(merged.customField).toBe('untouched')
360 expect(merged.createdAt).toBe('2024-06-01T00:00:00Z')
361 expect(merged.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. new')
362 expect(merged.topics).toEqual(['a'])
363 expect(merged.website).toBe('https://x.example')
364 })
365
366 it('drops website when homepage goes empty', () => {
367 const existing = { website: 'https://old.example' }
368 const merged = mergeRepoRecord(existing, base, {
369 full_name: 'alice/proj',
370 description: null,
371 homepage: '',
372 })
373 expect(merged).not.toHaveProperty('website')
374 })
375
376 it('falls back to base.createdAt when existing has none', () => {
377 const merged = mergeRepoRecord(undefined, base, {
378 full_name: 'alice/proj',
379 description: null,
380 homepage: null,
381 })
382 expect(merged.createdAt).toBe(base.createdAt)
383 })
384
385 it('never accumulates the read-only prefix across repeated merges', () => {
386 let current: Record<string, unknown> = {}
387 for (let i = 0; i < 5; i++) {
388 current = mergeRepoRecord(current, base, {
389 full_name: 'alice/proj',
390 description: 'stable text',
391 homepage: null,
392 })
393 }
394 expect(current.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. stable text')
395 })
396})
397
398describe('syncRepoMetadata', () => {
399 beforeEach(async () => {
400 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
401 clearEncryptionKeyCache()
402
403 setDb(await createTestDb())
404 await useDb().insert(installation).values({
405 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
406 })
407
408 githubGet.mockReset()
409 putRecordMock.mockReset()
410 listRecordsMock.mockReset()
411 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } })
412 })
413
414 afterEach(() => {
415 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
416 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
417 clearEncryptionKeyCache()
418 clearDb()
419 })
420
421 async function seedActiveMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) {
422 await useDb().insert(repoMapping).values({
423 installationId: 1,
424 githubRepoId: 9001,
425 githubFullName: 'alice/my-project',
426 tangledRepoDid: 'did:plc:repo-xyz',
427 tangledFullName: 'did:plc:abc/my-project',
428 knot: 'knot1.tangled.sh',
429 status: 'active',
430 ...over,
431 })
432 }
433
434 it('skips with no-mapping when row is missing', async () => {
435 const result = await syncRepoMetadata({
436 oauthSession: fakeOauthSession('did:plc:abc'),
437 installationId: 1,
438 githubRepoId: 9001,
439 })
440 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' })
441 expect(githubGet).not.toHaveBeenCalled()
442 expect(putRecordMock).not.toHaveBeenCalled()
443 })
444
445 it('skips when disabledAt is set', async () => {
446 await seedActiveMapping({ disabledAt: new Date() })
447 const result = await syncRepoMetadata({
448 oauthSession: fakeOauthSession('did:plc:abc'),
449 installationId: 1,
450 githubRepoId: 9001,
451 })
452 expect(result).toEqual({ status: 'skipped', reason: 'disabled' })
453 })
454
455 it('skips when the repo is now private on GitHub', async () => {
456 await seedActiveMapping()
457 githubGet.mockResolvedValue({ data: ghRepo({ private: true }) })
458 const result = await syncRepoMetadata({
459 oauthSession: fakeOauthSession('did:plc:abc'),
460 installationId: 1,
461 githubRepoId: 9001,
462 })
463 expect(result).toEqual({ status: 'skipped', reason: 'private' })
464 })
465
466 it('skips when no matching PDS record can be located', async () => {
467 await seedActiveMapping()
468 githubGet.mockResolvedValue({ data: ghRepo() })
469 listRecordsMock.mockResolvedValue({ data: { records: [] } })
470
471 const result = await syncRepoMetadata({
472 oauthSession: fakeOauthSession('did:plc:abc'),
473 installationId: 1,
474 githubRepoId: 9001,
475 })
476 expect(result).toEqual({ status: 'skipped', reason: 'no-pds-record' })
477 expect(putRecordMock).not.toHaveBeenCalled()
478 })
479
480 it('finds the matching record by repoDid and writes the merged record with swapRecord', async () => {
481 await seedActiveMapping()
482 githubGet.mockResolvedValue({ data: ghRepo({
483 description: 'updated text',
484 homepage: 'https://new.example',
485 topics: ['fresh'],
486 }) })
487 listRecordsMock.mockResolvedValue({
488 data: {
489 records: [
490 {
491 uri: 'at://did:plc:abc/sh.tangled.repo/some-other',
492 cid: 'bafy-other',
493 value: { $type: 'sh.tangled.repo', name: 'other', knot: 'knot1.tangled.sh', repoDid: 'did:plc:other', createdAt: '2025-01-01T00:00:00Z' },
494 },
495 {
496 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1',
497 cid: 'bafy-old',
498 value: {
499 $type: 'sh.tangled.repo',
500 name: 'my-project',
501 knot: 'knot1.tangled.sh',
502 repoDid: 'did:plc:repo-xyz',
503 createdAt: '2025-01-01T00:00:00Z',
504 description: '[READ-ONLY] Mirror of https://github.com/alice/my-project. older',
505 topics: ['stale'],
506 website: 'https://old.example',
507 },
508 },
509 ],
510 },
511 })
512
513 const result = await syncRepoMetadata({
514 oauthSession: fakeOauthSession('did:plc:abc'),
515 installationId: 1,
516 githubRepoId: 9001,
517 })
518 expect(result).toEqual({ status: 'synced' })
519
520 expect(putRecordMock).toHaveBeenCalledTimes(1)
521 const put = putRecordMock.mock.calls[0]?.[0]
522 expect(put?.rkey).toBe('rkey1')
523 expect(put?.swapRecord).toBe('bafy-old')
524 expect(put?.record.description).toBe(
525 '[READ-ONLY] Mirror of https://github.com/alice/my-project. updated text',
526 )
527 expect(put?.record.topics).toEqual(['fresh'])
528 expect(put?.record.website).toBe('https://new.example')
529 expect(put?.record.createdAt).toBe('2025-01-01T00:00:00Z')
530 })
531
532 it('paginates listRecords until it finds the right repoDid', async () => {
533 await seedActiveMapping()
534 githubGet.mockResolvedValue({ data: ghRepo() })
535 listRecordsMock
536 .mockResolvedValueOnce({
537 data: {
538 records: [{ uri: 'at://x/sh.tangled.repo/a', cid: 'c1', value: { repoDid: 'did:plc:other' } }],
539 cursor: 'next',
540 },
541 })
542 .mockResolvedValueOnce({
543 data: {
544 records: [{ uri: 'at://x/sh.tangled.repo/b', cid: 'c2', value: { repoDid: 'did:plc:repo-xyz', $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', createdAt: '2024-01-01T00:00:00Z' } }],
545 },
546 })
547
548 const result = await syncRepoMetadata({
549 oauthSession: fakeOauthSession('did:plc:abc'),
550 installationId: 1,
551 githubRepoId: 9001,
552 })
553 expect(result).toEqual({ status: 'synced' })
554 expect(listRecordsMock).toHaveBeenCalledTimes(2)
555 expect(putRecordMock.mock.calls[0]?.[0].rkey).toBe('b')
556 })
557
558 it('refreshes githubFullName when GitHub reports a new name', async () => {
559 await seedActiveMapping()
560 githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/renamed' }) })
561 listRecordsMock.mockResolvedValue({
562 data: {
563 records: [{
564 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1',
565 cid: 'bafy-old',
566 value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2024-01-01T00:00:00Z' },
567 }],
568 },
569 })
570
571 await syncRepoMetadata({
572 oauthSession: fakeOauthSession('did:plc:abc'),
573 installationId: 1,
574 githubRepoId: 9001,
575 })
576
577 const rows = await useDb().select().from(repoMapping)
578 expect(rows[0].githubFullName).toBe('alice/renamed')
579 })
580})