//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee //! ] [--author ] [--label ] [--limit N] [--page N]` — //! list issues / PRs with filters. Pretty `#NNN [author] title` output by //! default; `--json` for piping. `--limit` is the page size and `--page` //! the 1-based page number — walk pages incrementally for a large result //! set rather than pulling the whole population into one response. //! //! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string //! filters one-for-one so the mental model carries over. Closes the //! read-side curl-fallback gap (no boundary concerns — //! every agent + the operator queries the issue tracker constantly). use anyhow::Result; use clap::{Args as ClapArgs, ValueEnum}; use forgejo_api::structs::{ IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, }; use serde_json::Value; use crate::client::Client; use crate::verbs::print_json; /// What kind of items to return. Maps onto Forgejo's `type` query /// parameter: `issues` / `pulls`, or no filter at all for `both` /// (Forgejo returns issues + PRs when `type` is absent — same slice /// the forge UI's "Issues" tab shows without a type filter). #[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). Both, } impl Kind { fn query_type(self) -> Option { match self { Self::Issue => Some(IssueListIssuesQueryType::Issues), Self::Pr => Some(IssueListIssuesQueryType::Pulls), Self::Both => None, } } } /// 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 query_state(self) -> IssueListIssuesQueryState { match self { Self::Open => IssueListIssuesQueryState::Open, Self::Closed => IssueListIssuesQueryState::Closed, Self::All => IssueListIssuesQueryState::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, /// Page size — items per page (default: 30; forge's per-page cap, /// ~50, applies). Must be >= 1. #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))] limit: u64, /// Page number to fetch (1-based, default 1). Combine with `--limit` /// to page through large result sets incrementally. #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u64).range(1..))] page: u64, } pub fn run(client: &Client, args: Args) -> Result<()> { let (owner, name) = client.owner_repo()?; let query = IssueListIssuesQuery { state: Some(args.state.query_state()), // The forge parses `labels` as a comma-separated list of names. labels: (!args.labels.is_empty()).then(|| args.labels.join(",")), q: None, r#type: args.kind.query_type(), milestones: None, since: None, before: None, created_by: args.author.clone().filter(|s| !s.is_empty()), assigned_by: args.assignee.clone().filter(|s| !s.is_empty()), mentioned_by: args.mention.clone().filter(|s| !s.is_empty()), sort: None, }; let (_, issues) = client .api() .issue_list_issues(owner, name, query) .page(u32::try_from(args.page).unwrap_or(u32::MAX)) .page_size(u32::try_from(args.limit).unwrap_or(u32::MAX)) .send()?; let count = issues.len() as u64; let items = serde_json::to_value(issues)?; if client.json_mode() { return print_json(&items); } for item in items.as_array().into_iter().flatten() { print_row(item); } // When the returned page is exactly `--limit` items, more pages // may exist. Print a hint to stderr so the caller knows to fetch // the next page rather than assuming the result is complete. Only // fires on a full page — a short or empty page signals the end. if count == args.limit { eprintln!( "… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).", args.limit, args.page, args.page + 1 ); } Ok(()) } /// 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_query_types_match_forgejo_enum() { // The forge accepts only `issues` / `pulls` for the `type` // parameter (absent = both) — pin the mapping so a clap rename // doesn't silently start returning the wrong slice. assert_eq!( Kind::Issue.query_type(), Some(IssueListIssuesQueryType::Issues) ); assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls)); assert_eq!(Kind::Both.query_type(), None); } #[test] fn state_query_states_match_forgejo_enum() { assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open); assert_eq!( State::Closed.query_state(), IssueListIssuesQueryState::Closed ); assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All); } }