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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue