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('throws and writes nothing if the knot rejects the procedure', async () => {
208 githubGet.mockResolvedValue({ data: ghRepo() })
209 fakeFetch.mockResolvedValue(new Response('nope', { status: 500 }))
210
211 await expect(enrollRepo({
212 oauthSession: fakeOauthSession('did:plc:abc'),
213 installationId: 1,
214 githubRepoId: 9001,
215 })).rejects.toThrow(/knot1\.tangled\.sh returned 500/)
216
217 expect(putRecordMock).not.toHaveBeenCalled()
218 expect(await useDb().select().from(repoMapping)).toHaveLength(0)
219 })
220
221 it('omits website when GitHub homepage is empty', async () => {
222 githubGet.mockResolvedValue({ data: ghRepo({ homepage: '' }) })
223 fakeFetch.mockResolvedValue(new Response(
224 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
225 { status: 200 },
226 ))
227
228 await enrollRepo({
229 oauthSession: fakeOauthSession('did:plc:abc'),
230 installationId: 1,
231 githubRepoId: 9001,
232 })
233
234 const put = putRecordMock.mock.calls[0]?.[0]
235 expect(put?.record).not.toHaveProperty('website')
236 })
237
238 it('emits a marker-only description when GitHub description is null', async () => {
239 githubGet.mockResolvedValue({ data: ghRepo({ description: null }) })
240 fakeFetch.mockResolvedValue(new Response(
241 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
242 { status: 200 },
243 ))
244
245 await enrollRepo({
246 oauthSession: fakeOauthSession('did:plc:abc'),
247 installationId: 1,
248 githubRepoId: 9001,
249 })
250
251 const put = putRecordMock.mock.calls[0]?.[0]
252 expect(put?.record.description).toBe(
253 '[READ-ONLY] Mirror of https://github.com/alice/my-project.',
254 )
255 })
256})
257
258describe('stripReadOnlyMarker', () => {
259 it('returns an empty string for null / undefined / empty input', () => {
260 expect(stripReadOnlyMarker(null)).toBe('')
261 expect(stripReadOnlyMarker(undefined)).toBe('')
262 expect(stripReadOnlyMarker('')).toBe('')
263 })
264
265 it('passes through values without the marker', () => {
266 expect(stripReadOnlyMarker('plain description')).toBe('plain description')
267 })
268
269 it('strips a single marker', () => {
270 expect(stripReadOnlyMarker('[READ-ONLY] Mirror of https://github.com/alice/proj. real text'))
271 .toBe('real text')
272 })
273
274 it('strips repeated markers (defence against historical accumulation)', () => {
275 const doubled = '[READ-ONLY] Mirror of https://github.com/alice/proj. [READ-ONLY] Mirror of https://github.com/alice/proj. real text'
276 expect(stripReadOnlyMarker(doubled)).toBe('real text')
277 })
278})
279
280describe('buildReadOnlyDescription', () => {
281 it('prepends the marker to a non-empty description', () => {
282 expect(buildReadOnlyDescription('alice/proj', 'hello world'))
283 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. hello world')
284 })
285
286 it('emits a bare marker when the GitHub description is empty / null', () => {
287 expect(buildReadOnlyDescription('alice/proj', null))
288 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.')
289 expect(buildReadOnlyDescription('alice/proj', ' '))
290 .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.')
291 })
292
293 it('is idempotent: re-applying produces the same string', () => {
294 const once = buildReadOnlyDescription('alice/proj', 'hello')
295 const twice = buildReadOnlyDescription('alice/proj', once)
296 expect(twice).toBe(once)
297 const thrice = buildReadOnlyDescription('alice/proj', twice)
298 expect(thrice).toBe(once)
299 })
300})
301
302describe('mergeRepoRecord', () => {
303 const base = { name: 'proj', knot: 'knot1.tangled.sh', repoDid: 'did:plc:r', createdAt: '2025-01-01T00:00:00Z' }
304
305 it('preserves $type, name, knot, repoDid and unmanaged fields on existing records', () => {
306 const existing = {
307 $type: 'sh.tangled.repo',
308 name: 'proj',
309 knot: 'knot1.tangled.sh',
310 repoDid: 'did:plc:r',
311 createdAt: '2024-06-01T00:00:00Z',
312 customField: 'untouched',
313 }
314 const merged = mergeRepoRecord(existing, base, {
315 full_name: 'alice/proj',
316 description: 'new',
317 homepage: 'https://x.example',
318 topics: ['a'],
319 })
320 expect(merged.$type).toBe('sh.tangled.repo')
321 expect(merged.customField).toBe('untouched')
322 expect(merged.createdAt).toBe('2024-06-01T00:00:00Z')
323 expect(merged.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. new')
324 expect(merged.topics).toEqual(['a'])
325 expect(merged.website).toBe('https://x.example')
326 })
327
328 it('drops website when homepage goes empty', () => {
329 const existing = { website: 'https://old.example' }
330 const merged = mergeRepoRecord(existing, base, {
331 full_name: 'alice/proj',
332 description: null,
333 homepage: '',
334 })
335 expect(merged).not.toHaveProperty('website')
336 })
337
338 it('falls back to base.createdAt when existing has none', () => {
339 const merged = mergeRepoRecord(undefined, base, {
340 full_name: 'alice/proj',
341 description: null,
342 homepage: null,
343 })
344 expect(merged.createdAt).toBe(base.createdAt)
345 })
346
347 it('never accumulates the read-only prefix across repeated merges', () => {
348 let current: Record<string, unknown> = {}
349 for (let i = 0; i < 5; i++) {
350 current = mergeRepoRecord(current, base, {
351 full_name: 'alice/proj',
352 description: 'stable text',
353 homepage: null,
354 })
355 }
356 expect(current.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. stable text')
357 })
358})
359
360describe('syncRepoMetadata', () => {
361 beforeEach(async () => {
362 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
363 clearEncryptionKeyCache()
364
365 setDb(await createTestDb())
366 await useDb().insert(installation).values({
367 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
368 })
369
370 githubGet.mockReset()
371 putRecordMock.mockReset()
372 listRecordsMock.mockReset()
373 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } })
374 })
375
376 afterEach(() => {
377 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
378 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
379 clearEncryptionKeyCache()
380 clearDb()
381 })
382
383 async function seedActiveMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) {
384 await useDb().insert(repoMapping).values({
385 installationId: 1,
386 githubRepoId: 9001,
387 githubFullName: 'alice/my-project',
388 tangledRepoDid: 'did:plc:repo-xyz',
389 tangledFullName: 'did:plc:abc/my-project',
390 knot: 'knot1.tangled.sh',
391 status: 'active',
392 ...over,
393 })
394 }
395
396 it('skips with no-mapping when row is missing', async () => {
397 const result = await syncRepoMetadata({
398 oauthSession: fakeOauthSession('did:plc:abc'),
399 installationId: 1,
400 githubRepoId: 9001,
401 })
402 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' })
403 expect(githubGet).not.toHaveBeenCalled()
404 expect(putRecordMock).not.toHaveBeenCalled()
405 })
406
407 it('skips when disabledAt is set', async () => {
408 await seedActiveMapping({ disabledAt: new Date() })
409 const result = await syncRepoMetadata({
410 oauthSession: fakeOauthSession('did:plc:abc'),
411 installationId: 1,
412 githubRepoId: 9001,
413 })
414 expect(result).toEqual({ status: 'skipped', reason: 'disabled' })
415 })
416
417 it('skips when the repo is now private on GitHub', async () => {
418 await seedActiveMapping()
419 githubGet.mockResolvedValue({ data: ghRepo({ private: true }) })
420 const result = await syncRepoMetadata({
421 oauthSession: fakeOauthSession('did:plc:abc'),
422 installationId: 1,
423 githubRepoId: 9001,
424 })
425 expect(result).toEqual({ status: 'skipped', reason: 'private' })
426 })
427
428 it('skips when no matching PDS record can be located', async () => {
429 await seedActiveMapping()
430 githubGet.mockResolvedValue({ data: ghRepo() })
431 listRecordsMock.mockResolvedValue({ data: { records: [] } })
432
433 const result = await syncRepoMetadata({
434 oauthSession: fakeOauthSession('did:plc:abc'),
435 installationId: 1,
436 githubRepoId: 9001,
437 })
438 expect(result).toEqual({ status: 'skipped', reason: 'no-pds-record' })
439 expect(putRecordMock).not.toHaveBeenCalled()
440 })
441
442 it('finds the matching record by repoDid and writes the merged record with swapRecord', async () => {
443 await seedActiveMapping()
444 githubGet.mockResolvedValue({ data: ghRepo({
445 description: 'updated text',
446 homepage: 'https://new.example',
447 topics: ['fresh'],
448 }) })
449 listRecordsMock.mockResolvedValue({
450 data: {
451 records: [
452 {
453 uri: 'at://did:plc:abc/sh.tangled.repo/some-other',
454 cid: 'bafy-other',
455 value: { $type: 'sh.tangled.repo', name: 'other', knot: 'knot1.tangled.sh', repoDid: 'did:plc:other', createdAt: '2025-01-01T00:00:00Z' },
456 },
457 {
458 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1',
459 cid: 'bafy-old',
460 value: {
461 $type: 'sh.tangled.repo',
462 name: 'my-project',
463 knot: 'knot1.tangled.sh',
464 repoDid: 'did:plc:repo-xyz',
465 createdAt: '2025-01-01T00:00:00Z',
466 description: '[READ-ONLY] Mirror of https://github.com/alice/my-project. older',
467 topics: ['stale'],
468 website: 'https://old.example',
469 },
470 },
471 ],
472 },
473 })
474
475 const result = await syncRepoMetadata({
476 oauthSession: fakeOauthSession('did:plc:abc'),
477 installationId: 1,
478 githubRepoId: 9001,
479 })
480 expect(result).toEqual({ status: 'synced' })
481
482 expect(putRecordMock).toHaveBeenCalledTimes(1)
483 const put = putRecordMock.mock.calls[0]?.[0]
484 expect(put?.rkey).toBe('rkey1')
485 expect(put?.swapRecord).toBe('bafy-old')
486 expect(put?.record.description).toBe(
487 '[READ-ONLY] Mirror of https://github.com/alice/my-project. updated text',
488 )
489 expect(put?.record.topics).toEqual(['fresh'])
490 expect(put?.record.website).toBe('https://new.example')
491 expect(put?.record.createdAt).toBe('2025-01-01T00:00:00Z')
492 })
493
494 it('paginates listRecords until it finds the right repoDid', async () => {
495 await seedActiveMapping()
496 githubGet.mockResolvedValue({ data: ghRepo() })
497 listRecordsMock
498 .mockResolvedValueOnce({
499 data: {
500 records: [{ uri: 'at://x/sh.tangled.repo/a', cid: 'c1', value: { repoDid: 'did:plc:other' } }],
501 cursor: 'next',
502 },
503 })
504 .mockResolvedValueOnce({
505 data: {
506 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' } }],
507 },
508 })
509
510 const result = await syncRepoMetadata({
511 oauthSession: fakeOauthSession('did:plc:abc'),
512 installationId: 1,
513 githubRepoId: 9001,
514 })
515 expect(result).toEqual({ status: 'synced' })
516 expect(listRecordsMock).toHaveBeenCalledTimes(2)
517 expect(putRecordMock.mock.calls[0]?.[0].rkey).toBe('b')
518 })
519
520 it('refreshes githubFullName when GitHub reports a new name', async () => {
521 await seedActiveMapping()
522 githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/renamed' }) })
523 listRecordsMock.mockResolvedValue({
524 data: {
525 records: [{
526 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1',
527 cid: 'bafy-old',
528 value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2024-01-01T00:00:00Z' },
529 }],
530 },
531 })
532
533 await syncRepoMetadata({
534 oauthSession: fakeOauthSession('did:plc:abc'),
535 installationId: 1,
536 githubRepoId: 9001,
537 })
538
539 const rows = await useDb().select().from(repoMapping)
540 expect(rows[0].githubFullName).toBe('alice/renamed')
541 })
542})