refactor(hive-forge): port CLI verbs to forgejo-api

This commit is contained in:
müde 2026-07-07 09:24:53 +02:00
commit 4636987469
36 changed files with 1463 additions and 1153 deletions

View file

@ -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);
}
}