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.
This commit is contained in:
kimi-reviewer-andresmgsl 2026-07-22 17:47:48 +00:00
parent 54f80bbbcf
commit dadfca6093
3 changed files with 206 additions and 1 deletions

View file

@ -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 <owner> current repository owner (required)
-r, --repo <repo> repository name (required)
--to <new-owner> 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 <name> organization username, used in URLs (required)
--full-name <full-name> display name of the organization
-d, --description <description> organization description
--website <website> organization website
--location <location> organization location
--visibility <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 <org> organization name (required)
-l, --limit <number> 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 <org> organization name (required)
-f, --file <path> 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 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

View file

@ -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 };

View file

@ -442,6 +442,26 @@ repo
}
});
repo
.command('transfer')
.description('Transfer a repository to a new owner (user or organization)')
.requiredOption('-o, --owner <owner>', 'current repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--to <new-owner>', '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 <name>', 'organization username (short name used in URLs)')
.option('--full-name <full-name>', 'display name of the organization', '')
.option('-d, --description <description>', 'organization description', '')
.option('--website <website>', 'organization website', '')
.option('--location <location>', 'organization location', '')
.option('--visibility <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 <org>', 'organization name')
.option('-l, --limit <number>', '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 <org>', 'organization name')
.requiredOption('-f, --file <path>', '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);