hive-forge: global -r/--repo flag instead of per-verb [repo] positional (mara@#407)

This commit is contained in:
damocles 2026-05-25 02:12:04 +02:00 committed by Mara
parent 76cf2ffd36
commit badf21714b
21 changed files with 46 additions and 85 deletions

View file

@ -30,10 +30,14 @@ pub struct Client {
impl Client {
/// Build a client from the standard environment variables.
pub fn from_env() -> Result<Self> {
/// `repo_override` (from the global `-r/--repo` flag) takes
/// priority over `HIVE_FORGE_REPO`; the env var is the fallback
/// default.
pub fn from_env(repo_override: Option<String>) -> Result<Self> {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let default_repo =
std::env::var("HIVE_FORGE_REPO").unwrap_or_else(|_| DEFAULT_REPO.to_owned());
let default_repo = repo_override
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
let token = read_token().context("read forge-token")?;
let mut headers = HeaderMap::new();
@ -59,9 +63,11 @@ impl Client {
format!("{}/api/v1", self.base)
}
/// Pick the user-supplied repo or fall back to the default.
pub fn repo<'a>(&'a self, override_: Option<&'a str>) -> &'a str {
override_.unwrap_or(&self.default_repo)
/// Return the active repo. `from_env` already folded the
/// `-r/--repo` override into `default_repo`, so verbs just read
/// it as-is — no per-verb override plumbing.
pub fn repo(&self) -> &str {
&self.default_repo
}
/// GET `<api>/<path>` and decode JSON.

View file

@ -30,6 +30,11 @@ use clap::{Parser, Subcommand};
disable_help_subcommand = true
)]
struct Cli {
/// Repo override (default from `HIVE_FORGE_REPO`).
/// Applies to any verb; replaces the per-verb `[repo]` trailing
/// positional the bash helper used.
#[arg(short = 'r', long, global = true)]
repo: Option<String>,
#[command(subcommand)]
verb: Verb,
}
@ -80,7 +85,7 @@ enum Verb {
fn main() -> Result<()> {
let cli = Cli::parse();
let client = client::Client::from_env().context("initialize forge client")?;
let client = client::Client::from_env(cli.repo).context("initialize forge client")?;
match cli.verb {
Verb::View(a) => verbs::view::run(&client, a),
Verb::Issue(a) => verbs::issue::run(&client, a),

View file

@ -19,12 +19,10 @@ pub struct Args {
/// Remove the user instead of adding.
#[arg(long)]
remove: bool,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
let mut assignees: Vec<String> = current
.get("assignees")

View file

@ -1,6 +1,6 @@
//! `attach-issue <number> <file> [repo]` and `attach-comment
//! <comment-id> <file> [repo]` — upload a file as an attachment.
//! Prints the browser download URL.
//! `attach-issue <number> <file>` and `attach-comment <comment-id>
//! <file>` — upload a file as an attachment. Prints the browser
//! download URL.
use std::path::PathBuf;
@ -16,8 +16,6 @@ pub struct IssueArgs {
number: u64,
/// File path to upload.
file: PathBuf,
/// Repo override.
repo: Option<String>,
}
#[derive(ClapArgs)]
@ -26,8 +24,6 @@ pub struct CommentArgs {
id: u64,
/// File path to upload.
file: PathBuf,
/// Repo override.
repo: Option<String>,
}
pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
@ -37,7 +33,7 @@ pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
args.file.display()
);
}
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.post_multipart_file(
&format!("/repos/{repo}/issues/{}/assets", args.number),
&args.file,
@ -53,7 +49,7 @@ pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> {
args.file.display()
);
}
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.post_multipart_file(
&format!("/repos/{repo}/issues/comments/{}/assets", args.id),
&args.file,

View file

@ -10,12 +10,10 @@ use crate::client::Client;
pub struct Args {
/// Substring pattern to filter branch names.
pattern: Option<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?;
let names: Vec<&str> = v
.as_array()

View file

@ -11,12 +11,10 @@ use crate::verbs::print_json;
pub struct Args {
/// Issue or PR number.
number: u64,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.patch_json(
&format!("/repos/{repo}/issues/{}", args.number),
&json!({ "state": "closed" }),

View file

@ -19,13 +19,11 @@ pub struct Args {
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve_required(args.body.as_deref(), args.body_file.as_deref(), "comment")?;
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.post_json(
&format!("/repos/{repo}/issues/{}/comments", args.number),
&json!({ "body": body }),

View file

@ -19,8 +19,6 @@ pub struct Args {
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
@ -29,7 +27,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
args.body_file.as_deref(),
"comment-edit",
)?;
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.patch_json(
&format!("/repos/{repo}/issues/comments/{}", args.id),
&json!({ "body": body }),

View file

@ -15,12 +15,10 @@ pub struct Args {
/// Print full JSON envelope instead of just the body text.
#[arg(long)]
json: bool,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?;
if args.json {
let trimmed = json!({

View file

@ -9,12 +9,10 @@ use crate::client::Client;
pub struct Args {
/// PR number.
number: u64,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
print!("{diff}");
Ok(())

View file

@ -11,12 +11,10 @@ use crate::verbs::print_json;
pub struct Args {
/// Issue number.
number: u64,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
let trimmed = json!({
"number": v.get("number"),

View file

@ -22,13 +22,11 @@ pub struct Args {
/// Initial assignee login.
#[arg(long)]
assignee: Option<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let mut payload = json!({ "title": args.title, "body": body });
if let Some(a) = args.assignee {
payload["assignees"] = json!([a]);

View file

@ -44,8 +44,6 @@ pub struct Args {
/// Milestone id (0 to unset).
#[arg(long)]
milestone: Option<u64>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
@ -73,7 +71,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
payload.insert("milestone".into(), Value::Number(m.into()));
}
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let resp = client.patch_json(
&format!("/repos/{repo}/issues/{}", args.number),
&Value::Object(payload),

View file

@ -33,7 +33,7 @@ enum Action {
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(None);
let repo = client.repo();
match args.action.unwrap_or(Action::List) {
Action::List => {
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;

View file

@ -17,10 +17,7 @@ pub struct Args {
#[derive(Subcommand)]
enum Action {
/// List open milestones as JSON.
List {
/// Repo override.
repo: Option<String>,
},
List,
/// Create a milestone, print {id,title}.
Create {
/// Milestone title.
@ -32,22 +29,18 @@ enum Action {
/// Due date YYYY-MM-DD.
#[arg(long)]
due: Option<String>,
/// Repo override.
repo: Option<String>,
},
/// Close a milestone by id.
Close {
/// Milestone id.
id: u64,
/// Repo override.
repo: Option<String>,
},
}
pub fn run(client: &Client, args: Args) -> Result<()> {
match args.action.unwrap_or(Action::List { repo: None }) {
Action::List { repo } => {
let repo = client.repo(repo.as_deref());
let repo = client.repo();
match args.action.unwrap_or(Action::List) {
Action::List => {
let v =
client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
let trimmed: Vec<Value> = v
@ -69,13 +62,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
.unwrap_or_default();
print_json(&Value::Array(trimmed))
}
Action::Create {
title,
desc,
due,
repo,
} => {
let repo = client.repo(repo.as_deref());
Action::Create { title, desc, due } => {
let mut payload = json!({ "title": title });
if let Some(d) = desc.filter(|s| !s.is_empty()) {
payload["description"] = Value::String(d);
@ -89,8 +76,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
"title": resp.get("title"),
}))
}
Action::Close { id, repo } => {
let repo = client.repo(repo.as_deref());
Action::Close { id } => {
let resp = client.patch_json(
&format!("/repos/{repo}/milestones/{id}"),
&json!({ "state": "closed" }),

View file

@ -11,12 +11,10 @@ use crate::verbs::print_json;
pub struct Args {
/// PR number.
number: u64,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?;
let trimmed = json!({
"number": v.get("number"),

View file

@ -28,13 +28,11 @@ pub struct Args {
/// Open as draft.
#[arg(long)]
draft: bool,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let payload = json!({
"title": args.title,
"head": args.head,

View file

@ -11,12 +11,10 @@ use crate::verbs::print_json;
pub struct Args {
/// PR number.
number: u64,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/pulls/{}/reviews", args.number))?;
let trimmed: Vec<Value> = v
.as_array()

View file

@ -19,12 +19,10 @@ pub struct Args {
/// Unsubscribe (clear watch + ignore).
#[arg(long, group = "action")]
unwatch: bool,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
if args.unwatch {
client.delete(&format!("/repos/{repo}/subscription"), None)?;
println!("unsubscribed");

View file

@ -11,12 +11,10 @@ use crate::client::Client;
pub struct Args {
/// Branch name or commit SHA.
reference: String,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
// Try branch first; the bash helper silently treats failure as
// "not a branch, use the reference as a commit sha directly".
let commit_sha = match client.get_json(&format!("/repos/{repo}/branches/{}", args.reference)) {

View file

@ -11,12 +11,10 @@ use crate::client::Client;
pub struct Args {
/// Issue or PR number.
number: u64,
/// Repo override (default from `HIVE_FORGE_REPO`).
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let repo = client.repo();
let issue = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
let title = issue.get("title").and_then(Value::as_str).unwrap_or("");
let body = issue.get("body").and_then(Value::as_str).unwrap_or("");