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
|
|
@ -10,14 +10,18 @@
|
|||
//! - `assignments [--user NAME]`
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||||
use forgejo_api::structs::{
|
||||
Issue, IssueGetCommentsQuery, IssueListIssuesQuery, IssueListIssuesQueryState,
|
||||
IssueListIssuesQueryType, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
use crate::verbs::{print_json, rfc3339};
|
||||
|
||||
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
||||
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
||||
|
|
@ -53,11 +57,14 @@ enum Kind {
|
|||
}
|
||||
|
||||
impl Kind {
|
||||
fn forgejo_type(self) -> &'static str {
|
||||
/// Forgejo's `type` filter: `issues` / `pulls`, or absent for the
|
||||
/// both-kinds slice (the forge returns everything when `type` is
|
||||
/// omitted).
|
||||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
||||
match self {
|
||||
Kind::Issues => "issues",
|
||||
Kind::Pulls => "pulls",
|
||||
Kind::All => "all",
|
||||
Kind::Issues => Some(IssueListIssuesQueryType::Issues),
|
||||
Kind::Pulls => Some(IssueListIssuesQueryType::Pulls),
|
||||
Kind::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,11 +77,19 @@ enum State {
|
|||
}
|
||||
|
||||
impl State {
|
||||
fn as_str(self) -> &'static str {
|
||||
fn issue_state(self) -> IssueListIssuesQueryState {
|
||||
match self {
|
||||
State::Open => "open",
|
||||
State::Closed => "closed",
|
||||
State::All => "all",
|
||||
State::Open => IssueListIssuesQueryState::Open,
|
||||
State::Closed => IssueListIssuesQueryState::Closed,
|
||||
State::All => IssueListIssuesQueryState::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_state(self) -> RepoListPullRequestsQueryState {
|
||||
match self {
|
||||
State::Open => RepoListPullRequestsQueryState::Open,
|
||||
State::Closed => RepoListPullRequestsQueryState::Closed,
|
||||
State::All => RepoListPullRequestsQueryState::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,26 +140,44 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drain the repo's issue list (both kinds unless filtered) across
|
||||
/// pages, up to the runaway cap.
|
||||
fn fetch_issues(
|
||||
client: &Client,
|
||||
r#type: Option<IssueListIssuesQueryType>,
|
||||
state: IssueListIssuesQueryState,
|
||||
) -> Result<Vec<Issue>> {
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = IssueListIssuesQuery {
|
||||
state: Some(state),
|
||||
r#type,
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.issue_list_issues(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
items.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
// ───────────────────────── unassigned ─────────────────────────
|
||||
|
||||
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let items = client.get_json_all(
|
||||
&format!(
|
||||
"/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}",
|
||||
args.r#type.forgejo_type(),
|
||||
args.state.as_str()
|
||||
),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?;
|
||||
let filtered: Vec<Value> = items
|
||||
.into_iter()
|
||||
.filter(|it| {
|
||||
it.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(Vec::is_empty)
|
||||
})
|
||||
.map(trim_item)
|
||||
.iter()
|
||||
.filter(|it| it.assignees.as_ref().is_none_or(Vec::is_empty))
|
||||
.map(trim_issue)
|
||||
.collect();
|
||||
emit(client, &filtered, |it| {
|
||||
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
||||
|
|
@ -154,41 +187,64 @@ fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
|||
// ───────────────────────── no-reviewer ────────────────────────
|
||||
|
||||
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
// PR-only: `/repos/{repo}/pulls` doesn't return issues.
|
||||
let pulls = client.get_json_all(
|
||||
&format!(
|
||||
"/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}",
|
||||
args.state.as_str()
|
||||
),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
// PR-only: the pulls endpoint doesn't return issues.
|
||||
let mut pulls = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = RepoListPullRequestsQuery {
|
||||
state: Some(args.state.pull_state()),
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_pull_requests(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
pulls.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let needle = format!("@{}", args.reviewer);
|
||||
let mut missing: Vec<Value> = Vec::new();
|
||||
for pr in pulls {
|
||||
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
|
||||
if number == 0 {
|
||||
for pr in &pulls {
|
||||
let Some(number) = pr.number.filter(|n| *n > 0) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
|
||||
// that already @reviewer in the description.
|
||||
let body = pr.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
if body.contains(&needle) {
|
||||
if pr.body.as_deref().unwrap_or("").contains(&needle) {
|
||||
continue;
|
||||
}
|
||||
// Paginate so PRs with >50 comments don't yield false positives
|
||||
// (flagged in review). Same 1000-comment ceiling as elsewhere.
|
||||
let comments = client.get_json_all(
|
||||
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let mentioned = comments.iter().any(|c| {
|
||||
c.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|body| body.contains(&needle))
|
||||
});
|
||||
let mut mentioned = false;
|
||||
for page in 1..=MAX_PAGES {
|
||||
let (_, comments) = client
|
||||
.api()
|
||||
.issue_get_comments(owner, name, number, IssueGetCommentsQuery::default())
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = comments.len() < PAGE_LIMIT as usize;
|
||||
mentioned = comments
|
||||
.iter()
|
||||
.any(|c| c.body.as_deref().is_some_and(|body| body.contains(&needle)));
|
||||
if mentioned || short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !mentioned {
|
||||
missing.push(trim_item(pr));
|
||||
missing.push(json!({
|
||||
"number": pr.number,
|
||||
"title": pr.title,
|
||||
"state": pr.state,
|
||||
"url": pr.html_url,
|
||||
"is_pr": true,
|
||||
"assignees": logins(pr.assignees.as_deref()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
||||
|
|
@ -200,50 +256,64 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
|||
if args.days < 0 {
|
||||
bail!("--days must be non-negative");
|
||||
}
|
||||
let repo = client.repo();
|
||||
let branches = client.get_json_all(
|
||||
&format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let mut branches = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_branches(owner, name)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
branches.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Collect active PR head refs to skip — a branch with an open PR
|
||||
// isn't "stale", it's "in review".
|
||||
let open_pulls = client.get_json_all(
|
||||
&format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let active_heads: std::collections::HashSet<String> = open_pulls
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
p.get("head")
|
||||
.and_then(|h| h.get("ref"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = RepoListPullRequestsQuery {
|
||||
state: Some(RepoListPullRequestsQueryState::Open),
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_pull_requests(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
active_heads.extend(
|
||||
batch
|
||||
.iter()
|
||||
.filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())),
|
||||
);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cutoff_days = today_days_utc().context("compute today")? - args.days;
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let mut stale: Vec<Value> = Vec::new();
|
||||
for br in branches {
|
||||
let name = br.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if name.is_empty() || active_heads.contains(name) {
|
||||
for br in &branches {
|
||||
let branch_name = br.name.as_deref().unwrap_or("");
|
||||
if branch_name.is_empty() || active_heads.contains(branch_name) {
|
||||
continue;
|
||||
}
|
||||
let ts = br
|
||||
.get("commit")
|
||||
.and_then(|c| c.get("timestamp"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let Some(date_str) = ts.get(..10) else {
|
||||
let Some(ts) = br.commit.as_ref().and_then(|c| c.timestamp) else {
|
||||
continue;
|
||||
};
|
||||
let Some(days) = parse_yyyy_mm_dd_days(date_str) else {
|
||||
continue;
|
||||
};
|
||||
if days <= cutoff_days {
|
||||
// Whole days between the commit's calendar date and today —
|
||||
// date-granular, matching the old YYYY-MM-DD prefix math.
|
||||
let age_days = (today - ts.date()).whole_days();
|
||||
if age_days >= args.days {
|
||||
stale.push(json!({
|
||||
"name": name,
|
||||
"last_commit": ts,
|
||||
"age_days": (today_days_utc().unwrap_or(days) - days),
|
||||
"name": branch_name,
|
||||
"last_commit": rfc3339(Some(ts)),
|
||||
"age_days": age_days,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -257,27 +327,14 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
|||
// ───────────────────────── assignments ────────────────────────
|
||||
|
||||
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let items = client.get_json_all(
|
||||
&format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let items = fetch_issues(client, None, IssueListIssuesQueryState::Open)?;
|
||||
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
for it in items {
|
||||
let assignees: Vec<String> = it
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.get("login").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for it in &items {
|
||||
let assignees = logins(it.assignees.as_deref());
|
||||
if assignees.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let slim = trim_item(it);
|
||||
let slim = trim_issue(it);
|
||||
for u in assignees {
|
||||
if args.user.as_deref().is_some_and(|w| w != u) {
|
||||
continue;
|
||||
|
|
@ -317,27 +374,27 @@ fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
|||
|
||||
// ───────────────────────── shared helpers ─────────────────────
|
||||
|
||||
/// Strip a Forgejo issue/PR JSON down to the fields lint output cares
|
||||
/// Assignee logins from a typed user list (missing logins dropped).
|
||||
fn logins(users: Option<&[forgejo_api::structs::User]>) -> Vec<String> {
|
||||
users
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|u| u.login.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Strip a Forgejo issue/PR down to the fields lint output cares
|
||||
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
||||
fn trim_item(it: Value) -> Value {
|
||||
fn trim_issue(it: &Issue) -> Value {
|
||||
json!({
|
||||
"number": it.get("number"),
|
||||
"title": it.get("title"),
|
||||
"state": it.get("state"),
|
||||
"url": it.get("html_url"),
|
||||
// Forgejo's unified /issues endpoint always emits a
|
||||
// `pull_request` key — `null` for plain issues, an object
|
||||
// (with merged/url/etc.) for PRs. Treat any non-null as a PR.
|
||||
"is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()),
|
||||
"assignees": it
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.get("login").cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
"number": it.number,
|
||||
"title": it.title,
|
||||
"state": it.state,
|
||||
"url": it.html_url,
|
||||
// The unified issues endpoint marks PRs with a `pull_request`
|
||||
// object (absent/null for plain issues).
|
||||
"is_pr": it.pull_request.is_some(),
|
||||
"assignees": logins(it.assignees.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -375,77 +432,3 @@ where
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ───
|
||||
|
||||
/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock).
|
||||
fn today_days_utc() -> Result<i64> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock before epoch")?
|
||||
.as_secs();
|
||||
// `as_secs()` returns u64; clamp into i64 (won't overflow until y2554).
|
||||
Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp)
|
||||
/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse
|
||||
/// failure rather than panicking — lint output stays best-effort.
|
||||
fn parse_yyyy_mm_dd_days(stamp: &str) -> Option<i64> {
|
||||
let bytes = stamp.as_bytes();
|
||||
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
|
||||
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
|
||||
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
|
||||
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return None;
|
||||
}
|
||||
Some(days_from_civil(year, month, day))
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since
|
||||
/// 1970-01-01. Public-domain reference algorithm. Handles negative years.
|
||||
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = i64::from(y - era * 400); // [0, 399]
|
||||
let m = i64::from(month);
|
||||
let d = i64::from(day);
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
i64::from(era) * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn epoch_is_day_zero() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_dates() {
|
||||
// Hinnant reference values
|
||||
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
|
||||
assert_eq!(days_from_civil(2020, 2, 29), 18_321);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_iso_prefix() {
|
||||
assert_eq!(
|
||||
parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"),
|
||||
Some(18_321)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None);
|
||||
assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None);
|
||||
assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue