hive-forge: rewrite bash CLI helper as a rust binary (closes #280)
This commit is contained in:
parent
560360d2e3
commit
595e3c040c
28 changed files with 1434 additions and 612 deletions
58
hive-forge/src/verbs/assign.rs
Normal file
58
hive-forge/src/verbs/assign.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! `assign <number> <user> [--remove]` — add or remove a user from an
|
||||
//! issue/PR's assignee list. Forgejo has no dedicated POST endpoint —
|
||||
//! we read the current list, mutate, and PATCH the issue back (closes
|
||||
//! #353's "no such endpoint" trap; matches the bash helper's logic).
|
||||
|
||||
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 {
|
||||
/// Issue or PR number.
|
||||
number: u64,
|
||||
/// User login to assign (or unassign with `--remove`).
|
||||
user: String,
|
||||
/// Remove the user instead of adding.
|
||||
#[arg(long)]
|
||||
remove: bool,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo(None);
|
||||
let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||
let mut assignees: Vec<String> = current
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|u| u.get("login").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if args.remove {
|
||||
assignees.retain(|u| u != &args.user);
|
||||
} else if !assignees.contains(&args.user) {
|
||||
assignees.push(args.user.clone());
|
||||
}
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/{}", args.number),
|
||||
&json!({ "assignees": assignees }),
|
||||
)?;
|
||||
let logins: Vec<&str> = resp
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|u| u.get("login").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
print_json(&json!({
|
||||
"number": resp.get("number"),
|
||||
"assignees": logins,
|
||||
}))
|
||||
}
|
||||
69
hive-forge/src/verbs/attach.rs
Normal file
69
hive-forge/src/verbs/attach.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//! `attach-issue <number> <file> [repo]` and `attach-comment
|
||||
//! <comment-id> <file> [repo]` — upload a file as an attachment.
|
||||
//! Prints the browser download URL.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct IssueArgs {
|
||||
/// Issue number.
|
||||
number: u64,
|
||||
/// File path to upload.
|
||||
file: PathBuf,
|
||||
/// Repo override.
|
||||
repo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct CommentArgs {
|
||||
/// Comment id.
|
||||
id: u64,
|
||||
/// File path to upload.
|
||||
file: PathBuf,
|
||||
/// Repo override.
|
||||
repo: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
|
||||
if !args.file.is_file() {
|
||||
bail!(
|
||||
"hive-forge attach-issue: file not found: {}",
|
||||
args.file.display()
|
||||
);
|
||||
}
|
||||
let repo = client.repo(args.repo.as_deref());
|
||||
let resp = client.post_multipart_file(
|
||||
&format!("/repos/{repo}/issues/{}/assets", args.number),
|
||||
&args.file,
|
||||
)?;
|
||||
print_url(&resp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> {
|
||||
if !args.file.is_file() {
|
||||
bail!(
|
||||
"hive-forge attach-comment: file not found: {}",
|
||||
args.file.display()
|
||||
);
|
||||
}
|
||||
let repo = client.repo(args.repo.as_deref());
|
||||
let resp = client.post_multipart_file(
|
||||
&format!("/repos/{repo}/issues/comments/{}/assets", args.id),
|
||||
&args.file,
|
||||
)?;
|
||||
print_url(&resp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_url(v: &Value) {
|
||||
if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) {
|
||||
println!("{url}");
|
||||
}
|
||||
}
|
||||
34
hive-forge/src/verbs/branches.rs
Normal file
34
hive-forge/src/verbs/branches.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! `branches [pattern] [repo]` — list branches, optionally filtered.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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 v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?;
|
||||
let names: Vec<&str> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|b| b.get("name").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for n in names {
|
||||
if args.pattern.as_deref().is_none_or(|p| n.contains(p)) {
|
||||
println!("{n}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
28
hive-forge/src/verbs/close.rs
Normal file
28
hive-forge/src/verbs/close.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
//! `close <number>` — close an issue or PR.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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 resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/{}", args.number),
|
||||
&json!({ "state": "closed" }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"number": resp.get("number"),
|
||||
"state": resp.get("state"),
|
||||
}))
|
||||
}
|
||||
37
hive-forge/src/verbs/comment.rs
Normal file
37
hive-forge/src/verbs/comment.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! `comment <number> [body sources] [repo]` — post a comment on an
|
||||
//! issue or PR.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::body;
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue or PR number.
|
||||
number: u64,
|
||||
/// Inline body text.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// 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 resp = client.post_json(
|
||||
&format!("/repos/{repo}/issues/{}/comments", args.number),
|
||||
&json!({ "body": body }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"url": resp.get("html_url"),
|
||||
}))
|
||||
}
|
||||
42
hive-forge/src/verbs/comment_edit.rs
Normal file
42
hive-forge/src/verbs/comment_edit.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! `comment-edit <id> [body sources] [repo]` — edit an existing
|
||||
//! comment by id.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::body;
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Comment id.
|
||||
id: u64,
|
||||
/// Inline body text.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// 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-edit",
|
||||
)?;
|
||||
let repo = client.repo(args.repo.as_deref());
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/comments/{}", args.id),
|
||||
&json!({ "body": body }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"user": resp.get("user").and_then(|u| u.get("login")),
|
||||
"url": resp.get("html_url"),
|
||||
}))
|
||||
}
|
||||
40
hive-forge/src/verbs/comment_show.rs
Normal file
40
hive-forge/src/verbs/comment_show.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! `comment-show <id> [--json] [repo]` — print the body (or full
|
||||
//! JSON) of a single comment by id.
|
||||
|
||||
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 {
|
||||
/// Comment id.
|
||||
id: u64,
|
||||
/// 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 v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?;
|
||||
if args.json {
|
||||
let trimmed = json!({
|
||||
"id": v.get("id"),
|
||||
"user": v.get("user").and_then(|u| u.get("login")),
|
||||
"created_at": v.get("created_at"),
|
||||
"updated_at": v.get("updated_at"),
|
||||
"body": v.get("body"),
|
||||
"url": v.get("html_url"),
|
||||
});
|
||||
print_json(&trimmed)
|
||||
} else {
|
||||
let body = v.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
println!("{body}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
21
hive-forge/src/verbs/diff.rs
Normal file
21
hive-forge/src/verbs/diff.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! `diff <pr> [repo]` — print the unified diff for a PR.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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 diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
|
||||
print!("{diff}");
|
||||
Ok(())
|
||||
}
|
||||
37
hive-forge/src/verbs/issue.rs
Normal file
37
hive-forge/src/verbs/issue.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! `issue <number> [repo]` — print key fields of an issue as JSON.
|
||||
|
||||
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 {
|
||||
/// 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 v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||
let trimmed = json!({
|
||||
"number": v.get("number"),
|
||||
"title": v.get("title"),
|
||||
"state": v.get("state"),
|
||||
"user": v.get("user").and_then(|u| u.get("login")),
|
||||
"assignees": v.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default(),
|
||||
"labels": v.get("labels")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default(),
|
||||
"body": v.get("body"),
|
||||
});
|
||||
print_json(&trimmed)
|
||||
}
|
||||
41
hive-forge/src/verbs/issue_create.rs
Normal file
41
hive-forge/src/verbs/issue_create.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! `issue-create --title <t> [body sources] [--assignee <u>] [repo]`
|
||||
//! — create an issue. Prints the issue URL.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::body;
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue title (required).
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
/// Inline body text.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Read body from a file. `-` means stdin.
|
||||
#[arg(long = "body-file")]
|
||||
body_file: Option<String>,
|
||||
/// 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 mut payload = json!({ "title": args.title, "body": body });
|
||||
if let Some(a) = args.assignee {
|
||||
payload["assignees"] = json!([a]);
|
||||
}
|
||||
let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?;
|
||||
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||
println!("{url}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
87
hive-forge/src/verbs/issue_edit.rs
Normal file
87
hive-forge/src/verbs/issue_edit.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! `issue-edit <number> [--title <t>] [body sources] [--state s]
|
||||
//! [--milestone id] [repo]` — partial update of an issue. Fields not
|
||||
//! provided are left unchanged.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args as ClapArgs, ValueEnum};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::body;
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(Copy, Clone, ValueEnum)]
|
||||
pub enum StateArg {
|
||||
Open,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl StateArg {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Open => "open",
|
||||
Self::Closed => "closed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue number.
|
||||
number: u64,
|
||||
/// New title (omit to leave unchanged).
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
/// Inline body text (omit to leave unchanged).
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Read body from a file. `-` means stdin.
|
||||
#[arg(long = "body-file")]
|
||||
body_file: Option<String>,
|
||||
/// New state.
|
||||
#[arg(long, value_enum)]
|
||||
state: Option<StateArg>,
|
||||
/// Milestone id (0 to unset).
|
||||
#[arg(long)]
|
||||
milestone: Option<u64>,
|
||||
/// Repo override.
|
||||
repo: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
// Body is partial: only update the body field if a source was
|
||||
// actually given. Piped stdin without --body/--body-file leaves
|
||||
// body alone (the partial-update contract).
|
||||
let body_explicit = args.body.is_some() || args.body_file.is_some();
|
||||
let body = if body_explicit {
|
||||
body::resolve(args.body.as_deref(), args.body_file.as_deref())?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut payload = Map::new();
|
||||
if let Some(t) = args.title {
|
||||
payload.insert("title".into(), Value::String(t));
|
||||
}
|
||||
if let Some(b) = body {
|
||||
payload.insert("body".into(), Value::String(b));
|
||||
}
|
||||
if let Some(s) = args.state {
|
||||
payload.insert("state".into(), Value::String(s.as_str().to_owned()));
|
||||
}
|
||||
if let Some(m) = args.milestone {
|
||||
payload.insert("milestone".into(), Value::Number(m.into()));
|
||||
}
|
||||
|
||||
let repo = client.repo(args.repo.as_deref());
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/{}", args.number),
|
||||
&Value::Object(payload),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"number": resp.get("number"),
|
||||
"title": resp.get("title"),
|
||||
"state": resp.get("state"),
|
||||
"milestone": resp.get("milestone").and_then(|m| m.get("title")),
|
||||
}))
|
||||
}
|
||||
115
hive-forge/src/verbs/labels.rs
Normal file
115
hive-forge/src/verbs/labels.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! `labels <number> [list|add|remove] [labels...]` — manage labels on
|
||||
//! an issue or PR. Default action: list.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args as ClapArgs, Subcommand};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue or PR number.
|
||||
number: u64,
|
||||
#[command(subcommand)]
|
||||
action: Option<Action>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// List labels (default when no action is given).
|
||||
List,
|
||||
/// Add labels by name.
|
||||
Add {
|
||||
/// Label names to add.
|
||||
labels: Vec<String>,
|
||||
},
|
||||
/// Remove labels by name.
|
||||
Remove {
|
||||
/// Label names to remove.
|
||||
labels: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo(None);
|
||||
match args.action.unwrap_or(Action::List) {
|
||||
Action::List => {
|
||||
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
|
||||
print_label_names(&labels);
|
||||
}
|
||||
Action::Add { labels } => {
|
||||
if labels.is_empty() {
|
||||
bail!("hive-forge labels add: pass at least one label name");
|
||||
}
|
||||
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?;
|
||||
let ids = resolve_ids(&all, &labels);
|
||||
let resp = client.post_json(
|
||||
&format!("/repos/{repo}/issues/{}/labels", args.number),
|
||||
&json!({ "labels": ids }),
|
||||
)?;
|
||||
print_label_names(&resp);
|
||||
}
|
||||
Action::Remove { labels } => {
|
||||
if labels.is_empty() {
|
||||
bail!("hive-forge labels remove: pass at least one label name");
|
||||
}
|
||||
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?;
|
||||
for name in &labels {
|
||||
if let Some(id) = lookup_id(&all, name) {
|
||||
let _ = client.delete(
|
||||
&format!("/repos/{repo}/issues/{}/labels/{id}", args.number),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
|
||||
print_label_names(&labels);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_ids(all: &Value, names: &[String]) -> Vec<u64> {
|
||||
let Some(arr) = all.as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
arr.iter().find_map(|l| {
|
||||
let lname = l.get("name").and_then(Value::as_str)?;
|
||||
if lname == n {
|
||||
l.get("id").and_then(Value::as_u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lookup_id(all: &Value, name: &str) -> Option<u64> {
|
||||
let arr = all.as_array()?;
|
||||
arr.iter().find_map(|l| {
|
||||
let lname = l.get("name").and_then(Value::as_str)?;
|
||||
if lname == name {
|
||||
l.get("id").and_then(Value::as_u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn print_label_names(v: &Value) {
|
||||
let names: Vec<&str> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|l| l.get("name").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = print_json(&json!(names));
|
||||
}
|
||||
105
hive-forge/src/verbs/milestone.rs
Normal file
105
hive-forge/src/verbs/milestone.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! `milestone list|create|close` — manage milestones. Default action:
|
||||
//! list.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args as ClapArgs, Subcommand};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Option<Action>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// List open milestones as JSON.
|
||||
List {
|
||||
/// Repo override.
|
||||
repo: Option<String>,
|
||||
},
|
||||
/// Create a milestone, print {id,title}.
|
||||
Create {
|
||||
/// Milestone title.
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
/// Description.
|
||||
#[arg(long)]
|
||||
desc: Option<String>,
|
||||
/// 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 v =
|
||||
client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
|
||||
let trimmed: Vec<Value> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.get("id"),
|
||||
"title": m.get("title"),
|
||||
"open_issues": m.get("open_issues"),
|
||||
"closed_issues": m.get("closed_issues"),
|
||||
"due_on": m.get("due_on"),
|
||||
"description": m.get("description"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
print_json(&Value::Array(trimmed))
|
||||
}
|
||||
Action::Create {
|
||||
title,
|
||||
desc,
|
||||
due,
|
||||
repo,
|
||||
} => {
|
||||
let repo = client.repo(repo.as_deref());
|
||||
let mut payload = json!({ "title": title });
|
||||
if let Some(d) = desc.filter(|s| !s.is_empty()) {
|
||||
payload["description"] = Value::String(d);
|
||||
}
|
||||
if let Some(d) = due.filter(|s| !s.is_empty()) {
|
||||
payload["due_on"] = Value::String(format!("{d}T00:00:00Z"));
|
||||
}
|
||||
let resp = client.post_json(&format!("/repos/{repo}/milestones"), &payload)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"title": resp.get("title"),
|
||||
}))
|
||||
}
|
||||
Action::Close { id, repo } => {
|
||||
let repo = client.repo(repo.as_deref());
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/milestones/{id}"),
|
||||
&json!({ "state": "closed" }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"title": resp.get("title"),
|
||||
"state": resp.get("state"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
35
hive-forge/src/verbs/mod.rs
Normal file
35
hive-forge/src/verbs/mod.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! Per-verb subcommand modules. Each module exposes a `Args` struct
|
||||
//! (clap-derived) and a `run` fn taking `(&Client, Args) -> Result<()>`.
|
||||
//! Splitting one verb per module keeps each handler small and avoids
|
||||
//! the bash script's monolithic `case` statement.
|
||||
|
||||
pub mod assign;
|
||||
pub mod attach;
|
||||
pub mod branches;
|
||||
pub mod close;
|
||||
pub mod comment;
|
||||
pub mod comment_edit;
|
||||
pub mod comment_show;
|
||||
pub mod diff;
|
||||
pub mod issue;
|
||||
pub mod issue_create;
|
||||
pub mod issue_edit;
|
||||
pub mod labels;
|
||||
pub mod milestone;
|
||||
pub mod pr;
|
||||
pub mod pr_create;
|
||||
pub mod pr_reviews;
|
||||
pub mod subscription;
|
||||
pub mod tree_sha;
|
||||
pub mod view;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
|
||||
/// matching the bash script's `| jq` output shape.
|
||||
pub(crate) fn print_json(v: &Value) -> Result<()> {
|
||||
let s = serde_json::to_string_pretty(v)?;
|
||||
println!("{s}");
|
||||
Ok(())
|
||||
}
|
||||
32
hive-forge/src/verbs/pr.rs
Normal file
32
hive-forge/src/verbs/pr.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! `pr <number> [repo]` — print key fields of a PR as JSON.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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 v = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?;
|
||||
let trimmed = json!({
|
||||
"number": v.get("number"),
|
||||
"title": v.get("title"),
|
||||
"state": v.get("state"),
|
||||
"merged": v.get("merged"),
|
||||
"user": v.get("user").and_then(|u| u.get("login")),
|
||||
"head_sha": v.get("head").and_then(|h| h.get("sha")),
|
||||
"head_branch": v.get("head").and_then(|h| h.get("label")),
|
||||
"base_branch": v.get("base").and_then(|b| b.get("label")),
|
||||
});
|
||||
print_json(&trimmed)
|
||||
}
|
||||
51
hive-forge/src/verbs/pr_create.rs
Normal file
51
hive-forge/src/verbs/pr_create.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//! `pr-create --title <t> --head <branch> [--base <b>] [body sources]
|
||||
//! [--draft] [repo]` — create a PR. Prints the PR URL.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::body;
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// PR title.
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
/// Head branch.
|
||||
#[arg(long)]
|
||||
head: String,
|
||||
/// Base branch (default: main).
|
||||
#[arg(long, default_value = "main")]
|
||||
base: String,
|
||||
/// Inline body text.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Read body from a file. `-` means stdin.
|
||||
#[arg(long = "body-file")]
|
||||
body_file: Option<String>,
|
||||
/// 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 payload = json!({
|
||||
"title": args.title,
|
||||
"head": args.head,
|
||||
"base": args.base,
|
||||
"body": body,
|
||||
"draft": args.draft,
|
||||
"allow_maintainer_edit": true,
|
||||
});
|
||||
let resp = client.post_json(&format!("/repos/{repo}/pulls"), &payload)?;
|
||||
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||
println!("{url}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
38
hive-forge/src/verbs/pr_reviews.rs
Normal file
38
hive-forge/src/verbs/pr_reviews.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! `pr-reviews <number> [repo]` — list PR reviews with id/state/user/body.
|
||||
|
||||
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 {
|
||||
/// 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 v = client.get_json(&format!("/repos/{repo}/pulls/{}/reviews", args.number))?;
|
||||
let trimmed: Vec<Value> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.get("id"),
|
||||
"state": r.get("state"),
|
||||
"user": r.get("user").and_then(|u| u.get("login")),
|
||||
"body": r.get("body"),
|
||||
"comments_count": r.get("comments_count"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
print_json(&Value::Array(trimmed))
|
||||
}
|
||||
49
hive-forge/src/verbs/subscription.rs
Normal file
49
hive-forge/src/verbs/subscription.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! `subscription [--watch|--ignore|--unwatch] [repo]` — get or set
|
||||
//! the current user's watch subscription for a 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 {
|
||||
/// Subscribe (watch the repo).
|
||||
#[arg(long, group = "action")]
|
||||
watch: bool,
|
||||
/// Mute (mark ignored).
|
||||
#[arg(long, group = "action")]
|
||||
ignore: bool,
|
||||
/// 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());
|
||||
if args.unwatch {
|
||||
client.delete(&format!("/repos/{repo}/subscription"), None)?;
|
||||
println!("unsubscribed");
|
||||
return Ok(());
|
||||
}
|
||||
if args.watch || args.ignore {
|
||||
let (subscribed, ignored) = if args.ignore { (false, true) } else { (true, false) };
|
||||
let resp = client.post_json(
|
||||
&format!("/repos/{repo}/subscription"),
|
||||
&json!({ "subscribed": subscribed, "ignored": ignored }),
|
||||
)?;
|
||||
return print_json(&json!({
|
||||
"subscribed": resp.get("subscribed"),
|
||||
"ignored": resp.get("ignored"),
|
||||
}));
|
||||
}
|
||||
let resp: Value = client.get_json(&format!("/repos/{repo}/subscription"))?;
|
||||
print_json(&json!({
|
||||
"subscribed": resp.get("subscribed"),
|
||||
"ignored": resp.get("ignored"),
|
||||
}))
|
||||
}
|
||||
40
hive-forge/src/verbs/tree_sha.rs
Normal file
40
hive-forge/src/verbs/tree_sha.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! `tree-sha <ref> [repo]` — print the tree SHA of the commit at a
|
||||
//! branch name or commit SHA.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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());
|
||||
// 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)) {
|
||||
Ok(v) => v
|
||||
.get("commit")
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&args.reference)
|
||||
.to_owned(),
|
||||
Err(_) => args.reference.clone(),
|
||||
};
|
||||
let commit = client.get_json(&format!("/repos/{repo}/git/commits/{commit_sha}"))?;
|
||||
let sha = commit
|
||||
.get("tree")
|
||||
.and_then(|t| t.get("sha"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| commit.get("sha").and_then(Value::as_str))
|
||||
.unwrap_or("");
|
||||
println!("{sha}");
|
||||
Ok(())
|
||||
}
|
||||
62
hive-forge/src/verbs/view.rs
Normal file
62
hive-forge/src/verbs/view.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! `view <number> [repo]` — dump title + body + all comments as
|
||||
//! markdown.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
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 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("");
|
||||
let state = issue.get("state").and_then(Value::as_str).unwrap_or("?");
|
||||
let user = issue
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let kind = if issue.get("pull_request").is_some_and(|p| !p.is_null()) {
|
||||
"PR"
|
||||
} else {
|
||||
"issue"
|
||||
};
|
||||
println!("# {kind} #{} [{state}] by {user}", args.number);
|
||||
println!("{title}");
|
||||
if !body.is_empty() {
|
||||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
let comments = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{}/comments?limit=50",
|
||||
args.number
|
||||
))?;
|
||||
let arr = comments.as_array().cloned().unwrap_or_default();
|
||||
if !arr.is_empty() {
|
||||
println!();
|
||||
println!("---");
|
||||
println!("## Comments ({})", arr.len());
|
||||
println!();
|
||||
for c in &arr {
|
||||
let cu = c
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let cb = c.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
println!("**{cu}**: {cb}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue