//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee //! ] [--author ] [--label ] [--milestone ] //! [--search ] [--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). //! //! `--search` is why there is no separate `search` verb: the forge //! matches it against title **and body** on this same endpoint, and a //! text match is only useful *composed* with the other filters — a //! separate verb would have to grow every one of them back. //! //! Each row in the pretty (non-`--json`) output also carries a //! `(N/M deps done)` suffix when the item has dependencies and at least //! one is still open — omitted entirely once every dependency is closed //! or there are none, so a ready item's row looks exactly like it did //! before this existed. Forgejo has no server-side dependency filter on //! this endpoint, so this costs one extra request per row shown (not //! per match — only the current page). 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::{blocking_summaries, dependency_summaries, labels, milestone, 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. /// Validated client-side: a name the forge can't resolve is dropped /// from the filter rather than rejected, which returns MORE results /// than asked for, not fewer. #[arg(long = "label")] labels: Vec, /// Filter to items in any of these milestones, by title or id. /// Repeatable. Validated client-side against the repo's milestones /// (closed ones included), since the forge would silently discard a /// name it can't resolve and return the UNFILTERED list. #[arg(long = "milestone")] milestones: Vec, /// Full-text search over title AND body, server-side. Composes with /// every filter above — this is the duplicate-hunting path that /// grepping `list` output can't cover, since grep only ever sees /// the titles. #[arg(long)] search: Option, /// Page size — items per page (default: 30). The forge clamps this /// to its own `api.MAX_RESPONSE_ITEMS` (50 by default), so a large /// `--limit` silently returns a smaller page; the trailer reports /// the real total rather than trusting this number. 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()?; // Validate the name-based filters BEFORE querying. The forge // *discards* a label or milestone it can't resolve instead of // rejecting it, so a typo doesn't narrow the result set — it returns // the UNFILTERED one. That doesn't waste a query, it inverts the // answer: "is anything open in this milestone" comes back as every // open issue and reads as yes, and a duplicate check gets a list that // never narrowed and concludes there isn't one. // // The ids are discarded on purpose — this endpoint filters by name, // so resolution here is a spell-check, not a lookup. `resolve_ids` is // reused rather than reimplemented so the message stays identical to // the one the write side has always produced. // // Costs one extra round-trip per filtered invocation, and only when // the filter is actually used. if !args.labels.is_empty() { let all = labels::repo_labels(client)?; labels::resolve_ids(&all, &args.labels)?; } if !args.milestones.is_empty() { let all = milestone::repo_milestones(client, "all")?; milestone::ensure_filters_resolve(&all, &args.milestones)?; } 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: args.search.clone().filter(|s| !s.is_empty()), r#type: args.kind.query_type(), // Same comma-separated shape as `labels` above. milestones: (!args.milestones.is_empty()).then(|| args.milestones.join(",")), 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 (headers, 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 mut items = serde_json::to_value(issues)?; if client.json_mode() { hoist_merge_state(&mut items); return print_json(&items); } for item in items.as_array().into_iter().flatten() { let number = item.get("number").and_then(Value::as_u64); let progress = number .map(|n| dependency_summaries(client, owner, name, n)) .transpose()?; let blocks = number .map(|n| blocking_summaries(client, owner, name, n)) .transpose()?; print_row( item, progress.as_deref().and_then(dep_progress), blocks.as_deref().map_or(0, blocking_open_count), ); } if let Some(msg) = trailer( headers.x_total_count.and_then(|t| u64::try_from(t).ok()), count, args.page, args.limit, ) { eprintln!("{msg}"); } Ok(()) } /// Tell the caller, on stderr, whether this page is the whole answer. /// /// The forge clamps `page_size` to its own `api.MAX_RESPONSE_ITEMS`, so /// the requested `limit` is not the page size that was served: with /// `--limit 400` a full clamped page of 50 comes back, and the old /// `count == limit` test stayed silent — suppressing the warning in the /// one case where the truncation is invisible. Hence the header. /// /// `total` is `X-Total-Count`, which Forgejo sends on this endpoint. The /// *effective* page size isn't reported, but it doesn't need to be: a /// short page is by definition the last one, so whenever more pages can /// exist at all, `count` **is** the page size and `page * count` is how /// many rows have been shown up to here. /// /// Deliberately not clamping `--limit` client-side to 50: that number is /// the *remote's* configuration, and baking a peer's setting into the /// client is how you get a value that's wrong on the one deployment that /// changed it. fn trailer(total: Option, count: u64, page: u64, limit: u64) -> Option { // An empty page is the end, whatever else is true. if count == 0 { return None; } match total { Some(total) => { let shown = page.saturating_mul(count); let remaining = total.saturating_sub(shown); (remaining > 0).then(|| { format!( "… {shown} of {total} shown (page {page}, {count}/page) — {remaining} more; re-run with --page {}.", page + 1 ) }) } // No header (older forge, or a proxy that dropped it): fall back // to the full-page heuristic. It under-reports when the server // clamped, which is why it is the fallback and not the rule. None => (count == limit).then(|| { format!( "… {count} shown (page {page}); more results may exist — re-run with --page {}.", page + 1 ) }), } } /// Render one issue/PR as a single `#NNN [author] title` line, plus a /// `(N/M deps done)` suffix when `progress` is `Some` and a `(blocks N)` /// suffix when `blocking_open` is nonzero — the actionability signal the /// operator asked for: an issue blocking open work is worth picking over /// one with no unresolved followers, at a glance in `list`'s own output /// rather than a per-issue `show`. Defensive: missing fields drop to /// placeholders so a partial response from a future API change still /// produces readable output instead of panicking on `unwrap`. /// Copy a PR row's `merged` / `merged_at` from the nested `pull_request` /// object up to the top level of the row. /// /// Forgejo's issue-list endpoint answers "was this merged?" only inside /// `pull_request`, while `state` says `closed` for a merged PR *and* one /// closed without merging. So the obvious top-level query — `.merged_at`, /// or `.state` — is null/ambiguous for **every** row, and with a `//` /// default it renders as a confident "nothing merged" that cannot ever be /// right. Hoisting makes the obvious query the correct one instead of /// leaving a trap only a nested path avoids. /// /// Additive: the original `pull_request` object is left untouched, so a /// consumer already reading the nested path keeps working. Issue rows have /// no `pull_request` and pass through unchanged. /// /// ⚠️ The head branch is deliberately NOT hoisted — this endpoint does not /// carry it at all. The row's `ref` is an **empty string**, not the branch, /// so there is nothing to lift; `pr show ` has `head_branch`. fn hoist_merge_state(items: &mut Value) { let Some(rows) = items.as_array_mut() else { return; }; for row in rows { let Some(pr) = row.get("pull_request") else { continue; }; let merged = pr.get("merged").cloned(); let merged_at = pr.get("merged_at").cloned(); let Some(obj) = row.as_object_mut() else { continue; }; if let Some(v) = merged { obj.insert("merged".to_owned(), v); } if let Some(v) = merged_at { obj.insert("merged_at".to_owned(), v); } } } fn print_row(item: &Value, progress: Option<(usize, usize)>, blocking_open: usize) { 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 { " " }; let deps_suffix = progress.map(|(done, total)| format!(" ({done}/{total} deps done)")); let blocks_suffix = (blocking_open > 0).then(|| format!(" (blocks {blocking_open})")); println!( "#{number:>4} {kind} [{author}] {title}{}{}", deps_suffix.unwrap_or_default(), blocks_suffix.unwrap_or_default() ); } /// Count of *open* issues `blocking` this row's issue blocks — closed /// followers don't count toward "actionable" (see [`print_row`]'s doc /// comment); they're already done regardless of this one's own state. fn blocking_open_count(blocking: &[Value]) -> usize { blocking .iter() .filter(|b| b.get("state").and_then(Value::as_str) == Some("open")) .count() } /// Dependency completion progress from a `dependency_summaries` list — /// `Some((done, total))` only when there's at least one dependency AND /// at least one of them is still open. `None` for no dependencies, or /// all of them already closed: the operator asked for the counter to /// appear only "when there are deps that are not done", so a ready /// item's row stays exactly as clean as before this existed. fn dep_progress(deps: &[Value]) -> Option<(usize, usize)> { let total = deps.len(); if total == 0 { return None; } let done = deps .iter() .filter(|d| d.get("state").and_then(Value::as_str) == Some("closed")) .count(); (done < total).then_some((done, total)) } #[cfg(test)] mod tests { use super::*; fn dep(state: &str) -> Value { serde_json::json!({ "number": 1, "title": "x", "state": state }) } /// A merged PR, a closed-but-unmerged PR and a plain issue in one /// array — because telling the first two apart is the entire point, /// and `state` cannot: it reads `closed` for both. #[test] fn hoisting_separates_merged_from_closed_unmerged_and_leaves_issues_alone() { let mut items = serde_json::json!([ { "number": 1, "state": "closed", "pull_request": { "merged": true, "merged_at": "2026-08-26T22:47:32+02:00" } }, { "number": 2, "state": "closed", "pull_request": { "merged": false, "merged_at": null } }, { "number": 3, "state": "closed" }, ]); // Control: before hoisting, a top-level query cannot tell 1 from 2. assert!(items[0].get("merged").is_none(), "control: not hoisted yet"); assert_eq!( items[0]["state"], items[1]["state"], "control: `state` is identical" ); hoist_merge_state(&mut items); assert_eq!(items[0]["merged"], serde_json::json!(true), "merged PR"); assert_eq!( items[1]["merged"], serde_json::json!(false), "closed unmerged" ); assert_eq!( items[0]["merged_at"], serde_json::json!("2026-08-26T22:47:32+02:00") ); assert!(items[1]["merged_at"].is_null()); assert!( items[2].get("merged").is_none(), "an issue must not gain a `merged` field" ); // Additive: the nested path an existing consumer reads still works. assert_eq!(items[0]["pull_request"]["merged"], serde_json::json!(true)); } /// Non-array input (an error object, say) must not panic or mangle. #[test] fn hoisting_a_non_array_is_a_no_op() { let mut v = serde_json::json!({ "message": "not found" }); let before = v.clone(); hoist_merge_state(&mut v); assert_eq!(v, before); } #[test] fn dep_progress_none_when_no_dependencies() { assert_eq!(dep_progress(&[]), None); } #[test] fn dep_progress_none_when_all_dependencies_closed() { let deps = [dep("closed"), dep("closed")]; assert_eq!(dep_progress(&deps), None); } #[test] fn dep_progress_some_when_at_least_one_dependency_still_open() { let deps = [dep("closed"), dep("open"), dep("closed")]; assert_eq!(dep_progress(&deps), Some((2, 3))); } #[test] fn dep_progress_counts_all_open_as_zero_done() { let deps = [dep("open"), dep("open")]; assert_eq!(dep_progress(&deps), Some((0, 2))); } #[test] fn blocking_open_count_zero_when_none_blocking() { assert_eq!(blocking_open_count(&[]), 0); } #[test] fn blocking_open_count_ignores_closed_ones() { let blocking = [dep("closed"), dep("closed")]; assert_eq!(blocking_open_count(&blocking), 0); } #[test] fn blocking_open_count_counts_only_open_ones() { let blocking = [dep("closed"), dep("open"), dep("open")]; assert_eq!(blocking_open_count(&blocking), 2); } #[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); } #[test] fn trailer_fires_on_a_clamped_page_the_old_heuristic_missed() { // The regression this function exists for: `--limit 400` against // a forge that clamps to 50, with 187 matches. `count != limit`, // so the pre-header code printed nothing and the caller concluded // the list was complete. let msg = trailer(Some(187), 50, 1, 400).expect("must warn about the other 137"); assert!(msg.contains("50 of 187"), "{msg}"); assert!(msg.contains("137 more"), "{msg}"); assert!(msg.contains("--page 2"), "{msg}"); } #[test] fn trailer_counts_from_the_served_page_size_not_the_requested_one() { // Page 3 of a clamped 50/page walk has shown 150, not 3 * 400. let msg = trailer(Some(187), 50, 3, 400).expect("187 > 150"); assert!(msg.contains("150 of 187"), "{msg}"); assert!(msg.contains("37 more"), "{msg}"); } #[test] fn trailer_silent_once_the_total_is_accounted_for() { // Last page, short: 2 * 50 + ... -> 60 shown of 60. assert_eq!(trailer(Some(60), 10, 6, 10), None); // Exactly-full last page: no phantom "page 4". assert_eq!(trailer(Some(150), 50, 3, 50), None); // Empty page is the end even if the total disagrees (a filter // the forge discarded, a concurrent close) — never advertise a // next page we just proved is empty. assert_eq!(trailer(Some(999), 0, 9, 30), None); } #[test] fn trailer_falls_back_to_the_full_page_heuristic_without_the_header() { let msg = trailer(None, 30, 1, 30).expect("full page, unknown total"); assert!(msg.contains("30 shown (page 1)"), "{msg}"); assert!(msg.contains("--page 2"), "{msg}"); // Short page with no header: the end, as far as we can tell. assert_eq!(trailer(None, 12, 1, 30), None); } }