diff --git a/README.md b/README.md index 5361606..e17dfdc 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,111 @@ stoke org avatar -o heavy-duty -f heavy-duty-logo.png Calls `POST /api/v1/orgs/{org}/avatar`. +### `stoke org team list` + +List teams in an organization. + +```bash +stoke org team list -o heavy-duty +``` + +Calls `GET /api/v1/orgs/{org}/teams` and auto-paginates. + +### `stoke org team create` + +Create a team in an organization. + +```text +Options: + -o, --org organization name (required) + --name team name (required) + -d, --description team description + --permission read, write, admin (default: read) + --all-repos grant access to all current and future org repositories + --can-create-repo allow members to create repositories in the organization + --units comma-separated team units + (default: repo.code,repo.issues,repo.pulls,repo.releases,repo.wiki,repo.projects) +``` + +Example used to create the regular members team: + +```bash +stoke org team create -o heavy-duty --name members --permission write --all-repos +``` + +Calls `POST /api/v1/orgs/{org}/teams`. + +### `stoke org team member-list` + +List members of a team. Requires being a member of that team (or an instance admin). + +```bash +stoke org team member-list --team-id 2 +``` + +Calls `GET /api/v1/teams/{id}/members` and auto-paginates. + +### `stoke org team member-add` + +Add a user to a team. Adding a user to any team also makes them a member of the organization; membership in the `Owners` team is what makes a user an organization admin. + +```text +Options: + --team-id team id, see `stoke org team list` (required) + -u, --user username to add (required) +``` + +Example used to make Dan and Andres organization admins: + +```bash +stoke org team member-add --team-id 1 -u andres +stoke org team member-add --team-id 1 -u dan +``` + +Calls `PUT /api/v1/teams/{id}/members/{username}`. + +### `stoke org team member-remove` + +Remove a user from a team. + +```text +Options: + --team-id team id, see `stoke org team list` (required) + -u, --user username to remove (required) +``` + +```bash +stoke org team member-remove --team-id 1 -u kimi-reviewer-andresmgsl +``` + +Calls `DELETE /api/v1/teams/{id}/members/{username}`. + +### `stoke user list` + +Search/list users on the Forgejo instance. Non-admin searches are visibility-limited: an empty query typically returns only the authenticated user. + +```text +Options: + -q, --query search query (default: empty) + -l, --limit maximum users to display (default: 50; use 0 for all) +``` + +```bash +stoke user list -q andres +``` + +Calls `GET /api/v1/users/search` and auto-paginates. + +### `stoke user show` + +Show a single user profile. Useful to check whether a username exists. + +```bash +stoke user show -u andres +``` + +Calls `GET /api/v1/users/{username}`. + ## Architecture ```text diff --git a/src/api.js b/src/api.js index 9c18492..4ddbd6c 100644 --- a/src/api.js +++ b/src/api.js @@ -175,6 +175,49 @@ class ForgejoClient { image: base64Image, }); } + + async searchUsers(query = '', opts = {}) { + const pageSize = 50; + const all = []; + for (let page = 1; page <= 1000; page += 1) { + const queryString = new URLSearchParams({ + q: query, + ...opts, + limit: String(pageSize), + page: String(page), + }).toString(); + const res = await this.get(`/users/search?${queryString}`); + const items = Array.isArray(res?.data) ? res.data : []; + if (items.length === 0) break; + all.push(...items); + if (items.length < pageSize) break; + } + return all; + } + + async listOrgTeams(org, opts = {}) { + return this.getAll(`/orgs/${encodeURIComponent(org)}/teams`, opts); + } + + async createTeam(org, payload) { + return this.post(`/orgs/${encodeURIComponent(org)}/teams`, payload); + } + + async addTeamMember(teamId, username) { + return this.request('PUT', `/teams/${teamId}/members/${encodeURIComponent(username)}`); + } + + async listTeamMembers(teamId, opts = {}) { + return this.getAll(`/teams/${teamId}/members`, opts); + } + + async removeTeamMember(teamId, username) { + return this.del(`/teams/${teamId}/members/${encodeURIComponent(username)}`); + } + + async getUser(username) { + return this.get(`/users/${encodeURIComponent(username)}`); + } } module.exports = { ForgejoClient }; diff --git a/src/cli.js b/src/cli.js index ca58c3f..c262b7d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -682,6 +682,175 @@ org } }); +const team = org + .command('team') + .description('Manage organization teams'); + +team + .command('list') + .description('List teams in an organization') + .requiredOption('-o, --org ', 'organization name') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const teams = await client.listOrgTeams(options.org); + if (!teams.length) { + console.log('No teams found.'); + return; + } + for (const t of teams) { + console.log(`#${t.id} ${t.name} [${t.permission}]`); + } + } catch (err) { + console.error(`Failed to list teams: ${err.message}`); + process.exit(1); + } + }); + +team + .command('create') + .description('Create a team in an organization') + .requiredOption('-o, --org ', 'organization name') + .requiredOption('--name ', 'team name') + .option('-d, --description ', 'team description', '') + .option('--permission ', 'permission level: read, write, admin', 'read') + .option('--all-repos', 'grant access to all current and future organization repositories', false) + .option('--can-create-repo', 'allow members to create repositories in the organization', false) + .option('--units ', 'comma-separated team units', 'repo.code,repo.issues,repo.pulls,repo.releases,repo.wiki,repo.projects') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const payload = { + name: options.name, + description: options.description, + permission: options.permission, + includes_all_repositories: options.allRepos, + can_create_org_repo: options.canCreateRepo, + units: options.units.split(',').map((u) => u.trim()).filter(Boolean), + }; + const result = await client.createTeam(options.org, payload); + console.log(`Team created: #${result.id} ${result.name} [${result.permission}]`); + } catch (err) { + console.error(`Team creation failed: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + +team + .command('member-list') + .description('List members of a team') + .requiredOption('--team-id ', 'team id (see `stoke org team list`)') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const members = await client.listTeamMembers(Number(options.teamId)); + if (!members.length) { + console.log('No members found.'); + return; + } + for (const m of members) { + const name = m.full_name ? ` (${m.full_name})` : ''; + console.log(`${m.login}${name}`); + } + } catch (err) { + console.error(`Failed to list team members: ${err.message}`); + process.exit(1); + } + }); + +team + .command('member-add') + .description('Add a user to a team') + .requiredOption('--team-id ', 'team id (see `stoke org team list`)') + .requiredOption('-u, --user ', 'username to add') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + await client.addTeamMember(Number(options.teamId), options.user); + console.log(`Added ${options.user} to team #${options.teamId}.`); + } catch (err) { + console.error(`Failed to add team member: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + +team + .command('member-remove') + .description('Remove a user from a team') + .requiredOption('--team-id ', 'team id (see `stoke org team list`)') + .requiredOption('-u, --user ', 'username to remove') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + await client.removeTeamMember(Number(options.teamId), options.user); + console.log(`Removed ${options.user} from team #${options.teamId}.`); + } catch (err) { + console.error(`Failed to remove team member: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + +const user = program + .command('user') + .description('Manage users'); + +user + .command('list') + .description('Search/list users on the Forgejo instance') + .option('-q, --query ', 'search query (empty lists all visible users)', '') + .option('-l, --limit ', 'maximum users to return', '50') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const users = await client.searchUsers(options.query); + const limit = Number(options.limit); + const display = limit > 0 ? users.slice(0, limit) : users; + if (!display.length) { + console.log('No users found.'); + return; + } + for (const u of display) { + const name = u.full_name ? ` (${u.full_name})` : ''; + console.log(`${u.login}${name}`); + } + if (users.length > display.length) { + console.log(`...and ${users.length - display.length} more (use -l 0 for all).`); + } + } catch (err) { + console.error(`Failed to list users: ${err.message}`); + process.exit(1); + } + }); + +user + .command('show') + .description('Show a single user profile') + .requiredOption('-u, --user ', 'username to look up') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const u = await client.getUser(options.user); + console.log(`Login: ${u.login}`); + console.log(`Full name: ${u.full_name || '-'}`); + console.log(`Email: ${u.email || '-'}`); + console.log(`URL: ${u.html_url}`); + } catch (err) { + console.error(`Failed to show user: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + program.parseAsync(process.argv).catch((err) => { console.error(err); process.exit(1);