diff --git a/README.md b/README.md index d2a3b28..db4d1c4 100644 --- a/README.md +++ b/README.md @@ -247,11 +247,12 @@ The stored token is handed to git ephemerally through environment-based config ( ### `stoke repo create` -Create a new repository for the authenticated user. +Create a new repository for the authenticated user or an organization. ```text Options: --name repository name (required) + -o, --owner repository owner (authenticated user or organization) -d, --description repository description --private make the repository private --public make the repository public @@ -265,9 +266,13 @@ Example: ```bash stoke repo create --name stoke-test --private \ -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` diff --git a/changelog.d/24.md b/changelog.d/24.md new file mode 100644 index 0000000..5384946 --- /dev/null +++ b/changelog.d/24.md @@ -0,0 +1 @@ +- `repo create --owner` can now create organization-owned repositories while preserving the authenticated-user default. (#24). diff --git a/src/api.js b/src/api.js index 85a23e7..e493fb9 100644 --- a/src/api.js +++ b/src/api.js @@ -123,8 +123,14 @@ class ForgejoClient { return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`); } - async createRepo(payload) { - return this.post('/user/repos', 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(`/orgs/${encodeURIComponent(owner)}/repos`, payload); } async migrateRepo(payload) { diff --git a/src/cli.js b/src/cli.js index 8fe0bcd..1c5713f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -422,8 +422,9 @@ repo repo .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 ', 'repository name') + .option('-o, --owner ', 'repository owner (authenticated user or organization)') .option('-d, --description ', 'repository description', '') .option('--private', 'make the repository private', false) .option('--public', 'make the repository public') @@ -444,7 +445,7 @@ repo 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(`URL: ${result.html_url}`); console.log(`Clone (SSH): ${result.ssh_url}`); diff --git a/test/api.test.js b/test/api.test.js index f0233af..9b517f7 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -66,6 +66,41 @@ test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints 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 () => { mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404)); const client = new ForgejoClient('https://forge.test', 'tok'); diff --git a/test/cli.test.js b/test/cli.test.js index bbe1b43..d503850 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -56,6 +56,48 @@ test('invalid --team-id is rejected before any network call', () => { 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 /); +}); + +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', () => { const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']); assert.equal(res.status, 1);