hive-forge: add --label to issue-create and pr-create

This commit is contained in:
damocles 2026-07-27 15:54:39 +02:00 committed by mara
commit abde71b0ed
4 changed files with 94 additions and 9 deletions

View file

@ -48,9 +48,11 @@ hive-forge assign 42 damocles
hive-forge close 42
hive-forge labels 42 add feature
hive-forge issue-create --title "..." --body "..."
hive-forge issue-create --title "..." --body "..." --label area/ops --label type/bug # repeatable
hive-forge issue-edit 42 --title "new title"
hive-forge pr 42 # PR metadata as JSON
hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`
hive-forge pr-create --title "..." --head my-branch --label area/ops # repeatable, same as issue-create
hive-forge pr-reviews 42 # list reviews; inline comments included per review
hive-forge pr-reviews 42 --approve # submit APPROVED review
hive-forge pr-reviews 42 --request-changes -m "msg" # submit REQUEST_CHANGES review
@ -266,3 +268,12 @@ to discover valid label names before triaging or to audit the label set.
workflow — there is no single-job variant.
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
error checking, and output formatting.
- `issue-create --label <name>` / `pr-create --label <name>` are
repeatable and take the same spelling `labels <n> add` does. Unknown
names are silently dropped (matching `labels add`'s existing
behavior) rather than erroring, so a typo just means the label
doesn't land — check `hive-forge repo-labels` if one's missing. On
`pr-create --agit`, labels are applied as a follow-up call once the
PR number is parsed back out of the push output (the AGit push
itself has no label field), so they're silently skipped if that
parse fails — same fallback as the deferred multi-line body.

View file

@ -1,5 +1,5 @@
//! `issue-create --title <t> [body sources] [--assignee <u>] [repo]`
//! — create an issue. Prints the issue URL.
//! `issue-create --title <t> [body sources] [--assignee <u>]
//! [--label <name>]... [repo]` — create an issue. Prints the issue URL.
use anyhow::Result;
use clap::Args as ClapArgs;
@ -7,6 +7,7 @@ use forgejo_api::structs::CreateIssueOption;
use crate::body;
use crate::client::Client;
use crate::verbs::labels;
#[derive(ClapArgs)]
pub struct Args {
@ -22,24 +23,36 @@ pub struct Args {
/// Initial assignee login.
#[arg(long)]
assignee: Option<String>,
/// Label name to attach, repeatable (e.g. `--label area/ops --label
/// type/bug`). Same spelling `labels add` accepts. Unknown names are
/// silently dropped, matching `labels add`'s existing behavior.
#[arg(long = "label")]
labels: Vec<String>,
}
/// # Errors
///
/// Propagates any I/O error from the body input (`--body-file`,
/// stdin), any transport error from the Forgejo REST call (network
/// unreachable, 4xx/5xx response, token missing/invalid), and any
/// I/O error from writing the issue URL to stdout.
/// unreachable, 4xx/5xx response, token missing/invalid, the
/// `--label` lookup's own list-labels call), and any I/O error from
/// writing the issue URL to stdout.
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 (owner, name) = client.owner_repo()?;
let label_ids = if args.labels.is_empty() {
None
} else {
let all = labels::repo_labels(client)?;
Some(labels::resolve_ids(&all, &args.labels))
};
let payload = CreateIssueOption {
assignee: None,
assignees: args.assignee.map(|a| vec![a]),
body: Some(body),
closed: None,
due_date: None,
labels: None,
labels: label_ids,
milestone: None,
r#ref: None,
title: args.title,

View file

@ -91,7 +91,10 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
}
/// First page (100) of the repo's label set, for name → id resolution.
fn repo_labels(client: &Client) -> Result<Vec<Label>> {
/// `pub(crate)` so other creation verbs (`issue-create`, `pr-create`) can
/// resolve a `--label` name to the id the create-payload structs need
/// without duplicating the lookup.
pub(crate) fn repo_labels(client: &Client) -> Result<Vec<Label>> {
let (owner, name) = client.owner_repo()?;
let (_, labels) = client
.api()
@ -101,7 +104,10 @@ fn repo_labels(client: &Client) -> Result<Vec<Label>> {
Ok(labels)
}
fn resolve_ids(all: &[Label], names: &[String]) -> Vec<i64> {
/// Resolve label names to ids, silently dropping any name that doesn't
/// match a repo label (same behavior as `labels add`/`remove` above —
/// keeps this one non-fatal-typo policy in one place).
pub(crate) fn resolve_ids(all: &[Label], names: &[String]) -> Vec<i64> {
names.iter().filter_map(|n| lookup_id(all, n)).collect()
}

View file

@ -25,10 +25,12 @@ use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use forgejo_api::structs::{CreatePullRequestOption, EditIssueOption};
use forgejo_api::structs::{CreatePullRequestOption, EditIssueOption, IssueLabelsOption};
use serde_json::json;
use crate::body;
use crate::client::{Client, index};
use crate::verbs::labels;
#[derive(ClapArgs)]
pub struct Args {
@ -66,6 +68,14 @@ pub struct Args {
/// meaningful with `--agit`.
#[arg(long)]
topic: Option<String>,
/// Label name to attach, repeatable (e.g. `--label area/ops --label
/// type/bug`). Same spelling `labels add` accepts. In `--agit` mode
/// this is applied as a follow-up call once the PR number is known
/// (the `AGit` push itself has no label field), so it's silently
/// skipped if the PR URL couldn't be parsed back out of the push
/// output — same fallback as the deferred multi-line body.
#[arg(long = "label")]
labels: Vec<String>,
}
/// # Errors
@ -91,6 +101,12 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
push_branch(remote, head)?;
}
let (owner, name) = client.owner_repo()?;
let label_ids = if args.labels.is_empty() {
None
} else {
let all = labels::repo_labels(client)?;
Some(labels::resolve_ids(&all, &args.labels))
};
// Note: Forgejo's CreatePullRequestOption has no `draft` /
// `allow_maintainer_edit` fields (verified against the instance's
// swagger) — the raw client used to send both and the server
@ -102,7 +118,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
body: Some(body),
due_date: None,
head: Some(head.to_owned()),
labels: None,
labels: label_ids,
milestone: None,
title: Some(args.title.clone()),
};
@ -210,10 +226,49 @@ fn agit_create(client: &Client, args: &Args, body: &str) -> Result<()> {
);
}
}
if !args.labels.is_empty() {
apply_agit_labels(client, &args.labels, &url)?;
}
println!("{url}");
Ok(())
}
/// Follow-up label-add call for an `--agit`-created PR: the `AGit` push
/// itself has no label field, so this mirrors the deferred-body PATCH —
/// resolve the PR number back out of the parsed URL, then add the
/// resolved label ids via the same `issue_add_label` call `labels add`
/// uses. Warns (doesn't fail the whole command) if the PR number
/// couldn't be parsed, same fallback as the deferred body.
fn apply_agit_labels(client: &Client, names: &[String], url: &str) -> Result<()> {
let Some(number) = pr_number_from_url(url) else {
eprintln!(
"warning: --label NOT applied — could not parse PR number from {url}. \
Set it manually: hive-forge labels <pr#> add <name>..."
);
return Ok(());
};
let (owner, name) = client.owner_repo()?;
let all = labels::repo_labels(client)?;
let ids: Vec<serde_json::Value> = labels::resolve_ids(&all, names)
.into_iter()
.map(|id| json!(id))
.collect();
client
.api()
.issue_add_label(
owner,
name,
index(number)?,
IssueLabelsOption {
labels: Some(ids),
updated_at: None,
},
)
.send()
.with_context(|| format!("set labels on AGit PR #{number}"))?;
Ok(())
}
/// Extract the PR index from a Forgejo PR URL (`…/pulls/<n>`). Returns
/// `None` if the last path segment isn't a number.
fn pr_number_from_url(url: &str) -> Option<u64> {