From 9cc2d20df2d38098963f1104b1ae91a2eca17890 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 30 Apr 2026 07:39:26 +0200 Subject: [PATCH] fix: reject invalid repo names in provisioning before hitting GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/` accepted names that GitHub itself rejects: leading `.`, leading `-`, names ending in `.git`, lone `.` or `..`, or strings over 100 chars (GitHub's hard limit). Result: validation passes locally, the bot enqueues a `provision-repo` task, the GitHub call 422s, the user sees a generic "❌ Failed to create repository: ..." comment and has to re-file the form. Wasted task, wasted GitHub call, terrible UX. ## What Tighten validation in `src/provisioning.js`: - Length cap at 100 chars - Reject leading `.` or `-` - Reject the reserved `.` and `..` bare names - Reject names ending in `.git` - Reject empty/whitespace-only after trim Each rejection emits a specific user-readable message rather than the generic "Invalid repository name". ## Source Wave-1 QA bug-hunter and Junior dev both flagged this as Bug #25 in `docs/agent-fleet/bugs.md`. ## Test plan - [x] 814 tests pass (was 806; +8 covering each new rejection path plus a "well-formed name with allowed punctuation" sanity case) - [x] eslint clean ## Risk & rollout - Risk: low. Only tightens validation; no name that previously succeeded at GitHub now fails locally. - Rollout: self-update on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/integration/provisioning.test.js | 52 ++++++++++++++++++++++ src/provisioning.js | 47 +++++++++++++++---- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/__tests__/integration/provisioning.test.js b/__tests__/integration/provisioning.test.js index 7f330a8..d8363ac 100644 --- a/__tests__/integration/provisioning.test.js +++ b/__tests__/integration/provisioning.test.js @@ -88,6 +88,58 @@ describe('validateProvisionRequest', () => { expect(r.valid).toBe(true); expect(r.params.visibility).toBe('internal'); }); + + describe('repository name rules (GitHub parity)', () => { + it('rejects names starting with a dot', () => { + const r = validateProvisionRequest({ 'repository name': '.foo' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/cannot start with '\.' or '-'/); + }); + + it('rejects names starting with a dash', () => { + const r = validateProvisionRequest({ 'repository name': '-foo' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/cannot start with '\.' or '-'/); + }); + + it('rejects names ending in .git', () => { + const r = validateProvisionRequest({ 'repository name': 'foo.git' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/cannot end with '\.git'/); + }); + + it('rejects the reserved bare name "."', () => { + const r = validateProvisionRequest({ 'repository name': '.' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/reserved|cannot start/); + }); + + it('rejects the reserved bare name ".."', () => { + const r = validateProvisionRequest({ 'repository name': '..' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/reserved|cannot start/); + }); + + it('rejects empty / whitespace-only names', () => { + expect(validateProvisionRequest({ 'repository name': '' }).valid).toBe(false); + const r = validateProvisionRequest({ 'repository name': ' ' }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/cannot be empty|Missing field/); + }); + + it('rejects names longer than 100 characters', () => { + const longName = 'a'.repeat(101); + const r = validateProvisionRequest({ 'repository name': longName }); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/too long/); + }); + + it('accepts a well-formed name with allowed punctuation', () => { + const r = validateProvisionRequest({ 'repository name': 'valid-name_1.0' }); + expect(r.valid).toBe(true); + expect(r.params.name).toBe('valid-name_1.0'); + }); + }); }); describe('provisionRepo', () => { diff --git a/src/provisioning.js b/src/provisioning.js index 8d0a87e..a6e72ad 100644 --- a/src/provisioning.js +++ b/src/provisioning.js @@ -69,7 +69,39 @@ export function parseIssueFormBody(body) { } const VALID_VISIBILITIES = new Set(['public', 'private', 'internal']); -const REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/; +// GitHub's actual repository-name rules: 1-100 chars from [a-zA-Z0-9._-], +// must not start with '.' or '-', must not end with '.git', and the bare +// names '.' and '..' are reserved. The previous loose `^[a-zA-Z0-9._-]+$` +// regex passed all of these to the API and surfaced as a generic 422. +const REPO_NAME_CHARSET_RE = /^[a-zA-Z0-9._-]+$/; +const REPO_NAME_MAX_LEN = 100; + +/** + * Validate a candidate repository name against GitHub's actual rules. + * Returns null when the name is acceptable, or a user-friendly error string + * explaining the specific reason for rejection. + */ +function repoNameError(name) { + if (typeof name !== 'string' || name.trim() === '') { + return 'Repository name cannot be empty.'; + } + if (name.length > REPO_NAME_MAX_LEN) { + return `Repository name "${name}" is too long (max ${REPO_NAME_MAX_LEN} characters).`; + } + if (!REPO_NAME_CHARSET_RE.test(name)) { + return `Invalid repository name "${name}". Use only letters, numbers, dots, hyphens, underscores.`; + } + if (name === '.' || name === '..') { + return `Repository name "${name}" is reserved.`; + } + if (name.startsWith('.') || name.startsWith('-')) { + return `Invalid repository name "${name}". Names cannot start with '.' or '-'.`; + } + if (name.endsWith('.git')) { + return `Invalid repository name "${name}". Names cannot end with '.git'.`; + } + return null; +} /** * Validate the parsed issue-form fields. Returns either {valid: true, params} @@ -77,13 +109,12 @@ const REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/; * message that will be posted back on the issue. */ export function validateProvisionRequest(fields) { - const name = fields['repository name'] || fields.name; - if (!name) return { valid: false, error: 'Missing field "Repository name".' }; - if (!REPO_NAME_RE.test(name)) { - return { - valid: false, - error: `Invalid repository name "${name}". Use only letters, numbers, dots, hyphens, underscores.` - }; + const rawName = fields['repository name'] || fields.name; + if (!rawName) return { valid: false, error: 'Missing field "Repository name".' }; + const name = typeof rawName === 'string' ? rawName.trim() : rawName; + const nameError = repoNameError(name); + if (nameError) { + return { valid: false, error: nameError }; } const visibilityRaw = (fields.visibility || 'private').toLowerCase();