diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index ace4a3b3..2cae40b1 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -206,6 +206,23 @@ impl Client { decode_json(resp, &format!("PUT {url}")) } + /// PUT a JSON body to `/` 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(&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 `/`. 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<()> { diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 4f99bfb1..7b63d70c 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -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), diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index ad0d8fbd..578e1610 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -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; diff --git a/hive-forge/src/verbs/repo_add_collaborator.rs b/hive-forge/src/verbs/repo_add_collaborator.rs new file mode 100644 index 00000000..7fa24068 --- /dev/null +++ b/hive-forge/src/verbs/repo_add_collaborator.rs @@ -0,0 +1,63 @@ +//! `repo-add-collaborator [--permission read|write|admin] [-r ]` +//! — add `` 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(()) +} diff --git a/hive-forge/src/verbs/repo_create.rs b/hive-forge/src/verbs/repo_create.rs new file mode 100644 index 00000000..edaef007 --- /dev/null +++ b/hive-forge/src/verbs/repo_create.rs @@ -0,0 +1,77 @@ +//! `repo-create [--description ] [--private] [--default-branch ] +//! [--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, + /// 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, + /// Create under this organisation (`POST /orgs//repos`) instead + /// of the authenticated user's namespace. + #[arg(long)] + org: Option, + /// 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(()) +}