306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
//! Per-verb subcommand modules. Each module exposes a `Args` struct
|
|
//! (clap-derived) and a `run` fn taking `(&Client, Args) -> Result<()>`.
|
|
//! Splitting one verb per module keeps each handler small and avoids
|
|
//! the bash script's monolithic `case` statement.
|
|
|
|
pub mod artifact_get;
|
|
pub mod assign;
|
|
pub mod attach;
|
|
pub mod attachment_get;
|
|
pub mod branches;
|
|
pub mod ci_log;
|
|
pub mod ci_rerun;
|
|
pub mod clone;
|
|
pub mod close;
|
|
pub mod comment;
|
|
pub mod comment_edit;
|
|
pub mod comment_show;
|
|
pub mod comments;
|
|
pub mod diff;
|
|
pub mod issue;
|
|
pub mod issue_cmd;
|
|
pub mod issue_create;
|
|
pub mod issue_edit;
|
|
pub mod labels;
|
|
pub mod lint;
|
|
pub mod list;
|
|
pub mod milestone;
|
|
pub mod pr;
|
|
pub mod pr_assign_reviewer;
|
|
pub mod pr_cmd;
|
|
pub mod pr_commits;
|
|
pub mod pr_create;
|
|
pub mod pr_merge;
|
|
pub mod pr_reviews;
|
|
pub mod pr_status;
|
|
pub mod reopen;
|
|
pub mod repo_add_collaborator;
|
|
pub mod repo_create;
|
|
pub mod repo_labels;
|
|
pub mod repo_search;
|
|
pub mod subscription;
|
|
pub mod timeline;
|
|
pub mod tree_sha;
|
|
pub mod view;
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
use anyhow::Result;
|
|
use serde_json::{Value, json};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
|
|
use crate::client::{Client, index};
|
|
|
|
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
|
|
/// matching the bash script's `| jq` output shape.
|
|
pub(crate) fn print_json(v: &Value) -> Result<()> {
|
|
let s = serde_json::to_string_pretty(v)?;
|
|
println!("{s}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Format an optional timestamp as its RFC 3339 string — the shape the
|
|
/// raw API emitted, so output stays stable across the typed-client
|
|
/// port. `None` (and the never-in-practice unformattable timestamp)
|
|
/// map to `None` so callers keep their existing null/placeholder
|
|
/// handling.
|
|
pub(crate) fn rfc3339(ts: Option<OffsetDateTime>) -> Option<String> {
|
|
ts.and_then(|t| t.format(&Rfc3339).ok())
|
|
}
|
|
|
|
/// Parse a `--since`/`--before` CLI argument as RFC 3339 — the inverse of
|
|
/// [`rfc3339`], so a value copied straight from this tool's own output
|
|
/// (every row prints its `created_at` in this exact shape) round-trips
|
|
/// without reformatting. A bad value gets a message naming what was
|
|
/// typed, not a bare parser error.
|
|
pub(crate) fn parse_rfc3339(s: &str) -> Result<OffsetDateTime> {
|
|
OffsetDateTime::parse(s, &Rfc3339)
|
|
.map_err(|e| anyhow::anyhow!("`{s}` isn't a valid RFC 3339 timestamp: {e}"))
|
|
}
|
|
|
|
/// Forgejo's per-page cap, shared by every listing verb that over-fetches
|
|
/// by one to detect truncation without an exact total (`timeline`'s
|
|
/// `--limit`, `comments`' `--since`). The API silently clamps a requested
|
|
/// page size to this value, so it's pinned explicitly rather than left as
|
|
/// a hidden default downstream math could drift out of sync with.
|
|
pub(crate) const PAGE_SIZE: u64 = 50;
|
|
|
|
/// The highest `--limit` an over-fetch-by-one truncation check
|
|
/// (`fetch_limit = limit + 1`) can still detect: `PAGE_SIZE - 1`. At
|
|
/// `limit == PAGE_SIZE` the `+1` request silently clamps to `PAGE_SIZE`
|
|
/// server-side and the truncation check goes blind exactly when there's
|
|
/// the most data to miss.
|
|
pub(crate) const MAX_LIMIT: u64 = PAGE_SIZE - 1;
|
|
|
|
/// Cap `requested` at [`MAX_LIMIT`], reporting whether it had to. Pure so
|
|
/// the boundary math is unit-testable without a network call.
|
|
pub(crate) fn clamp_limit(requested: u64) -> (u64, bool) {
|
|
let limit = requested.min(MAX_LIMIT);
|
|
(limit, limit < requested)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod page_limit_tests {
|
|
use super::{MAX_LIMIT, PAGE_SIZE, clamp_limit};
|
|
|
|
#[test]
|
|
fn clamp_limit_passes_small_requests_through() {
|
|
assert_eq!(clamp_limit(10), (10, false));
|
|
assert_eq!(clamp_limit(MAX_LIMIT), (MAX_LIMIT, false));
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_limit_caps_requests_above_the_boundary() {
|
|
// Regression: `limit + 1` must never exceed Forgejo's PAGE_SIZE,
|
|
// or the over-fetch-by-one truncation check goes silently blind.
|
|
assert_eq!(clamp_limit(PAGE_SIZE), (MAX_LIMIT, true));
|
|
assert_eq!(clamp_limit(1000), (MAX_LIMIT, true));
|
|
}
|
|
}
|
|
|
|
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` sub-command
|
|
/// validation.
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) enum Kind {
|
|
Pr,
|
|
Issue,
|
|
}
|
|
|
|
/// Verify `number` is the expected kind before a kind-namespaced verb (one
|
|
/// of the generics that work on both — close/comment/labels/…) acts on it —
|
|
/// the validation win the `pr <verb>` / `issue <verb>` split buys over the
|
|
/// old generic verbs. Forgejo's `/issues/{n}` endpoint serves both issues and
|
|
/// PRs and marks PRs with a non-null `pull_request` field, so one GET
|
|
/// classifies it. Errors with a "use the other command" message on mismatch.
|
|
pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
let issue = client
|
|
.api()
|
|
.issue_get_issue(owner, name, index(number)?)
|
|
.send()?;
|
|
let is_pr = issue.pull_request.is_some();
|
|
match (expected, is_pr) {
|
|
(Kind::Pr, false) => {
|
|
anyhow::bail!(
|
|
"#{number} is an issue, not a PR — use `hive-forge issue <verb> {number}`"
|
|
)
|
|
}
|
|
(Kind::Issue, true) => {
|
|
anyhow::bail!("#{number} is a PR, not an issue — use `hive-forge pr <verb> {number}`")
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of
|
|
/// characters that show up in the values we splice into *web-route* paths
|
|
/// (the typed client encodes its own path segments) — artifact names —
|
|
/// without pulling in a fresh workspace dep. Unreserved bytes
|
|
/// (`[A-Za-z0-9-._~]`) pass through, so the common identifier case is a
|
|
/// no-op; everything else is `%XX`-escaped. Used by `artifact-get` (the
|
|
/// artifact-name path segment on the web download route).
|
|
pub(crate) fn pct_encode(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for b in s.bytes() {
|
|
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
|
out.push(b as char);
|
|
} else {
|
|
write!(out, "%{b:02X}").unwrap();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// One reviewer's latest verdict on a PR, plus forgejo's `stale` /
|
|
/// `dismissed` bits.
|
|
///
|
|
/// `stale` is set by forgejo when the PR head commit changed after this
|
|
/// review was submitted (branch protection then wants a fresh review);
|
|
/// `dismissed` is set when the review was explicitly dismissed. Either way
|
|
/// the review no longer applies to the current head even though its `state`
|
|
/// string still reads `APPROVED` / `REQUEST_CHANGES` — so surfacing them
|
|
/// stops the CLI from reporting a no-longer-valid review as still-good, and
|
|
/// [`ReviewInfo::superseded`] rolls both into one "doesn't count" check.
|
|
pub(crate) struct ReviewInfo {
|
|
pub login: String,
|
|
pub state: String,
|
|
pub stale: bool,
|
|
pub dismissed: bool,
|
|
}
|
|
|
|
impl ReviewInfo {
|
|
/// True when the review no longer applies to the current head — stale
|
|
/// (head moved) or dismissed. Such a verdict neither blocks a merge nor
|
|
/// counts as a fresh approval.
|
|
pub(crate) fn superseded(&self) -> bool {
|
|
self.stale || self.dismissed
|
|
}
|
|
}
|
|
|
|
/// Latest non-comment review per reviewer on a PR. Reviews come
|
|
/// oldest-first, so a later verdict from the same user supersedes an
|
|
/// earlier one; `COMMENT` / `PENDING` reviews carry no verdict and are
|
|
/// skipped. Shared by `pr-status` (health view + readiness verdict) and
|
|
/// `pr-merge` (pre-merge changes-requested gate) so the verdict semantics
|
|
/// stay in one place.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates the forge API errors from listing the PR's reviews.
|
|
pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec<ReviewInfo>> {
|
|
let (owner, name) = crate::client::split_repo(repo)?;
|
|
let pr = index(pr)?;
|
|
// Paginate (50/page, 10-page runaway cap — same ceiling the raw
|
|
// client used) so a heavily re-reviewed PR doesn't truncate.
|
|
let mut reviews = Vec::new();
|
|
for page in 1..=10u32 {
|
|
let (_, batch) = client
|
|
.api()
|
|
.repo_list_pull_reviews(owner, name, pr)
|
|
.page(page)
|
|
.page_size(50)
|
|
.send()?;
|
|
let short = batch.len() < 50;
|
|
reviews.extend(batch);
|
|
if short {
|
|
break;
|
|
}
|
|
}
|
|
let mut latest: Vec<ReviewInfo> = Vec::new();
|
|
for r in &reviews {
|
|
let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else {
|
|
continue;
|
|
};
|
|
let st = r.state.as_deref().unwrap_or("");
|
|
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
|
|
continue;
|
|
}
|
|
let stale = r.stale.unwrap_or(false);
|
|
let dismissed = r.dismissed.unwrap_or(false);
|
|
if let Some(slot) = latest.iter_mut().find(|info| info.login == login) {
|
|
st.clone_into(&mut slot.state);
|
|
slot.stale = stale;
|
|
slot.dismissed = dismissed;
|
|
} else {
|
|
latest.push(ReviewInfo {
|
|
login: login.to_owned(),
|
|
state: st.to_owned(),
|
|
stale,
|
|
dismissed,
|
|
});
|
|
}
|
|
}
|
|
Ok(latest)
|
|
}
|
|
|
|
/// The current dependency list for an issue or PR — each entry names
|
|
/// another issue/PR this one is blocked on. Forgejo's dependency endpoint
|
|
/// works on the shared issue/PR index (PRs are issues internally under
|
|
/// the hood), so `issue show` and `pr show` both call this instead of
|
|
/// duplicating the fetch-and-shape step. A reviewer asked whether
|
|
/// `show`/`view` surface dependencies — they didn't (only `timeline`
|
|
/// rendered them, as history); this is the current-state complement.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates the forge API errors from listing dependencies.
|
|
pub(crate) fn dependency_summaries(
|
|
client: &Client,
|
|
owner: &str,
|
|
name: &str,
|
|
number: u64,
|
|
) -> Result<Vec<Value>> {
|
|
let deps = client
|
|
.api()
|
|
.issue_list_issue_dependencies(owner, name, index(number)?)
|
|
.send()?;
|
|
Ok(deps
|
|
.into_iter()
|
|
.map(|d| json!({ "number": d.number, "title": d.title }))
|
|
.collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::pct_encode;
|
|
|
|
#[test]
|
|
fn pct_encode_passes_unreserved_through() {
|
|
// Plain artifact names round-trip verbatim — no performance
|
|
// regression on the common case.
|
|
assert_eq!(pct_encode("damocles"), "damocles");
|
|
assert_eq!(pct_encode("area-ops"), "area-ops");
|
|
assert_eq!(pct_encode("area_ops"), "area_ops");
|
|
assert_eq!(pct_encode("pr1ma-paper-pdf"), "pr1ma-paper-pdf");
|
|
}
|
|
|
|
#[test]
|
|
fn pct_encode_escapes_reserved() {
|
|
// `&` / `/` / spaces in any spliced value must escape so they
|
|
// can't break out of the path/query segment.
|
|
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
|
|
assert_eq!(pct_encode("x&y"), "x%26y");
|
|
assert_eq!(pct_encode("a/b"), "a%2Fb");
|
|
}
|
|
}
|