diff --git a/server/utils/repo-health.ts b/server/utils/repo-health.ts index 8c431cc..fedf89f 100644 --- a/server/utils/repo-health.ts +++ b/server/utils/repo-health.ts @@ -61,8 +61,8 @@ export function explainRepoError(raw: string): string | null { ) { return 'A repo with this name already exists on your tangled account. Rename or remove the existing one, then resync.' } - if (err.includes('invalid path sequence')) { - return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.' + if (err.includes('invalid path sequence') || err.includes('can\'t be used as a tangled repo name')) { + return 'This repository name can\'t be used as a tangled repo name, so it can\'t be mirrored.' } if (err.includes('repository access blocked') || err.includes('repo no longer exists')) { return 'The mirror on tangled is no longer reachable. Try a resync; if it persists, let us know.' diff --git a/server/utils/tangled-repo.ts b/server/utils/tangled-repo.ts index 7cd1ee1..41d9f4d 100644 --- a/server/utils/tangled-repo.ts +++ b/server/utils/tangled-repo.ts @@ -90,7 +90,7 @@ const DEFAULT_KNOT = 'knot1.tangled.sh' export interface EnrolResult { status: 'enrolled' | 'already' | 'skipped' - reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict' + reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict' | 'invalid-name' } /** @@ -113,6 +113,23 @@ function isNameConflictResponse(status: number, body: string): boolean { return lc.includes('accesscontrol') || lc.includes('sufficient access permissions') } +/** + * Stored `repo_mapping.lastError` when the GitHub repo name can't be a tangled + * repo name (e.g. `.github`, whose leading dot the knot rejects as an invalid + * path sequence). Terminal: the name won't become valid on retry. + */ +export const INVALID_NAME_ERROR + = 'this repository name can\'t be used as a tangled repo name, so it can\'t be mirrored' + +/** + * True when the knot rejects `repo.create` because the name isn't a valid repo + * path (surfaced as a `Generic` 400 mentioning an invalid path sequence). + */ +function isInvalidNameResponse(status: number, body: string): boolean { + if (status !== 400) return false + return body.toLowerCase().includes('invalid path sequence') +} + /** * List the names of the user's existing `sh.tangled.repo` records. Used to * detect a collision before calling the knot so we fail fast with a clear @@ -245,6 +262,10 @@ export async function enrollRepo(opts: { await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR) return { status: 'skipped', reason: 'name-conflict' } } + if (isInvalidNameResponse(knotResponse.status, body)) { + await recordEnrolError(db, opts, repo.full_name, INVALID_NAME_ERROR) + return { status: 'skipped', reason: 'invalid-name' } + } throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`) } const knotJson: { repoDid?: string } = await knotResponse.json() diff --git a/test/unit/tangled-repo.spec.ts b/test/unit/tangled-repo.spec.ts index 0f1af28..5e8db63 100644 --- a/test/unit/tangled-repo.spec.ts +++ b/test/unit/tangled-repo.spec.ts @@ -7,6 +7,7 @@ import { clearEncryptionKeyCache } from '../../server/utils/encryption' import { buildReadOnlyDescription, enrollRepo, + INVALID_NAME_ERROR, mergeRepoRecord, NAME_CONFLICT_ERROR, stripReadOnlyMarker, @@ -235,6 +236,26 @@ describe('enrollRepo', () => { expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR) }) + it('records a terminal error for a name the knot rejects as an invalid path', async () => { + githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/.github' }) }) + fakeFetch.mockResolvedValue(new Response( + JSON.stringify({ error: 'Generic', message: 'Repository name contains invalid path sequence' }), + { status: 400 }, + )) + + const result = await enrollRepo({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + }) + expect(result).toEqual({ status: 'skipped', reason: 'invalid-name' }) + + const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`) + expect(rows).toHaveLength(1) + expect(rows[0]!.status).toBe('error') + expect(rows[0]!.lastError).toBe(INVALID_NAME_ERROR) + }) + it('still throws on non-conflict knot errors so the queue retries', async () => { githubGet.mockResolvedValue({ data: ghRepo() }) fakeFetch.mockResolvedValue(new Response('upstream boom', { status: 502 }))