Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions __tests__/integration/provisioning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
47 changes: 39 additions & 8 deletions src/provisioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,52 @@ 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}
* with normalised provisioning params, or {valid: false, error} with a human
* 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();
Expand Down
Loading