import { and, eq, sql } from 'drizzle-orm' import { repoMapping } from '../db/schema' import { useDb } from './db' import { RemoteRejectedError } from './git-wire/errors' export interface ActiveMapping { id: number githubFullName: string tangledRepoDid: string knot: string lastSyncedRefs: Record } export type SkipReason = 'no-mapping' | 'disabled' /** * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet). */ export async function loadActiveMapping( installationId: number, githubRepoId: number, ): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> { const db = useDb() const rows = await db.select().from(repoMapping).where( and( eq(repoMapping.installationId, installationId), eq(repoMapping.githubRepoId, githubRepoId), ), ).limit(1) if (rows.length === 0) return { skip: 'no-mapping' } const row = rows[0]! if (row.disabledAt) return { skip: 'disabled' } if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' } return { mapping: { id: row.id, githubFullName: row.githubFullName, tangledRepoDid: row.tangledRepoDid, knot: row.knot, // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record, }, } } /** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */ export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise { const db = useDb() await db.update(repoMapping) .set({ lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, updatedAt: new Date(), }) .where(eq(repoMapping.id, mappingId)) } /** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */ export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise { const db = useDb() await db.update(repoMapping) .set({ lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, updatedAt: new Date(), }) .where(eq(repoMapping.id, mappingId)) } /** Mark a mapping `status='error'` so the worker stops retrying. */ export async function markMappingError(mappingId: number, message: string): Promise { const db = useDb() await db.update(repoMapping) .set({ status: 'error', lastError: message, updatedAt: new Date() }) .where(eq(repoMapping.id, mappingId)) } /** Human-readable `lastError` text for a terminal knot rejection. */ export function terminalRejectionMessage(err: RemoteRejectedError): string { if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' return 'knot reports repo no longer exists; stopping sync' } /** * True for knot rejections we treat as terminal: the repo is gone, our key is * rejected, or the pack blew the size cap. Callers mark the mapping `error` * and stop retrying; anything else re-throws for the queue's backoff. */ export function isTerminalRejection(err: unknown): err is RemoteRejectedError { return err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big') } /** jsonb path array element for a ref, escaping embedded quotes. */ function jsonbPathElement(ref: string): string { return `"${ref.replaceAll('"', '\\"')}"` }