diff --git a/docs/4-implementation/agent-tools/forge-cli.md b/docs/4-implementation/agent-tools/forge-cli.md index bfa05cba0..130b5611b 100644 --- a/docs/4-implementation/agent-tools/forge-cli.md +++ b/docs/4-implementation/agent-tools/forge-cli.md @@ -102,7 +102,7 @@ Deadline: hive-forge stops after HIVE_FORGE_DEADLINE seconds (default 900), prin * `comment-show` — Print the body (or full JSON) of a single comment by id * `comment-edit` — Edit an existing comment by id * `clone` — Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with credentials autoinjected. Pairs with `pr create --agit` -* `repo-create` — Create a forge repo under the current user (or `--org`). Prints the repo URL +* `repo-create` — Create a forge repo under the current user (or `--org`). Prints the repo URL. Agent accounts can't create repos (limit 0) * `repo-add-collaborator` — Add a collaborator to the active repo (`-r`/`HIVE_FORGE_REPO`) with a permission level. Companion to `repo-create` * `repo-labels` — List every label defined on the repo (name + description), optionally filtered by a name substring * `repo-search` — Search the forge for repositories by keyword, topic, or description @@ -1008,7 +1008,7 @@ Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with credentials autoinjecte ## `hive-forge repo-create` -Create a forge repo under the current user (or `--org`). Prints the repo URL +Create a forge repo under the current user (or `--org`). Prints the repo URL. Agent accounts can't create repos (limit 0) **Usage:** `hive-forge repo-create [OPTIONS] ` diff --git a/docs/4-implementation/agent-tools/forge.md b/docs/4-implementation/agent-tools/forge.md index 0b4615d64..49c42e676 100644 --- a/docs/4-implementation/agent-tools/forge.md +++ b/docs/4-implementation/agent-tools/forge.md @@ -224,16 +224,18 @@ plain comment show under `last comment`, not `reviews`. ### Repo management -Agents **can't create repos directly via forge token** — Forgejo -disables push-to-create and the agent token doesn't have the Create -scope. +Agent accounts can't create repos: swarm-controller sets their +repo-creation limit to 0 (see `docs/2-handbook/agents/security.md`), so +an operator merge stays the only path to main. Ask the operator to +create the repo (for example `agents/`) and add you as a +collaborator. Forgejo also disables push-to-create. -The CLI verbs below (`repo-create` / `repo-add-collaborator`) use the -agent's own forge token, so a repo lands under the agent's user account -or an org the agent belongs to. +`repo-create` is for accounts that can create repos (operators, +non-agent users). On an agent account it fails with a message naming +the limit and the operator path. **`repo-create `** — create a repo under the authenticated user -and print its URL. Key flags: +and print its URL. Not available to agent accounts. Key flags: - `--org ` — create under an org namespace instead - `--private` — private repo (default: public) diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 810c1d10f..acb8bd016 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -110,7 +110,7 @@ enum Verb { /// credentials autoinjected. Pairs with `pr create --agit`. Clone(verbs::clone::Args), /// Create a forge repo under the current user (or `--org`). Prints - /// the repo URL. + /// the repo URL. Agent accounts can't create repos (limit 0). RepoCreate(verbs::repo_create::Args), /// Add a collaborator to the active repo (`-r`/`HIVE_FORGE_REPO`) /// with a permission level. Companion to `repo-create`. diff --git a/hive-forge/src/verbs/repo_create.rs b/hive-forge/src/verbs/repo_create.rs index 63e30ec18..33fe7fb39 100644 --- a/hive-forge/src/verbs/repo_create.rs +++ b/hive-forge/src/verbs/repo_create.rs @@ -3,15 +3,19 @@ //! 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). +//! Wraps `POST /api/v1/user/repos` (or `/orgs/{org}/repos`). For accounts +//! that may create repos (operators, non-agent users). Agent accounts +//! cannot: swarm-controller sets their repo-creation limit to 0, and +//! Forgejo reports that as a bare 500 on the user path, which +//! `explain_create_error` turns into a message naming the operator path. +//! The instance has push-to-create disabled, so `git push` to a +//! non-existent repo 403s. Pairs with `clone` and `repo-add-collaborator`. use anyhow::Result; use clap::Args as ClapArgs; +use forgejo_api::ForgejoError; use forgejo_api::structs::CreateRepoOption; +use reqwest::StatusCode; use crate::client::Client; use crate::verbs::print_json; @@ -40,6 +44,25 @@ pub struct Args { auto_init: bool, } +/// Forgejo reports "user has reached maximum limit of repositories" as a +/// 500 the client keeps no body for, and `GET /user` does not expose +/// `max_repo_creation`, so a 500 on the user path is matched as "most +/// likely the creation limit". Every other error, and the org path, +/// passes through unchanged. +fn explain_create_error(org: Option<&str>, err: ForgejoError) -> anyhow::Error { + let at_limit = org.is_none() + && matches!(&err, ForgejoError::UnexpectedStatusCode(s) if *s == StatusCode::INTERNAL_SERVER_ERROR); + if !at_limit { + return err.into(); + } + anyhow::anyhow!( + "this account most likely cannot create repositories (the forge answered 500, which is \ + how Forgejo reports a repo-creation limit of 0). On hyperhive, agent accounts are \ + locked to 0 by design, so an operator merge stays the only path to main. Ask the \ + operator to create the repo (for example `agents/`) and add you as a collaborator." + ) +} + /// # Errors /// /// Propagates any transport error from the Forgejo REST call (network @@ -62,10 +85,12 @@ pub fn run(client: &Client, args: Args) -> Result<()> { trust_model: None, }; - let resp = match args.org.as_deref() { - Some(org) => client.api().create_org_repo(org, payload).send()?, - None => client.api().create_current_user_repo(payload).send()?, - }; + let org = args.org.as_deref(); + let resp = match org { + Some(org) => client.api().create_org_repo(org, payload).send(), + None => client.api().create_current_user_repo(payload).send(), + } + .map_err(|e| explain_create_error(org, e))?; if client.json_mode() { return print_json(&serde_json::to_value(&resp)?); @@ -76,3 +101,40 @@ pub fn run(client: &Client, args: Args) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn status(code: StatusCode) -> ForgejoError { + ForgejoError::UnexpectedStatusCode(code) + } + + #[test] + fn user_path_500_names_the_limit_and_the_operator_path() { + let msg = explain_create_error(None, status(StatusCode::INTERNAL_SERVER_ERROR)).to_string(); + assert!(msg.contains("cannot create repositories"), "{msg}"); + assert!(msg.contains("locked to 0"), "{msg}"); + assert!(msg.contains("add you as a collaborator"), "{msg}"); + } + + #[test] + fn other_user_path_errors_pass_through() { + for code in [ + StatusCode::FORBIDDEN, + StatusCode::CONFLICT, + StatusCode::BAD_GATEWAY, + ] { + let msg = explain_create_error(None, status(code)).to_string(); + assert_eq!(msg, status(code).to_string()); + } + } + + #[test] + fn org_path_errors_pass_through() { + for code in [StatusCode::FORBIDDEN, StatusCode::INTERNAL_SERVER_ERROR] { + let msg = explain_create_error(Some("acme"), status(code)).to_string(); + assert_eq!(msg, status(code).to_string()); + } + } +}