docs/tools/forge.md already listed the nine renamed verbs as removed, then used them ~30 more times in pasteable blocks. Sweeps every occurrence a reader would type, including three runtime messages that told the user to run a verb the same binary rejects. The renamed-verb list itself keeps the old names; it is what documents them. Refs #4155
689 lines
26 KiB
Rust
689 lines
26 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_common;
|
||
pub mod ci_log;
|
||
pub mod ci_rerun;
|
||
pub mod ci_runs;
|
||
pub mod clone;
|
||
pub mod close;
|
||
pub mod comment;
|
||
pub mod comment_edit;
|
||
pub mod comment_show;
|
||
pub mod comments;
|
||
pub mod credential_helper;
|
||
pub mod dependency;
|
||
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 reaction;
|
||
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 forgejo_api::structs::{Attachment, Reaction};
|
||
use serde::Deserialize;
|
||
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,
|
||
}
|
||
|
||
/// Whether `number` is a PR rather than a plain issue. Forgejo's
|
||
/// `/issues/{n}` endpoint serves both and marks PRs with a non-null
|
||
/// `pull_request` field, so one GET classifies it — shared by
|
||
/// [`assert_kind`] and by `dependency`'s failure-diagnosis path, which
|
||
/// needs the same classification without wanting an error on mismatch.
|
||
pub(crate) fn is_pr(client: &Client, owner: &str, name: &str, number: u64) -> Result<bool> {
|
||
let issue = client
|
||
.api()
|
||
.issue_get_issue(owner, name, index(number)?)
|
||
.send()?;
|
||
Ok(issue.pull_request.is_some())
|
||
}
|
||
|
||
/// 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. 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 is_pr_result = is_pr(client, owner, name, number)?;
|
||
match (expected, is_pr_result) {
|
||
(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
|
||
}
|
||
|
||
/// Render an unresolved-name list with a `did you mean` where one fits.
|
||
///
|
||
/// A filter value that doesn't resolve is a **near-miss far more often
|
||
/// than an invention** (`area/opps` for `area/ops`), and an error that
|
||
/// only lists all 18 available names makes the reader do the diff by eye
|
||
/// — on the one occasion they already know they mistyped something.
|
||
pub(crate) fn with_suggestions(unresolved: &[&str], available: &[&str]) -> String {
|
||
unresolved
|
||
.iter()
|
||
.map(|u| match nearest(u, available) {
|
||
Some(s) => format!("{u} (did you mean \"{s}\"?)"),
|
||
None => (*u).to_owned(),
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
}
|
||
|
||
/// Closest candidate to `needle`, when one is close enough to be worth
|
||
/// suggesting.
|
||
///
|
||
/// Thresholded rather than always returning the minimum: **a wrong
|
||
/// suggestion is worse than none**, because it invites a second failed
|
||
/// attempt at a name that was never there. The bound scales with the
|
||
/// needle (a third of its length, capped at 3) so a short name doesn't
|
||
/// match half the repo and a long one still tolerates a typo or two.
|
||
fn nearest<'a>(needle: &str, candidates: &[&'a str]) -> Option<&'a str> {
|
||
let limit = (needle.chars().count() / 3).clamp(1, 3);
|
||
candidates
|
||
.iter()
|
||
.map(|c| (edit_distance(needle, c), *c))
|
||
.filter(|(d, _)| *d <= limit)
|
||
// Tie-break on the shorter candidate, then alphabetically, so the
|
||
// suggestion is stable rather than dependent on the order the
|
||
// forge happened to return its labels in.
|
||
.min_by_key(|(d, c)| (*d, c.len(), *c))
|
||
.map(|(_, c)| c)
|
||
}
|
||
|
||
/// Levenshtein distance, two-row DP. Small enough not to justify a
|
||
/// workspace dependency for the one place it is used.
|
||
fn edit_distance(a: &str, b: &str) -> usize {
|
||
let b: Vec<char> = b.chars().collect();
|
||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||
let mut cur = vec![0_usize; b.len() + 1];
|
||
for (i, ca) in a.chars().enumerate() {
|
||
cur[0] = i + 1;
|
||
for (j, cb) in b.iter().enumerate() {
|
||
let cost = usize::from(ca != *cb);
|
||
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||
}
|
||
std::mem::swap(&mut prev, &mut cur);
|
||
}
|
||
prev[b.len()]
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// ⚠️ **`stale` here is not forgejo's flag alone.** That flag is eventually
|
||
/// consistent: seconds after a push it still reports the pre-push answer, so
|
||
/// a verdict against the previous head reads as current in exactly the window
|
||
/// where someone runs the CLI right after pushing. [`latest_reviews`] therefore
|
||
/// ORs it with a direct comparison of the review's own `commit_id` against the
|
||
/// PR head — the flag is right *eventually*, the comparison is right
|
||
/// *immediately*, and either alone is worse than both.
|
||
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
|
||
}
|
||
}
|
||
|
||
/// Whether this review was submitted against a commit that is no longer the
|
||
/// PR head — the race-free half of the staleness check.
|
||
///
|
||
/// Fails **closed on unknowns**, i.e. "not stale": either side missing means
|
||
/// we cannot show the head moved, and the cost of guessing wrong in that
|
||
/// direction is one redundant re-review, where the other direction would
|
||
/// silently void every verdict on the PR (blocking nothing, but reporting a
|
||
/// ready PR as unreviewed and inviting a re-request that dismisses a real
|
||
/// approval).
|
||
fn reviewed_older_head(reviewed_sha: Option<&str>, head_sha: Option<&str>) -> bool {
|
||
let (Some(head), Some(reviewed)) = (head_sha, reviewed_sha) else {
|
||
return false;
|
||
};
|
||
// Both emptiness checks matter, and for the same reason: a blank string
|
||
// is a value the forge sent, not a sha it has. Treating one as real would
|
||
// make every review compare unequal and mark the whole PR stale — the
|
||
// direction this whole function exists to avoid.
|
||
!head.is_empty() && !reviewed.is_empty() && reviewed != head
|
||
}
|
||
|
||
/// 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)?;
|
||
// The head this PR currently points at, used to age out verdicts forgejo
|
||
// has not marked stale yet (see `ReviewInfo`). Best-effort: on any failure
|
||
// we fall back to forgejo's flag alone, which is today's behaviour — a
|
||
// missing head must never make every review look superseded.
|
||
let head_sha = client
|
||
.api()
|
||
.repo_get_pull_request(owner, name, pr)
|
||
.send()
|
||
.ok()
|
||
.and_then(|pull| pull.head.as_ref().and_then(|h| h.sha.clone()));
|
||
// 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)
|
||
|| reviewed_older_head(r.commit_id.as_deref(), head_sha.as_deref());
|
||
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, with its `number`/`title`/
|
||
/// `state`. Forgejo's dependency endpoint works on the shared issue/PR
|
||
/// index (PRs are issues internally under the hood), so `issue show`,
|
||
/// `pr show`, and `list`'s dep-progress annotation all 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.
|
||
/// `state` was added alongside `list`'s annotation so a caller can tell
|
||
/// open deps from closed ones without a second fetch.
|
||
///
|
||
/// # 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, "state": d.state }))
|
||
.collect())
|
||
}
|
||
|
||
/// One entry in a `GET .../blocks` response — same `number`/`title`/
|
||
/// `state` shape [`dependency_summaries`] maps down to, but only those
|
||
/// fields: this struct exists to be lenient (see
|
||
/// [`Client::get_api_json`]'s doc comment), not to model the full Issue
|
||
/// response body.
|
||
#[derive(Deserialize)]
|
||
struct BlockingIssue {
|
||
#[serde(default)]
|
||
number: u64,
|
||
#[serde(default)]
|
||
title: String,
|
||
#[serde(default)]
|
||
state: String,
|
||
}
|
||
|
||
/// The issues *blocked by* `number` — the reverse of
|
||
/// [`dependency_summaries`]. Forgejo's dependency API is one-directional
|
||
/// in `forgejo-api`'s generated client (only the forward `GET
|
||
/// .../dependencies` is wrapped), but the reverse route is real — `GET
|
||
/// .../blocks`, confirmed against Forgejo/Gitea's actual API surface, just
|
||
/// not covered by the crate — so this goes through
|
||
/// [`Client::get_api_json`] instead, same escape hatch [`issue_reactions`]
|
||
/// already uses for an uncovered route.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Propagates the forge API errors from listing blocking issues.
|
||
pub(crate) fn blocking_summaries(
|
||
client: &Client,
|
||
owner: &str,
|
||
name: &str,
|
||
number: u64,
|
||
) -> Result<Vec<Value>> {
|
||
let blocking: NullableVec<BlockingIssue> = client.get_api_json(
|
||
&format!("/repos/{owner}/{name}/issues/{number}/blocks"),
|
||
&[],
|
||
)?;
|
||
Ok(blocking
|
||
.0
|
||
.into_iter()
|
||
.map(|b| json!({ "number": b.number, "title": b.title, "state": b.state }))
|
||
.collect())
|
||
}
|
||
|
||
/// Deserialize a possibly-null JSON array as an empty `Vec`.
|
||
///
|
||
/// Several Forgejo list endpoints return an explicit `null` body instead of
|
||
/// `[]` when the collection is empty — a commit's combined-status
|
||
/// `statuses` field when no CI is configured (`pr_status`'s own use of
|
||
/// this), and issue/comment reactions when nothing has reacted yet (a real
|
||
/// incident: `issue show`/`pr show` failing on *every* item because every
|
||
/// item's reaction fetch hit this). `#[serde(default)]` alone only
|
||
/// covers a *missing* key — a *present* `null` still fails to deserialize
|
||
/// into a bare `Vec<T>`, which is why this explicit `deserialize_with` is
|
||
/// needed rather than the derive default.
|
||
pub(crate) fn null_as_empty<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
T: Deserialize<'de>,
|
||
{
|
||
Ok(Option::<Vec<T>>::deserialize(de)?.unwrap_or_default())
|
||
}
|
||
|
||
/// A JSON array that tolerates Forgejo's null-for-empty quirk (see
|
||
/// [`null_as_empty`]). Used to fetch reactions directly via
|
||
/// [`Client::get_api_json`] rather than through forgejo-api's generated
|
||
/// client, which deserializes straight into a bare `Vec<Reaction>` with no
|
||
/// null tolerance and fails on exactly the common case of "nothing has
|
||
/// reacted to this yet".
|
||
struct NullableVec<T>(Vec<T>);
|
||
|
||
impl<'de, T: Deserialize<'de>> Deserialize<'de> for NullableVec<T> {
|
||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
Ok(Self(null_as_empty(de)?))
|
||
}
|
||
}
|
||
|
||
/// The current reactions on an issue or PR itself (not a comment) — each
|
||
/// entry's `content` is Forgejo's shortcode (`"+1"`, `"heart"`, …, the
|
||
/// same vocabulary GitHub uses), `user` its login. Same shared-index
|
||
/// rationale as [`dependency_summaries`]: PRs are issues internally, so
|
||
/// `issue`/`pr`/`view` and the `reaction` verb's own listing all call
|
||
/// this instead of duplicating the fetch-and-shape step.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Propagates the forge API errors from listing reactions.
|
||
pub(crate) fn issue_reactions(
|
||
client: &Client,
|
||
owner: &str,
|
||
name: &str,
|
||
number: u64,
|
||
) -> Result<Vec<Value>> {
|
||
let reactions: NullableVec<Reaction> = client.get_api_json(
|
||
&format!("/repos/{owner}/{name}/issues/{number}/reactions"),
|
||
&[],
|
||
)?;
|
||
Ok(reaction_values(reactions.0))
|
||
}
|
||
|
||
/// The current reactions on a single comment, by comment id (not the
|
||
/// parent issue/PR number — Forgejo's comment-reaction endpoints are
|
||
/// keyed on the comment alone, same as [`comment_show`]'s lookup).
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Propagates the forge API errors from listing reactions.
|
||
pub(crate) fn comment_reactions(
|
||
client: &Client,
|
||
owner: &str,
|
||
name: &str,
|
||
comment_id: u64,
|
||
) -> Result<Vec<Value>> {
|
||
let reactions: NullableVec<Reaction> = client.get_api_json(
|
||
&format!("/repos/{owner}/{name}/issues/comments/{comment_id}/reactions"),
|
||
&[],
|
||
)?;
|
||
Ok(reaction_values(reactions.0))
|
||
}
|
||
|
||
fn reaction_values(reactions: Vec<Reaction>) -> Vec<Value> {
|
||
reactions
|
||
.into_iter()
|
||
.map(|r| json!({ "content": r.content, "user": r.user.and_then(|u| u.login) }))
|
||
.collect()
|
||
}
|
||
|
||
/// A one-line `content×count` summary of a reaction list (e.g. `+1×2
|
||
/// heart×1`), grouped and sorted by content name for a stable rendering.
|
||
/// `None` for an empty list, so a row/section with no reactions omits the
|
||
/// summary entirely rather than printing something empty.
|
||
pub(crate) fn reaction_summary(reactions: &[Value]) -> Option<String> {
|
||
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||
for r in reactions {
|
||
if let Some(content) = r.get("content").and_then(Value::as_str) {
|
||
*counts.entry(content).or_insert(0) += 1;
|
||
}
|
||
}
|
||
if counts.is_empty() {
|
||
return None;
|
||
}
|
||
Some(
|
||
counts
|
||
.into_iter()
|
||
.map(|(content, n)| format!("{content}×{n}"))
|
||
.collect::<Vec<_>>()
|
||
.join(" "),
|
||
)
|
||
}
|
||
|
||
/// A single attachment as one display line — `[file: <name>] <url>`,
|
||
/// mirroring the `[file: ...]` marker convention `read_room` already
|
||
/// uses for matrix attachments. `None` when the attachment has no
|
||
/// download URL (shouldn't happen server-side, but a missing pointer
|
||
/// is worse silently dropped than shown as "?").
|
||
///
|
||
/// Forgejo already returns `assets` inline on the same `Comment`/`Issue`
|
||
/// fetch every render path here already makes — this just reads a field
|
||
/// that was sitting unused, the gap that made an attachment link
|
||
/// unreadable from a non-visual CLI read without guessing the UUID by
|
||
/// hand (hit in practice on the swarm-controller extraction thread).
|
||
pub(crate) fn attachment_line(a: &Attachment) -> Option<String> {
|
||
let name = a.name.as_deref().unwrap_or("?");
|
||
let url = a.browser_download_url.as_ref()?;
|
||
Some(format!("[file: {name}] {url}"))
|
||
}
|
||
|
||
/// JSON form of an attachment list (`{"name", "url"}` per entry), for
|
||
/// `--json` output — same data [`attachment_line`] renders as text.
|
||
pub(crate) fn attachment_json(assets: Option<&[Attachment]>) -> Vec<Value> {
|
||
assets
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.map(|a| {
|
||
json!({
|
||
"name": a.name,
|
||
"url": a.browser_download_url.as_ref().map(ToString::to_string),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{NullableVec, pct_encode, reviewed_older_head};
|
||
|
||
#[test]
|
||
fn nullable_vec_treats_a_json_null_as_empty() {
|
||
// The exact shape of the real incident this guards: Forgejo answers
|
||
// a reactions (or combined-status) list with a bare `null` body
|
||
// when the collection is empty, not `[]` — deserializing straight
|
||
// into `Vec<T>` fails on it, `NullableVec<T>` must not.
|
||
let v: NullableVec<i64> = serde_json::from_str("null").unwrap();
|
||
assert_eq!(v.0, Vec::<i64>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn nullable_vec_passes_a_real_array_through() {
|
||
let v: NullableVec<i64> = serde_json::from_str("[1,2,3]").unwrap();
|
||
assert_eq!(v.0, vec![1, 2, 3]);
|
||
}
|
||
|
||
#[test]
|
||
fn review_on_an_older_commit_is_stale() {
|
||
// The bug this exists for: forgejo still reports `stale: false` here
|
||
// in the seconds after a push, so the comparison has to catch it.
|
||
assert!(reviewed_older_head(Some("81292f14"), Some("f4c47088")));
|
||
}
|
||
|
||
#[test]
|
||
fn review_on_the_current_head_is_not_stale() {
|
||
assert!(!reviewed_older_head(Some("f4c47088"), Some("f4c47088")));
|
||
}
|
||
|
||
/// Every unknown fails *toward* keeping the verdict. Voiding every review
|
||
/// on a forge that stopped reporting one of these would be a far louder
|
||
/// wrong answer than one redundant re-review.
|
||
#[test]
|
||
fn unknown_commit_or_head_is_not_stale() {
|
||
assert!(!reviewed_older_head(None, Some("f4c47088")));
|
||
assert!(!reviewed_older_head(Some("81292f14"), None));
|
||
assert!(!reviewed_older_head(Some(""), Some("f4c47088")));
|
||
// Absent and blank have to behave the same on BOTH sides — a blank
|
||
// head that counted as real would mark every review on the PR stale.
|
||
assert!(!reviewed_older_head(Some("81292f14"), Some("")));
|
||
assert!(!reviewed_older_head(Some(""), Some("")));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[test]
|
||
fn nearest_finds_the_one_character_typo() {
|
||
let all = ["area/ops", "area/broker", "type/bug"];
|
||
assert_eq!(super::nearest("area/opps", &all), Some("area/ops"));
|
||
assert_eq!(super::nearest("type/bugs", &all), Some("type/bug"));
|
||
}
|
||
|
||
#[test]
|
||
fn nearest_suggests_nothing_for_an_invention() {
|
||
// The important half: a wrong suggestion invites a second failed
|
||
// attempt at a name that was never there, so far-away input must
|
||
// fall back to "here is everything".
|
||
let all = ["area/ops", "area/broker", "type/bug"];
|
||
assert_eq!(super::nearest("frontend", &all), None);
|
||
assert_eq!(super::nearest("", &all), None);
|
||
}
|
||
|
||
#[test]
|
||
fn nearest_is_stable_when_two_candidates_tie() {
|
||
// Both are distance 1 from "v3"; the answer must not depend on
|
||
// the order the forge returned them in.
|
||
let forward = ["v1", "v2"];
|
||
let reversed = ["v2", "v1"];
|
||
assert_eq!(
|
||
super::nearest("v3", &forward),
|
||
super::nearest("v3", &reversed)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn with_suggestions_annotates_only_the_near_misses() {
|
||
let all = ["area/ops", "type/bug"];
|
||
let rendered = super::with_suggestions(&["area/opps", "frontend"], &all);
|
||
assert!(
|
||
rendered.contains(r#"area/opps (did you mean "area/ops"?)"#),
|
||
"{rendered}"
|
||
);
|
||
assert!(rendered.contains("frontend"), "{rendered}");
|
||
assert!(
|
||
!rendered.contains(r"frontend (did you mean"),
|
||
"invented name must not get a suggestion: {rendered}"
|
||
);
|
||
}
|
||
}
|