mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

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

fix: skip enrolment on tangled name conflicts instead of retrying

Daniel Roe (Jul 10, 2026, 12:41 PM +0200) 4b4288a1 fd8e4181

+166 -4
+6 -2
server/utils/repo-health.ts
··· 51 51 export function explainRepoError(raw: string): string | null { 52 52 const err = raw.toLowerCase() 53 53 54 - if (err.includes('sufficient access permissions') || err.includes('accesscontrol')) { 55 - return 'Tangled rejected the mirror, usually because a repo with this name already exists on your account. Rename or remove the existing one, then resync.' 54 + if ( 55 + err.includes('a repo with this name already exists') 56 + || err.includes('sufficient access permissions') 57 + || err.includes('accesscontrol') 58 + ) { 59 + return 'A repo with this name already exists on your tangled account. Rename or remove the existing one, then resync.' 56 60 } 57 61 if (err.includes('invalid path sequence')) { 58 62 return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.'
+99 -2
server/utils/tangled-repo.ts
··· 90 90 91 91 export interface EnrolResult { 92 92 status: 'enrolled' | 'already' | 'skipped' 93 - reason?: 'private' | 'fork' | 'no-identity' 93 + reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict' 94 + } 95 + 96 + /** 97 + * User-facing message stored in `repo_mapping.lastError` when enrolment is 98 + * blocked by a name that already exists on the user's tangled account. Kept as 99 + * a constant so `repo-health` and tests can match it exactly. Issue #4. 100 + */ 101 + export const NAME_CONFLICT_ERROR 102 + = 'a repo with this name already exists on your tangled account; rename or remove it, then resync' 103 + 104 + /** 105 + * True when the knot's `repo.create` rejection is a name collision: the user 106 + * already has a `sh.tangled.repo` of that name, so the knot refuses to mint a 107 + * second. The knot surfaces this as an `AccessControl` 400 ("DID does not have 108 + * sufficient access permissions"). 109 + */ 110 + function isNameConflictResponse(status: number, body: string): boolean { 111 + if (status !== 400) return false 112 + const lc = body.toLowerCase() 113 + return lc.includes('accesscontrol') || lc.includes('sufficient access permissions') 114 + } 115 + 116 + /** 117 + * List the names of the user's existing `sh.tangled.repo` records. Used to 118 + * detect a collision before calling the knot so we fail fast with a clear 119 + * status instead of a knot 400 (issue #4). 120 + */ 121 + export async function listExistingRepoNames(agent: Agent, did: string): Promise<Set<string>> { 122 + const names = new Set<string>() 123 + let cursor: string | undefined 124 + do { 125 + // eslint-disable-next-line no-await-in-loop -- sequential pagination 126 + const page = await agent.com.atproto.repo.listRecords({ 127 + repo: did, 128 + collection: REPO_LEXICON, 129 + limit: LIST_RECORDS_PAGE_SIZE, 130 + cursor, 131 + }) 132 + for (const rec of page.data.records) { 133 + const value = rec.value as Record<string, unknown> 134 + if (typeof value.name === 'string') names.add(value.name) 135 + } 136 + cursor = page.data.cursor 137 + } while (cursor) 138 + return names 94 139 } 95 140 96 141 /** ··· 123 168 }): Promise<EnrolResult> { 124 169 const db = useDb() 125 170 126 - const existing = await db.select({ id: repoMapping.id, status: repoMapping.status }) 171 + const existing = await db.select({ id: repoMapping.id, status: repoMapping.status, tangledFullName: repoMapping.tangledFullName }) 127 172 .from(repoMapping) 128 173 .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`) 129 174 if (existing.length > 0 && !opts.force) { ··· 150 195 151 196 // 3. Service-auth JWT for the knot procedure. 152 197 const agent = new Agent(opts.oauthSession) 198 + 199 + // Fail fast on a name collision (issue #4): if the user already has a 200 + // `sh.tangled.repo` of this name that isn't the one this mapping owns, the 201 + // knot would reject `repo.create` with an opaque `AccessControl` 400 and the 202 + // job would retry to exhaustion. Record a clear error status instead and 203 + // stop, so the dashboard can explain it and the user can act. 204 + const ownName = `${opts.oauthSession.did}/${name}` 205 + const alreadyOurs = existing[0]?.tangledFullName === ownName 206 + if (!alreadyOurs) { 207 + const existingNames = await listExistingRepoNames(agent, opts.oauthSession.did) 208 + if (existingNames.has(name)) { 209 + await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR) 210 + return { status: 'skipped', reason: 'name-conflict' } 211 + } 212 + } 213 + 153 214 const aud = `did:web:${knot}` 154 215 const exp = Math.floor(Date.now() / 1000) + 60 155 216 const { data: { token } } = await agent.com.atproto.server.getServiceAuth({ ··· 177 238 }) 178 239 if (!knotResponse.ok) { 179 240 const body = await knotResponse.text() 241 + // A name collision can still race in between the pre-check and this call 242 + // (or the pre-check's listRecords lagged the firehose). Treat it as the 243 + // same terminal, user-actionable condition rather than a retryable throw. 244 + if (isNameConflictResponse(knotResponse.status, body)) { 245 + await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR) 246 + return { status: 'skipped', reason: 'name-conflict' } 247 + } 180 248 throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`) 181 249 } 182 250 const knotJson: { repoDid?: string } = await knotResponse.json() ··· 234 302 } 235 303 236 304 return { status: 'enrolled' } 305 + } 306 + 307 + /** 308 + * Upsert a `repo_mapping` row in `error` state with a user-facing `lastError`. 309 + * Used when enrolment can't complete but we still want the repo to show on the 310 + * dashboard with an explanation rather than vanish silently. 311 + */ 312 + async function recordEnrolError( 313 + db: ReturnType<typeof useDb>, 314 + opts: { installationId: number, githubRepoId: number }, 315 + githubFullName: string, 316 + message: string, 317 + ): Promise<void> { 318 + const existing = await db.select({ id: repoMapping.id }) 319 + .from(repoMapping) 320 + .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`) 321 + if (existing.length > 0) { 322 + await db.update(repoMapping) 323 + .set({ status: 'error', lastError: message, updatedAt: new Date() }) 324 + .where(sql`${repoMapping.id} = ${existing[0]!.id}`) 325 + return 326 + } 327 + await db.insert(repoMapping).values({ 328 + installationId: opts.installationId, 329 + githubRepoId: opts.githubRepoId, 330 + githubFullName, 331 + status: 'error', 332 + lastError: message, 333 + }) 237 334 } 238 335 239 336 export interface SyncMetadataResult {
+2
test/unit/repo-health.spec.ts
··· 62 62 63 63 describe('explainRepoError', () => { 64 64 it('maps the production error strings we actually emit', () => { 65 + expect(explainRepoError('a repo with this name already exists on your tangled account; rename or remove it, then resync')).toMatch(/already exists/i) 66 + expect(explainRepoError('knot knot1.tangled.sh returned 400: {"error":"AccessControl"}')).toMatch(/already exists/i) 65 67 expect(explainRepoError('receive-pack: unpack error (pack signature mismatch detected)')).toMatch(/transient connection/i) 66 68 expect(explainRepoError('receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)')).toMatch(/transient connection/i) 67 69 expect(explainRepoError('knot rejected our ssh key; stopping sync')).toMatch(/ssh key/i)
+59
test/unit/tangled-repo.spec.ts
··· 8 8 buildReadOnlyDescription, 9 9 enrollRepo, 10 10 mergeRepoRecord, 11 + NAME_CONFLICT_ERROR, 11 12 stripReadOnlyMarker, 12 13 syncRepoMetadata, 13 14 } from '../../server/utils/tangled-repo' ··· 96 97 97 98 getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } }) 98 99 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } }) 100 + // Default: the user has no existing tangled repos, so the name-conflict 101 + // pre-check passes. Individual tests override this to force a collision. 102 + listRecordsMock.mockResolvedValue({ data: { records: [] } }) 99 103 }) 100 104 101 105 afterEach(() => { ··· 185 189 }) 186 190 expect(result).toEqual({ status: 'skipped', reason: 'fork' }) 187 191 expect(fakeFetch).not.toHaveBeenCalled() 192 + }) 193 + 194 + it('skips enrolment and records an error when the name already exists on tangled', async () => { 195 + githubGet.mockResolvedValue({ data: ghRepo() }) 196 + listRecordsMock.mockResolvedValue({ 197 + data: { records: [{ uri: 'at://did:plc:abc/sh.tangled.repo/existing', cid: 'bafy', value: { name: 'my-project' } }] }, 198 + }) 199 + 200 + const result = await enrollRepo({ 201 + oauthSession: fakeOauthSession('did:plc:abc'), 202 + installationId: 1, 203 + githubRepoId: 9001, 204 + }) 205 + expect(result).toEqual({ status: 'skipped', reason: 'name-conflict' }) 206 + // Knot procedure never called; PDS record never written. 207 + expect(fakeFetch).not.toHaveBeenCalled() 208 + expect(putRecordMock).not.toHaveBeenCalled() 209 + 210 + const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`) 211 + expect(rows).toHaveLength(1) 212 + expect(rows[0]!.status).toBe('error') 213 + expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR) 214 + expect(rows[0]!.tangledRepoDid).toBeNull() 215 + }) 216 + 217 + it('classifies a knot AccessControl 400 as a name conflict rather than retrying', async () => { 218 + githubGet.mockResolvedValue({ data: ghRepo() }) 219 + // Pre-check sees no collision (firehose lag), but the knot rejects. 220 + fakeFetch.mockResolvedValue(new Response( 221 + JSON.stringify({ error: 'AccessControl', message: 'DID does not have sufficient access permissions for this operation' }), 222 + { status: 400 }, 223 + )) 224 + 225 + const result = await enrollRepo({ 226 + oauthSession: fakeOauthSession('did:plc:abc'), 227 + installationId: 1, 228 + githubRepoId: 9001, 229 + }) 230 + expect(result).toEqual({ status: 'skipped', reason: 'name-conflict' }) 231 + 232 + const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`) 233 + expect(rows).toHaveLength(1) 234 + expect(rows[0]!.status).toBe('error') 235 + expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR) 236 + }) 237 + 238 + it('still throws on non-conflict knot errors so the queue retries', async () => { 239 + githubGet.mockResolvedValue({ data: ghRepo() }) 240 + fakeFetch.mockResolvedValue(new Response('upstream boom', { status: 502 })) 241 + 242 + await expect(enrollRepo({ 243 + oauthSession: fakeOauthSession('did:plc:abc'), 244 + installationId: 1, 245 + githubRepoId: 9001, 246 + })).rejects.toThrow(/returned 502/) 188 247 }) 189 248 190 249 it('no-ops if a mapping already exists', async () => {