//! `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 std::fmt::Write as _; 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() { write!(path, "&assigned_by={}", pct_encode(u)).unwrap(); } if let Some(u) = args.author.as_deref() && !u.is_empty() { write!(path, "&created_by={}", pct_encode(u)).unwrap(); } if let Some(u) = args.mention.as_deref() && !u.is_empty() { write!(path, "&mentioned_by={}", pct_encode(u)).unwrap(); } 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(); write!(path, "&labels={}", encoded.join(",")).unwrap(); } 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 { write!(out, "%{b:02X}").unwrap(); } } 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"); } }