hive-forge: list verb — filter issues/PRs by kind/state/assignee/author/label (#694 part 2)
This commit is contained in:
parent
4ab4d99340
commit
8f9c77df06
5 changed files with 223 additions and 2 deletions
|
|
@ -220,7 +220,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||||
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
|
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
|
||||||
comments, comment-show, comment-edit, issue-create,
|
comments, comment-show, comment-edit, issue-create,
|
||||||
issue-edit, pr-create, pr-reviews, assign,
|
issue-edit, pr-create, pr-reviews, assign,
|
||||||
close, labels, milestone, branches,
|
close, labels, list, milestone, branches,
|
||||||
tree-sha, diff, subscription, attach-issue,
|
tree-sha, diff, subscription, attach-issue,
|
||||||
attach-comment, lint). Replaces the 600-line
|
attach-comment, lint). Replaces the 600-line
|
||||||
hive-forge-tools.nix bash script (closes #280).
|
hive-forge-tools.nix bash script (closes #280).
|
||||||
|
|
|
||||||
|
|
@ -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 <branch>`, `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)`).
|
**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 <branch>`, `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 <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (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 <branch> [--base main] [--body "..." | --body-file <path>] [--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 <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` 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 <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (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 <branch> [--base main] [--body "..." | --body-file <path>] [--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 <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||||
<!-- role:agent -->
|
<!-- role:agent -->
|
||||||
|
|
||||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` 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.
|
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` 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.
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ enum Verb {
|
||||||
Labels(verbs::labels::Args),
|
Labels(verbs::labels::Args),
|
||||||
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
||||||
Lint(verbs::lint::Args),
|
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).
|
/// Manage milestones (list / create / close).
|
||||||
Milestone(verbs::milestone::Args),
|
Milestone(verbs::milestone::Args),
|
||||||
/// List reviews on a PR.
|
/// List reviews on a PR.
|
||||||
|
|
@ -112,6 +116,7 @@ fn main() -> Result<()> {
|
||||||
Verb::Close(a) => verbs::close::run(&client, a),
|
Verb::Close(a) => verbs::close::run(&client, a),
|
||||||
Verb::Labels(a) => verbs::labels::run(&client, a),
|
Verb::Labels(a) => verbs::labels::run(&client, a),
|
||||||
Verb::Lint(a) => verbs::lint::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::Milestone(a) => verbs::milestone::run(&client, a),
|
||||||
Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a),
|
Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a),
|
||||||
Verb::Branches(a) => verbs::branches::run(&client, a),
|
Verb::Branches(a) => verbs::branches::run(&client, a),
|
||||||
|
|
|
||||||
215
hive-forge/src/verbs/list.rs
Normal file
215
hive-forge/src/verbs/list.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee
|
||||||
|
//! <user>] [--author <user>] [--label <name>] [--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<String>,
|
||||||
|
/// Filter to items authored by this user (single login).
|
||||||
|
#[arg(long)]
|
||||||
|
author: Option<String>,
|
||||||
|
/// Filter to items mentioning this user.
|
||||||
|
#[arg(long)]
|
||||||
|
mention: Option<String>,
|
||||||
|
/// Filter to items carrying any of these label names. Repeatable.
|
||||||
|
#[arg(long = "label")]
|
||||||
|
labels: Vec<String>,
|
||||||
|
/// 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<String> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod issue_create;
|
||||||
pub mod issue_edit;
|
pub mod issue_edit;
|
||||||
pub mod labels;
|
pub mod labels;
|
||||||
pub mod lint;
|
pub mod lint;
|
||||||
|
pub mod list;
|
||||||
pub mod milestone;
|
pub mod milestone;
|
||||||
pub mod pr;
|
pub mod pr;
|
||||||
pub mod pr_create;
|
pub mod pr_create;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue