diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index 145c75f..ba8605c 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -1,5 +1,5 @@ -import { sql } from 'drizzle-orm' -import { userIdentity } from '../db/schema' +import { and, eq, sql } from 'drizzle-orm' +import { repoMapping, userIdentity } from '../db/schema' import { useOAuthClient } from './atproto-oauth' import { useDb } from './db' import { installationOctokit } from './github-app' @@ -8,13 +8,15 @@ import { enqueue } from './queue' import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref' import { syncPush, type PushPayload } from './sync-push' import { generateAndPublishKey } from './tangled-pubkey' -import { enrollRepo } from './tangled-repo' +import { enrollRepo, syncRepoMetadata } from './tangled-repo' /** * Map of job kind → handler. Each commit fills in its slice: * - 'github.push' → commit 12 (sync push events) * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops) - * - 'github.repository' → commit 14/15 (description, lifecycle) + * - 'github.repository' → metadata sync + lifecycle (edited, + * renamed, privatized, publicized, + * transferred, deleted) * - 'github.installation_repositories' → commit 10 (fan-out enrolment) * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) * - 'tangled.create-repo' → commit 10 (per-repo enrolment) @@ -58,6 +60,23 @@ interface BackfillInstallationPayload { page: number } +type RepositoryAction = + | 'created' + | 'edited' + | 'renamed' + | 'transferred' + | 'deleted' + | 'privatized' + | 'publicized' + | 'archived' + | 'unarchived' + +interface RepositoryPayload { + installationId: number + githubRepoId: number + action: RepositoryAction +} + function asObject(value: unknown): Record { if (value === null || typeof value !== 'object') { throw new TypeError(`expected object payload, got ${typeof value}`) @@ -107,6 +126,39 @@ function refPayload(kind: 'create' | 'delete', value: unknown): CreateRefPayload } } +const REPOSITORY_ACTIONS = new Set([ + 'created', + 'edited', + 'renamed', + 'transferred', + 'deleted', + 'privatized', + 'publicized', + 'archived', + 'unarchived', +]) + +function isRepositoryAction(action: string): action is RepositoryAction { + return REPOSITORY_ACTIONS.has(action) +} + +function repositoryPayload(value: unknown): RepositoryPayload { + const o = asObject(value) + if ( + typeof o.installationId !== 'number' + || typeof o.githubRepoId !== 'number' + || typeof o.action !== 'string' + || !isRepositoryAction(o.action) + ) { + throw new TypeError('invalid github.repository payload') + } + return { + installationId: o.installationId, + githubRepoId: o.githubRepoId, + action: o.action, + } +} + function installationRepositoriesPayload(value: unknown): InstallationRepositoriesPayload { const o = asObject(value) if ( @@ -196,19 +248,141 @@ export async function dispatch(envelope: JobEnvelope): Promise { } if (envelope.kind === 'github.installation_repositories') { - const { installationId, action, addedRepoIds } = installationRepositoriesPayload(envelope.payload) - if (action !== 'added') return - - // Fan out one tangled.create-repo job per added repo. The fan-out keeps - // each unit small enough to fit comfortably in the per-job lease, lets - // failures retry independently, and runs the OAuth precondition check - // per repo (an install can outlive a tangled identity disconnection). - for (const id of addedRepoIds) { - // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design - await enqueue('tangled.create-repo', { installationId, githubRepoId: id }) + const { installationId, action, addedRepoIds, removedRepoIds } = installationRepositoriesPayload(envelope.payload) + + if (action === 'added') { + // Fan out one tangled.create-repo job per added repo. The fan-out keeps + // each unit small enough to fit comfortably in the per-job lease, lets + // failures retry independently, and runs the OAuth precondition check + // per repo (an install can outlive a tangled identity disconnection). + for (const id of addedRepoIds) { + // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design + await enqueue('tangled.create-repo', { installationId, githubRepoId: id }) + } + return + } + + // 'removed': the install no longer has access to these repos. We can't + // see them via the install token any more, so syncing has to stop. Leave + // the tangled mirror in place (PLAN.md: "don't delete user data on our + // say-so"). The user can manually re-add the repo on GitHub to re-enable. + if (action === 'removed' && removedRepoIds.length > 0) { + const db = useDb() + await db.update(repoMapping) + .set({ disabledAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(repoMapping.installationId, installationId), + sql`${repoMapping.githubRepoId} IN ${removedRepoIds}`, + )) } return } + if (envelope.kind === 'github.repository') { + await handleRepositoryEvent(repositoryPayload(envelope.payload)) + return + } + // Other kinds: still no-op until handlers land in their commits. } + +async function handleRepositoryEvent(payload: RepositoryPayload): Promise { + const { installationId, githubRepoId, action } = payload + const db = useDb() + + // Most lifecycle actions only need to touch the local mapping, no PDS work. + // `edited` and `publicized` (when we already have a mapping) go through the + // OAuth-authed metadata sync helper. + if (action === 'privatized' || action === 'transferred' || action === 'deleted') { + await db.update(repoMapping) + .set({ disabledAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(repoMapping.installationId, installationId), + eq(repoMapping.githubRepoId, githubRepoId), + )) + return + } + + if (action === 'renamed') { + // Refetch the current full_name from GitHub; the webhook envelope is + // intentionally tiny. We do NOT rename on the tangled side — there's no + // procedure and a fresh-create-and-delete would lose stars/refs. Surface + // the change in `lastError` with an `info:` prefix so the dashboard can + // flag it without a schema change. + const rows = await db.select({ id: repoMapping.id, githubFullName: repoMapping.githubFullName }) + .from(repoMapping) + .where(and( + eq(repoMapping.installationId, installationId), + eq(repoMapping.githubRepoId, githubRepoId), + )) + .limit(1) + if (rows.length === 0) return + const row = rows[0]! + + const octokit = await installationOctokit(installationId) + const { data: repo } = await octokit.request('GET /repositories/{repository_id}', { + repository_id: githubRepoId, + }) + if (repo.full_name === row.githubFullName) return + + await db.update(repoMapping) + .set({ + githubFullName: repo.full_name, + lastError: `info: renamed on github from ${row.githubFullName} to ${repo.full_name}; tangled mirror name unchanged`, + updatedAt: new Date(), + }) + .where(eq(repoMapping.id, row.id)) + return + } + + if (action === 'publicized') { + const rows = await db.select().from(repoMapping).where(and( + eq(repoMapping.installationId, installationId), + eq(repoMapping.githubRepoId, githubRepoId), + )).limit(1) + const row = rows[0] + + if (!row) { + // Repo flipped public without ever having been enrolled (the install + // was added while it was private). Kick off a fresh enrolment. + await enqueue('tangled.create-repo', { installationId, githubRepoId }) + return + } + + await db.update(repoMapping) + .set({ disabledAt: null, updatedAt: new Date() }) + .where(eq(repoMapping.id, row.id)) + + if (!row.tangledRepoDid) { + await enqueue('tangled.create-repo', { installationId, githubRepoId }) + return + } + + // Already mirrored, just refresh metadata in case description/topics + // changed while it was private. + await runMetadataSync(installationId, githubRepoId) + return + } + + if (action === 'edited') { + await runMetadataSync(installationId, githubRepoId) + return + } + + // 'created', 'archived', 'unarchived' — nothing for us to do. Repo creation + // surfaces via `installation_repositories.added`; archive state isn't part + // of the mirror surface in v1. +} + +async function runMetadataSync(installationId: number, githubRepoId: number): Promise { + const db = useDb() + const identity = await db.select({ did: userIdentity.did }) + .from(userIdentity) + .where(sql`${userIdentity.installationId} = ${installationId}`) + // No tangled identity yet — OAuth callback will backfill on completion. + if (identity.length === 0) return + + const client = await useOAuthClient() + const session = await client.restore(identity[0]!.did) + await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) +} diff --git a/server/utils/tangled-repo.ts b/server/utils/tangled-repo.ts index dd652ee..edafb9c 100644 --- a/server/utils/tangled-repo.ts +++ b/server/utils/tangled-repo.ts @@ -1,13 +1,85 @@ import { Agent } from '@atproto/api' import type { OAuthSession } from '@atproto/oauth-client-node' import { now as tidNow } from '@atcute/tid' -import { sql } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' import { repoMapping } from '../db/schema' import { useDb } from './db' import { installationOctokit } from './github-app' const REPO_LEXICON = 'sh.tangled.repo' const REPO_CREATE_NSID = 'sh.tangled.repo.create' +const LIST_RECORDS_PAGE_SIZE = 100 + +/** + * GitHub repo fields we mirror into the `sh.tangled.repo` record. Kept narrow + * so the merge helper is easy to reason about and to test without pulling in + * the full Octokit type. + */ +export interface GithubRepoMetadata { + full_name: string + description: string | null + homepage: string | null + topics?: string[] +} + +/** + * Strip our `[READ-ONLY] Mirror of ...` prefix from a description, if present. + * Idempotent: returns the original string when there's no prefix to remove. + * Guards against accumulating prefixes if a GitHub description ever round-trips + * back through our marker (e.g. a user copy-pasted the tangled description + * into GitHub). + */ +export function stripReadOnlyMarker(value: string | null | undefined): string { + if (!value) return '' + let s = value + // Strip repeatedly so any accidental doubling is collapsed. + for (;;) { + const next = s.replace(/^\[READ-ONLY\]\s*Mirror of https:\/\/github\.com\/[^\s.]+\/[^\s.]+\.\s*/, '') + if (next === s) return s + s = next + } +} + +/** + * Build the `description` we want on the tangled-side record from GitHub's + * current state. Always rebuilt from scratch so we never compound the marker. + */ +export function buildReadOnlyDescription(githubFullName: string, githubDescription: string | null | undefined): string { + const stripped = stripReadOnlyMarker(githubDescription).trim() + const prefix = `[READ-ONLY] Mirror of https://github.com/${githubFullName}.` + return stripped ? `${prefix} ${stripped}` : prefix +} + +/** + * Merge GitHub metadata into an existing PDS record value, preserving fields + * we don't manage (`$type`, `name`, `knot`, `repoDid`, `createdAt`, plus any + * future additions). Pass `existing = undefined` for the initial enrolment + * write. + */ +export function mergeRepoRecord( + existing: Record | undefined, + base: { name: string, knot: string, repoDid: string, createdAt: string }, + gh: GithubRepoMetadata, +): Record { + const description = buildReadOnlyDescription(gh.full_name, gh.description) + const website = gh.homepage && gh.homepage.length > 0 ? gh.homepage : undefined + const topics = Array.isArray(gh.topics) ? gh.topics : undefined + + // Start from existing so unknown fields survive a round-trip. Then overlay + // the immutable base (in case the existing record is malformed) and the + // managed metadata. + const merged: Record = { ...existing } + merged.$type = REPO_LEXICON + merged.name = base.name + merged.knot = base.knot + merged.repoDid = base.repoDid + merged.createdAt = (typeof existing?.createdAt === 'string' && existing.createdAt) || base.createdAt + merged.description = description + if (topics !== undefined) merged.topics = topics + if (website !== undefined) merged.website = website + else delete merged.website + return merged +} /** * Default knot for users with no `sh.tangled.knot` records. PLAN.md "Open @@ -104,18 +176,23 @@ export async function enrollRepo(opts: { throw new Error(`knot ${knot} returned no repoDid`) } - // 5. PDS record so the appview firehose discovers the repo. + // 5. PDS record so the appview firehose discovers the repo. Includes the + // read-only marker and current GitHub metadata from the off — no follow-up + // metadata sync needed at enrolment time. + const record = mergeRepoRecord(undefined, + { name, knot, repoDid, createdAt: new Date().toISOString() }, + { + full_name: repo.full_name, + description: repo.description, + homepage: repo.homepage, + topics: repo.topics, + }, + ) await agent.com.atproto.repo.putRecord({ repo: opts.oauthSession.did, collection: REPO_LEXICON, rkey, - record: { - $type: REPO_LEXICON, - name, - knot, - repoDid, - createdAt: new Date().toISOString(), - }, + record, }) // 6. Persist mapping. @@ -131,3 +208,110 @@ export async function enrollRepo(opts: { return { status: 'enrolled' } } + +export interface SyncMetadataResult { + status: 'synced' | 'skipped' + reason?: 'no-mapping' | 'disabled' | 'private' | 'fork' | 'no-pds-record' +} + +/** + * Refresh the `sh.tangled.repo` record on the user's PDS to match GitHub's + * current description, topics, and homepage. Triggered on `repository.edited`. + * + * We don't store the rkey locally, so we discover it by listing the user's + * `sh.tangled.repo` records and matching on `repoDid` (which we do store). + * `swapRecord` is passed for optimistic concurrency in case two webhook + * deliveries race. + */ +export async function syncRepoMetadata(opts: { + oauthSession: OAuthSession + installationId: number + githubRepoId: number +}): Promise { + const db = useDb() + + const rows = await db.select().from(repoMapping).where( + and( + eq(repoMapping.installationId, opts.installationId), + eq(repoMapping.githubRepoId, opts.githubRepoId), + ), + ).limit(1) + if (rows.length === 0) return { status: 'skipped', reason: 'no-mapping' } + const row = rows[0]! + + if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } + if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } + + // Refetch GitHub state rather than trusting the webhook body. + const octokit = await installationOctokit(opts.installationId) + const { data: repo } = await octokit.request('GET /repositories/{repository_id}', { + repository_id: opts.githubRepoId, + }) + if (repo.private) return { status: 'skipped', reason: 'private' } + if (repo.fork) return { status: 'skipped', reason: 'fork' } + + const [, name] = repo.full_name.split('/') + if (!name) throw new Error(`unexpected github full_name shape: ${repo.full_name}`) + + const agent = new Agent(opts.oauthSession) + + // Discover the rkey by walking the collection until we find the record + // matching this repo's `repoDid`. Typical installs have <100 records so + // pagination is mostly defensive. + let cursor: string | undefined + let found: { uri: string, cid: string, value: Record } | undefined + do { + // eslint-disable-next-line no-await-in-loop -- sequential pagination + const page = await agent.com.atproto.repo.listRecords({ + repo: opts.oauthSession.did, + collection: REPO_LEXICON, + limit: LIST_RECORDS_PAGE_SIZE, + cursor, + }) + for (const rec of page.data.records) { + const value = rec.value as Record + if (value.repoDid === row.tangledRepoDid) { + found = { uri: rec.uri, cid: rec.cid, value } + break + } + } + cursor = found ? undefined : page.data.cursor + } while (cursor) + + if (!found) return { status: 'skipped', reason: 'no-pds-record' } + + const rkey = found.uri.split('/').pop() + if (!rkey) throw new Error(`could not parse rkey from at-uri: ${found.uri}`) + + const createdAt = typeof found.value.createdAt === 'string' + ? found.value.createdAt + : new Date().toISOString() + + const record = mergeRepoRecord(found.value, + { name, knot: row.knot, repoDid: row.tangledRepoDid, createdAt }, + { + full_name: repo.full_name, + description: repo.description, + homepage: repo.homepage, + topics: repo.topics, + }, + ) + + await agent.com.atproto.repo.putRecord({ + repo: opts.oauthSession.did, + collection: REPO_LEXICON, + rkey, + record, + swapRecord: found.cid, + }) + + // Refresh the cached display name. githubFullName is display-only (joins go + // through githubRepoId), but the dashboard reads it. + if (row.githubFullName !== repo.full_name) { + await db.update(repoMapping) + .set({ githubFullName: repo.full_name, updatedAt: new Date() }) + .where(eq(repoMapping.id, row.id)) + } + + return { status: 'synced' } +} diff --git a/test/unit/repository-handler.spec.ts b/test/unit/repository-handler.spec.ts new file mode 100644 index 0000000..2aa6715 --- /dev/null +++ b/test/unit/repository-handler.spec.ts @@ -0,0 +1,319 @@ +import crypto from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installation, job, repoMapping, userIdentity } from '../../server/db/schema' +import { clearDb, setDb, useDb } from '../../server/utils/db' +import { clearEncryptionKeyCache } from '../../server/utils/encryption' +import { createTestDb } from '../utils/db' + +const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY + +interface GithubRepoLike { + id: number + full_name: string + private: boolean + fork: boolean + default_branch: string + description: string | null + homepage: string | null + topics: string[] +} + +const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>() +const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record, swapRecord?: string }) => Promise>() +const listRecordsMock = vi.fn<(input: { repo: string, collection: string, limit?: number, cursor?: string }) => Promise<{ data: { records: Array<{ uri: string, cid: string, value: Record }>, cursor?: string } }>>() +const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>() + +vi.mock('@atproto/api', () => ({ + Agent: class { + com = { + atproto: { + repo: { putRecord: putRecordMock, listRecords: listRecordsMock }, + }, + } + }, +})) + +vi.mock('../../server/utils/github-app', () => ({ + installationOctokit: async () => ({ + request: githubGet, + }), + clearGitHubAppCache: () => {}, +})) + +vi.mock('../../server/utils/atproto-oauth', () => ({ + useOAuthClient: async () => ({ restore: restoreMock }), +})) + +const { dispatch } = await import('../../server/utils/job-handlers') + +function ghRepo(over: Partial = {}): GithubRepoLike { + return { + id: 9001, + full_name: 'alice/my-project', + private: false, + fork: false, + default_branch: 'main', + description: 'a cool thing', + homepage: 'https://my-project.example', + topics: ['cool'], + ...over, + } +} + +async function seedMapping(over: Partial = {}) { + await useDb().insert(repoMapping).values({ + installationId: 1, + githubRepoId: 9001, + githubFullName: 'alice/my-project', + tangledRepoDid: 'did:plc:repo-xyz', + tangledFullName: 'did:plc:abc/my-project', + knot: 'knot1.tangled.sh', + status: 'active', + ...over, + }) +} + +function envelope(action: string, over: Record = {}) { + return { + id: 1, + kind: 'github.repository', + payload: { installationId: 1, githubRepoId: 9001, action, ...over }, + attempts: 1, + } +} + +describe('github.repository job handler', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + await useDb().insert(userIdentity).values({ + did: 'did:plc:abc', + installationId: 1, + }) + + githubGet.mockReset() + putRecordMock.mockReset() + listRecordsMock.mockReset() + restoreMock.mockReset() + + restoreMock.mockResolvedValue({ did: 'did:plc:abc' }) + putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } }) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + describe('edited', () => { + it('refreshes PDS metadata via listRecords + putRecord with swapRecord', async () => { + await seedMapping() + githubGet.mockResolvedValue({ data: ghRepo({ description: 'new text', topics: ['fresh'] }) }) + listRecordsMock.mockResolvedValue({ + data: { + records: [{ + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', + cid: 'bafy-old', + value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, + }], + }, + }) + + await dispatch(envelope('edited')) + + expect(putRecordMock).toHaveBeenCalledTimes(1) + const put = putRecordMock.mock.calls[0]?.[0] + expect(put?.swapRecord).toBe('bafy-old') + expect(put?.record.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/my-project. new text') + expect(put?.record.topics).toEqual(['fresh']) + }) + + it('is a no-op when no user identity exists for the install', async () => { + await useDb().delete(userIdentity) + await seedMapping() + + await dispatch(envelope('edited')) + + expect(githubGet).not.toHaveBeenCalled() + expect(putRecordMock).not.toHaveBeenCalled() + }) + }) + + describe('privatized', () => { + it('sets disabledAt on the mapping', async () => { + await seedMapping() + await dispatch(envelope('privatized')) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeInstanceOf(Date) + expect(putRecordMock).not.toHaveBeenCalled() + }) + }) + + describe('transferred / deleted', () => { + it('sets disabledAt for transferred', async () => { + await seedMapping() + await dispatch(envelope('transferred')) + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeInstanceOf(Date) + }) + + it('sets disabledAt for deleted', async () => { + await seedMapping() + await dispatch(envelope('deleted')) + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeInstanceOf(Date) + }) + + it('leaves the tangled mirror fields untouched (we do not delete user data)', async () => { + await seedMapping() + await dispatch(envelope('deleted')) + const rows = await useDb().select().from(repoMapping) + expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz') + expect(rows[0].tangledFullName).toBe('did:plc:abc/my-project') + }) + }) + + describe('publicized', () => { + it('clears disabledAt and runs a metadata sync when already mirrored', async () => { + await seedMapping({ disabledAt: new Date() }) + githubGet.mockResolvedValue({ data: ghRepo() }) + listRecordsMock.mockResolvedValue({ + data: { + records: [{ + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', + cid: 'bafy-old', + value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, + }], + }, + }) + + await dispatch(envelope('publicized')) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeNull() + expect(putRecordMock).toHaveBeenCalledTimes(1) + }) + + it('enqueues a fresh tangled.create-repo when no tangledRepoDid exists', async () => { + await seedMapping({ tangledRepoDid: null, knot: null, disabledAt: new Date(), status: 'pending' }) + + await dispatch(envelope('publicized')) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeNull() + + const jobs = await useDb().select().from(job) + const kinds = jobs.map(j => j.kind) + expect(kinds).toContain('tangled.create-repo') + expect(putRecordMock).not.toHaveBeenCalled() + }) + + it('enqueues a tangled.create-repo when no mapping row exists at all', async () => { + await dispatch(envelope('publicized')) + + const jobs = await useDb().select().from(job) + expect(jobs.map(j => j.kind)).toContain('tangled.create-repo') + }) + }) + + describe('renamed', () => { + it('updates githubFullName and writes an info: note to lastError', async () => { + await seedMapping() + githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/new-name' }) }) + + await dispatch(envelope('renamed')) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].githubFullName).toBe('alice/new-name') + expect(rows[0].lastError).toMatch(/^info: renamed on github from alice\/my-project to alice\/new-name/) + + // No tangled-side rename. + expect(putRecordMock).not.toHaveBeenCalled() + }) + + it('skips the write when GitHub reports the same name (webhook noise)', async () => { + await seedMapping() + githubGet.mockResolvedValue({ data: ghRepo() }) + + await dispatch(envelope('renamed')) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].lastError).toBeNull() + }) + }) + + describe('archived / created', () => { + it('archived is a no-op', async () => { + await seedMapping() + await dispatch(envelope('archived')) + const rows = await useDb().select().from(repoMapping) + expect(rows[0].disabledAt).toBeNull() + expect(rows[0].lastError).toBeNull() + expect(putRecordMock).not.toHaveBeenCalled() + }) + + it('created is a no-op (handled by installation_repositories.added)', async () => { + await seedMapping() + await dispatch(envelope('created')) + expect(putRecordMock).not.toHaveBeenCalled() + }) + }) + + it('rejects unknown actions at the payload guard', async () => { + await expect(dispatch(envelope('not-a-real-action'))).rejects.toThrow(/invalid github\.repository payload/) + }) +}) + +describe('github.installation_repositories removed', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + it('sets disabledAt on the matching repo_mapping rows', async () => { + await seedMapping() + await useDb().insert(repoMapping).values({ + installationId: 1, + githubRepoId: 9002, + githubFullName: 'alice/keep-me', + status: 'active', + }) + + await dispatch({ + id: 1, + kind: 'github.installation_repositories', + payload: { + installationId: 1, + action: 'removed', + addedRepoIds: [], + removedRepoIds: [9001], + }, + attempts: 1, + }) + + const rows = await useDb().select().from(repoMapping) + const byRepoId = Object.fromEntries(rows.map(r => [r.githubRepoId, r])) + expect(byRepoId[9001]?.disabledAt).toBeInstanceOf(Date) + expect(byRepoId[9002]?.disabledAt).toBeNull() + }) +}) diff --git a/test/unit/tangled-repo.spec.ts b/test/unit/tangled-repo.spec.ts index 91686f8..86ba763 100644 --- a/test/unit/tangled-repo.spec.ts +++ b/test/unit/tangled-repo.spec.ts @@ -4,7 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installation, repoMapping } from '../../server/db/schema' import { clearDb, setDb, useDb } from '../../server/utils/db' import { clearEncryptionKeyCache } from '../../server/utils/encryption' -import { enrollRepo } from '../../server/utils/tangled-repo' +import { + buildReadOnlyDescription, + enrollRepo, + mergeRepoRecord, + stripReadOnlyMarker, + syncRepoMetadata, +} from '../../server/utils/tangled-repo' import { createTestDb } from '../utils/db' const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY @@ -15,18 +21,22 @@ interface GithubRepoLike { private: boolean fork: boolean default_branch: string + description: string | null + homepage: string | null + topics: string[] } const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>() const getServiceAuthMock = vi.fn<(input: { aud: string, lxm: string, exp: number }) => Promise<{ data: { token: string } }>>() -const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record }) => Promise>() +const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record, swapRecord?: string }) => Promise>() +const listRecordsMock = vi.fn<(input: { repo: string, collection: string, limit?: number, cursor?: string }) => Promise<{ data: { records: Array<{ uri: string, cid: string, value: Record }>, cursor?: string } }>>() vi.mock('@atproto/api', () => ({ Agent: class { com = { atproto: { server: { getServiceAuth: getServiceAuthMock }, - repo: { putRecord: putRecordMock }, + repo: { putRecord: putRecordMock, listRecords: listRecordsMock }, }, } }, @@ -59,6 +69,9 @@ function ghRepo(over: Partial = {}): GithubRepoLike { private: false, fork: false, default_branch: 'main', + description: 'a cool thing', + homepage: 'https://my-project.example', + topics: ['cool', 'thing'], ...over, } } @@ -76,6 +89,7 @@ describe('enrollRepo', () => { githubGet.mockReset() getServiceAuthMock.mockReset() putRecordMock.mockReset() + listRecordsMock.mockReset() fakeFetch.mockReset() // eslint-disable-next-line ts/no-unsafe-type-assertion globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch @@ -126,12 +140,17 @@ describe('enrollRepo', () => { expect(body.defaultBranch).toBe('main') expect(typeof body.rkey).toBe('string') - // PDS record written with the same rkey. + // PDS record written with the same rkey, marker, topics, website. expect(putRecordMock).toHaveBeenCalledTimes(1) const put = putRecordMock.mock.calls[0]?.[0] expect(put?.rkey).toBe(body.rkey) expect(put?.record.repoDid).toBe('did:plc:repo-xyz') expect(put?.record.knot).toBe('knot1.tangled.sh') + expect(put?.record.description).toBe( + '[READ-ONLY] Mirror of https://github.com/alice/my-project. a cool thing', + ) + expect(put?.record.topics).toEqual(['cool', 'thing']) + expect(put?.record.website).toBe('https://my-project.example') // Mapping persisted. const rows = await useDb().select().from(repoMapping) @@ -198,4 +217,326 @@ describe('enrollRepo', () => { expect(putRecordMock).not.toHaveBeenCalled() expect(await useDb().select().from(repoMapping)).toHaveLength(0) }) + + it('omits website when GitHub homepage is empty', async () => { + githubGet.mockResolvedValue({ data: ghRepo({ homepage: '' }) }) + fakeFetch.mockResolvedValue(new Response( + JSON.stringify({ repoDid: 'did:plc:repo-xyz' }), + { status: 200 }, + )) + + await enrollRepo({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + + const put = putRecordMock.mock.calls[0]?.[0] + expect(put?.record).not.toHaveProperty('website') + }) + + it('emits a marker-only description when GitHub description is null', async () => { + githubGet.mockResolvedValue({ data: ghRepo({ description: null }) }) + fakeFetch.mockResolvedValue(new Response( + JSON.stringify({ repoDid: 'did:plc:repo-xyz' }), + { status: 200 }, + )) + + await enrollRepo({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + + const put = putRecordMock.mock.calls[0]?.[0] + expect(put?.record.description).toBe( + '[READ-ONLY] Mirror of https://github.com/alice/my-project.', + ) + }) +}) + +describe('stripReadOnlyMarker', () => { + it('returns an empty string for null / undefined / empty input', () => { + expect(stripReadOnlyMarker(null)).toBe('') + expect(stripReadOnlyMarker(undefined)).toBe('') + expect(stripReadOnlyMarker('')).toBe('') + }) + + it('passes through values without the marker', () => { + expect(stripReadOnlyMarker('plain description')).toBe('plain description') + }) + + it('strips a single marker', () => { + expect(stripReadOnlyMarker('[READ-ONLY] Mirror of https://github.com/alice/proj. real text')) + .toBe('real text') + }) + + it('strips repeated markers (defence against historical accumulation)', () => { + const doubled = '[READ-ONLY] Mirror of https://github.com/alice/proj. [READ-ONLY] Mirror of https://github.com/alice/proj. real text' + expect(stripReadOnlyMarker(doubled)).toBe('real text') + }) +}) + +describe('buildReadOnlyDescription', () => { + it('prepends the marker to a non-empty description', () => { + expect(buildReadOnlyDescription('alice/proj', 'hello world')) + .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. hello world') + }) + + it('emits a bare marker when the GitHub description is empty / null', () => { + expect(buildReadOnlyDescription('alice/proj', null)) + .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.') + expect(buildReadOnlyDescription('alice/proj', ' ')) + .toBe('[READ-ONLY] Mirror of https://github.com/alice/proj.') + }) + + it('is idempotent: re-applying produces the same string', () => { + const once = buildReadOnlyDescription('alice/proj', 'hello') + const twice = buildReadOnlyDescription('alice/proj', once) + expect(twice).toBe(once) + const thrice = buildReadOnlyDescription('alice/proj', twice) + expect(thrice).toBe(once) + }) +}) + +describe('mergeRepoRecord', () => { + const base = { name: 'proj', knot: 'knot1.tangled.sh', repoDid: 'did:plc:r', createdAt: '2025-01-01T00:00:00Z' } + + it('preserves $type, name, knot, repoDid and unmanaged fields on existing records', () => { + const existing = { + $type: 'sh.tangled.repo', + name: 'proj', + knot: 'knot1.tangled.sh', + repoDid: 'did:plc:r', + createdAt: '2024-06-01T00:00:00Z', + customField: 'untouched', + } + const merged = mergeRepoRecord(existing, base, { + full_name: 'alice/proj', + description: 'new', + homepage: 'https://x.example', + topics: ['a'], + }) + expect(merged.$type).toBe('sh.tangled.repo') + expect(merged.customField).toBe('untouched') + expect(merged.createdAt).toBe('2024-06-01T00:00:00Z') + expect(merged.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. new') + expect(merged.topics).toEqual(['a']) + expect(merged.website).toBe('https://x.example') + }) + + it('drops website when homepage goes empty', () => { + const existing = { website: 'https://old.example' } + const merged = mergeRepoRecord(existing, base, { + full_name: 'alice/proj', + description: null, + homepage: '', + }) + expect(merged).not.toHaveProperty('website') + }) + + it('falls back to base.createdAt when existing has none', () => { + const merged = mergeRepoRecord(undefined, base, { + full_name: 'alice/proj', + description: null, + homepage: null, + }) + expect(merged.createdAt).toBe(base.createdAt) + }) + + it('never accumulates the read-only prefix across repeated merges', () => { + let current: Record = {} + for (let i = 0; i < 5; i++) { + current = mergeRepoRecord(current, base, { + full_name: 'alice/proj', + description: 'stable text', + homepage: null, + }) + } + expect(current.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/proj. stable text') + }) +}) + +describe('syncRepoMetadata', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + + githubGet.mockReset() + putRecordMock.mockReset() + listRecordsMock.mockReset() + putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } }) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + async function seedActiveMapping(over: Partial = {}) { + await useDb().insert(repoMapping).values({ + installationId: 1, + githubRepoId: 9001, + githubFullName: 'alice/my-project', + tangledRepoDid: 'did:plc:repo-xyz', + tangledFullName: 'did:plc:abc/my-project', + knot: 'knot1.tangled.sh', + status: 'active', + ...over, + }) + } + + it('skips with no-mapping when row is missing', async () => { + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' }) + expect(githubGet).not.toHaveBeenCalled() + expect(putRecordMock).not.toHaveBeenCalled() + }) + + it('skips when disabledAt is set', async () => { + await seedActiveMapping({ disabledAt: new Date() }) + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'skipped', reason: 'disabled' }) + }) + + it('skips when the repo is now private on GitHub', async () => { + await seedActiveMapping() + githubGet.mockResolvedValue({ data: ghRepo({ private: true }) }) + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'skipped', reason: 'private' }) + }) + + it('skips when no matching PDS record can be located', async () => { + await seedActiveMapping() + githubGet.mockResolvedValue({ data: ghRepo() }) + listRecordsMock.mockResolvedValue({ data: { records: [] } }) + + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'skipped', reason: 'no-pds-record' }) + expect(putRecordMock).not.toHaveBeenCalled() + }) + + it('finds the matching record by repoDid and writes the merged record with swapRecord', async () => { + await seedActiveMapping() + githubGet.mockResolvedValue({ data: ghRepo({ + description: 'updated text', + homepage: 'https://new.example', + topics: ['fresh'], + }) }) + listRecordsMock.mockResolvedValue({ + data: { + records: [ + { + uri: 'at://did:plc:abc/sh.tangled.repo/some-other', + cid: 'bafy-other', + value: { $type: 'sh.tangled.repo', name: 'other', knot: 'knot1.tangled.sh', repoDid: 'did:plc:other', createdAt: '2025-01-01T00:00:00Z' }, + }, + { + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', + cid: 'bafy-old', + value: { + $type: 'sh.tangled.repo', + name: 'my-project', + knot: 'knot1.tangled.sh', + repoDid: 'did:plc:repo-xyz', + createdAt: '2025-01-01T00:00:00Z', + description: '[READ-ONLY] Mirror of https://github.com/alice/my-project. older', + topics: ['stale'], + website: 'https://old.example', + }, + }, + ], + }, + }) + + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'synced' }) + + expect(putRecordMock).toHaveBeenCalledTimes(1) + const put = putRecordMock.mock.calls[0]?.[0] + expect(put?.rkey).toBe('rkey1') + expect(put?.swapRecord).toBe('bafy-old') + expect(put?.record.description).toBe( + '[READ-ONLY] Mirror of https://github.com/alice/my-project. updated text', + ) + expect(put?.record.topics).toEqual(['fresh']) + expect(put?.record.website).toBe('https://new.example') + expect(put?.record.createdAt).toBe('2025-01-01T00:00:00Z') + }) + + it('paginates listRecords until it finds the right repoDid', async () => { + await seedActiveMapping() + githubGet.mockResolvedValue({ data: ghRepo() }) + listRecordsMock + .mockResolvedValueOnce({ + data: { + records: [{ uri: 'at://x/sh.tangled.repo/a', cid: 'c1', value: { repoDid: 'did:plc:other' } }], + cursor: 'next', + }, + }) + .mockResolvedValueOnce({ + data: { + 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' } }], + }, + }) + + const result = await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'synced' }) + expect(listRecordsMock).toHaveBeenCalledTimes(2) + expect(putRecordMock.mock.calls[0]?.[0].rkey).toBe('b') + }) + + it('refreshes githubFullName when GitHub reports a new name', async () => { + await seedActiveMapping() + githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/renamed' }) }) + listRecordsMock.mockResolvedValue({ + data: { + records: [{ + uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', + cid: 'bafy-old', + value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2024-01-01T00:00:00Z' }, + }], + }, + }) + + await syncRepoMetadata({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + + const rows = await useDb().select().from(repoMapping) + expect(rows[0].githubFullName).toBe('alice/renamed') + }) })