mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import { and, eq, sql } from 'drizzle-orm' 2import { repoMapping } from '../db/schema' 3import { useDb } from './db' 4import { RemoteRejectedError } from './git-wire/errors' 5 6export interface ActiveMapping { 7 id: number 8 githubFullName: string 9 tangledRepoDid: string 10 knot: string 11 lastSyncedRefs: Record<string, string> 12} 13 14export type SkipReason = 'no-mapping' | 'disabled' 15 16/** 17 * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm 18 * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or 19 * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet). 20 */ 21export async function loadActiveMapping( 22 installationId: number, 23 githubRepoId: number, 24): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> { 25 const db = useDb() 26 const rows = await db.select().from(repoMapping).where( 27 and( 28 eq(repoMapping.installationId, installationId), 29 eq(repoMapping.githubRepoId, githubRepoId), 30 ), 31 ).limit(1) 32 33 if (rows.length === 0) return { skip: 'no-mapping' } 34 const row = rows[0]! 35 36 if (row.disabledAt) return { skip: 'disabled' } 37 if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' } 38 39 return { 40 mapping: { 41 id: row.id, 42 githubFullName: row.githubFullName, 43 tangledRepoDid: row.tangledRepoDid, 44 knot: row.knot, 45 // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` 46 lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record<string, string>, 47 }, 48 } 49} 50 51/** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */ 52export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> { 53 const db = useDb() 54 await db.update(repoMapping) 55 .set({ 56 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, 57 updatedAt: new Date(), 58 }) 59 .where(eq(repoMapping.id, mappingId)) 60} 61 62/** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */ 63export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> { 64 const db = useDb() 65 await db.update(repoMapping) 66 .set({ 67 lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, 68 updatedAt: new Date(), 69 }) 70 .where(eq(repoMapping.id, mappingId)) 71} 72 73/** Mark a mapping `status='error'` so the worker stops retrying. */ 74export async function markMappingError(mappingId: number, message: string): Promise<void> { 75 const db = useDb() 76 await db.update(repoMapping) 77 .set({ status: 'error', lastError: message, updatedAt: new Date() }) 78 .where(eq(repoMapping.id, mappingId)) 79} 80 81/** Human-readable `lastError` text for a terminal knot rejection. */ 82export function terminalRejectionMessage(err: RemoteRejectedError): string { 83 if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` 84 if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' 85 return 'knot reports repo no longer exists; stopping sync' 86} 87 88/** 89 * True for knot rejections we treat as terminal: the repo is gone, our key is 90 * rejected, or the pack blew the size cap. Callers mark the mapping `error` 91 * and stop retrying; anything else re-throws for the queue's backoff. 92 */ 93export function isTerminalRejection(err: unknown): err is RemoteRejectedError { 94 return err instanceof RemoteRejectedError 95 && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big') 96} 97 98/** jsonb path array element for a ref, escaping embedded quotes. */ 99function jsonbPathElement(ref: string): string { 100 return `"${ref.replaceAll('"', '\\"')}"` 101}