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

@ -28,11 +28,12 @@
use anyhow::Result;
use clap::Args as ClapArgs;
use forgejo_api::structs::IssueGetCommentsQuery;
use serde_json::{Value, json};
use crate::client::Client;
use crate::client::{Client, index};
use crate::notify;
use crate::verbs::print_json;
use crate::verbs::{print_json, rfc3339};
/// Forgejo's per-page comment cap. The API caps `limit` at 50 even
/// if a higher value is requested; pin it explicitly so the math
@ -59,12 +60,12 @@ pub struct Args {
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let thread = match args.tail {
Some(n) => fetch_tail(client, repo, args.number, n)?,
None => fetch_head(client, repo, args.number, args.limit)?,
Some(n) => fetch_tail(client, args.number, n)?,
None => fetch_head(client, args.number, args.limit)?,
};
// Merge in PR review bodies (empty for issues — degrades to a
// no-op) so review feedback isn't silently dropped.
let comments = merge_chronological(thread, fetch_review_bodies(client, repo, args.number));
let comments = merge_chronological(thread, fetch_review_bodies(client, args.number));
// Reading the thread clears its unread notification so the
// read-before-comment guard (in `comment`) lets a reply through.
notify::mark_read_best_effort(client, repo, args.number);
@ -106,6 +107,16 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
}
}
/// Serialize a typed comment page back to the JSON `Value` shape the
/// merge + render pipeline works on (the structs serialize to the API
/// wire shape, so downstream field access is unchanged).
fn to_values<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
items
.into_iter()
.map(|c| serde_json::to_value(&c).map_err(Into::into))
.collect()
}
/// Fetch a PR's review *bodies* and normalise them to the comment
/// shape so they merge alongside issue-thread comments.
///
@ -119,34 +130,37 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
/// `created_at` is synthesised from the review's `submitted_at` so
/// the chronological merge sorts uniformly; `kind:"review"` + the
/// review `state` tag the entry for display.
fn fetch_review_bodies(client: &Client, repo: &str, number: u64) -> Vec<Value> {
fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
let Ok((owner, name)) = client.owner_repo() else {
return Vec::new();
};
let Ok(idx) = index(number) else {
return Vec::new();
};
let reviews = client
.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))
.ok()
.and_then(|v| v.as_array().cloned())
.api()
.repo_list_pull_reviews(owner, name, idx)
.send()
.map(|(_, reviews)| reviews)
.unwrap_or_default();
reviews
.into_iter()
.filter_map(|r| {
let state = r.get("state").and_then(Value::as_str).unwrap_or("");
let state = r.state.as_deref().unwrap_or("").to_owned();
if state == "PENDING" {
return None;
}
if r.get("body")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.is_empty()
{
if r.body.as_deref().unwrap_or("").trim().is_empty() {
return None;
}
let submitted = rfc3339(r.submitted_at);
Some(json!({
"id": r.get("id"),
"user": r.get("user"),
"created_at": r.get("submitted_at"),
"updated_at": r.get("submitted_at"),
"body": r.get("body"),
"html_url": r.get("html_url"),
"id": r.id,
"user": r.user,
"created_at": submitted,
"updated_at": submitted,
"body": r.body,
"html_url": r.html_url,
"kind": "review",
"state": state,
}))
@ -170,11 +184,19 @@ fn merge_chronological(mut items: Vec<Value>, reviews: Vec<Value>) -> Vec<Value>
}
/// Fetch the first page's worth of comments (existing behaviour).
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
let v = client.get_json(&format!(
"/repos/{repo}/issues/{number}/comments?limit={limit}"
))?;
Ok(v.as_array().cloned().unwrap_or_default())
fn fetch_head(client: &Client, number: u64, limit: u64) -> Result<Vec<Value>> {
let (owner, name) = client.owner_repo()?;
let (_, comments) = client
.api()
.issue_get_comments(
owner,
name,
index(number)?,
IssueGetCommentsQuery::default(),
)
.page_size(u32::try_from(limit).unwrap_or(u32::MAX))
.send()?;
to_values(comments)
}
/// Fetch the last `n` comments on an issue/PR in chronological order.
@ -186,16 +208,17 @@ fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Ve
/// first to know how many exist, then start paginating from the
/// page that contains item `total - n`. Work is bounded by
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
#[allow(
clippy::cast_possible_truncation,
reason = "the forge `comments` count cast to usize is a small issue-thread length, never anywhere near usize::MAX even on a 32-bit target, so it cannot truncate in practice"
)]
fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<Value>> {
fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
if n == 0 {
return Ok(Vec::new());
}
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize;
let (owner, name) = client.owner_repo()?;
let idx = index(number)?;
let issue = client.api().issue_get_issue(owner, name, idx).send()?;
let total = issue
.comments
.and_then(|c| usize::try_from(c).ok())
.unwrap_or(0);
if total == 0 {
return Ok(Vec::new());
}
@ -210,17 +233,19 @@ fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<
let last_page = (total - 1) / page_size + 1;
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
for page in start_page..=last_page {
let v = client.get_json(&format!(
"/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}"
))?;
let arr = v.as_array().cloned().unwrap_or_default();
let (_, arr) = client
.api()
.issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default())
.page(u32::try_from(page).unwrap_or(u32::MAX))
.page_size(u32::try_from(PAGE_SIZE).unwrap_or(u32::MAX))
.send()?;
if arr.is_empty() {
// Page came back empty — either we miscounted (comments
// deleted between the issue GET and now) or upstream's
// playing tricks. Stop rather than spin.
break;
}
merged.extend(arr);
merged.extend(to_values(arr)?);
}
// The first fetched page contains items from `start_page` × 50
// back; we overshoot by `start_idx % 50` items. Slice the tail