From 8f9c77df060c8c632087a381d70b23b508f57ff2 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 13:48:17 +0200 Subject: [PATCH] =?UTF-8?q?hive-forge:=20list=20verb=20=E2=80=94=20filter?= =?UTF-8?q?=20issues/PRs=20by=20kind/state/assignee/author/label=20(#694?= =?UTF-8?q?=20part=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- hive-ag3nt/prompts/system.md | 2 +- hive-forge/src/main.rs | 5 + hive-forge/src/verbs/list.rs | 215 +++++++++++++++++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 1 + 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 hive-forge/src/verbs/list.rs diff --git a/CLAUDE.md b/CLAUDE.md index cb5d65c1..85486055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,7 +220,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary) src/verbs/.rs one module per verb (view, issue, pr, comment, comments, comment-show, comment-edit, issue-create, issue-edit, pr-create, pr-reviews, assign, - close, labels, milestone, branches, + close, labels, list, milestone, branches, tree-sha, diff, subscription, attach-issue, attach-comment, lint). Replaces the 600-line hive-forge-tools.nix bash script (closes #280). diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 1014f161..0ef2fe0d 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -130,7 +130,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`). -The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. +The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent. diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index e26a06f4..375a7111 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -75,6 +75,10 @@ enum Verb { Labels(verbs::labels::Args), /// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments). Lint(verbs::lint::Args), + /// List issues / PRs with filters (`--kind`, `--state`, `--assignee`, + /// `--author`, `--label`, `--limit`). Pretty rows by default; pass + /// `--json` for raw JSON. + List(verbs::list::Args), /// Manage milestones (list / create / close). Milestone(verbs::milestone::Args), /// List reviews on a PR. @@ -112,6 +116,7 @@ fn main() -> Result<()> { Verb::Close(a) => verbs::close::run(&client, a), Verb::Labels(a) => verbs::labels::run(&client, a), Verb::Lint(a) => verbs::lint::run(&client, a), + Verb::List(a) => verbs::list::run(&client, a), Verb::Milestone(a) => verbs::milestone::run(&client, a), Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a), Verb::Branches(a) => verbs::branches::run(&client, a), diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs new file mode 100644 index 00000000..605858f9 --- /dev/null +++ b/hive-forge/src/verbs/list.rs @@ -0,0 +1,215 @@ +//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee +//! ] [--author ] [--label ] [--limit N]` — list +//! issues / PRs with filters. Pretty `#NNN [author] title` output by +//! default; `--json` for piping. +//! +//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string +//! filters one-for-one so the mental model carries over. Closes the +//! second of the four #694 gaps (read-side; no boundary concerns — +//! every agent + the operator queries the issue tracker constantly). + +use anyhow::Result; +use clap::{Args as ClapArgs, ValueEnum}; +use serde_json::Value; + +use crate::client::Client; +use crate::verbs::print_json; + +/// What kind of items to return. Matches Forgejo's `type` query +/// parameter values verbatim (`issues` / `pulls` / `all`) so the +/// mapping is one-for-one and a future enum addition upstream +/// stays trivially supportable. +#[derive(Copy, Clone, Debug, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum Kind { + /// Issues only (excludes PRs). + Issue, + /// Pull requests only. + Pr, + /// Issues + pull requests (default; matches the forge UI's + /// "Issues" tab when no type filter is applied). + Both, +} + +impl Kind { + fn api_value(self) -> &'static str { + match self { + Self::Issue => "issues", + Self::Pr => "pulls", + Self::Both => "all", + } + } +} + +/// State filter. Matches Forgejo's `state` query parameter +/// (`open` / `closed` / `all`). +#[derive(Copy, Clone, Debug, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum State { + Open, + Closed, + All, +} + +impl State { + fn api_value(self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::All => "all", + } + } +} + +#[derive(ClapArgs)] +pub struct Args { + /// What to return: issues, PRs, or both (default: both). + #[arg(long, value_enum, default_value_t = Kind::Both)] + kind: Kind, + /// Issue/PR state (default: open). + #[arg(long, value_enum, default_value_t = State::Open)] + state: State, + /// Filter to items assigned to this user (single login). + #[arg(long)] + assignee: Option, + /// Filter to items authored by this user (single login). + #[arg(long)] + author: Option, + /// Filter to items mentioning this user. + #[arg(long)] + mention: Option, + /// Filter to items carrying any of these label names. Repeatable. + #[arg(long = "label")] + labels: Vec, + /// Max items to return (default: 30; forge's per-page cap applies). + #[arg(long, default_value_t = 30)] + limit: u64, +} + +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let mut path = format!( + "/repos/{repo}/issues?type={}&state={}&limit={}", + args.kind.api_value(), + args.state.api_value(), + args.limit + ); + if let Some(u) = args.assignee.as_deref() + && !u.is_empty() + { + path.push_str(&format!("&assigned_by={}", pct_encode(u))); + } + if let Some(u) = args.author.as_deref() + && !u.is_empty() + { + path.push_str(&format!("&created_by={}", pct_encode(u))); + } + if let Some(u) = args.mention.as_deref() + && !u.is_empty() + { + path.push_str(&format!("&mentioned_by={}", pct_encode(u))); + } + if !args.labels.is_empty() { + // Encode each label individually so a comma INSIDE a label + // (rare but legal) gets escaped while the field separator + // stays a literal comma the forge will parse as N labels. + let encoded: Vec = args.labels.iter().map(|l| pct_encode(l)).collect(); + path.push_str(&format!("&labels={}", encoded.join(","))); + } + let resp = client.get_json(&path)?; + if client.json_mode() { + return print_json(&resp); + } + let Some(items) = resp.as_array() else { + // Forge returned something other than an array — most likely + // an error envelope; fall back to JSON-dumping so the user + // can see what came back. + return print_json(&resp); + }; + for item in items { + print_row(item); + } + Ok(()) +} + +/// Minimal RFC 3986 unreserved-set percent encoder. Covers the +/// subset of characters that show up in usernames + label names +/// (spaces in labels are the realistic non-ASCII case) without +/// pulling in a fresh workspace dep. Username regex is gitea-style +/// `[a-zA-Z0-9_-]` so the no-op fast path covers all of them. +fn pct_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// Render one issue/PR as a single `#NNN [author] title` line. +/// Defensive: missing fields drop to placeholders so a partial +/// response from a future API change still produces readable output +/// instead of panicking on `unwrap`. +fn print_row(item: &Value) { + let number = item.get("number").and_then(Value::as_u64).unwrap_or(0); + let title = item.get("title").and_then(Value::as_str).unwrap_or(""); + let author = item + .get("user") + .and_then(|u| u.get("login")) + .and_then(Value::as_str) + .unwrap_or("?"); + // Tag PRs visually so a mixed-kind result stays scannable. The + // forge always populates `pull_request` but sets it to `null` for + // issues; we check non-null specifically rather than just-present + // (which would render every issue as a PR). + let is_pr = item + .get("pull_request") + .is_some_and(|v| !v.is_null()); + let kind = if is_pr { "PR" } else { " " }; + println!("#{number:>4} {kind} [{author}] {title}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kind_api_values_match_forgejo_enum() { + // The forge accepts only `issues` / `pulls` / `all` for the + // `type` parameter — pin the wire mapping so a clap rename + // doesn't silently start returning the wrong slice. + assert_eq!(Kind::Issue.api_value(), "issues"); + assert_eq!(Kind::Pr.api_value(), "pulls"); + assert_eq!(Kind::Both.api_value(), "all"); + } + + #[test] + fn state_api_values_match_forgejo_enum() { + assert_eq!(State::Open.api_value(), "open"); + assert_eq!(State::Closed.api_value(), "closed"); + assert_eq!(State::All.api_value(), "all"); + } + + #[test] + fn pct_encode_passes_unreserved_through() { + // Usernames + plain label names round-trip verbatim — no + // performance regression on the common case. + assert_eq!(pct_encode("damocles"), "damocles"); + assert_eq!(pct_encode("area-ops"), "area-ops"); + assert_eq!(pct_encode("area_ops"), "area_ops"); + } + + #[test] + fn pct_encode_escapes_spaces_and_specials() { + // Forgejo labels can contain spaces ("good first issue" is a + // canonical example); the comma separator gets encoded too + // when it appears INSIDE a label name (we re-add the joined + // form unencoded above as the field separator). + assert_eq!(pct_encode("good first issue"), "good%20first%20issue"); + assert_eq!(pct_encode("a&b"), "a%26b"); + assert_eq!(pct_encode("a/b"), "a%2Fb"); + } +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index d8f0bcb0..5030759c 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -17,6 +17,7 @@ pub mod issue_create; pub mod issue_edit; pub mod labels; pub mod lint; +pub mod list; pub mod milestone; pub mod pr; pub mod pr_create;