194 lines
7.1 KiB
Rust
194 lines
7.1 KiB
Rust
//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee
|
|
//! <user>] [--author <user>] [--label <name>] [--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<IssueListIssuesQueryType> {
|
|
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<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>,
|
|
/// 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 walk a large result set incrementally — fetch page 1, process,
|
|
/// fetch page 2, … until a short/empty page. This keeps each call
|
|
/// token-bounded (one page at a time) instead of pulling a whole
|
|
/// repo's population into a single response. Must be >= 1 (the forge
|
|
/// pages are 1-based; page 0 is rejected).
|
|
#[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);
|
|
}
|
|
}
|