feat(hive-forge): add repo-create + repo-add-collaborator verbs

This commit is contained in:
atlas 2026-06-14 21:33:34 +02:00 committed by mara
commit 3790faf318
5 changed files with 168 additions and 0 deletions

View file

@ -206,6 +206,23 @@ impl Client {
decode_json(resp, &format!("PUT {url}"))
}
/// PUT a JSON body to `<api>/<path>` for an endpoint that returns
/// `204 No Content` (empty body), so there is nothing to decode.
/// Used by `repo-add-collaborator` (Forgejo's add-collaborator PUT
/// answers 204 on success).
pub fn put_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let url = format!("{}{}", self.api(), path);
let resp = self
.http
.put(&url)
.header(CONTENT_TYPE, "application/json")
.json(body)
.send()
.context("PUT")?;
check_status(resp, &format!("PUT {url}"))?;
Ok(())
}
/// DELETE `<api>/<path>`. Optional JSON body for endpoints that
/// need it (Forgejo's subscription unwatch uses bodyless DELETE).
pub fn delete(&self, path: &str, body: Option<&Value>) -> Result<()> {

View file

@ -80,6 +80,13 @@ enum Verb {
/// Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with
/// credentials auto-injected. Pairs with `pr-create --agit`.
Clone(verbs::clone::Args),
/// Create a forge repo under the current user (or `--org`). Prints
/// the repo URL. The instance disables push-to-create, so this is
/// the supported path to a new repo. Pairs with `repo-add-collaborator`.
RepoCreate(verbs::repo_create::Args),
/// Add a collaborator to the active repo (`-r`/`HIVE_FORGE_REPO`)
/// with a permission level. Companion to `repo-create`.
RepoAddCollaborator(verbs::repo_add_collaborator::Args),
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
Lint(verbs::lint::Args),
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
@ -130,6 +137,8 @@ fn main() -> Result<()> {
Verb::Labels(a) => verbs::labels::run(&client, a),
Verb::PrStatus(a) => verbs::pr_status::run(&client, a),
Verb::Clone(a) => verbs::clone::run(&client, a),
Verb::RepoCreate(a) => verbs::repo_create::run(&client, a),
Verb::RepoAddCollaborator(a) => verbs::repo_add_collaborator::run(&client, a),
Verb::Lint(a) => verbs::lint::run(&client, a),
Verb::List(a) => verbs::list::run(&client, a),
Verb::Milestone(a) => verbs::milestone::run(&client, a),

View file

@ -25,6 +25,8 @@ pub mod pr;
pub mod pr_create;
pub mod pr_reviews;
pub mod pr_status;
pub mod repo_add_collaborator;
pub mod repo_create;
pub mod subscription;
pub mod timeline;
pub mod tree_sha;

View file

@ -0,0 +1,63 @@
//! `repo-add-collaborator <user> [--permission read|write|admin] [-r <repo>]`
//! — add `<user>` as a collaborator on the active repo (from `-r` /
//! `HIVE_FORGE_REPO`). Prints a one-line confirmation.
//!
//! Wraps `PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}`.
//! A fresh agent-namespace repo (see `repo-create`) usually needs peers
//! added before they can collaborate; this is the companion verb.
use anyhow::Result;
use clap::Args as ClapArgs;
use clap::ValueEnum;
use serde_json::json;
use crate::client::Client;
/// Collaborator permission level accepted by Forgejo.
#[derive(Clone, Copy, ValueEnum)]
pub enum Permission {
/// Pull (read-only) access.
Read,
/// Push (read/write) access.
Write,
/// Full administrative access to the repo.
Admin,
}
impl Permission {
/// The wire string Forgejo's API expects.
fn as_api(self) -> &'static str {
match self {
Permission::Read => "read",
Permission::Write => "write",
Permission::Admin => "admin",
}
}
}
#[derive(ClapArgs)]
pub struct Args {
/// Collaborator's forge login to add.
user: String,
/// Permission level to grant (default: write — a freshly added
/// collaborator usually needs to push).
#[arg(long, value_enum, default_value_t = Permission::Write)]
permission: Permission,
}
/// # Errors
///
/// Propagates any transport error from the Forgejo REST call (network
/// unreachable, 4xx/5xx response such as an unknown user or insufficient
/// permission on the repo, token missing/invalid) and any I/O error from
/// writing the confirmation to stdout.
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let perm = args.permission.as_api();
client.put_no_content(
&format!("/repos/{repo}/collaborators/{}", args.user),
&json!({ "permission": perm }),
)?;
println!("added {} to {repo} ({perm})", args.user);
Ok(())
}

View file

@ -0,0 +1,77 @@
//! `repo-create <name> [--description <d>] [--private] [--default-branch <b>]
//! [--org <org>] [--auto-init]` — create a forge repository under the current
//! user (or under `--org`). Prints the new repo's web URL (or the full repo
//! object under `--json`).
//!
//! Wraps `POST /api/v1/user/repos` (or `/orgs/{org}/repos`) so agents don't
//! reach for raw API calls when the operator greenlights a new repo — the
//! instance has push-to-create disabled, so `git push` to a non-existent
//! repo 403s. Pairs with `clone` (then push your content) and
//! `repo-add-collaborator` (add peers to a fresh agent-namespace repo).
use anyhow::Result;
use clap::Args as ClapArgs;
use serde_json::{Value, json};
use crate::client::Client;
use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Repository name (required). Created under the authenticated user
/// unless `--org` is given.
name: String,
/// Repository description.
#[arg(long)]
description: Option<String>,
/// Create the repo as private (default: public).
#[arg(long)]
private: bool,
/// Default branch name (e.g. `main`). Forgejo applies it to the
/// initial commit, so it only takes effect alongside `--auto-init`.
#[arg(long = "default-branch")]
default_branch: Option<String>,
/// Create under this organisation (`POST /orgs/<org>/repos`) instead
/// of the authenticated user's namespace.
#[arg(long)]
org: Option<String>,
/// Seed an initial commit (README) so the repo is non-empty and can
/// be cloned immediately. Omit to create a bare repo you push into.
#[arg(long = "auto-init")]
auto_init: bool,
}
/// # Errors
///
/// Propagates any transport error from the Forgejo REST call (network
/// unreachable, 4xx/5xx response such as a name clash or insufficient
/// permission on the target namespace, token missing/invalid) and any
/// I/O error from writing the result to stdout.
pub fn run(client: &Client, args: Args) -> Result<()> {
let mut payload = json!({
"name": args.name,
"private": args.private,
"auto_init": args.auto_init,
});
if let Some(d) = args.description {
payload["description"] = json!(d);
}
if let Some(b) = args.default_branch {
payload["default_branch"] = json!(b);
}
let path = match args.org.as_deref() {
Some(org) => format!("/orgs/{org}/repos"),
None => "/user/repos".to_owned(),
};
let resp = client.post_json(&path, &payload)?;
if client.json_mode() {
return print_json(&resp);
}
// Default human path: print the web URL, like issue-create / pr-create.
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
println!("{url}");
}
Ok(())
}