Merge pull request 'feat: support organization-owned repository creation' (#35) from build/24-repo-create-owner into main
All checks were successful
ci / test (push) Successful in 24s
All checks were successful
ci / test (push) Successful in 24s
Reviewed-on: #35 Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders> Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders> Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
This commit is contained in:
commit
c09943ea32
6 changed files with 96 additions and 6 deletions
|
|
@ -247,11 +247,12 @@ The stored token is handed to git ephemerally through environment-based config (
|
||||||
|
|
||||||
### `stoke repo create`
|
### `stoke repo create`
|
||||||
|
|
||||||
Create a new repository for the authenticated user.
|
Create a new repository for the authenticated user or an organization.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Options:
|
Options:
|
||||||
--name <name> repository name (required)
|
--name <name> repository name (required)
|
||||||
|
-o, --owner <owner> repository owner (authenticated user or organization)
|
||||||
-d, --description <description> repository description
|
-d, --description <description> repository description
|
||||||
--private make the repository private
|
--private make the repository private
|
||||||
--public make the repository public
|
--public make the repository public
|
||||||
|
|
@ -265,9 +266,13 @@ Example:
|
||||||
```bash
|
```bash
|
||||||
stoke repo create --name stoke-test --private \
|
stoke repo create --name stoke-test --private \
|
||||||
-d "Test repository created via stoke"
|
-d "Test repository created via stoke"
|
||||||
|
stoke repo create -o heavy-duty --name shared-project --private
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `POST /api/v1/user/repos`.
|
When `--owner` is omitted or names the authenticated user (case-insensitively),
|
||||||
|
calls `POST /api/v1/user/repos`. For another owner, calls
|
||||||
|
`POST /api/v1/orgs/{owner}/repos`; Forgejo returns `403` when the caller cannot
|
||||||
|
create repositories for that organization.
|
||||||
|
|
||||||
### `stoke repo list`
|
### `stoke repo list`
|
||||||
|
|
||||||
|
|
|
||||||
1
changelog.d/24.md
Normal file
1
changelog.d/24.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- `repo create --owner` can now create organization-owned repositories while preserving the authenticated-user default. (#24).
|
||||||
|
|
@ -123,9 +123,15 @@ class ForgejoClient {
|
||||||
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRepo(payload) {
|
async createRepo(payload, owner) {
|
||||||
|
if (!owner) return this.post('/user/repos', payload);
|
||||||
|
|
||||||
|
const authenticatedUser = await this.get('/user');
|
||||||
|
if (owner.toLowerCase() === authenticatedUser.login.toLowerCase()) {
|
||||||
return this.post('/user/repos', payload);
|
return this.post('/user/repos', payload);
|
||||||
}
|
}
|
||||||
|
return this.post(`/orgs/${encodeURIComponent(owner)}/repos`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
async migrateRepo(payload) {
|
async migrateRepo(payload) {
|
||||||
return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS });
|
return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS });
|
||||||
|
|
|
||||||
|
|
@ -422,8 +422,9 @@ repo
|
||||||
|
|
||||||
repo
|
repo
|
||||||
.command('create')
|
.command('create')
|
||||||
.description('Create a new repository for the authenticated user')
|
.description('Create a new repository for the authenticated user or an organization')
|
||||||
.requiredOption('--name <name>', 'repository name')
|
.requiredOption('--name <name>', 'repository name')
|
||||||
|
.option('-o, --owner <owner>', 'repository owner (authenticated user or organization)')
|
||||||
.option('-d, --description <description>', 'repository description', '')
|
.option('-d, --description <description>', 'repository description', '')
|
||||||
.option('--private', 'make the repository private', false)
|
.option('--private', 'make the repository private', false)
|
||||||
.option('--public', 'make the repository public')
|
.option('--public', 'make the repository public')
|
||||||
|
|
@ -444,7 +445,7 @@ repo
|
||||||
default_branch: options.defaultBranch,
|
default_branch: options.defaultBranch,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await client.createRepo(payload);
|
const result = await client.createRepo(payload, options.owner);
|
||||||
console.log(`Repository created: ${result.full_name}`);
|
console.log(`Repository created: ${result.full_name}`);
|
||||||
console.log(`URL: ${result.html_url}`);
|
console.log(`URL: ${result.html_url}`);
|
||||||
console.log(`Clone (SSH): ${result.ssh_url}`);
|
console.log(`Clone (SSH): ${result.ssh_url}`);
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,41 @@ test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints
|
||||||
assert.match(opts.headers.Authorization, /^Basic /);
|
assert.match(opts.headers.Authorization, /^Basic /);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createRepo without an owner keeps the authenticated-user route', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ full_name: 'bot/project' }, 201));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.createRepo({ name: 'project' });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/user/repos');
|
||||||
|
assert.equal(calls[0].opts.method, 'POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createRepo treats a case-insensitive authenticated owner as the user route', async () => {
|
||||||
|
const calls = mockFetch((url) => {
|
||||||
|
if (url.endsWith('/user')) return jsonResponse({ login: 'BuildBot' });
|
||||||
|
return jsonResponse({ full_name: 'BuildBot/project' }, 201);
|
||||||
|
});
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.createRepo({ name: 'project' }, 'buildbot');
|
||||||
|
assert.deepEqual(calls.map(({ url }) => url), [
|
||||||
|
'https://forge.test/api/v1/user',
|
||||||
|
'https://forge.test/api/v1/user/repos',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createRepo routes a different owner to the organization endpoint', async () => {
|
||||||
|
const calls = mockFetch((url) => {
|
||||||
|
if (url.endsWith('/user')) return jsonResponse({ login: 'buildbot' });
|
||||||
|
return jsonResponse({ full_name: 'heavy-duty/project' }, 201);
|
||||||
|
});
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.createRepo({ name: 'project' }, 'heavy-duty');
|
||||||
|
assert.deepEqual(calls.map(({ url }) => url), [
|
||||||
|
'https://forge.test/api/v1/user',
|
||||||
|
'https://forge.test/api/v1/orgs/heavy-duty/repos',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('API errors carry message, status and body', async () => {
|
test('API errors carry message, status and body', async () => {
|
||||||
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
|
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
|
||||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,48 @@ test('invalid --team-id is rejected before any network call', () => {
|
||||||
assert.match(res.stderr, /Id must be a positive integer/);
|
assert.match(res.stderr, /Id must be a positive integer/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repo create help lists the owner option', () => {
|
||||||
|
const res = run(['repo', 'create', '--help']);
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
assert.match(res.stdout, /-o, --owner <owner>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo create surfaces an organization permission failure and HTTP status', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-repo-create-403.json`);
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
requests.push({ method: req.method, url: req.url });
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
if (req.url === '/api/v1/user') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ login: 'buildbot' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end(JSON.stringify({ message: 'user does not have permission to create repositories' }));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await spawnAsync(
|
||||||
|
['repo', 'create', '--owner', 'heavy-duty', '--name', 'project'],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /user does not have permission to create repositories/);
|
||||||
|
assert.match(res.stderr, /HTTP status: 403/);
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{ method: 'GET', url: '/api/v1/user' },
|
||||||
|
{ method: 'POST', url: '/api/v1/orgs/heavy-duty/repos' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('pr merge validates --number before any network call', () => {
|
test('pr merge validates --number before any network call', () => {
|
||||||
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
|
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
|
||||||
assert.equal(res.status, 1);
|
assert.equal(res.status, 1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue