mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import crypto from 'node:crypto' 2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 3import { installation, repoMapping } from '../../server/db/schema' 4import { clearDb, setDb, useDb } from '../../server/utils/db' 5import { clearEncryptionKeyCache } from '../../server/utils/encryption' 6import { createTestDb } from '../utils/db' 7 8const 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. 12const gitMock = vi.fn<(args: string[], opts?: unknown) => Promise<{ stdout: string, stderr: string }>>() 13const sshLoadMock = vi.fn<(installationId: number) => Promise<{ gitSshCommand: string, cleanup: () => void }>>() 14const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 15 16vi.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 24vi.mock('../../server/utils/ssh-cmd', () => ({ 25 loadSshCommandForInstall: (id: number) => sshLoadMock(id), 26})) 27 28vi.mock('../../server/utils/github-app', () => ({ 29 installationOctokit: async () => ({ 30 auth: octokitAuthMock, 31 }), 32})) 33 34const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref') 35 36describe('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})