190 lines
6.6 KiB
Rust
190 lines
6.6 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 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<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 repo = client.repo();
|
|
let mut path = format!(
|
|
"/repos/{repo}/issues?type={}&state={}&limit={}&page={}",
|
|
args.kind.api_value(),
|
|
args.state.api_value(),
|
|
args.limit,
|
|
args.page
|
|
);
|
|
if let Some(u) = args.assignee.as_deref()
|
|
&& !u.is_empty()
|
|
{
|
|
write!(path, "&assigned_by={}", super::pct_encode(u)).unwrap();
|
|
}
|
|
if let Some(u) = args.author.as_deref()
|
|
&& !u.is_empty()
|
|
{
|
|
write!(path, "&created_by={}", super::pct_encode(u)).unwrap();
|
|
}
|
|
if let Some(u) = args.mention.as_deref()
|
|
&& !u.is_empty()
|
|
{
|
|
write!(path, "&mentioned_by={}", super::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<String> = args.labels.iter().map(|l| super::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(())
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|