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, job, repoMapping, userIdentity } 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 10interface GithubRepoLike { 11 id: number 12 full_name: string 13 private: boolean 14 fork: boolean 15 default_branch: string 16 description: string | null 17 homepage: string | null 18 topics: string[] 19} 20 21const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>() 22const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown>, swapRecord?: string }) => Promise<unknown>>() 23const listRecordsMock = vi.fn<(input: { repo: string, collection: string, limit?: number, cursor?: string }) => Promise<{ data: { records: Array<{ uri: string, cid: string, value: Record<string, unknown> }>, cursor?: string } }>>() 24const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>() 25 26vi.mock('@atproto/api', () => ({ 27 Agent: class { 28 com = { 29 atproto: { 30 repo: { putRecord: putRecordMock, listRecords: listRecordsMock }, 31 }, 32 } 33 }, 34})) 35 36vi.mock('../../server/utils/github-app', () => ({ 37 installationOctokit: async () => ({ 38 request: githubGet, 39 }), 40 clearGitHubAppCache: () => {}, 41})) 42 43vi.mock('../../server/utils/atproto-oauth', () => ({ 44 useOAuthClient: async () => ({ restore: restoreMock }), 45})) 46 47const { dispatch } = await import('../../server/utils/job-handlers') 48 49function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike { 50 return { 51 id: 9001, 52 full_name: 'alice/my-project', 53 private: false, 54 fork: false, 55 default_branch: 'main', 56 description: 'a cool thing', 57 homepage: 'https://my-project.example', 58 topics: ['cool'], 59 ...over, 60 } 61} 62 63async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 64 await useDb().insert(repoMapping).values({ 65 installationId: 1, 66 githubRepoId: 9001, 67 githubFullName: 'alice/my-project', 68 tangledRepoDid: 'did:plc:repo-xyz', 69 tangledFullName: 'did:plc:abc/my-project', 70 knot: 'knot1.tangled.sh', 71 status: 'active', 72 ...over, 73 }) 74} 75 76function envelope(action: string, over: Record<string, unknown> = {}) { 77 return { 78 id: 1, 79 kind: 'github.repository', 80 payload: { installationId: 1, githubRepoId: 9001, action, ...over }, 81 attempts: 1, 82 } 83} 84 85describe('github.repository job handler', () => { 86 beforeEach(async () => { 87 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 88 clearEncryptionKeyCache() 89 90 setDb(await createTestDb()) 91 await useDb().insert(installation).values({ 92 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 93 }) 94 await useDb().insert(userIdentity).values({ 95 did: 'did:plc:abc', 96 installationId: 1, 97 }) 98 99 githubGet.mockReset() 100 putRecordMock.mockReset() 101 listRecordsMock.mockReset() 102 restoreMock.mockReset() 103 104 restoreMock.mockResolvedValue({ did: 'did:plc:abc' }) 105 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', cid: 'bafy-new' } }) 106 }) 107 108 afterEach(() => { 109 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 110 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 111 clearEncryptionKeyCache() 112 clearDb() 113 }) 114 115 describe('edited', () => { 116 it('refreshes PDS metadata via listRecords + putRecord with swapRecord', async () => { 117 await seedMapping() 118 githubGet.mockResolvedValue({ data: ghRepo({ description: 'new text', topics: ['fresh'] }) }) 119 listRecordsMock.mockResolvedValue({ 120 data: { 121 records: [{ 122 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', 123 cid: 'bafy-old', 124 value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, 125 }], 126 }, 127 }) 128 129 await dispatch(envelope('edited')) 130 131 expect(putRecordMock).toHaveBeenCalledTimes(1) 132 const put = putRecordMock.mock.calls[0]?.[0] 133 expect(put?.swapRecord).toBe('bafy-old') 134 expect(put?.record.description).toBe('[READ-ONLY] Mirror of https://github.com/alice/my-project. new text') 135 expect(put?.record.topics).toEqual(['fresh']) 136 }) 137 138 it('is a no-op when no user identity exists for the install', async () => { 139 await useDb().delete(userIdentity) 140 await seedMapping() 141 142 await dispatch(envelope('edited')) 143 144 expect(githubGet).not.toHaveBeenCalled() 145 expect(putRecordMock).not.toHaveBeenCalled() 146 }) 147 }) 148 149 describe('privatized', () => { 150 it('sets disabledAt on the mapping', async () => { 151 await seedMapping() 152 await dispatch(envelope('privatized')) 153 154 const rows = await useDb().select().from(repoMapping) 155 expect(rows[0]!.disabledAt).toBeInstanceOf(Date) 156 expect(putRecordMock).not.toHaveBeenCalled() 157 }) 158 }) 159 160 describe('transferred / deleted', () => { 161 it('sets disabledAt for transferred', async () => { 162 await seedMapping() 163 await dispatch(envelope('transferred')) 164 const rows = await useDb().select().from(repoMapping) 165 expect(rows[0]!.disabledAt).toBeInstanceOf(Date) 166 }) 167 168 it('sets disabledAt for deleted', async () => { 169 await seedMapping() 170 await dispatch(envelope('deleted')) 171 const rows = await useDb().select().from(repoMapping) 172 expect(rows[0]!.disabledAt).toBeInstanceOf(Date) 173 }) 174 175 it('leaves the tangled mirror fields untouched (we do not delete user data)', async () => { 176 await seedMapping() 177 await dispatch(envelope('deleted')) 178 const rows = await useDb().select().from(repoMapping) 179 expect(rows[0]!.tangledRepoDid).toBe('did:plc:repo-xyz') 180 expect(rows[0]!.tangledFullName).toBe('did:plc:abc/my-project') 181 }) 182 }) 183 184 describe('publicized', () => { 185 it('clears disabledAt and runs a metadata sync when already mirrored', async () => { 186 await seedMapping({ disabledAt: new Date() }) 187 githubGet.mockResolvedValue({ data: ghRepo() }) 188 listRecordsMock.mockResolvedValue({ 189 data: { 190 records: [{ 191 uri: 'at://did:plc:abc/sh.tangled.repo/rkey1', 192 cid: 'bafy-old', 193 value: { $type: 'sh.tangled.repo', name: 'my-project', knot: 'knot1.tangled.sh', repoDid: 'did:plc:repo-xyz', createdAt: '2025-01-01T00:00:00Z' }, 194 }], 195 }, 196 }) 197 198 await dispatch(envelope('publicized')) 199 200 const rows = await useDb().select().from(repoMapping) 201 expect(rows[0]!.disabledAt).toBeNull() 202 expect(putRecordMock).toHaveBeenCalledTimes(1) 203 }) 204 205 it('enqueues a fresh tangled.create-repo when no tangledRepoDid exists', async () => { 206 await seedMapping({ tangledRepoDid: null, knot: null, disabledAt: new Date(), status: 'pending' }) 207 208 await dispatch(envelope('publicized')) 209 210 const rows = await useDb().select().from(repoMapping) 211 expect(rows[0]!.disabledAt).toBeNull() 212 213 const jobs = await useDb().select().from(job) 214 const kinds = jobs.map(j => j.kind) 215 expect(kinds).toContain('tangled.create-repo') 216 expect(putRecordMock).not.toHaveBeenCalled() 217 }) 218 219 it('enqueues a tangled.create-repo when no mapping row exists at all', async () => { 220 await dispatch(envelope('publicized')) 221 222 const jobs = await useDb().select().from(job) 223 expect(jobs.map(j => j.kind)).toContain('tangled.create-repo') 224 }) 225 }) 226 227 describe('renamed', () => { 228 it('updates githubFullName and writes an info: note to lastError', async () => { 229 await seedMapping() 230 githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/new-name' }) }) 231 232 await dispatch(envelope('renamed')) 233 234 const rows = await useDb().select().from(repoMapping) 235 expect(rows[0]!.githubFullName).toBe('alice/new-name') 236 expect(rows[0]!.lastError).toMatch(/^info: renamed on github from alice\/my-project to alice\/new-name/) 237 238 // No tangled-side rename. 239 expect(putRecordMock).not.toHaveBeenCalled() 240 }) 241 242 it('skips the write when GitHub reports the same name (webhook noise)', async () => { 243 await seedMapping() 244 githubGet.mockResolvedValue({ data: ghRepo() }) 245 246 await dispatch(envelope('renamed')) 247 248 const rows = await useDb().select().from(repoMapping) 249 expect(rows[0]!.lastError).toBeNull() 250 }) 251 }) 252 253 describe('archived / created', () => { 254 it('archived is a no-op', async () => { 255 await seedMapping() 256 await dispatch(envelope('archived')) 257 const rows = await useDb().select().from(repoMapping) 258 expect(rows[0]!.disabledAt).toBeNull() 259 expect(rows[0]!.lastError).toBeNull() 260 expect(putRecordMock).not.toHaveBeenCalled() 261 }) 262 263 it('created is a no-op (handled by installation_repositories.added)', async () => { 264 await seedMapping() 265 await dispatch(envelope('created')) 266 expect(putRecordMock).not.toHaveBeenCalled() 267 }) 268 }) 269 270 it('rejects unknown actions at the payload guard', async () => { 271 await expect(dispatch(envelope('not-a-real-action'))).rejects.toThrow(/invalid github\.repository payload/) 272 }) 273}) 274 275describe('github.installation_repositories removed', () => { 276 beforeEach(async () => { 277 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 278 clearEncryptionKeyCache() 279 280 setDb(await createTestDb()) 281 await useDb().insert(installation).values({ 282 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 283 }) 284 }) 285 286 afterEach(() => { 287 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 288 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 289 clearEncryptionKeyCache() 290 clearDb() 291 }) 292 293 it('sets disabledAt on the matching repo_mapping rows', async () => { 294 await seedMapping() 295 await useDb().insert(repoMapping).values({ 296 installationId: 1, 297 githubRepoId: 9002, 298 githubFullName: 'alice/keep-me', 299 status: 'active', 300 }) 301 302 await dispatch({ 303 id: 1, 304 kind: 'github.installation_repositories', 305 payload: { 306 installationId: 1, 307 action: 'removed', 308 addedRepoIds: [], 309 removedRepoIds: [9001], 310 }, 311 attempts: 1, 312 }) 313 314 const rows = await useDb().select().from(repoMapping) 315 const byRepoId = Object.fromEntries(rows.map(r => [r.githubRepoId, r])) 316 expect(byRepoId[9001]?.disabledAt).toBeInstanceOf(Date) 317 expect(byRepoId[9002]?.disabledAt).toBeNull() 318 }) 319})