diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 145a5541..50aaaaa0 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -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 ` / `pr-create --label ` are + repeatable and take the same spelling `labels 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. diff --git a/hive-forge/src/verbs/issue_create.rs b/hive-forge/src/verbs/issue_create.rs index 6783cfe2..79c5b3e0 100644 --- a/hive-forge/src/verbs/issue_create.rs +++ b/hive-forge/src/verbs/issue_create.rs @@ -1,5 +1,5 @@ -//! `issue-create --title [body sources] [--assignee ] [repo]` -//! — create an issue. Prints the issue URL. +//! `issue-create --title [body sources] [--assignee ] +//! [--label ]... [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, + /// 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, } /// # 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, diff --git a/hive-forge/src/verbs/labels.rs b/hive-forge/src/verbs/labels.rs index 169d1986..8a8c067e 100644 --- a/hive-forge/src/verbs/labels.rs +++ b/hive-forge/src/verbs/labels.rs @@ -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> { +/// `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> { let (owner, name) = client.owner_repo()?; let (_, labels) = client .api() @@ -101,7 +104,10 @@ fn repo_labels(client: &Client) -> Result> { Ok(labels) } -fn resolve_ids(all: &[Label], names: &[String]) -> Vec { +/// 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 { names.iter().filter_map(|n| lookup_id(all, n)).collect() } diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index 45545d4e..6ff0fd3e 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -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, + /// 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, } /// # 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 add ..." + ); + return Ok(()); + }; + let (owner, name) = client.owner_repo()?; + let all = labels::repo_labels(client)?; + let ids: Vec = 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/`). Returns /// `None` if the last path segment isn't a number. fn pr_number_from_url(url: &str) -> Option {