mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

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})