From dadfca6093676018888c3da19d60e8e9527e2e72 Mon Sep 17 00:00:00 2001 From: kimi-reviewer-andresmgsl Date: Wed, 22 Jul 2026 17:47:48 +0000 Subject: [PATCH] Add org and repo transfer commands New commands, one per Forgejo API call used to move the heavy-duty repositories into the new heavy-duty organization: - stoke org create (POST /api/v1/orgs) - stoke org repos (GET /api/v1/orgs/{org}/repos) - stoke org avatar (POST /api/v1/orgs/{org}/avatar) - stoke repo transfer (POST /api/v1/repos/{owner}/{repo}/transfer) All commands documented in the README. --- README.md | 78 +++++++++++++++++++++++++++++++++++++- src/api.js | 20 ++++++++++ src/cli.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 68c5469..5361606 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,25 @@ stoke repo import-batch -f repos.json --dry-run Calls `POST /api/v1/repos/migrate` once per entry. +### `stoke repo transfer` + +Transfer a repository to a new owner (a user or an organization). The authenticated user must have admin rights on the repository and permission to create repositories under the new owner (e.g. be an organization owner), in which case the transfer completes immediately. + +```text +Options: + -o, --owner current repository owner (required) + -r, --repo repository name (required) + --to new owner: username or organization name (required) +``` + +Example used to move the heavy-duty repositories into the `heavy-duty` organization: + +```bash +stoke repo transfer -o kimi-reviewer-andresmgsl -r box --to heavy-duty +``` + +Calls `POST /api/v1/repos/{owner}/{repo}/transfer`. + ### `stoke issue list` List issues in a repository. @@ -334,6 +353,63 @@ stoke collaborator add -o kimi-reviewer-andresmgsl -r infra -u dan --permission Calls `PUT /api/v1/repos/{owner}/{repo}/collaborators/{user}`. +### `stoke org create` + +Create a new organization. The authenticated user becomes its first owner. + +```text +Options: + --name organization username, used in URLs (required) + --full-name display name of the organization + -d, --description organization description + --website organization website + --location organization location + --visibility public, limited, private (default: public) +``` + +Example used to create the Heavy Duty Builders organization: + +```bash +stoke org create --name heavy-duty --full-name "Heavy Duty Builders" \ + --website https://heavyduty.builders --visibility public +``` + +Calls `POST /api/v1/orgs`. + +### `stoke org repos` + +List repositories owned by an organization. + +```text +Options: + -o, --org organization name (required) + -l, --limit maximum repositories to display (default: 50; use 0 for all) +``` + +```bash +stoke org repos -o heavy-duty -l 0 +``` + +Calls `GET /api/v1/orgs/{org}/repos` and auto-paginates. + +### `stoke org avatar` + +Set the avatar (logo) of an organization from a local image file. The image is read, base64-encoded and uploaded; the caller must be an owner of the organization. + +```text +Options: + -o, --org organization name (required) + -f, --file path to the image file (png, jpeg, gif, ...) (required) +``` + +Example used to set the Heavy Duty Builders logo (sourced from the official GitHub organization avatar at `https://github.com/heavy-duty.png`): + +```bash +stoke org avatar -o heavy-duty -f heavy-duty-logo.png +``` + +Calls `POST /api/v1/orgs/{org}/avatar`. + ## Architecture ```text @@ -356,7 +432,7 @@ src/ ## Verification: heavy-duty repository imports -The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl`. +The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl` and later transferred to the `heavy-duty` organization (`https://forgejo.heavyduty.builders/heavy-duty`) using `stoke repo transfer`. | Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | diff --git a/src/api.js b/src/api.js index d73ee5d..9c18492 100644 --- a/src/api.js +++ b/src/api.js @@ -155,6 +155,26 @@ class ForgejoClient { name: newName, }); } + + async transferRepo(owner, repo, newOwner) { + return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`, { + new_owner: newOwner, + }); + } + + async createOrg(payload) { + return this.post('/orgs', payload); + } + + async listOrgRepos(org, opts = {}) { + return this.getAll(`/orgs/${encodeURIComponent(org)}/repos`, opts); + } + + async updateOrgAvatar(org, base64Image) { + return this.post(`/orgs/${encodeURIComponent(org)}/avatar`, { + image: base64Image, + }); + } } module.exports = { ForgejoClient }; diff --git a/src/cli.js b/src/cli.js index 6de14ac..ca58c3f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -442,6 +442,26 @@ repo } }); +repo + .command('transfer') + .description('Transfer a repository to a new owner (user or organization)') + .requiredOption('-o, --owner ', 'current repository owner') + .requiredOption('-r, --repo ', 'repository name') + .requiredOption('--to ', 'new owner (username or organization name)') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const result = await client.transferRepo(options.owner, options.repo, options.to); + console.log(`Repository transferred: ${result.full_name}`); + console.log(`URL: ${result.html_url}`); + } catch (err) { + console.error(`Repository transfer failed: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + const issue = program .command('issue') .description('Manage issues'); @@ -573,6 +593,95 @@ collaborator } }); +const org = program + .command('org') + .description('Manage organizations'); + +org + .command('create') + .description('Create a new organization') + .requiredOption('--name ', 'organization username (short name used in URLs)') + .option('--full-name ', 'display name of the organization', '') + .option('-d, --description ', 'organization description', '') + .option('--website ', 'organization website', '') + .option('--location ', 'organization location', '') + .option('--visibility ', 'visibility: public, limited, private', 'public') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const payload = { + username: options.name, + full_name: options.fullName, + description: options.description, + website: options.website, + location: options.location, + visibility: options.visibility, + }; + const result = await client.createOrg(payload); + console.log(`Organization created: ${result.username}`); + if (result.full_name) console.log(`Full name: ${result.full_name}`); + console.log(`URL: ${config.url}/${result.username}`); + } catch (err) { + console.error(`Organization creation failed: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + +org + .command('repos') + .description('List repositories owned by an organization') + .requiredOption('-o, --org ', 'organization name') + .option('-l, --limit ', 'maximum repositories to return', '50') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const repos = await client.listOrgRepos(options.org); + const limit = Number(options.limit); + const display = limit > 0 ? repos.slice(0, limit) : repos; + if (!display.length) { + console.log('No repositories found.'); + return; + } + for (const r of display) { + const vis = r.private ? 'private' : 'public'; + console.log(`${r.full_name} [${vis}] ${r.html_url}`); + } + if (repos.length > display.length) { + console.log(`...and ${repos.length - display.length} more (use -l 0 for all).`); + } + } catch (err) { + console.error(`Failed to list organization repositories: ${err.message}`); + process.exit(1); + } + }); + +org + .command('avatar') + .description('Set the avatar (logo) of an organization from an image file') + .requiredOption('-o, --org ', 'organization name') + .requiredOption('-f, --file ', 'path to the image file (png, jpeg, gif, ...)') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + let image; + try { + image = fs.readFileSync(options.file); + } catch (err) { + throw new Error(`Could not read image file ${options.file}: ${err.message}`); + } + await client.updateOrgAvatar(options.org, image.toString('base64')); + console.log(`Avatar updated for organization ${options.org}.`); + } catch (err) { + console.error(`Failed to update organization avatar: ${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);