mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

feat: handle branch and tag create and delete events

+540 -18
+31 -2
server/utils/job-handlers.ts
··· 5 5 import { installationOctokit } from './github-app' 6 6 import type { JobEnvelope } from './queue' 7 7 import { enqueue } from './queue' 8 + import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref' 8 9 import { syncPush, type PushPayload } from './sync-push' 9 10 import { generateAndPublishKey } from './tangled-pubkey' 10 11 import { enrollRepo } from './tangled-repo' 11 12 12 13 /** 13 14 * Map of job kind → handler. Each commit fills in its slice: 14 - * - 'github.push' → this commit (sync push events) 15 - * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops) 15 + * - 'github.push' → commit 12 (sync push events) 16 + * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops) 16 17 * - 'github.repository' → commit 14/15 (description, lifecycle) 17 18 * - 'github.installation_repositories' → commit 10 (fan-out enrolment) 18 19 * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) ··· 88 89 return { installationId: o.installationId, page: o.page } 89 90 } 90 91 92 + function refPayload(kind: 'create' | 'delete', value: unknown): CreateRefPayload { 93 + const o = asObject(value) 94 + if ( 95 + typeof o.installationId !== 'number' 96 + || typeof o.githubRepoId !== 'number' 97 + || (o.refType !== 'branch' && o.refType !== 'tag') 98 + || typeof o.ref !== 'string' 99 + ) { 100 + throw new TypeError(`invalid github.${kind} payload`) 101 + } 102 + return { 103 + installationId: o.installationId, 104 + githubRepoId: o.githubRepoId, 105 + refType: o.refType, 106 + ref: o.ref, 107 + } 108 + } 109 + 91 110 function installationRepositoriesPayload(value: unknown): InstallationRepositoriesPayload { 92 111 const o = asObject(value) 93 112 if ( ··· 113 132 114 133 if (envelope.kind === 'github.push') { 115 134 await syncPush(envelope.payload as PushPayload) 135 + return 136 + } 137 + 138 + if (envelope.kind === 'github.create') { 139 + await syncCreateRef(refPayload('create', envelope.payload)) 140 + return 141 + } 142 + 143 + if (envelope.kind === 'github.delete') { 144 + await syncDeleteRef(refPayload('delete', envelope.payload) as DeleteRefPayload) 116 145 return 117 146 } 118 147
+16
server/utils/sync-push-host.ts
··· 1 + /** 2 + * Map a knot hostname (as stored on `sh.tangled.repo`) to the SSH host we 3 + * actually push to. For the appview-hosted knot, the HTTPS XRPC endpoint is 4 + * `knot1.tangled.sh` (Cloudflare-fronted) but SSH lives on `tangled.org`. 5 + * Self-hosted knots serve both on the same host (their `knot` value may 6 + * include a `:port` suffix for non-default SSH; git URL parsing handles it). 7 + * 8 + * The official UI does this same mapping in 9 + * `appview/pages/templates/repo/empty.html`. If tangled adds more 10 + * appview-hosted knots in future this'll need updating \u2014 see PLAN.md 11 + * "Deferred / follow-ups". 12 + */ 13 + export function sshHostForKnot(knot: string): string { 14 + if (knot === 'knot1.tangled.sh') return 'tangled.org' 15 + return knot 16 + }
+1 -16
server/utils/sync-push.ts
··· 7 7 import { classifyPushFailure, git, RemoteRejectedPushError } from './git' 8 8 import { installationOctokit } from './github-app' 9 9 import { loadSshCommandForInstall } from './ssh-cmd' 10 + import { sshHostForKnot } from './sync-push-host' 10 11 11 12 const ZERO_SHA = '0000000000000000000000000000000000000000' 12 13 ··· 143 144 // Escape any double-quotes inside the ref. We only support standard git ref 144 145 // names which never contain quotes, but be defensive. 145 146 return `"${ref.replaceAll('"', '\\"')}"` 146 - } 147 - 148 - /** 149 - * Map a knot hostname (as stored on `sh.tangled.repo`) to the SSH host we 150 - * actually push to. For the appview-hosted knot, the HTTPS XRPC endpoint is 151 - * `knot1.tangled.sh` (Cloudflare-fronted) but SSH lives on `tangled.org`. 152 - * Self-hosted knots serve both on the same host (their `knot` value may 153 - * include a `:port` suffix for non-default SSH; git URL parsing handles it). 154 - * 155 - * The official UI does this same mapping in 156 - * `appview/pages/templates/repo/empty.html`. If tangled adds more 157 - * appview-hosted knots in future this'll need updating. 158 - */ 159 - function sshHostForKnot(knot: string): string { 160 - if (knot === 'knot1.tangled.sh') return 'tangled.org' 161 - return knot 162 147 } 163 148 164 149 async function markMappingError(mappingId: number, message: string): Promise<void> {
+238
server/utils/sync-ref.ts
··· 1 + import { mkdtempSync, rmSync } from 'node:fs' 2 + import os from 'node:os' 3 + import path from 'node:path' 4 + import { and, eq, sql } from 'drizzle-orm' 5 + import { repoMapping } from '../db/schema' 6 + import { useDb } from './db' 7 + import { classifyPushFailure, git } from './git' 8 + import { installationOctokit } from './github-app' 9 + import { loadSshCommandForInstall } from './ssh-cmd' 10 + import { sshHostForKnot } from './sync-push-host' 11 + 12 + export type RefType = 'branch' | 'tag' 13 + 14 + export interface CreateRefPayload { 15 + installationId: number 16 + githubRepoId: number 17 + refType: RefType 18 + /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) \u2014 NOT 19 + * the `refs/...` qualified form. */ 20 + ref: string 21 + } 22 + 23 + export interface DeleteRefPayload extends CreateRefPayload {} 24 + 25 + export interface RefResult { 26 + status: 'synced' | 'skipped' 27 + reason?: 'no-mapping' | 'disabled' | 'not-branch-or-tag' | 'repo-gone' 28 + } 29 + 30 + /** 31 + * Mirror a branch or tag creation from GitHub to the configured knot. 32 + * 33 + * Triggered by GitHub's `create` webhook event. For branches, GitHub also 34 + * sends a parallel `push` event (with `before = 0000…`), so the branch will 35 + * usually have been created already by the time this fires \u2014 the push to 36 + * knot is then a no-op via ref-tip dedupe. For lightweight and annotated 37 + * tags, no `push` event is sent, so this is the only path that creates them 38 + * on the knot. 39 + */ 40 + export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> { 41 + if (payload.refType !== 'branch' && payload.refType !== 'tag') { 42 + return { status: 'skipped', reason: 'not-branch-or-tag' } 43 + } 44 + 45 + const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId) 46 + if ('skip' in mapping) return mapping.skip 47 + 48 + const fullRef = qualifyRef(payload.refType, payload.ref) 49 + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-create-')) 50 + let sshCleanup: (() => void) | undefined 51 + 52 + try { 53 + await git(['init', '--bare', '-q'], { cwd: tmpDir }) 54 + 55 + const octokit = await installationOctokit(payload.installationId) 56 + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 57 + const githubUrl = `https://x-access-token:${token}@github.com/${mapping.githubFullName}.git` 58 + 59 + // Fetch the ref by name. Tags carry whatever object git stores at the 60 + // ref (commit for lightweight; tag object for annotated); fetch gives 61 + // us all the reachable objects either way. 62 + await git( 63 + ['fetch', '--no-tags', '-q', githubUrl, `+${fullRef}:${fullRef}`], 64 + { cwd: tmpDir, timeout: 120_000 }, 65 + ) 66 + 67 + const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 68 + sshCleanup = cleanup 69 + const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}` 70 + 71 + try { 72 + await git( 73 + ['push', '-q', knotUrl, `+${fullRef}:${fullRef}`], 74 + { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 120_000 }, 75 + ) 76 + } 77 + catch (err) { 78 + const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 79 + const classified = classifyPushFailure(stderr) 80 + if (classified?.reason === 'repo-gone') { 81 + await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 82 + return { status: 'skipped', reason: 'repo-gone' } 83 + } 84 + throw classified ?? err 85 + } 86 + 87 + // For branches we get the SHA from the local ref after fetch; for tags 88 + // we still update lastSyncedRefs so a subsequent push event with the 89 + // same SHA short-circuits via ref-tip dedupe. 90 + const { stdout: sha } = await git(['rev-parse', fullRef], { cwd: tmpDir }) 91 + await updateLastSyncedRef(mapping.id, fullRef, sha.trim()) 92 + 93 + return { status: 'synced' } 94 + } 95 + finally { 96 + sshCleanup?.() 97 + try { 98 + rmSync(tmpDir, { recursive: true, force: true }) 99 + } 100 + catch { 101 + // best-effort 102 + } 103 + } 104 + } 105 + 106 + /** 107 + * Mirror a branch or tag deletion from GitHub to the configured knot. 108 + * 109 + * Triggered by GitHub's `delete` webhook event. For branches, GitHub also 110 + * sends a parallel `push` event with `after = 0000\u2026`, which `syncPush` 111 + * currently skips (`reason: 'deletion'`) \u2014 this is the path that actually 112 + * removes the ref on the knot. Tag deletion arrives only via this event. 113 + * 114 + * Deletion is idempotent: if the ref doesn't exist on the knot we treat it 115 + * as success. We wanted it gone, it's gone. 116 + */ 117 + export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> { 118 + if (payload.refType !== 'branch' && payload.refType !== 'tag') { 119 + return { status: 'skipped', reason: 'not-branch-or-tag' } 120 + } 121 + 122 + const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId) 123 + if ('skip' in mapping) return mapping.skip 124 + 125 + const fullRef = qualifyRef(payload.refType, payload.ref) 126 + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-delete-')) 127 + let sshCleanup: (() => void) | undefined 128 + 129 + try { 130 + // No fetch needed; we're only telling the remote to drop a ref. 131 + await git(['init', '--bare', '-q'], { cwd: tmpDir }) 132 + 133 + const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 134 + sshCleanup = cleanup 135 + const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}` 136 + 137 + // The `:<ref>` (empty source) refspec means "delete <ref> on the remote". 138 + try { 139 + await git( 140 + ['push', '-q', knotUrl, `:${fullRef}`], 141 + { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 60_000 }, 142 + ) 143 + } 144 + catch (err) { 145 + const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 146 + // "remote ref does not exist" is success for our purposes \u2014 the ref is 147 + // gone, which is what we wanted. 148 + if (/remote ref does not exist|unable to delete.*does not exist/i.test(stderr)) { 149 + await clearLastSyncedRef(mapping.id, fullRef) 150 + return { status: 'synced' } 151 + } 152 + const classified = classifyPushFailure(stderr) 153 + if (classified?.reason === 'repo-gone') { 154 + await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 155 + return { status: 'skipped', reason: 'repo-gone' } 156 + } 157 + throw classified ?? err 158 + } 159 + 160 + await clearLastSyncedRef(mapping.id, fullRef) 161 + return { status: 'synced' } 162 + } 163 + finally { 164 + sshCleanup?.() 165 + try { 166 + rmSync(tmpDir, { recursive: true, force: true }) 167 + } 168 + catch { 169 + // best-effort 170 + } 171 + } 172 + } 173 + 174 + function qualifyRef(refType: RefType, ref: string): string { 175 + return refType === 'tag' ? `refs/tags/${ref}` : `refs/heads/${ref}` 176 + } 177 + 178 + type ActiveMapping = { 179 + id: number 180 + githubFullName: string 181 + tangledRepoDid: string 182 + knot: string 183 + } | { skip: RefResult } 184 + 185 + async function loadActiveMapping(installationId: number, githubRepoId: number): Promise<ActiveMapping> { 186 + const db = useDb() 187 + const rows = await db.select().from(repoMapping).where( 188 + and( 189 + eq(repoMapping.installationId, installationId), 190 + eq(repoMapping.githubRepoId, githubRepoId), 191 + ), 192 + ).limit(1) 193 + 194 + if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } } 195 + const row = rows[0]! 196 + 197 + if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } } 198 + if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } } 199 + 200 + return { 201 + id: row.id, 202 + githubFullName: row.githubFullName, 203 + tangledRepoDid: row.tangledRepoDid, 204 + knot: row.knot, 205 + } 206 + } 207 + 208 + async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> { 209 + const db = useDb() 210 + await db.update(repoMapping) 211 + .set({ 212 + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, 213 + updatedAt: new Date(), 214 + }) 215 + .where(eq(repoMapping.id, mappingId)) 216 + } 217 + 218 + async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> { 219 + const db = useDb() 220 + // jsonb minus text removes a top-level key. Safe no-op if absent. 221 + await db.update(repoMapping) 222 + .set({ 223 + lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, 224 + updatedAt: new Date(), 225 + }) 226 + .where(eq(repoMapping.id, mappingId)) 227 + } 228 + 229 + async function markMappingError(mappingId: number, message: string): Promise<void> { 230 + const db = useDb() 231 + await db.update(repoMapping) 232 + .set({ status: 'error', lastError: message, updatedAt: new Date() }) 233 + .where(eq(repoMapping.id, mappingId)) 234 + } 235 + 236 + function jsonbPath(ref: string): string { 237 + return `"${ref.replaceAll('"', '\\"')}"` 238 + }
+254
test/unit/sync-ref.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 3 + import { installation, repoMapping } 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 + // Mock git + ssh + octokit so we can exercise mapping-lookup + envelope 11 + // branches without invoking real binaries. 12 + const gitMock = vi.fn<(args: string[], opts?: unknown) => Promise<{ stdout: string, stderr: string }>>() 13 + const sshLoadMock = vi.fn<(installationId: number) => Promise<{ gitSshCommand: string, cleanup: () => void }>>() 14 + const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 15 + 16 + vi.mock('../../server/utils/git', async () => { 17 + const actual = await vi.importActual<typeof import('../../server/utils/git')>('../../server/utils/git') 18 + return { 19 + ...actual, 20 + git: (args: string[], opts: unknown) => gitMock(args, opts), 21 + } 22 + }) 23 + 24 + vi.mock('../../server/utils/ssh-cmd', () => ({ 25 + loadSshCommandForInstall: (id: number) => sshLoadMock(id), 26 + })) 27 + 28 + vi.mock('../../server/utils/github-app', () => ({ 29 + installationOctokit: async () => ({ 30 + auth: octokitAuthMock, 31 + }), 32 + })) 33 + 34 + const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref') 35 + 36 + describe('sync-ref', () => { 37 + beforeEach(async () => { 38 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 39 + clearEncryptionKeyCache() 40 + 41 + setDb(await createTestDb()) 42 + const db = useDb() 43 + await db.insert(installation).values({ 44 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 45 + }) 46 + 47 + gitMock.mockReset() 48 + sshLoadMock.mockReset() 49 + octokitAuthMock.mockReset() 50 + 51 + sshLoadMock.mockResolvedValue({ gitSshCommand: 'ssh -i /tmp/key', cleanup: () => {} }) 52 + octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 53 + gitMock.mockResolvedValue({ stdout: 'abc1234567890abc1234567890abc1234567890a', stderr: '' }) 54 + }) 55 + 56 + afterEach(() => { 57 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 58 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 59 + clearEncryptionKeyCache() 60 + clearDb() 61 + }) 62 + 63 + async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 64 + const db = useDb() 65 + await db.insert(repoMapping).values({ 66 + installationId: 1, 67 + githubRepoId: 9001, 68 + githubFullName: 'alice/my-project', 69 + tangledRepoDid: 'did:plc:repo-xyz', 70 + tangledFullName: 'did:plc:abc/my-project', 71 + knot: 'knot1.tangled.sh', 72 + status: 'active', 73 + ...over, 74 + }) 75 + } 76 + 77 + describe('syncCreateRef', () => { 78 + it('skips non-branch/tag ref types', async () => { 79 + await seedMapping() 80 + const result = await syncCreateRef({ 81 + installationId: 1, 82 + githubRepoId: 9001, 83 + refType: 'repository' as never, 84 + ref: 'whatever', 85 + }) 86 + expect(result).toEqual({ status: 'skipped', reason: 'not-branch-or-tag' }) 87 + expect(gitMock).not.toHaveBeenCalled() 88 + }) 89 + 90 + it('skips when no mapping exists', async () => { 91 + const result = await syncCreateRef({ 92 + installationId: 1, 93 + githubRepoId: 9001, 94 + refType: 'branch', 95 + ref: 'main', 96 + }) 97 + expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' }) 98 + }) 99 + 100 + it('skips when mapping is disabled', async () => { 101 + await seedMapping({ disabledAt: new Date() }) 102 + const result = await syncCreateRef({ 103 + installationId: 1, 104 + githubRepoId: 9001, 105 + refType: 'branch', 106 + ref: 'main', 107 + }) 108 + expect(result).toEqual({ status: 'skipped', reason: 'disabled' }) 109 + }) 110 + 111 + it('qualifies branch refs as refs/heads/<name>', async () => { 112 + await seedMapping() 113 + await syncCreateRef({ 114 + installationId: 1, 115 + githubRepoId: 9001, 116 + refType: 'branch', 117 + ref: 'feature-x', 118 + }) 119 + 120 + // git init, git fetch, git push, git rev-parse 121 + const calls = gitMock.mock.calls.map(c => c[0]) 122 + const fetch = calls.find(args => args[0] === 'fetch') 123 + const push = calls.find(args => args[0] === 'push') 124 + expect(fetch).toBeDefined() 125 + expect(fetch).toContain('+refs/heads/feature-x:refs/heads/feature-x') 126 + expect(push).toContain('+refs/heads/feature-x:refs/heads/feature-x') 127 + }) 128 + 129 + it('qualifies tag refs as refs/tags/<name>', async () => { 130 + await seedMapping() 131 + await syncCreateRef({ 132 + installationId: 1, 133 + githubRepoId: 9001, 134 + refType: 'tag', 135 + ref: 'v1.0.0', 136 + }) 137 + const calls = gitMock.mock.calls.map(c => c[0]) 138 + const fetch = calls.find(args => args[0] === 'fetch') 139 + expect(fetch).toContain('+refs/tags/v1.0.0:refs/tags/v1.0.0') 140 + }) 141 + 142 + it('routes push for knot1.tangled.sh via tangled.org', async () => { 143 + await seedMapping() 144 + await syncCreateRef({ 145 + installationId: 1, 146 + githubRepoId: 9001, 147 + refType: 'branch', 148 + ref: 'main', 149 + }) 150 + const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')! 151 + const url = push.find(a => a.startsWith('ssh://'))! 152 + expect(url).toContain('@tangled.org/') 153 + }) 154 + 155 + it('updates lastSyncedRefs with the fetched SHA', async () => { 156 + await seedMapping() 157 + gitMock.mockImplementation(async args => { 158 + if (args[0] === 'rev-parse') { 159 + return { stdout: 'deadbeef1234567890deadbeef1234567890dead', stderr: '' } 160 + } 161 + return { stdout: '', stderr: '' } 162 + }) 163 + 164 + await syncCreateRef({ 165 + installationId: 1, 166 + githubRepoId: 9001, 167 + refType: 'branch', 168 + ref: 'main', 169 + }) 170 + 171 + const db = useDb() 172 + const rows = await db.select().from(repoMapping) 173 + const refs = rows[0].lastSyncedRefs as Record<string, string> 174 + expect(refs['refs/heads/main']).toBe('deadbeef1234567890deadbeef1234567890dead') 175 + }) 176 + }) 177 + 178 + describe('syncDeleteRef', () => { 179 + it('uses the empty-source refspec to delete on the remote', async () => { 180 + await seedMapping() 181 + await syncDeleteRef({ 182 + installationId: 1, 183 + githubRepoId: 9001, 184 + refType: 'branch', 185 + ref: 'old-branch', 186 + }) 187 + const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')! 188 + expect(push).toContain(':refs/heads/old-branch') 189 + expect(push.some(s => s.startsWith(':'))).toBe(true) 190 + }) 191 + 192 + it('treats "remote ref does not exist" as success', async () => { 193 + await seedMapping() 194 + gitMock.mockImplementation(async args => { 195 + if (args[0] === 'push') { 196 + throw Object.assign(new Error('exit 1'), { 197 + stderr: 'error: unable to delete \'refs/tags/v9\': remote ref does not exist\n', 198 + }) 199 + } 200 + return { stdout: '', stderr: '' } 201 + }) 202 + 203 + const result = await syncDeleteRef({ 204 + installationId: 1, 205 + githubRepoId: 9001, 206 + refType: 'tag', 207 + ref: 'v9', 208 + }) 209 + expect(result).toEqual({ status: 'synced' }) 210 + }) 211 + 212 + it('removes the ref from lastSyncedRefs', async () => { 213 + const db = useDb() 214 + await seedMapping({ 215 + lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' }, 216 + }) 217 + 218 + await syncDeleteRef({ 219 + installationId: 1, 220 + githubRepoId: 9001, 221 + refType: 'branch', 222 + ref: 'old', 223 + }) 224 + 225 + const rows = await db.select().from(repoMapping) 226 + const refs = rows[0].lastSyncedRefs as Record<string, string> 227 + expect(refs).toEqual({ 'refs/heads/main': 'abc' }) 228 + }) 229 + 230 + it('marks mapping as error if knot reports repo gone', async () => { 231 + await seedMapping() 232 + gitMock.mockImplementation(async args => { 233 + if (args[0] === 'push') { 234 + throw Object.assign(new Error('exit 1'), { 235 + stderr: 'fatal: repository not found\n', 236 + }) 237 + } 238 + return { stdout: '', stderr: '' } 239 + }) 240 + 241 + const result = await syncDeleteRef({ 242 + installationId: 1, 243 + githubRepoId: 9001, 244 + refType: 'branch', 245 + ref: 'main', 246 + }) 247 + expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' }) 248 + 249 + const db = useDb() 250 + const rows = await db.select().from(repoMapping) 251 + expect(rows[0].status).toBe('error') 252 + }) 253 + }) 254 + })