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