mirror your GitHub repos to tangled.org automatically
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})