refactor(hive-forge): port CLI verbs to forgejo-api
This commit is contained in:
parent
b8a3927c43
commit
4636987469
36 changed files with 1463 additions and 1153 deletions
|
|
@ -10,19 +10,20 @@
|
|||
//! 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 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. 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.
|
||||
/// 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 {
|
||||
|
|
@ -30,17 +31,16 @@ pub enum Kind {
|
|||
Issue,
|
||||
/// Pull requests only.
|
||||
Pr,
|
||||
/// Issues + pull requests (default; matches the forge UI's
|
||||
/// "Issues" tab when no type filter is applied).
|
||||
/// Issues + pull requests (default).
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn api_value(self) -> &'static str {
|
||||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
||||
match self {
|
||||
Self::Issue => "issues",
|
||||
Self::Pr => "pulls",
|
||||
Self::Both => "all",
|
||||
Self::Issue => Some(IssueListIssuesQueryType::Issues),
|
||||
Self::Pr => Some(IssueListIssuesQueryType::Pulls),
|
||||
Self::Both => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,11 +56,11 @@ pub enum State {
|
|||
}
|
||||
|
||||
impl State {
|
||||
fn api_value(self) -> &'static str {
|
||||
fn query_state(self) -> IssueListIssuesQueryState {
|
||||
match self {
|
||||
Self::Open => "open",
|
||||
Self::Closed => "closed",
|
||||
Self::All => "all",
|
||||
Self::Open => IssueListIssuesQueryState::Open,
|
||||
Self::Closed => IssueListIssuesQueryState::Closed,
|
||||
Self::All => IssueListIssuesQueryState::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -100,54 +100,40 @@ pub struct Args {
|
|||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
};
|
||||
for item in items {
|
||||
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 items.len() as u64 == args.limit {
|
||||
if count == args.limit {
|
||||
eprintln!(
|
||||
"… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).",
|
||||
args.limit,
|
||||
|
|
@ -184,19 +170,25 @@ 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
|
||||
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.api_value(), "issues");
|
||||
assert_eq!(Kind::Pr.api_value(), "pulls");
|
||||
assert_eq!(Kind::Both.api_value(), "all");
|
||||
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_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");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue