mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

feat: sync repository metadata and lifecycle events

+1044 -26
+187 -13
server/utils/job-handlers.ts
··· 1 - import { sql } from 'drizzle-orm' 2 - import { userIdentity } from '../db/schema' 1 + import { and, eq, sql } from 'drizzle-orm' 2 + import { repoMapping, userIdentity } from '../db/schema' 3 3 import { useOAuthClient } from './atproto-oauth' 4 4 import { useDb } from './db' 5 5 import { installationOctokit } from './github-app' ··· 8 8 import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref' 9 9 import { syncPush, type PushPayload } from './sync-push' 10 10 import { generateAndPublishKey } from './tangled-pubkey' 11 - import { enrollRepo } from './tangled-repo' 11 + import { enrollRepo, syncRepoMetadata } from './tangled-repo' 12 12 13 13 /** 14 14 * Map of job kind → handler. Each commit fills in its slice: 15 15 * - 'github.push' → commit 12 (sync push events) 16 16 * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops) 17 - * - 'github.repository' → commit 14/15 (description, lifecycle) 17 + * - 'github.repository' → metadata sync + lifecycle (edited, 18 + * renamed, privatized, publicized, 19 + * transferred, deleted) 18 20 * - 'github.installation_repositories' → commit 10 (fan-out enrolment) 19 21 * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) 20 22 * - 'tangled.create-repo' → commit 10 (per-repo enrolment) ··· 58 60 page: number 59 61 } 60 62 63 + type RepositoryAction = 64 + | 'created' 65 + | 'edited' 66 + | 'renamed' 67 + | 'transferred' 68 + | 'deleted' 69 + | 'privatized' 70 + | 'publicized' 71 + | 'archived' 72 + | 'unarchived' 73 + 74 + interface RepositoryPayload { 75 + installationId: number 76 + githubRepoId: number 77 + action: RepositoryAction 78 + } 79 + 61 80 function asObject(value: unknown): Record<string, unknown> { 62 81 if (value === null || typeof value !== 'object') { 63 82 throw new TypeError(`expected object payload, got ${typeof value}`) ··· 107 126 } 108 127 } 109 128 129 + const REPOSITORY_ACTIONS = new Set<string>([ 130 + 'created', 131 + 'edited', 132 + 'renamed', 133 + 'transferred', 134 + 'deleted', 135 + 'privatized', 136 + 'publicized', 137 + 'archived', 138 + 'unarchived', 139 + ]) 140 + 141 + function isRepositoryAction(action: string): action is RepositoryAction { 142 + return REPOSITORY_ACTIONS.has(action) 143 + } 144 + 145 + function repositoryPayload(value: unknown): RepositoryPayload { 146 + const o = asObject(value) 147 + if ( 148 + typeof o.installationId !== 'number' 149 + || typeof o.githubRepoId !== 'number' 150 + || typeof o.action !== 'string' 151 + || !isRepositoryAction(o.action) 152 + ) { 153 + throw new TypeError('invalid github.repository payload') 154 + } 155 + return { 156 + installationId: o.installationId, 157 + githubRepoId: o.githubRepoId, 158 + action: o.action, 159 + } 160 + } 161 + 110 162 function installationRepositoriesPayload(value: unknown): InstallationRepositoriesPayload { 111 163 const o = asObject(value) 112 164 if ( ··· 196 248 } 197 249 198 250 if (envelope.kind === 'github.installation_repositories') { 199 - const { installationId, action, addedRepoIds } = installationRepositoriesPayload(envelope.payload) 200 - if (action !== 'added') return 251 + const { installationId, action, addedRepoIds, removedRepoIds } = installationRepositoriesPayload(envelope.payload) 201 252 202 - // Fan out one tangled.create-repo job per added repo. The fan-out keeps 203 - // each unit small enough to fit comfortably in the per-job lease, lets 204 - // failures retry independently, and runs the OAuth precondition check 205 - // per repo (an install can outlive a tangled identity disconnection). 206 - for (const id of addedRepoIds) { 207 - // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design 208 - await enqueue('tangled.create-repo', { installationId, githubRepoId: id }) 253 + if (action === 'added') { 254 + // Fan out one tangled.create-repo job per added repo. The fan-out keeps 255 + // each unit small enough to fit comfortably in the per-job lease, lets 256 + // failures retry independently, and runs the OAuth precondition check 257 + // per repo (an install can outlive a tangled identity disconnection). 258 + for (const id of addedRepoIds) { 259 + // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design 260 + await enqueue('tangled.create-repo', { installationId, githubRepoId: id }) 261 + } 262 + return 209 263 } 264 + 265 + // 'removed': the install no longer has access to these repos. We can't 266 + // see them via the install token any more, so syncing has to stop. Leave 267 + // the tangled mirror in place (PLAN.md: "don't delete user data on our 268 + // say-so"). The user can manually re-add the repo on GitHub to re-enable. 269 + if (action === 'removed' && removedRepoIds.length > 0) { 270 + const db = useDb() 271 + await db.update(repoMapping) 272 + .set({ disabledAt: new Date(), updatedAt: new Date() }) 273 + .where(and( 274 + eq(repoMapping.installationId, installationId), 275 + sql`${repoMapping.githubRepoId} IN ${removedRepoIds}`, 276 + )) 277 + } 278 + return 279 + } 280 + 281 + if (envelope.kind === 'github.repository') { 282 + await handleRepositoryEvent(repositoryPayload(envelope.payload)) 210 283 return 211 284 } 212 285 213 286 // Other kinds: still no-op until handlers land in their commits. 214 287 } 288 + 289 + async function handleRepositoryEvent(payload: RepositoryPayload): Promise<void> { 290 + const { installationId, githubRepoId, action } = payload 291 + const db = useDb() 292 + 293 + // Most lifecycle actions only need to touch the local mapping, no PDS work. 294 + // `edited` and `publicized` (when we already have a mapping) go through the 295 + // OAuth-authed metadata sync helper. 296 + if (action === 'privatized' || action === 'transferred' || action === 'deleted') { 297 + await db.update(repoMapping) 298 + .set({ disabledAt: new Date(), updatedAt: new Date() }) 299 + .where(and( 300 + eq(repoMapping.installationId, installationId), 301 + eq(repoMapping.githubRepoId, githubRepoId), 302 + )) 303 + return 304 + } 305 + 306 + if (action === 'renamed') { 307 + // Refetch the current full_name from GitHub; the webhook envelope is 308 + // intentionally tiny. We do NOT rename on the tangled side — there's no 309 + // procedure and a fresh-create-and-delete would lose stars/refs. Surface 310 + // the change in `lastError` with an `info:` prefix so the dashboard can 311 + // flag it without a schema change. 312 + const rows = await db.select({ id: repoMapping.id, githubFullName: repoMapping.githubFullName }) 313 + .from(repoMapping) 314 + .where(and( 315 + eq(repoMapping.installationId, installationId), 316 + eq(repoMapping.githubRepoId, githubRepoId), 317 + )) 318 + .limit(1) 319 + if (rows.length === 0) return 320 + const row = rows[0]! 321 + 322 + const octokit = await installationOctokit(installationId) 323 + const { data: repo } = await octokit.request('GET /repositories/{repository_id}', { 324 + repository_id: githubRepoId, 325 + }) 326 + if (repo.full_name === row.githubFullName) return 327 + 328 + await db.update(repoMapping) 329 + .set({ 330 + githubFullName: repo.full_name, 331 + lastError: `info: renamed on github from ${row.githubFullName} to ${repo.full_name}; tangled mirror name unchanged`, 332 + updatedAt: new Date(), 333 + }) 334 + .where(eq(repoMapping.id, row.id)) 335 + return 336 + } 337 + 338 + if (action === 'publicized') { 339 + const rows = await db.select().from(repoMapping).where(and( 340 + eq(repoMapping.installationId, installationId), 341 + eq(repoMapping.githubRepoId, githubRepoId), 342 + )).limit(1) 343 + const row = rows[0] 344 + 345 + if (!row) { 346 + // Repo flipped public without ever having been enrolled (the install 347 + // was added while it was private). Kick off a fresh enrolment. 348 + await enqueue('tangled.create-repo', { installationId, githubRepoId }) 349 + return 350 + } 351 + 352 + await db.update(repoMapping) 353 + .set({ disabledAt: null, updatedAt: new Date() }) 354 + .where(eq(repoMapping.id, row.id)) 355 + 356 + if (!row.tangledRepoDid) { 357 + await enqueue('tangled.create-repo', { installationId, githubRepoId }) 358 + return 359 + } 360 + 361 + // Already mirrored, just refresh metadata in case description/topics 362 + // changed while it was private. 363 + await runMetadataSync(installationId, githubRepoId) 364 + return 365 + } 366 + 367 + if (action === 'edited') { 368 + await runMetadataSync(installationId, githubRepoId) 369 + return 370 + } 371 + 372 + // 'created', 'archived', 'unarchived' — nothing for us to do. Repo creation 373 + // surfaces via `installation_repositories.added`; archive state isn't part 374 + // of the mirror surface in v1. 375 + } 376 + 377 + async function runMetadataSync(installationId: number, githubRepoId: number): Promise<void> { 378 + const db = useDb() 379 + const identity = await db.select({ did: userIdentity.did }) 380 + .from(userIdentity) 381 + .where(sql`${userIdentity.installationId} = ${installationId}`) 382 + // No tangled identity yet — OAuth callback will backfill on completion. 383 + if (identity.length === 0) return 384 + 385 + const client = await useOAuthClient() 386 + const session = await client.restore(identity[0]!.did) 387 + await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) 388 + }
+193 -9
server/utils/tangled-repo.ts
··· 1 1 import { Agent } from '@atproto/api' 2 2 import type { OAuthSession } from '@atproto/oauth-client-node' 3 3 import { now as tidNow } from '@atcute/tid' 4 - import { sql } from 'drizzle-orm' 4 + import { and, eq, sql } from 'drizzle-orm' 5 5 import { repoMapping } from '../db/schema' 6 6 import { useDb } from './db' 7 7 import { installationOctokit } from './github-app' 8 8 9 9 const REPO_LEXICON = 'sh.tangled.repo' 10 10 const REPO_CREATE_NSID = 'sh.tangled.repo.create' 11 + const LIST_RECORDS_PAGE_SIZE = 100 12 + 13 + /** 14 + * GitHub repo fields we mirror into the `sh.tangled.repo` record. Kept narrow 15 + * so the merge helper is easy to reason about and to test without pulling in 16 + * the full Octokit type. 17 + */ 18 + export interface GithubRepoMetadata { 19 + full_name: string 20 + description: string | null 21 + homepage: string | null 22 + topics?: string[] 23 + } 24 + 25 + /** 26 + * Strip our `[READ-ONLY] Mirror of ...` prefix from a description, if present. 27 + * Idempotent: returns the original string when there's no prefix to remove. 28 + * Guards against accumulating prefixes if a GitHub description ever round-trips 29 + * back through our marker (e.g. a user copy-pasted the tangled description 30 + * into GitHub). 31 + */ 32 + export function stripReadOnlyMarker(value: string | null | undefined): string { 33 + if (!value) return '' 34 + let s = value 35 + // Strip repeatedly so any accidental doubling is collapsed. 36 + for (;;) { 37 + const next = s.replace(/^\[READ-ONLY\]\s*Mirror of https:\/\/github\.com\/[^\s.]+\/[^\s.]+\.\s*/, '') 38 + if (next === s) return s 39 + s = next 40 + } 41 + } 42 + 43 + /** 44 + * Build the `description` we want on the tangled-side record from GitHub's 45 + * current state. Always rebuilt from scratch so we never compound the marker. 46 + */ 47 + export function buildReadOnlyDescription(githubFullName: string, githubDescription: string | null | undefined): string { 48 + const stripped = stripReadOnlyMarker(githubDescription).trim() 49 + const prefix = `[READ-ONLY] Mirror of https://github.com/${githubFullName}.` 50 + return stripped ? `${prefix} ${stripped}` : prefix 51 + } 52 + 53 + /** 54 + * Merge GitHub metadata into an existing PDS record value, preserving fields 55 + * we don't manage (`$type`, `name`, `knot`, `repoDid`, `createdAt`, plus any 56 + * future additions). Pass `existing = undefined` for the initial enrolment 57 + * write. 58 + */ 59 + export function mergeRepoRecord( 60 + existing: Record<string, unknown> | undefined, 61 + base: { name: string, knot: string, repoDid: string, createdAt: string }, 62 + gh: GithubRepoMetadata, 63 + ): Record<string, unknown> { 64 + const description = buildReadOnlyDescription(gh.full_name, gh.description) 65 + const website = gh.homepage && gh.homepage.length > 0 ? gh.homepage : undefined 66 + const topics = Array.isArray(gh.topics) ? gh.topics : undefined 67 + 68 + // Start from existing so unknown fields survive a round-trip. Then overlay 69 + // the immutable base (in case the existing record is malformed) and the 70 + // managed metadata. 71 + const merged: Record<string, unknown> = { ...existing } 72 + merged.$type = REPO_LEXICON 73 + merged.name = base.name 74 + merged.knot = base.knot 75 + merged.repoDid = base.repoDid 76 + merged.createdAt = (typeof existing?.createdAt === 'string' && existing.createdAt) || base.createdAt 77 + merged.description = description 78 + if (topics !== undefined) merged.topics = topics 79 + if (website !== undefined) merged.website = website 80 + else delete merged.website 81 + return merged 82 + } 11 83 12 84 /** 13 85 * Default knot for users with no `sh.tangled.knot` records. PLAN.md "Open ··· 104 176 throw new Error(`knot ${knot} returned no repoDid`) 105 177 } 106 178 107 - // 5. PDS record so the appview firehose discovers the repo. 179 + // 5. PDS record so the appview firehose discovers the repo. Includes the 180 + // read-only marker and current GitHub metadata from the off — no follow-up 181 + // metadata sync needed at enrolment time. 182 + const record = mergeRepoRecord(undefined, 183 + { name, knot, repoDid, createdAt: new Date().toISOString() }, 184 + { 185 + full_name: repo.full_name, 186 + description: repo.description, 187 + homepage: repo.homepage, 188 + topics: repo.topics, 189 + }, 190 + ) 108 191 await agent.com.atproto.repo.putRecord({ 109 192 repo: opts.oauthSession.did, 110 193 collection: REPO_LEXICON, 111 194 rkey, 112 - record: { 113 - $type: REPO_LEXICON, 114 - name, 115 - knot, 116 - repoDid, 117 - createdAt: new Date().toISOString(), 118 - }, 195 + record, 119 196 }) 120 197 121 198 // 6. Persist mapping. ··· 131 208 132 209 return { status: 'enrolled' } 133 210 } 211 + 212 + export interface SyncMetadataResult { 213 + status: 'synced' | 'skipped' 214 + reason?: 'no-mapping' | 'disabled' | 'private' | 'fork' | 'no-pds-record' 215 + } 216 + 217 + /** 218 + * Refresh the `sh.tangled.repo` record on the user's PDS to match GitHub's 219 + * current description, topics, and homepage. Triggered on `repository.edited`. 220 + * 221 + * We don't store the rkey locally, so we discover it by listing the user's 222 + * `sh.tangled.repo` records and matching on `repoDid` (which we do store). 223 + * `swapRecord` is passed for optimistic concurrency in case two webhook 224 + * deliveries race. 225 + */ 226 + export async function syncRepoMetadata(opts: { 227 + oauthSession: OAuthSession 228 + installationId: number 229 + githubRepoId: number 230 + }): Promise<SyncMetadataResult> { 231 + const db = useDb() 232 + 233 + const rows = await db.select().from(repoMapping).where( 234 + and( 235 + eq(repoMapping.installationId, opts.installationId), 236 + eq(repoMapping.githubRepoId, opts.githubRepoId), 237 + ), 238 + ).limit(1) 239 + if (rows.length === 0) return { status: 'skipped', reason: 'no-mapping' } 240 + const row = rows[0]! 241 + 242 + if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } 243 + if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } 244 + 245 + // Refetch GitHub state rather than trusting the webhook body. 246 + const octokit = await installationOctokit(opts.installationId) 247 + const { data: repo } = await octokit.request('GET /repositories/{repository_id}', { 248 + repository_id: opts.githubRepoId, 249 + }) 250 + if (repo.private) return { status: 'skipped', reason: 'private' } 251 + if (repo.fork) return { status: 'skipped', reason: 'fork' } 252 + 253 + const [, name] = repo.full_name.split('/') 254 + if (!name) throw new Error(`unexpected github full_name shape: ${repo.full_name}`) 255 + 256 + const agent = new Agent(opts.oauthSession) 257 + 258 + // Discover the rkey by walking the collection until we find the record 259 + // matching this repo's `repoDid`. Typical installs have <100 records so 260 + // pagination is mostly defensive. 261 + let cursor: string | undefined 262 + let found: { uri: string, cid: string, value: Record<string, unknown> } | undefined 263 + do { 264 + // eslint-disable-next-line no-await-in-loop -- sequential pagination 265 + const page = await agent.com.atproto.repo.listRecords({ 266 + repo: opts.oauthSession.did, 267 + collection: REPO_LEXICON, 268 + limit: LIST_RECORDS_PAGE_SIZE, 269 + cursor, 270 + }) 271 + for (const rec of page.data.records) { 272 + const value = rec.value as Record<string, unknown> 273 + if (value.repoDid === row.tangledRepoDid) { 274 + found = { uri: rec.uri, cid: rec.cid, value } 275 + break 276 + } 277 + } 278 + cursor = found ? undefined : page.data.cursor 279 + } while (cursor) 280 + 281 + if (!found) return { status: 'skipped', reason: 'no-pds-record' } 282 + 283 + const rkey = found.uri.split('/').pop() 284 + if (!rkey) throw new Error(`could not parse rkey from at-uri: ${found.uri}`) 285 + 286 + const createdAt = typeof found.value.createdAt === 'string' 287 + ? found.value.createdAt 288 + : new Date().toISOString() 289 + 290 + const record = mergeRepoRecord(found.value, 291 + { name, knot: row.knot, repoDid: row.tangledRepoDid, createdAt }, 292 + { 293 + full_name: repo.full_name, 294 + description: repo.description, 295 + homepage: repo.homepage, 296 + topics: repo.topics, 297 + }, 298 + ) 299 + 300 + await agent.com.atproto.repo.putRecord({ 301 + repo: opts.oauthSession.did, 302 + collection: REPO_LEXICON, 303 + rkey, 304 + record, 305 + swapRecord: found.cid, 306 + }) 307 + 308 + // Refresh the cached display name. githubFullName is display-only (joins go 309 + // through githubRepoId), but the dashboard reads it. 310 + if (row.githubFullName !== repo.full_name) { 311 + await db.update(repoMapping) 312 + .set({ githubFullName: repo.full_name, updatedAt: new Date() }) 313 + .where(eq(repoMapping.id, row.id)) 314 + } 315 + 316 + return { status: 'synced' } 317 + }
+319
test/unit/repository-handler.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 3 + import { installation, job, repoMapping, userIdentity } from '../../server/db/schema' 4 + import { clearDb, setDb, useDb } from '../../server/utils/db' 5 + import { clearEncryptionKeyCache } from '../../server/utils/encryption' 6 + import { createTestDb } from '../utils/db' 7 + 8 + const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 9 + 10 + interface GithubRepoLike { 11 + id: number 12 + full_name: string 13 + private: boolean 14 + fork: boolean 15 + default_branch: string 16 + description: string | null 17 + homepage: string | null 18 + topics: string[] 19 + } 20 + 21 + const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>() 22 + const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown>, swapRecord?: string }) => Promise<unknown>>() 23 + const 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 } }>>() 24 + const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>() 25 + 26 + vi.mock('@atproto/api', () => ({ 27 + Agent: class { 28 + com = { 29 + atproto: { 30 + repo: { putRecord: putRecordMock, listRecords: listRecordsMock }, 31 + }, 32 + } 33 + }, 34 + })) 35 + 36 + vi.mock('../../server/utils/github-app', () => ({ 37 + installationOctokit: async () => ({ 38 + request: githubGet, 39 + }), 40 + clearGitHubAppCache: () => {}, 41 + })) 42 + 43 + vi.mock('../../server/utils/atproto-oauth', () => ({ 44 + useOAuthClient: async () => ({ restore: restoreMock }), 45 + })) 46 + 47 + const { dispatch } = await import('../../server/utils/job-handlers') 48 + 49 + function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike { 50 + return { 51 + id: 9001, 52 + full_name: 'alice/my-project', 53 + private: false, 54 + fork: false, 55 + default_branch: 'main', 56 + description: 'a cool thing', 57 + homepage: 'https://my-project.example', 58 + topics: ['cool'], 59 + ...over, 60 + } 61 + } 62 + 63 + async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 64 + await useDb().insert(repoMapping).values({ 65 + installationId: 1, 66 + githubRepoId: 9001, 67 + githubFullName: 'alice/my-project', 68 + tangledRepoDid: 'did:plc:repo-xyz', 69 + tangledFullName: 'did:plc:abc/my-project', 70 + knot: 'knot1.tangled.sh', 71 + status: 'active', 72 + ...over, 73 + }) 74 + } 75 + 76 + function envelope(action: string, over: Record<string, unknown> = {}) { 77 + return { 78 + id: 1, 79 + kind: 'github.repository', 80 + payload: { installationId: 1, githubRepoId: 9001, action, ...over }, 81 + attempts: 1, 82 + } 83 + } 84 + 85 + describe('github.repository job handler', () => { 86 + beforeEach(async () => { 87 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 88 + clearEncryptionKeyCache() 89 + 90 + setDb(await createTestDb()) 91 + await useDb().insert(installation).values({ 92 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 93 + }) 94 + await useDb().insert(userIdentity).values({ 95 + did: 'did:plc:abc', 96 + installationId: 1, 97 + }) 98 + 99 + githubGet.mockReset() 100 + putRecordMock.mockReset() 101 + listRecordsMock.mockReset() 102 + restoreMock.mockReset() 103 + 104 + restoreMock.mockResolvedValue({ did: 'did:plc:abc' }) 105 + putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } }) 106 + }) 107 + 108 + afterEach(() => { 109 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 110 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 111 + clearEncryptionKeyCache() 112 + clearDb() 113 + }) 114 + 115 + describe('edited', () => { 116 + it('refreshes PDS metadata via listRecords + putRecord with swapRecord', async () => { 117 + await seedMapping() 118 + githubGet.mockResolvedValue({ data: ghRepo({ description: 'new text', topics: ['fresh'] }) }) 119 + listRecordsMock.mockResolvedValue({ 120 + data: { 121 + records: [{ 122 + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', 123 + cid: 'bafy-old', 124 + value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, 125 + }], 126 + }, 127 + }) 128 + 129 + await dispatch(envelope('edited')) 130 + 131 + expect(putRecordMock).toHaveBeenCalledTimes(1) 132 + const put = putRecordMock.mock.calls[0]?.[0] 133 + expect(put?.swapRecord).toBe('bafy-old') 134 + expect(put?.record.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/my-project. new text') 135 + expect(put?.record.topics).toEqual(['fresh']) 136 + }) 137 + 138 + it('is a no-op when no user identity exists for the install', async () => { 139 + await useDb().delete(userIdentity) 140 + await seedMapping() 141 + 142 + await dispatch(envelope('edited')) 143 + 144 + expect(githubGet).not.toHaveBeenCalled() 145 + expect(putRecordMock).not.toHaveBeenCalled() 146 + }) 147 + }) 148 + 149 + describe('privatized', () => { 150 + it('sets disabledAt on the mapping', async () => { 151 + await seedMapping() 152 + await dispatch(envelope('privatized')) 153 + 154 + const rows = await useDb().select().from(repoMapping) 155 + expect(rows[0].disabledAt).toBeInstanceOf(Date) 156 + expect(putRecordMock).not.toHaveBeenCalled() 157 + }) 158 + }) 159 + 160 + describe('transferred / deleted', () => { 161 + it('sets disabledAt for transferred', async () => { 162 + await seedMapping() 163 + await dispatch(envelope('transferred')) 164 + const rows = await useDb().select().from(repoMapping) 165 + expect(rows[0].disabledAt).toBeInstanceOf(Date) 166 + }) 167 + 168 + it('sets disabledAt for deleted', async () => { 169 + await seedMapping() 170 + await dispatch(envelope('deleted')) 171 + const rows = await useDb().select().from(repoMapping) 172 + expect(rows[0].disabledAt).toBeInstanceOf(Date) 173 + }) 174 + 175 + it('leaves the tangled mirror fields untouched (we do not delete user data)', async () => { 176 + await seedMapping() 177 + await dispatch(envelope('deleted')) 178 + const rows = await useDb().select().from(repoMapping) 179 + expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz') 180 + expect(rows[0].tangledFullName).toBe('did:plc:abc/my-project') 181 + }) 182 + }) 183 + 184 + describe('publicized', () => { 185 + it('clears disabledAt and runs a metadata sync when already mirrored', async () => { 186 + await seedMapping({ disabledAt: new Date() }) 187 + githubGet.mockResolvedValue({ data: ghRepo() }) 188 + listRecordsMock.mockResolvedValue({ 189 + data: { 190 + records: [{ 191 + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', 192 + cid: 'bafy-old', 193 + value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, 194 + }], 195 + }, 196 + }) 197 + 198 + await dispatch(envelope('publicized')) 199 + 200 + const rows = await useDb().select().from(repoMapping) 201 + expect(rows[0].disabledAt).toBeNull() 202 + expect(putRecordMock).toHaveBeenCalledTimes(1) 203 + }) 204 + 205 + it('enqueues a fresh tangled.create-repo when no tangledRepoDid exists', async () => { 206 + await seedMapping({ tangledRepoDid: null, knot: null, disabledAt: new Date(), status: 'pending' }) 207 + 208 + await dispatch(envelope('publicized')) 209 + 210 + const rows = await useDb().select().from(repoMapping) 211 + expect(rows[0].disabledAt).toBeNull() 212 + 213 + const jobs = await useDb().select().from(job) 214 + const kinds = jobs.map(j => j.kind) 215 + expect(kinds).toContain('tangled.create-repo') 216 + expect(putRecordMock).not.toHaveBeenCalled() 217 + }) 218 + 219 + it('enqueues a tangled.create-repo when no mapping row exists at all', async () => { 220 + await dispatch(envelope('publicized')) 221 + 222 + const jobs = await useDb().select().from(job) 223 + expect(jobs.map(j => j.kind)).toContain('tangled.create-repo') 224 + }) 225 + }) 226 + 227 + describe('renamed', () => { 228 + it('updates githubFullName and writes an info: note to lastError', async () => { 229 + await seedMapping() 230 + githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/new-name' }) }) 231 + 232 + await dispatch(envelope('renamed')) 233 + 234 + const rows = await useDb().select().from(repoMapping) 235 + expect(rows[0].githubFullName).toBe('alice/new-name') 236 + expect(rows[0].lastError).toMatch(/^info: renamed on github from alice\/my-project to alice\/new-name/) 237 + 238 + // No tangled-side rename. 239 + expect(putRecordMock).not.toHaveBeenCalled() 240 + }) 241 + 242 + it('skips the write when GitHub reports the same name (webhook noise)', async () => { 243 + await seedMapping() 244 + githubGet.mockResolvedValue({ data: ghRepo() }) 245 + 246 + await dispatch(envelope('renamed')) 247 + 248 + const rows = await useDb().select().from(repoMapping) 249 + expect(rows[0].lastError).toBeNull() 250 + }) 251 + }) 252 + 253 + describe('archived / created', () => { 254 + it('archived is a no-op', async () => { 255 + await seedMapping() 256 + await dispatch(envelope('archived')) 257 + const rows = await useDb().select().from(repoMapping) 258 + expect(rows[0].disabledAt).toBeNull() 259 + expect(rows[0].lastError).toBeNull() 260 + expect(putRecordMock).not.toHaveBeenCalled() 261 + }) 262 + 263 + it('created is a no-op (handled by installation_repositories.added)', async () => { 264 + await seedMapping() 265 + await dispatch(envelope('created')) 266 + expect(putRecordMock).not.toHaveBeenCalled() 267 + }) 268 + }) 269 + 270 + it('rejects unknown actions at the payload guard', async () => { 271 + await expect(dispatch(envelope('not-a-real-action'))).rejects.toThrow(/invalid github\.repository payload/) 272 + }) 273 + }) 274 + 275 + describe('github.installation_repositories removed', () => { 276 + beforeEach(async () => { 277 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 278 + clearEncryptionKeyCache() 279 + 280 + setDb(await createTestDb()) 281 + await useDb().insert(installation).values({ 282 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 283 + }) 284 + }) 285 + 286 + afterEach(() => { 287 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 288 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 289 + clearEncryptionKeyCache() 290 + clearDb() 291 + }) 292 + 293 + it('sets disabledAt on the matching repo_mapping rows', async () => { 294 + await seedMapping() 295 + await useDb().insert(repoMapping).values({ 296 + installationId: 1, 297 + githubRepoId: 9002, 298 + githubFullName: 'alice/keep-me', 299 + status: 'active', 300 + }) 301 + 302 + await dispatch({ 303 + id: 1, 304 + kind: 'github.installation_repositories', 305 + payload: { 306 + installationId: 1, 307 + action: 'removed', 308 + addedRepoIds: [], 309 + removedRepoIds: [9001], 310 + }, 311 + attempts: 1, 312 + }) 313 + 314 + const rows = await useDb().select().from(repoMapping) 315 + const byRepoId = Object.fromEntries(rows.map(r => [r.githubRepoId, r])) 316 + expect(byRepoId[9001]?.disabledAt).toBeInstanceOf(Date) 317 + expect(byRepoId[9002]?.disabledAt).toBeNull() 318 + }) 319 + })
+345 -4
test/unit/tangled-repo.spec.ts
··· 4 4 import { installation, repoMapping } from '../../server/db/schema' 5 5 import { clearDb, setDb, useDb } from '../../server/utils/db' 6 6 import { clearEncryptionKeyCache } from '../../server/utils/encryption' 7 - import { enrollRepo } from '../../server/utils/tangled-repo' 7 + import { 8 + buildReadOnlyDescription, 9 + enrollRepo, 10 + mergeRepoRecord, 11 + stripReadOnlyMarker, 12 + syncRepoMetadata, 13 + } from '../../server/utils/tangled-repo' 8 14 import { createTestDb } from '../utils/db' 9 15 10 16 const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY ··· 15 21 private: boolean 16 22 fork: boolean 17 23 default_branch: string 24 + description: string | null 25 + homepage: string | null 26 + topics: string[] 18 27 } 19 28 20 29 const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>() 21 30 const getServiceAuthMock = vi.fn<(input: { aud: string, lxm: string, exp: number }) => Promise<{ data: { token: string } }>>() 22 - const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown> }) => Promise<unknown>>() 31 + const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown>, swapRecord?: string }) => Promise<unknown>>() 32 + const 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 } }>>() 23 33 24 34 vi.mock('@atproto/api', () => ({ 25 35 Agent: class { 26 36 com = { 27 37 atproto: { 28 38 server: { getServiceAuth: getServiceAuthMock }, 29 - repo: { putRecord: putRecordMock }, 39 + repo: { putRecord: putRecordMock, listRecords: listRecordsMock }, 30 40 }, 31 41 } 32 42 }, ··· 59 69 private: false, 60 70 fork: false, 61 71 default_branch: 'main', 72 + description: 'a cool thing', 73 + homepage: 'https://my-project.example', 74 + topics: ['cool', 'thing'], 62 75 ...over, 63 76 } 64 77 } ··· 76 89 githubGet.mockReset() 77 90 getServiceAuthMock.mockReset() 78 91 putRecordMock.mockReset() 92 + listRecordsMock.mockReset() 79 93 fakeFetch.mockReset() 80 94 // eslint-disable-next-line ts/no-unsafe-type-assertion 81 95 globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch ··· 126 140 expect(body.defaultBranch).toBe('main') 127 141 expect(typeof body.rkey).toBe('string') 128 142 129 - // PDS record written with the same rkey. 143 + // PDS record written with the same rkey, marker, topics, website. 130 144 expect(putRecordMock).toHaveBeenCalledTimes(1) 131 145 const put = putRecordMock.mock.calls[0]?.[0] 132 146 expect(put?.rkey).toBe(body.rkey) 133 147 expect(put?.record.repoDid).toBe('did:plc:repo-xyz') 134 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') 135 154 136 155 // Mapping persisted. 137 156 const rows = await useDb().select().from(repoMapping) ··· 197 216 198 217 expect(putRecordMock).not.toHaveBeenCalled() 199 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 + 258 + describe('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 + 280 + describe('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 + 302 + describe('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 + 360 + describe('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') 200 541 }) 201 542 })