diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index c6ffa3d6..289d1037 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -1,11 +1,14 @@ -//! Blocking HTTP client for the Forgejo REST API. Identity is the -//! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All -//! verbs go through this client so error surfaces, header set, and -//! 4xx/5xx body unwrapping stay consistent. +//! App-level Forgejo client wrapper. Identity is the per-agent token +//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through +//! the typed [`forgejo_api::sync::Forgejo`] client (exposed via +//! [`Client::api`]); a minimal raw `reqwest` client remains for the +//! few *web-router* routes Forgejo does not serve under `/api/v1/` +//! (attachment downloads, Actions artifact/log routes). use std::path::PathBuf; use anyhow::{Context, Result, bail}; +use forgejo_api::{Auth, ForgejoError}; use reqwest::blocking::{Client as HttpClient, Response}; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::Serialize; @@ -18,14 +21,23 @@ const DEFAULT_URL: &str = "http://localhost:3000"; /// take a repo override. const DEFAULT_REPO: &str = "hyperhive/hyperhive"; -/// Blocking Forgejo API client. Cheap to clone (wraps an -/// `Arc` internally). +/// Forgejo client pair: the typed `/api/v1` client plus a raw +/// `reqwest` client for web-router-only routes. pub struct Client { - http: HttpClient, + /// Typed Forgejo REST client — every `/api/v1` call goes through + /// this (see [`Client::api`]). + api: forgejo_api::sync::Forgejo, + /// Raw HTTP client for the routes Forgejo serves only through its + /// web router, NOT under `/api/v1/` (so `forgejo-api` has no + /// method for them): attachment downloads at `/attachments/`, + /// Actions artifact zips, the run-view log streamer, and the + /// persisted-log download. Carries the same `Authorization: token` + /// header the typed client sends. + web: HttpClient, base: String, - /// Per-agent forge token. Kept alongside the pre-built auth header - /// so verbs that shell out to `git` (e.g. `clone`) can assemble - /// an authenticated push URL without re-reading the token file. + /// Per-agent forge token. Kept so verbs that shell out to `git` + /// (e.g. `clone`) can assemble an authenticated push URL without + /// re-reading the token file. token: String, /// Default repo used when a verb doesn't carry an explicit /// `[repo]` override. @@ -49,19 +61,24 @@ impl Client { .unwrap_or_else(|| DEFAULT_REPO.to_owned()); let token = read_token().context("read forge-token")?; + let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?; + let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url) + .context("build forgejo client")?; + let mut headers = HeaderMap::new(); let auth = format!("token {token}"); let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?; auth_val.set_sensitive(true); headers.insert(AUTHORIZATION, auth_val); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - - let http = HttpClient::builder() + let web = HttpClient::builder() .default_headers(headers) .build() .context("build reqwest client")?; + Ok(Self { - http, + api, + web, base, token, default_repo, @@ -69,6 +86,13 @@ impl Client { }) } + /// The typed Forgejo REST client. All `/api/v1` traffic goes + /// through this. + #[must_use] + pub fn api(&self) -> &forgejo_api::sync::Forgejo { + &self.api + } + /// Assemble an authenticated git URL for `repo` (e.g. /// `internal/knowledge`) by injecting the agent's forge user + /// token into the base URL's authority: `http://:@host/.git`. @@ -95,11 +119,6 @@ impl Client { self.json_mode } - /// Resolve the API base path (`/api/v1`). - fn api(&self) -> String { - format!("{}/api/v1", self.base) - } - /// Return the active repo. `from_env` already folded the /// `-r/--repo` override into `default_repo`, so verbs just read /// it as-is — no per-verb override plumbing. @@ -108,177 +127,10 @@ impl Client { &self.default_repo } - /// GET `/` and decode JSON. - pub fn get_json(&self, path: &str) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self.http.get(&url).send().context("GET")?; - decode_json(resp, &format!("GET {url}")) - } - - /// GET `/` and decode JSON. Returns `None` if the - /// server responds with 404 (resource absent rather than an error). - pub fn get_json_optional(&self, path: &str) -> Result> { - let url = format!("{}{}", self.api(), path); - let resp = self.http.get(&url).send().context("GET")?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - decode_json(resp, &format!("GET {url}")).map(Some) - } - - /// GET a paginated list endpoint and concatenate all pages. - /// `path` should NOT include `page=` (we own it); other query - /// params (`?limit=N&state=open&...`) are preserved. Pages drain - /// while the response carries a `Link: rel="next"` header, up to - /// `max_pages` (the runaway-loop safety cap). Returns the merged - /// array. Used by `lint` for repo-wide queries. - pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result> { - let sep = if path.contains('?') { '&' } else { '?' }; - let mut merged = Vec::new(); - for page in 1..=max_pages { - let url = format!("{}{}{sep}page={page}", self.api(), path); - let resp = self.http.get(&url).send().context("GET")?; - let has_next = resp - .headers() - .get(reqwest::header::LINK) - .and_then(|v| v.to_str().ok()) - .is_some_and(|s| s.contains("rel=\"next\"")); - let v = decode_json(resp, &format!("GET {url}"))?; - let arr = v.as_array().cloned().unwrap_or_default(); - let empty = arr.is_empty(); - merged.extend(arr); - if empty || !has_next { - break; - } - } - Ok(merged) - } - - /// GET `/` and return the raw response body as text - /// (used by `diff` which fetches a `text/plain` blob). - pub fn get_text(&self, path: &str, accept: &str) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .get(&url) - .header(ACCEPT, accept) - .send() - .context("GET")?; - decode_text(resp, &format!("GET {url}")) - } - - /// POST a JSON body to `/` and decode the response. - pub fn post_json(&self, path: &str, body: &B) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .post(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("POST")?; - decode_json(resp, &format!("POST {url}")) - } - - /// PATCH a JSON body to `/` and decode the response. - pub fn patch_json(&self, path: &str, body: &B) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .patch(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("PATCH")?; - decode_json(resp, &format!("PATCH {url}")) - } - - /// PUT a JSON body to `/` and decode the response. - pub fn put_json(&self, path: &str, body: &B) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .put(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("PUT")?; - decode_json(resp, &format!("PUT {url}")) - } - - /// PUT a JSON body to `/` for an endpoint that returns - /// `204 No Content` (empty body), so there is nothing to decode. - /// Used by `repo-add-collaborator` (Forgejo's add-collaborator PUT - /// answers 204 on success). - /// - /// # Errors - /// - /// Returns an error if the request fails to send (transport/network - /// error) or the server responds with a non-2xx status (the response - /// body is included in the error). - pub fn put_no_content(&self, path: &str, body: &B) -> Result<()> { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .put(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("PUT")?; - check_status(resp, &format!("PUT {url}"))?; - Ok(()) - } - - /// PATCH `/` for an endpoint that returns a 2xx with an - /// empty body (nothing to decode). Used to mark a notification thread - /// read (`/notifications/threads/{id}` answers `205 Reset Content`). - /// - /// # Errors - /// - /// Returns an error if the request fails to send (transport/network - /// error) or the server responds with a non-2xx status (the response - /// body is included in the error). - pub fn patch_no_content(&self, path: &str) -> Result<()> { - let url = format!("{}{}", self.api(), path); - let resp = self.http.patch(&url).send().context("PATCH")?; - check_status(resp, &format!("PATCH {url}"))?; - Ok(()) - } - - /// POST a JSON body to `/` for an endpoint that returns a - /// 2xx with an empty body (so there is nothing to decode). Used by - /// `pr-merge` — Forgejo's merge endpoint answers `200 OK` with no body - /// on success and a non-2xx (e.g. `405`) when the PR is not mergeable. - /// - /// # Errors - /// - /// Returns an error if the request fails to send (transport/network - /// error) or the server responds with a non-2xx status (the response - /// body is included in the error). - pub fn post_no_content(&self, path: &str, body: &B) -> Result<()> { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .post(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("POST")?; - check_status(resp, &format!("POST {url}"))?; - Ok(()) - } - - /// DELETE `/`. Optional JSON body for endpoints that - /// need it (Forgejo's subscription unwatch uses bodyless DELETE). - pub fn delete(&self, path: &str, body: Option<&Value>) -> Result<()> { - let url = format!("{}{}", self.api(), path); - let mut req = self.http.delete(&url); - if let Some(b) = body { - req = req.header(CONTENT_TYPE, "application/json").json(b); - } - let resp = req.send().context("DELETE")?; - check_status(resp, &format!("DELETE {url}"))?; - Ok(()) + /// The active repo split into `(owner, name)` for the typed + /// client's per-segment path arguments. + pub fn owner_repo(&self) -> Result<(&str, &str)> { + split_repo(self.repo()) } /// Build the full URL for a Forgejo attachment by UUID. @@ -300,9 +152,9 @@ impl Client { } /// POST a JSON body to a base-relative *web* path (NOT under - /// `/api/v1/`) and decode the JSON response. Used for endpoints - /// Forgejo only serves through its web router — e.g. the Actions - /// run-view log streamer at + /// `/api/v1/`, so no `forgejo-api` method exists) and decode the + /// JSON response. Used for endpoints Forgejo only serves through + /// its web router — e.g. the Actions run-view log streamer at /// `///actions/runs//jobs/`. The /// agent's `Authorization: token` header is sent as usual; the /// web router accepts a token-authed doer and, because the handler @@ -312,44 +164,67 @@ impl Client { pub fn post_json_web(&self, path: &str, body: &B) -> Result { let url = self.web_url(path); let resp = self - .http + .web .post(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("POST")?; - decode_json(resp, &format!("POST {url}")) + let resp = check_status(resp, &format!("POST {url}"))?; + resp.json::() + .with_context(|| format!("decode JSON for POST {url}")) } /// GET a raw (non-API) URL and return the response body as bytes. - /// The client's auth headers are still sent — Forgejo requires them - /// for private attachment downloads. Uses the full URL as-is; the - /// caller is responsible for constructing it (see `attachment_url`). + /// Stays on the raw `reqwest` client because these are *web-router* + /// routes (`/attachments/`, Actions artifact / persisted-log + /// downloads) with no `/api/v1` equivalent. The client's auth + /// headers are still sent — Forgejo requires them for private + /// attachment downloads. Uses the full URL as-is; the caller is + /// responsible for constructing it (see `attachment_url`). pub fn get_bytes_raw(&self, url: &str) -> Result> { - let resp = self.http.get(url).send().context("GET")?; + let resp = self.web.get(url).send().context("GET")?; let resp = check_status(resp, &format!("GET {url}"))?; resp.bytes() .map(|b| b.to_vec()) .with_context(|| format!("read bytes for GET {url}")) } +} - /// POST a multipart file upload, returning the parsed response. - /// Used by `attach-issue` / `attach-comment`. - pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result { - let url = format!("{}{}", self.api(), path); - let form = reqwest::blocking::multipart::Form::new() - .file("attachment", file) - .with_context(|| format!("read {}", file.display()))?; - let resp = self - .http - .post(&url) - .multipart(form) - .send() - .context("POST")?; - decode_json(resp, &format!("POST {url}")) +/// Split an `owner/name` repo string into its two path segments for +/// the typed client. Errors on anything that isn't exactly +/// `owner/name` with both halves non-empty. +pub fn split_repo(repo: &str) -> Result<(&str, &str)> { + match repo.split_once('/') { + Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => { + Ok((owner, name)) + } + _ => bail!("hive-forge: repo must be of the form owner/name, got {repo:?}"), } } +/// True when a typed-client error is Forgejo saying 404 (resource +/// absent rather than a transport / auth / server failure). Used by +/// verbs that treat "not there" as a normal state (e.g. +/// `subscription` reads a 404 as "not watching"). +#[must_use] +pub fn is_not_found(err: &ForgejoError) -> bool { + match err { + ForgejoError::ApiError(e) => { + matches!(e.error_kind(), forgejo_api::ApiErrorKind::NotFound { .. }) + } + ForgejoError::UnexpectedStatusCode(code) => *code == reqwest::StatusCode::NOT_FOUND, + _ => false, + } +} + +/// Convert a CLI-side `u64` issue/PR/comment number to the `i64` the +/// typed client's path arguments use. Numbers past `i64::MAX` don't +/// exist on any forge; error instead of wrapping. +pub fn index(n: u64) -> Result { + i64::try_from(n).with_context(|| format!("number {n} out of range")) +} + /// Locate and read the forge token. Falls back to `$PWD/forge-token` /// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. fn read_token() -> Result { @@ -364,9 +239,10 @@ fn read_token() -> Result { Ok(raw.trim().to_owned()) } -/// Surface non-2xx HTTP responses as anyhow errors with the response -/// body included (matches `curl --fail-with-body`) — turns -/// silent failures into errors with a clear message. +/// Surface non-2xx HTTP responses on the raw web routes as anyhow +/// errors with the response body included (matches `curl +/// --fail-with-body`) — turns silent failures into errors with a +/// clear message. fn check_status(resp: Response, op: &str) -> Result { let status = resp.status(); if status.is_success() { @@ -376,13 +252,28 @@ fn check_status(resp: Response, op: &str) -> Result { bail!("hive-forge: {op} failed ({status}): {body}"); } -fn decode_json(resp: Response, op: &str) -> Result { - let resp = check_status(resp, op)?; - resp.json::() - .with_context(|| format!("decode JSON for {op}")) -} +#[cfg(test)] +mod tests { + use super::split_repo; -fn decode_text(resp: Response, op: &str) -> Result { - let resp = check_status(resp, op)?; - resp.text().with_context(|| format!("decode text for {op}")) + #[test] + fn split_repo_accepts_owner_name() { + assert_eq!( + split_repo("hyperhive/hyperhive").unwrap(), + ("hyperhive", "hyperhive") + ); + assert_eq!( + split_repo("internal/knowledge").unwrap(), + ("internal", "knowledge") + ); + } + + #[test] + fn split_repo_rejects_malformed() { + assert!(split_repo("no-slash").is_err()); + assert!(split_repo("/name").is_err()); + assert!(split_repo("owner/").is_err()); + assert!(split_repo("a/b/c").is_err()); + assert!(split_repo("").is_err()); + } } diff --git a/hive-forge/src/notify.rs b/hive-forge/src/notify.rs index d29179dd..cfa50631 100644 --- a/hive-forge/src/notify.rs +++ b/hive-forge/src/notify.rs @@ -13,12 +13,12 @@ //! `forge cli: agents keep commenting without reading prev comments`.) use anyhow::Result; -use serde_json::Value; +use forgejo_api::structs::{NotifyGetRepoListQuery, NotifyReadThreadQuery}; -use crate::client::Client; +use crate::client::{Client, index, split_repo}; /// Forgejo's per-page notification cap. -const PAGE_SIZE: u64 = 50; +const PAGE_SIZE: u32 = 50; /// How many pages of unread notifications to scan for the thread. /// Notifications come newest-first and a thread the caller is about to /// comment on was just active, so it sits near the top; this cap keeps @@ -26,7 +26,7 @@ const PAGE_SIZE: u64 = 50; /// firehose), at the cost of not detecting a match buried past /// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for /// a courtesy guard). -const MAX_PAGES: u64 = 5; +const MAX_PAGES: u32 = 5; /// The notification thread id of an UNREAD notification on /// `#`, or `None` when the thread has no unread @@ -34,24 +34,30 @@ const MAX_PAGES: u64 = 5; /// number can't false-match another repo. Errors only on transport / /// non-2xx — callers degrade open on `Err`. pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result> { + let (owner, name) = split_repo(repo)?; for page in 1..=MAX_PAGES { - let v = client.get_json(&format!( - "/repos/{repo}/notifications?all=false&page={page}&limit={PAGE_SIZE}" - ))?; - let arr = v.as_array().cloned().unwrap_or_default(); - let len = arr.len() as u64; - for n in &arr { + let query = NotifyGetRepoListQuery { + all: Some(false), + ..Default::default() + }; + let (_, threads) = client + .api() + .notify_get_repo_list(owner, name, query) + .page(page) + .page_size(PAGE_SIZE) + .send()?; + for n in &threads { let subject_url = n - .get("subject") - .and_then(|s| s.get("url")) - .and_then(Value::as_str) - .unwrap_or(""); + .subject + .as_ref() + .and_then(|s| s.url.as_ref()) + .map_or("", url::Url::as_str); if subject_matches(subject_url, number) { - return Ok(n.get("id").and_then(Value::as_u64)); + return Ok(n.id.and_then(|id| u64::try_from(id).ok())); } } // Last (short) page reached — stop. - if len < PAGE_SIZE { + if threads.len() < PAGE_SIZE as usize { break; } } @@ -62,7 +68,11 @@ pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result Result<()> { - client.patch_no_content(&format!("/notifications/threads/{thread_id}")) + client + .api() + .notify_read_thread(index(thread_id)?, NotifyReadThreadQuery::default()) + .send()?; + Ok(()) } /// Reading a thread (`comments` / `view`) is the "I've seen it" signal: diff --git a/hive-forge/src/verbs/artifact_get.rs b/hive-forge/src/verbs/artifact_get.rs index b4b28f2d..3edce3bf 100644 --- a/hive-forge/src/verbs/artifact_get.rs +++ b/hive-forge/src/verbs/artifact_get.rs @@ -23,7 +23,7 @@ use std::path::PathBuf; use anyhow::{Result, bail}; use clap::Args as ClapArgs; -use serde_json::Value; +use forgejo_api::structs::ListActionRunsQuery; use crate::client::Client; @@ -45,28 +45,29 @@ pub struct Args { /// Pages over the REST runs list (newest-first) to find the run whose /// run-page `html_url` ends in `/runs/`, returning its global /// run id — the identifier the web artifact-download route requires. -fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result { +fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result { const PER_PAGE: u32 = 50; const MAX_PAGES: u32 = 40; + let (owner, name) = client.owner_repo()?; for page in 1..=MAX_PAGES { - let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); - let body = client.get_json(&path)?; - let runs = body - .get("workflow_runs") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + let body = client + .api() + .list_action_runs(owner, name, ListActionRunsQuery::default()) + .page(page) + .page_size(PER_PAGE) + .send()?; + let runs = body.workflow_runs.unwrap_or_default(); if runs.is_empty() { break; } for run in &runs { let tail = run - .get("html_url") - .and_then(Value::as_str) - .and_then(|u| u.rsplit('/').next()) + .html_url + .as_ref() + .and_then(|u| u.as_str().rsplit('/').next()) .and_then(|s| s.parse::().ok()); if tail == Some(run_number) - && let Some(id) = run.get("id").and_then(Value::as_u64) + && let Some(id) = run.id { return Ok(id); } diff --git a/hive-forge/src/verbs/assign.rs b/hive-forge/src/verbs/assign.rs index c4a7ff8f..f7a63592 100644 --- a/hive-forge/src/verbs/assign.rs +++ b/hive-forge/src/verbs/assign.rs @@ -5,9 +5,10 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::EditIssueOption; +use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -22,37 +23,45 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; + let (owner, name) = client.owner_repo()?; + let idx = index(args.number)?; + let current = client.api().issue_get_issue(owner, name, idx).send()?; let mut assignees: Vec = current - .get("assignees") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|u| u.get("login").and_then(Value::as_str).map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); + .assignees + .unwrap_or_default() + .into_iter() + .filter_map(|u| u.login) + .collect(); if args.remove { assignees.retain(|u| u != &args.user); } else if !assignees.contains(&args.user) { assignees.push(args.user.clone()); } - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "assignees": assignees }), - )?; + let payload = EditIssueOption { + assignee: None, + assignees: Some(assignees), + body: None, + due_date: None, + milestone: None, + r#ref: None, + state: None, + title: None, + unset_due_date: None, + updated_at: None, + }; + let resp = client + .api() + .issue_edit_issue(owner, name, idx, payload) + .send()?; let logins: Vec<&str> = resp - .get("assignees") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|u| u.get("login").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); + .assignees + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|u| u.login.as_deref()) + .collect(); print_json(&json!({ - "number": resp.get("number"), + "number": resp.number, "assignees": logins, })) } diff --git a/hive-forge/src/verbs/attach.rs b/hive-forge/src/verbs/attach.rs index a7d60eee..8819a879 100644 --- a/hive-forge/src/verbs/attach.rs +++ b/hive-forge/src/verbs/attach.rs @@ -2,13 +2,15 @@ //! ` — upload a file as an attachment. Prints the browser //! download URL. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use clap::Args as ClapArgs; -use serde_json::Value; +use forgejo_api::structs::{ + Attachment, IssueCreateIssueAttachmentQuery, IssueCreateIssueCommentAttachmentQuery, +}; -use crate::client::Client; +use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct IssueArgs { @@ -39,11 +41,16 @@ pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> { args.file.display() ); } - let repo = client.repo(); - let resp = client.post_multipart_file( - &format!("/repos/{repo}/issues/{}/assets", args.number), - &args.file, - )?; + let (owner, name) = client.owner_repo()?; + let bytes = read_file(&args.file)?; + let query = IssueCreateIssueAttachmentQuery { + name: file_name(&args.file), + updated_at: None, + }; + let resp = client + .api() + .issue_create_issue_attachment(owner, name, index(args.number)?, &bytes, query) + .send()?; print_url(&resp); Ok(()) } @@ -61,17 +68,33 @@ pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> { args.file.display() ); } - let repo = client.repo(); - let resp = client.post_multipart_file( - &format!("/repos/{repo}/issues/comments/{}/assets", args.id), - &args.file, - )?; + let (owner, name) = client.owner_repo()?; + let bytes = read_file(&args.file)?; + let query = IssueCreateIssueCommentAttachmentQuery { + name: file_name(&args.file), + updated_at: None, + }; + let resp = client + .api() + .issue_create_issue_comment_attachment(owner, name, index(args.id)?, &bytes, query) + .send()?; print_url(&resp); Ok(()) } -fn print_url(v: &Value) { - if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) { +fn read_file(path: &Path) -> Result> { + std::fs::read(path).with_context(|| format!("read {}", path.display())) +} + +/// The upload's attachment name — the file's basename, matching what +/// the old multipart form (which sent the file with its real name) +/// made the server record. +fn file_name(path: &Path) -> Option { + path.file_name().map(|n| n.to_string_lossy().into_owned()) +} + +fn print_url(v: &Attachment) { + if let Some(url) = &v.browser_download_url { println!("{url}"); } } diff --git a/hive-forge/src/verbs/branches.rs b/hive-forge/src/verbs/branches.rs index 287f5024..5973c14f 100644 --- a/hive-forge/src/verbs/branches.rs +++ b/hive-forge/src/verbs/branches.rs @@ -2,7 +2,6 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::Value; use crate::client::Client; @@ -13,17 +12,16 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?; - let names: Vec<&str> = v - .as_array() - .map(|a| { - a.iter() - .filter_map(|b| b.get("name").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); - for n in names { + let (owner, name) = client.owner_repo()?; + let (_, branches) = client + .api() + .repo_list_branches(owner, name) + .page_size(100) + .send()?; + for branch in &branches { + let Some(n) = branch.name.as_deref() else { + continue; + }; if args.pattern.as_deref().is_none_or(|p| n.contains(p)) { println!("{n}"); } diff --git a/hive-forge/src/verbs/ci_rerun.rs b/hive-forge/src/verbs/ci_rerun.rs index e7d47d96..7ca8ed29 100644 --- a/hive-forge/src/verbs/ci_rerun.rs +++ b/hive-forge/src/verbs/ci_rerun.rs @@ -22,16 +22,16 @@ //! - `--branch ` → dispatches `--workflow` on that branch directly. //! - `--run ` → looks the run up in the Actions runs list (by the //! `runs/` tail of its `html_url`, same convention as `ci-log` / -//! `artifact-get`) and dispatches the SAME workflow on the SAME branch the -//! run used. +//! `artifact-get`) and dispatches the SAME workflow on the SAME ref the +//! run used (the run record's `prettyref` + `workflow_id`). //! //! Dispatch re-runs the whole workflow, so there is no single-job variant. use anyhow::{Context as _, Result, bail}; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::{ActionRun, DispatchWorkflowOption, ListActionRunsQuery}; -use crate::client::Client; +use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct Args { @@ -60,12 +60,12 @@ pub struct Args { /// /// Returns an error if none of `--pr` / `--run` / `--branch` is given, if a /// `--pr` / `--run` handle can't be resolved (unknown PR/run, or a run -/// missing its branch), or if the dispatch POST fails (network, or a non-2xx +/// missing its ref), or if the dispatch POST fails (network, or a non-2xx /// such as `404` for an unknown workflow file or branch). pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) { - (Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, repo, pr)?), + (Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, pr)?), (_, Some(run), _) => resolve_run(client, repo, run, &args.workflow)?, (_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()), (None, None, None) => { @@ -73,9 +73,16 @@ pub fn run(client: &Client, args: Args) -> Result<()> { } }; - let path = format!("/repos/{repo}/actions/workflows/{workflow}/dispatches"); + let (owner, name) = client.owner_repo()?; + let body = DispatchWorkflowOption { + inputs: None, + r#ref: branch.clone(), + return_run_info: None, + }; client - .post_no_content(&path, &json!({ "ref": branch })) + .api() + .dispatch_workflow(owner, name, &workflow, body) + .send() .with_context(|| { format!( "dispatch workflow {workflow} on {branch} ({repo}) — the workflow \ @@ -89,19 +96,21 @@ pub fn run(client: &Client, args: Args) -> Result<()> { /// Resolve a PR's head branch name (`head.ref`) — the branch a same-repo PR /// pushes to, which is the ref we dispatch the workflow on. -fn branch_for_pr(client: &Client, repo: &str, pr: u64) -> Result { - let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; - pull.get("head") - .and_then(|h| h.get("ref")) - .and_then(Value::as_str) - .map(str::to_string) +fn branch_for_pr(client: &Client, pr: u64) -> Result { + let (owner, name) = client.owner_repo()?; + let pull = client + .api() + .repo_get_pull_request(owner, name, index(pr)?) + .send()?; + pull.head + .and_then(|h| h.r#ref) .with_context(|| format!("ci-rerun: PR #{pr} has no head.ref")) } /// Page the Actions runs list (newest-first) to find the run whose run-page /// `html_url` ends in `/runs/`, returning the `(workflow, branch)` /// to dispatch a fresh run of it. `fallback_workflow` is used when the run -/// carries no workflow `path`. +/// carries no workflow file name. fn resolve_run( client: &Client, repo: &str, @@ -110,21 +119,22 @@ fn resolve_run( ) -> Result<(String, String)> { const PER_PAGE: u32 = 50; const MAX_PAGES: u32 = 40; + let (owner, name) = client.owner_repo()?; for page in 1..=MAX_PAGES { - let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); - let body = client.get_json(&path)?; - let runs = body - .get("workflow_runs") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + let body = client + .api() + .list_action_runs(owner, name, ListActionRunsQuery::default()) + .page(page) + .page_size(PER_PAGE) + .send()?; + let runs = body.workflow_runs.unwrap_or_default(); if runs.is_empty() { break; } for run in &runs { if run_number_of(run) == Some(run_number) { return run_dispatch_target(run, fallback_workflow) - .with_context(|| format!("ci-rerun: run #{run_number} has no head_branch")); + .with_context(|| format!("ci-rerun: run #{run_number} has no ref")); } } } @@ -133,24 +143,24 @@ fn resolve_run( /// The per-repo run NUMBER from a run object's `html_url` (`…/runs/` tail), /// matching the `runs/` the UI shows and `pr-status` surfaces. -fn run_number_of(run: &Value) -> Option { - run.get("html_url") - .and_then(Value::as_str) - .and_then(|u| u.rsplit('/').next()) +fn run_number_of(run: &ActionRun) -> Option { + run.html_url + .as_ref() + .and_then(|u| u.as_str().rsplit('/').next()) .and_then(|s| s.parse::().ok()) } -/// Pull the `(workflow-file, branch)` dispatch target out of a run object: -/// `head_branch` is the branch, and the workflow file is the basename of the -/// run's `path` (e.g. `.forgejo/workflows/ci.yml` → `ci.yml`), falling back to -/// `fallback_workflow` when the run carries no usable `path`. `None` only when -/// the run has no `head_branch`. -fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String, String)> { - let branch = run.get("head_branch").and_then(Value::as_str)?; +/// Pull the `(workflow-file, ref)` dispatch target out of a run record: +/// `prettyref` is the ref the run ran on (the branch name for push / +/// dispatch runs — PR-event runs carry a `#` pseudo-ref the dispatch +/// endpoint will reject with a clear 404), and `workflow_id` is the +/// workflow file name (e.g. `ci.yml`), falling back to +/// `fallback_workflow` when absent. `None` only when the run has no ref. +fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(String, String)> { + let branch = run.prettyref.as_deref().filter(|s| !s.is_empty())?; let workflow = run - .get("path") - .and_then(Value::as_str) - .and_then(|p| p.rsplit('/').next()) + .workflow_id + .as_deref() .filter(|s| !s.is_empty()) .unwrap_or(fallback_workflow); Some((workflow.to_string(), branch.to_string())) @@ -158,28 +168,41 @@ fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String, #[cfg(test)] mod tests { - use super::{run_dispatch_target, run_number_of}; + use super::{ActionRun, run_dispatch_target, run_number_of}; use serde_json::json; + /// Build a typed run record from an API-shaped JSON fixture. The + /// struct's fields are all optional, but the timestamp / URL + /// fields deserialize through `with`-modules that require the + /// keys to be *present* (as `null`) — fill those in so partial + /// fixtures stay terse. + fn run_from(mut v: serde_json::Value) -> ActionRun { + let obj = v.as_object_mut().unwrap(); + for key in ["created", "started", "stopped", "updated", "html_url"] { + obj.entry(key).or_insert(serde_json::Value::Null); + } + serde_json::from_value(v).unwrap() + } + #[test] fn parses_run_number_from_html_url() { - let run = json!({ "html_url": "http://forge/h/h/actions/runs/750" }); + let run = run_from(json!({ "html_url": "http://forge/h/h/actions/runs/750" })); assert_eq!(run_number_of(&run), Some(750)); - let run = json!({ "html_url": "https://forge/o/r/actions/runs/42" }); + let run = run_from(json!({ "html_url": "https://forge/o/r/actions/runs/42" })); assert_eq!(run_number_of(&run), Some(42)); assert_eq!( - run_number_of(&json!({ "html_url": "http://forge/o/r/x" })), + run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))), None ); - assert_eq!(run_number_of(&json!({})), None); + assert_eq!(run_number_of(&run_from(json!({}))), None); } #[test] fn extracts_workflow_and_branch() { - let run = json!({ - "head_branch": "atlas/foo", - "path": ".forgejo/workflows/ci.yml", - }); + let run = run_from(json!({ + "prettyref": "atlas/foo", + "workflow_id": "ci.yml", + })); assert_eq!( run_dispatch_target(&run, "fallback.yml"), Some(("ci.yml".to_string(), "atlas/foo".to_string())) @@ -187,8 +210,8 @@ mod tests { } #[test] - fn falls_back_to_default_workflow_without_path() { - let run = json!({ "head_branch": "b" }); + fn falls_back_to_default_workflow_without_file() { + let run = run_from(json!({ "prettyref": "b" })); assert_eq!( run_dispatch_target(&run, "fallback.yml"), Some(("fallback.yml".to_string(), "b".to_string())) @@ -196,7 +219,7 @@ mod tests { } #[test] - fn no_branch_means_no_target() { - assert_eq!(run_dispatch_target(&json!({}), "ci.yml"), None); + fn no_ref_means_no_target() { + assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None); } } diff --git a/hive-forge/src/verbs/close.rs b/hive-forge/src/verbs/close.rs index d67c5aea..d67a5099 100644 --- a/hive-forge/src/verbs/close.rs +++ b/hive-forge/src/verbs/close.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -14,13 +14,31 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "state": "closed" }), - )?; + let (owner, name) = client.owner_repo()?; + let resp = client + .api() + .issue_edit_issue(owner, name, index(args.number)?, state_edit("closed")) + .send()?; print_json(&json!({ - "number": resp.get("number"), - "state": resp.get("state"), + "number": resp.number, + "state": resp.state, })) } + +/// An `EditIssueOption` that only sets `state` — shared by `close` / +/// `reopen` (the null fields ride along and Forgejo treats them as +/// "leave unchanged"). +pub(crate) fn state_edit(state: &str) -> forgejo_api::structs::EditIssueOption { + forgejo_api::structs::EditIssueOption { + assignee: None, + assignees: None, + body: None, + due_date: None, + milestone: None, + r#ref: None, + state: Some(state.to_owned()), + title: None, + unset_due_date: None, + updated_at: None, + } +} diff --git a/hive-forge/src/verbs/comment.rs b/hive-forge/src/verbs/comment.rs index c7d24d03..0f2e4297 100644 --- a/hive-forge/src/verbs/comment.rs +++ b/hive-forge/src/verbs/comment.rs @@ -11,10 +11,11 @@ use anyhow::{Result, bail}; use clap::Args as ClapArgs; +use forgejo_api::structs::CreateIssueCommentOption; use serde_json::json; use crate::body; -use crate::client::Client; +use crate::client::{Client, index}; use crate::notify; use crate::verbs::print_json; @@ -63,12 +64,21 @@ pub fn run(client: &Client, args: Args) -> Result<()> { } } - let resp = client.post_json( - &format!("/repos/{repo}/issues/{}/comments", args.number), - &json!({ "body": body }), - )?; + let (owner, name) = client.owner_repo()?; + let resp = client + .api() + .issue_create_comment( + owner, + name, + index(args.number)?, + CreateIssueCommentOption { + body, + updated_at: None, + }, + ) + .send()?; print_json(&json!({ - "id": resp.get("id"), - "url": resp.get("html_url"), + "id": resp.id, + "url": resp.html_url, })) } diff --git a/hive-forge/src/verbs/comment_edit.rs b/hive-forge/src/verbs/comment_edit.rs index a2c9aaaf..eaf968d7 100644 --- a/hive-forge/src/verbs/comment_edit.rs +++ b/hive-forge/src/verbs/comment_edit.rs @@ -1,12 +1,13 @@ //! `comment-edit [body sources] [repo]` — edit an existing //! comment by id. -use anyhow::Result; +use anyhow::{Result, bail}; use clap::Args as ClapArgs; +use forgejo_api::structs::EditIssueCommentOption; use serde_json::json; use crate::body; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -33,14 +34,26 @@ pub fn run(client: &Client, args: Args) -> Result<()> { args.body_file.as_deref(), "comment-edit", )?; - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/comments/{}", args.id), - &json!({ "body": body }), - )?; + let (owner, name) = client.owner_repo()?; + let Some(resp) = client + .api() + .issue_edit_comment( + owner, + name, + index(args.id)?, + EditIssueCommentOption { + body, + updated_at: None, + }, + ) + .send()? + else { + // Forgejo answers 204 (no content) when the edit was a no-op. + bail!("hive-forge comment-edit: comment {} not updated", args.id); + }; print_json(&json!({ - "id": resp.get("id"), - "user": resp.get("user").and_then(|u| u.get("login")), - "url": resp.get("html_url"), + "id": resp.id, + "user": resp.user.as_ref().and_then(|u| u.login.as_deref()), + "url": resp.html_url, })) } diff --git a/hive-forge/src/verbs/comment_show.rs b/hive-forge/src/verbs/comment_show.rs index 65e6e8bb..ab576af3 100644 --- a/hive-forge/src/verbs/comment_show.rs +++ b/hive-forge/src/verbs/comment_show.rs @@ -1,12 +1,12 @@ //! `comment-show ` — print the body (or full JSON envelope //! when `--json` is set globally) of a single comment by id. -use anyhow::Result; +use anyhow::{Result, bail}; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use serde_json::json; -use crate::client::Client; -use crate::verbs::print_json; +use crate::client::{Client, index}; +use crate::verbs::{print_json, rfc3339}; #[derive(ClapArgs)] pub struct Args { @@ -15,20 +15,26 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?; + let (owner, name) = client.owner_repo()?; + let Some(c) = client + .api() + .issue_get_comment(owner, name, index(args.id)?) + .send()? + else { + bail!("hive-forge comment-show: comment {} not found", args.id); + }; if client.json_mode() { let trimmed = json!({ - "id": v.get("id"), - "user": v.get("user").and_then(|u| u.get("login")), - "created_at": v.get("created_at"), - "updated_at": v.get("updated_at"), - "body": v.get("body"), - "url": v.get("html_url"), + "id": c.id, + "user": c.user.as_ref().and_then(|u| u.login.as_deref()), + "created_at": rfc3339(c.created_at), + "updated_at": rfc3339(c.updated_at), + "body": c.body, + "url": c.html_url, }); print_json(&trimmed) } else { - let body = v.get("body").and_then(Value::as_str).unwrap_or(""); + let body = c.body.as_deref().unwrap_or(""); println!("{body}"); Ok(()) } diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 51fadc21..bde87687 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -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(items: Vec) -> Result> { + 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 { +fn fetch_review_bodies(client: &Client, number: u64) -> Vec { + 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, reviews: Vec) -> Vec } /// Fetch the first page's worth of comments (existing behaviour). -fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result> { - 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> { + 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 Result> { +fn fetch_tail(client: &Client, number: u64, n: usize) -> Result> { 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::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 diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index 57f27bbf..17bdebe1 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -15,8 +15,9 @@ use anyhow::Result; use clap::Args as ClapArgs; +use forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery; -use crate::client::Client; +use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct Args { @@ -31,11 +32,17 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let diff = client.get_text( - &format!("/repos/{repo}/pulls/{}.diff", args.number), - "text/plain", - )?; + let (owner, name) = client.owner_repo()?; + let diff = client + .api() + .repo_download_pull_diff_or_patch( + owner, + name, + index(args.number)?, + "diff", + RepoDownloadPullDiffOrPatchQuery::default(), + ) + .send()?; let out = if args.full { diff } else { diff --git a/hive-forge/src/verbs/issue.rs b/hive-forge/src/verbs/issue.rs index fdca71db..dd2cde97 100644 --- a/hive-forge/src/verbs/issue.rs +++ b/hive-forge/src/verbs/issue.rs @@ -2,9 +2,9 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -14,22 +14,33 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; + let (owner, name) = client.owner_repo()?; + let issue = client + .api() + .issue_get_issue(owner, name, index(args.number)?) + .send()?; + let assignees: Vec<&str> = issue + .assignees + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|u| u.login.as_deref()) + .collect(); + let labels: Vec<&str> = issue + .labels + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|l| l.name.as_deref()) + .collect(); let trimmed = json!({ - "number": v.get("number"), - "title": v.get("title"), - "state": v.get("state"), - "user": v.get("user").and_then(|u| u.get("login")), - "assignees": v.get("assignees") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::>()) - .unwrap_or_default(), - "labels": v.get("labels") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::>()) - .unwrap_or_default(), - "body": v.get("body"), + "number": issue.number, + "title": issue.title, + "state": issue.state, + "user": issue.user.as_ref().and_then(|u| u.login.as_deref()), + "assignees": assignees, + "labels": labels, + "body": issue.body, }); print_json(&trimmed) } diff --git a/hive-forge/src/verbs/issue_create.rs b/hive-forge/src/verbs/issue_create.rs index e785bd0d..6783cfe2 100644 --- a/hive-forge/src/verbs/issue_create.rs +++ b/hive-forge/src/verbs/issue_create.rs @@ -3,7 +3,7 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::CreateIssueOption; use crate::body; use crate::client::Client; @@ -32,13 +32,23 @@ pub struct Args { /// I/O error from writing the issue URL to stdout. pub fn run(client: &Client, args: Args) -> Result<()> { let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default(); - let repo = client.repo(); - let mut payload = json!({ "title": args.title, "body": body }); - if let Some(a) = args.assignee { - payload["assignees"] = json!([a]); - } - let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?; - if let Some(url) = resp.get("html_url").and_then(Value::as_str) { + let (owner, name) = client.owner_repo()?; + let payload = CreateIssueOption { + assignee: None, + assignees: args.assignee.map(|a| vec![a]), + body: Some(body), + closed: None, + due_date: None, + labels: None, + milestone: None, + r#ref: None, + title: args.title, + }; + let issue = client + .api() + .issue_create_issue(owner, name, payload) + .send()?; + if let Some(url) = issue.html_url { println!("{url}"); } Ok(()) diff --git a/hive-forge/src/verbs/issue_edit.rs b/hive-forge/src/verbs/issue_edit.rs index 9a891199..f51496ee 100644 --- a/hive-forge/src/verbs/issue_edit.rs +++ b/hive-forge/src/verbs/issue_edit.rs @@ -4,10 +4,11 @@ use anyhow::Result; use clap::{Args as ClapArgs, ValueEnum}; -use serde_json::{Map, Value, json}; +use forgejo_api::structs::EditIssueOption; +use serde_json::json; use crate::body; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(Copy, Clone, ValueEnum)] @@ -55,7 +56,9 @@ pub struct Args { pub fn run(client: &Client, args: Args) -> Result<()> { // Body is partial: only update the body field if a source was // actually given. Piped stdin without --body/--body-file leaves - // body alone (the partial-update contract). + // body alone (the partial-update contract). Absent fields ride as + // JSON `null`, which Forgejo's partial-update binding treats as + // "leave unchanged". let body_explicit = args.body.is_some() || args.body_file.is_some(); let body = if body_explicit { body::resolve(args.body.as_deref(), args.body_file.as_deref())? @@ -63,29 +66,28 @@ pub fn run(client: &Client, args: Args) -> Result<()> { None }; - let mut payload = Map::new(); - if let Some(t) = args.title { - payload.insert("title".into(), Value::String(t)); - } - if let Some(b) = body { - payload.insert("body".into(), Value::String(b)); - } - if let Some(s) = args.state { - payload.insert("state".into(), Value::String(s.as_str().to_owned())); - } - if let Some(m) = args.milestone { - payload.insert("milestone".into(), Value::Number(m.into())); - } + let payload = EditIssueOption { + assignee: None, + assignees: None, + body, + due_date: None, + milestone: args.milestone.map(index).transpose()?, + r#ref: None, + state: args.state.map(|s| s.as_str().to_owned()), + title: args.title, + unset_due_date: None, + updated_at: None, + }; - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &Value::Object(payload), - )?; + let (owner, name) = client.owner_repo()?; + let resp = client + .api() + .issue_edit_issue(owner, name, index(args.number)?, payload) + .send()?; print_json(&json!({ - "number": resp.get("number"), - "title": resp.get("title"), - "state": resp.get("state"), - "milestone": resp.get("milestone").and_then(|m| m.get("title")), + "number": resp.number, + "title": resp.title, + "state": resp.state, + "milestone": resp.milestone.as_ref().and_then(|m| m.title.as_deref()), })) } diff --git a/hive-forge/src/verbs/labels.rs b/hive-forge/src/verbs/labels.rs index a3118303..169d1986 100644 --- a/hive-forge/src/verbs/labels.rs +++ b/hive-forge/src/verbs/labels.rs @@ -3,9 +3,10 @@ use anyhow::{Result, bail}; use clap::{Args as ClapArgs, Subcommand}; -use serde_json::{Value, json}; +use forgejo_api::structs::{DeleteLabelsOption, IssueLabelsOption, IssueListLabelsQuery, Label}; +use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -33,85 +34,84 @@ enum Action { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); + let (owner, name) = client.owner_repo()?; + let idx = index(args.number)?; match args.action.unwrap_or(Action::List) { Action::List => { - let labels = - client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; + let labels = client.api().issue_get_labels(owner, name, idx).send()?; print_label_names(&labels); } Action::Add { labels } => { if labels.is_empty() { bail!("hive-forge labels add: pass at least one label name"); } - let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; - let ids = resolve_ids(&all, &labels); - let resp = client.post_json( - &format!("/repos/{repo}/issues/{}/labels", args.number), - &json!({ "labels": ids }), - )?; + let all = repo_labels(client)?; + let ids: Vec = resolve_ids(&all, &labels) + .into_iter() + .map(|id| json!(id)) + .collect(); + let resp = client + .api() + .issue_add_label( + owner, + name, + idx, + IssueLabelsOption { + labels: Some(ids), + updated_at: None, + }, + ) + .send()?; print_label_names(&resp); } Action::Remove { labels } => { if labels.is_empty() { bail!("hive-forge labels remove: pass at least one label name"); } - let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; - for name in &labels { - if let Some(id) = lookup_id(&all, name) { - let _ = client.delete( - &format!("/repos/{repo}/issues/{}/labels/{id}", args.number), - None, - ); + let all = repo_labels(client)?; + for label in &labels { + if let Some(id) = lookup_id(&all, label) { + let _ = client + .api() + .issue_remove_label( + owner, + name, + idx, + &id.to_string(), + DeleteLabelsOption { updated_at: None }, + ) + .send(); } } - let labels = - client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; + let labels = client.api().issue_get_labels(owner, name, idx).send()?; print_label_names(&labels); } } Ok(()) } -fn resolve_ids(all: &Value, names: &[String]) -> Vec { - let Some(arr) = all.as_array() else { - return Vec::new(); - }; - names - .iter() - .filter_map(|n| { - arr.iter().find_map(|l| { - let lname = l.get("name").and_then(Value::as_str)?; - if lname == n { - l.get("id").and_then(Value::as_u64) - } else { - None - } - }) - }) - .collect() +/// First page (100) of the repo's label set, for name → id resolution. +fn repo_labels(client: &Client) -> Result> { + let (owner, name) = client.owner_repo()?; + let (_, labels) = client + .api() + .issue_list_labels(owner, name, IssueListLabelsQuery::default()) + .page_size(100) + .send()?; + Ok(labels) } -fn lookup_id(all: &Value, name: &str) -> Option { - let arr = all.as_array()?; - arr.iter().find_map(|l| { - let lname = l.get("name").and_then(Value::as_str)?; - if lname == name { - l.get("id").and_then(Value::as_u64) - } else { - None - } - }) +fn resolve_ids(all: &[Label], names: &[String]) -> Vec { + names.iter().filter_map(|n| lookup_id(all, n)).collect() } -fn print_label_names(v: &Value) { - let names: Vec<&str> = v - .as_array() - .map(|a| { - a.iter() - .filter_map(|l| l.get("name").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); +fn lookup_id(all: &[Label], name: &str) -> Option { + all.iter() + .find(|l| l.name.as_deref() == Some(name)) + .and_then(|l| l.id) +} + +fn print_label_names(labels: &[Label]) { + let names: Vec<&str> = labels.iter().filter_map(|l| l.name.as_deref()).collect(); let _ = print_json(&json!(names)); } diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index ff4dd516..be7d378a 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -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 { 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, + state: IssueListIssuesQueryState, +) -> Result> { + 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 = 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 = 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 = 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 = 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 = 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> = BTreeMap::new(); - for it in items { - let assignees: Vec = 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 { + 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::>() - }) - .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 { - 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 { - 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); - } -} diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index cff914fd..471da239 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -10,19 +10,20 @@ //! read-side curl-fallback gap (no boundary concerns — //! every agent + the operator queries the issue tracker constantly). -use std::fmt::Write as _; - use anyhow::Result; use clap::{Args as ClapArgs, ValueEnum}; +use forgejo_api::structs::{ + IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, +}; use serde_json::Value; use crate::client::Client; use crate::verbs::print_json; -/// What kind of items to return. Matches Forgejo's `type` query -/// parameter values verbatim (`issues` / `pulls` / `all`) so the -/// mapping is one-for-one and a future enum addition upstream -/// stays trivially supportable. +/// What kind of items to return. Maps onto Forgejo's `type` query +/// parameter: `issues` / `pulls`, or no filter at all for `both` +/// (Forgejo returns issues + PRs when `type` is absent — same slice +/// the forge UI's "Issues" tab shows without a type filter). #[derive(Copy, Clone, Debug, ValueEnum)] #[clap(rename_all = "kebab-case")] pub enum Kind { @@ -30,17 +31,16 @@ pub enum Kind { Issue, /// Pull requests only. Pr, - /// Issues + pull requests (default; matches the forge UI's - /// "Issues" tab when no type filter is applied). + /// Issues + pull requests (default). Both, } impl Kind { - fn api_value(self) -> &'static str { + fn query_type(self) -> Option { match self { - Self::Issue => "issues", - Self::Pr => "pulls", - Self::Both => "all", + Self::Issue => Some(IssueListIssuesQueryType::Issues), + Self::Pr => Some(IssueListIssuesQueryType::Pulls), + Self::Both => None, } } } @@ -56,11 +56,11 @@ pub enum State { } impl State { - fn api_value(self) -> &'static str { + fn query_state(self) -> IssueListIssuesQueryState { match self { - Self::Open => "open", - Self::Closed => "closed", - Self::All => "all", + Self::Open => IssueListIssuesQueryState::Open, + Self::Closed => IssueListIssuesQueryState::Closed, + Self::All => IssueListIssuesQueryState::All, } } } @@ -100,54 +100,40 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let mut path = format!( - "/repos/{repo}/issues?type={}&state={}&limit={}&page={}", - args.kind.api_value(), - args.state.api_value(), - args.limit, - args.page - ); - if let Some(u) = args.assignee.as_deref() - && !u.is_empty() - { - write!(path, "&assigned_by={}", super::pct_encode(u)).unwrap(); - } - if let Some(u) = args.author.as_deref() - && !u.is_empty() - { - write!(path, "&created_by={}", super::pct_encode(u)).unwrap(); - } - if let Some(u) = args.mention.as_deref() - && !u.is_empty() - { - write!(path, "&mentioned_by={}", super::pct_encode(u)).unwrap(); - } - if !args.labels.is_empty() { - // Encode each label individually so a comma INSIDE a label - // (rare but legal) gets escaped while the field separator - // stays a literal comma the forge will parse as N labels. - let encoded: Vec = args.labels.iter().map(|l| super::pct_encode(l)).collect(); - write!(path, "&labels={}", encoded.join(",")).unwrap(); - } - let resp = client.get_json(&path)?; - if client.json_mode() { - return print_json(&resp); - } - let Some(items) = resp.as_array() else { - // Forge returned something other than an array — most likely - // an error envelope; fall back to JSON-dumping so the user - // can see what came back. - return print_json(&resp); + let (owner, name) = client.owner_repo()?; + let query = IssueListIssuesQuery { + state: Some(args.state.query_state()), + // The forge parses `labels` as a comma-separated list of names. + labels: (!args.labels.is_empty()).then(|| args.labels.join(",")), + q: None, + r#type: args.kind.query_type(), + milestones: None, + since: None, + before: None, + created_by: args.author.clone().filter(|s| !s.is_empty()), + assigned_by: args.assignee.clone().filter(|s| !s.is_empty()), + mentioned_by: args.mention.clone().filter(|s| !s.is_empty()), + sort: None, }; - for item in items { + let (_, issues) = client + .api() + .issue_list_issues(owner, name, query) + .page(u32::try_from(args.page).unwrap_or(u32::MAX)) + .page_size(u32::try_from(args.limit).unwrap_or(u32::MAX)) + .send()?; + let count = issues.len() as u64; + let items = serde_json::to_value(issues)?; + if client.json_mode() { + return print_json(&items); + } + for item in items.as_array().into_iter().flatten() { print_row(item); } // When the returned page is exactly `--limit` items, more pages // may exist. Print a hint to stderr so the caller knows to fetch // the next page rather than assuming the result is complete. Only // fires on a full page — a short or empty page signals the end. - if items.len() as u64 == args.limit { + if count == args.limit { eprintln!( "… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).", args.limit, @@ -184,19 +170,25 @@ mod tests { use super::*; #[test] - fn kind_api_values_match_forgejo_enum() { - // The forge accepts only `issues` / `pulls` / `all` for the - // `type` parameter — pin the wire mapping so a clap rename + fn kind_query_types_match_forgejo_enum() { + // The forge accepts only `issues` / `pulls` for the `type` + // parameter (absent = both) — pin the mapping so a clap rename // doesn't silently start returning the wrong slice. - assert_eq!(Kind::Issue.api_value(), "issues"); - assert_eq!(Kind::Pr.api_value(), "pulls"); - assert_eq!(Kind::Both.api_value(), "all"); + assert_eq!( + Kind::Issue.query_type(), + Some(IssueListIssuesQueryType::Issues) + ); + assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls)); + assert_eq!(Kind::Both.query_type(), None); } #[test] - fn state_api_values_match_forgejo_enum() { - assert_eq!(State::Open.api_value(), "open"); - assert_eq!(State::Closed.api_value(), "closed"); - assert_eq!(State::All.api_value(), "all"); + fn state_query_states_match_forgejo_enum() { + assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open); + assert_eq!( + State::Closed.query_state(), + IssueListIssuesQueryState::Closed + ); + assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All); } } diff --git a/hive-forge/src/verbs/milestone.rs b/hive-forge/src/verbs/milestone.rs index ee92a28f..268dc76c 100644 --- a/hive-forge/src/verbs/milestone.rs +++ b/hive-forge/src/verbs/milestone.rs @@ -1,12 +1,17 @@ //! `milestone list|create|close` — manage milestones. Default action: //! list. -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Args as ClapArgs, Subcommand}; +use forgejo_api::structs::{ + CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery, +}; use serde_json::{Value, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; -use crate::client::Client; -use crate::verbs::print_json; +use crate::client::{Client, index}; +use crate::verbs::{print_json, rfc3339}; #[derive(ClapArgs)] pub struct Args { @@ -38,52 +43,71 @@ enum Action { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); + let (owner, name) = client.owner_repo()?; match args.action.unwrap_or(Action::List) { Action::List => { - let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?; - let trimmed: Vec = v - .as_array() - .map(|a| { - a.iter() - .map(|m| { - json!({ - "id": m.get("id"), - "title": m.get("title"), - "open_issues": m.get("open_issues"), - "closed_issues": m.get("closed_issues"), - "due_on": m.get("due_on"), - "description": m.get("description"), - }) - }) - .collect() + let query = IssueGetMilestonesListQuery { + state: Some("open".to_owned()), + name: None, + }; + let (_, milestones) = client + .api() + .issue_get_milestones_list(owner, name, query) + .page_size(50) + .send()?; + let trimmed: Vec = milestones + .iter() + .map(|m| { + json!({ + "id": m.id, + "title": m.title, + "open_issues": m.open_issues, + "closed_issues": m.closed_issues, + "due_on": rfc3339(m.due_on), + "description": m.description, + }) }) - .unwrap_or_default(); + .collect(); print_json(&Value::Array(trimmed)) } Action::Create { title, desc, due } => { - let mut payload = json!({ "title": title }); - if let Some(d) = desc.filter(|s| !s.is_empty()) { - payload["description"] = Value::String(d); - } - if let Some(d) = due.filter(|s| !s.is_empty()) { - payload["due_on"] = Value::String(format!("{d}T00:00:00Z")); - } - let resp = client.post_json(&format!("/repos/{repo}/milestones"), &payload)?; + let due_on = due + .filter(|s| !s.is_empty()) + .map(|d| { + OffsetDateTime::parse(&format!("{d}T00:00:00Z"), &Rfc3339) + .with_context(|| format!("milestone create: bad --due date {d:?}")) + }) + .transpose()?; + let payload = CreateMilestoneOption { + description: desc.filter(|s| !s.is_empty()), + due_on, + state: None, + title: Some(title), + }; + let resp = client + .api() + .issue_create_milestone(owner, name, payload) + .send()?; print_json(&json!({ - "id": resp.get("id"), - "title": resp.get("title"), + "id": resp.id, + "title": resp.title, })) } Action::Close { id } => { - let resp = client.patch_json( - &format!("/repos/{repo}/milestones/{id}"), - &json!({ "state": "closed" }), - )?; + let payload = EditMilestoneOption { + description: None, + due_on: None, + state: Some("closed".to_owned()), + title: None, + }; + let resp = client + .api() + .issue_edit_milestone(owner, name, index(id)?, payload) + .send()?; print_json(&json!({ - "id": resp.get("id"), - "title": resp.get("title"), - "state": resp.get("state"), + "id": resp.id, + "title": resp.title, + "state": resp.state, })) } } diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index ffe8c420..db0849cc 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -46,8 +46,10 @@ use std::fmt::Write as _; use anyhow::Result; use serde_json::Value; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; -use crate::client::Client; +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. @@ -57,6 +59,15 @@ pub(crate) fn print_json(v: &Value) -> Result<()> { 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) -> Option { + ts.and_then(|t| t.format(&Rfc3339).ok()) +} + /// Issue-vs-PR kind, for the `pr ` / `issue ` sub-command /// validation. #[derive(Clone, Copy)] @@ -72,9 +83,12 @@ pub(crate) enum Kind { /// 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 repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/issues/{number}"))?; - let is_pr = v.get("pull_request").is_some_and(|p| !p.is_null()); + 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!( @@ -89,12 +103,12 @@ pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Resul } /// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of -/// characters that show up in the values we splice into request paths — -/// usernames, label names, 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. -/// Shared by `list` (query-string filters) and `artifact-get` (the -/// artifact-name path segment). +/// 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() { @@ -119,17 +133,30 @@ pub(crate) fn latest_reviews( repo: &str, pr: u64, ) -> Result> { - let reviews = client.get_json_all(&format!("/repos/{repo}/pulls/{pr}/reviews"), 10)?; + 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<(String, String)> = Vec::new(); for r in &reviews { - let Some(login) = r - .get("user") - .and_then(|u| u.get("login")) - .and_then(Value::as_str) - else { + let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else { continue; }; - let st = r.get("state").and_then(Value::as_str).unwrap_or(""); + let st = r.state.as_deref().unwrap_or(""); if st == "COMMENT" || st == "PENDING" || st.is_empty() { continue; } @@ -148,8 +175,8 @@ mod tests { #[test] fn pct_encode_passes_unreserved_through() { - // Usernames + plain label/artifact names round-trip verbatim — - // no performance regression on the common case. + // 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"); @@ -158,9 +185,8 @@ mod tests { #[test] fn pct_encode_escapes_reserved() { - // Forgejo labels can contain spaces ("good first issue" is the - // canonical example); `&` / `/` in any spliced value must escape - // so they can't break out of the path/query segment. + // `&` / `/` / 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"); diff --git a/hive-forge/src/verbs/pr.rs b/hive-forge/src/verbs/pr.rs index a3e101af..46aa5e6d 100644 --- a/hive-forge/src/verbs/pr.rs +++ b/hive-forge/src/verbs/pr.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -14,17 +14,20 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?; + let (owner, name) = client.owner_repo()?; + let pull = client + .api() + .repo_get_pull_request(owner, name, index(args.number)?) + .send()?; let trimmed = json!({ - "number": v.get("number"), - "title": v.get("title"), - "state": v.get("state"), - "merged": v.get("merged"), - "user": v.get("user").and_then(|u| u.get("login")), - "head_sha": v.get("head").and_then(|h| h.get("sha")), - "head_branch": v.get("head").and_then(|h| h.get("label")), - "base_branch": v.get("base").and_then(|b| b.get("label")), + "number": pull.number, + "title": pull.title, + "state": pull.state, + "merged": pull.merged, + "user": pull.user.as_ref().and_then(|u| u.login.as_deref()), + "head_sha": pull.head.as_ref().and_then(|h| h.sha.as_deref()), + "head_branch": pull.head.as_ref().and_then(|h| h.label.as_deref()), + "base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()), }); print_json(&trimmed) } diff --git a/hive-forge/src/verbs/pr_assign_reviewer.rs b/hive-forge/src/verbs/pr_assign_reviewer.rs index 5f4718e0..b0d14f9a 100644 --- a/hive-forge/src/verbs/pr_assign_reviewer.rs +++ b/hive-forge/src/verbs/pr_assign_reviewer.rs @@ -8,9 +8,9 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::json; +use forgejo_api::structs::PullReviewRequestOptions; -use crate::client::Client; +use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct Args { @@ -24,17 +24,26 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let path = format!("/repos/{repo}/pulls/{}/requested_reviewers", args.number); - let body = json!({ "reviewers": [args.user] }); + let (owner, name) = client.owner_repo()?; + let idx = index(args.number)?; + let body = PullReviewRequestOptions { + reviewers: Some(vec![args.user.clone()]), + team_reviewers: None, + }; if args.remove { - client.delete(&path, Some(&body))?; + client + .api() + .repo_delete_pull_review_requests(owner, name, idx, body) + .send()?; println!( "review request withdrawn: {} on #{}", args.user, args.number ); } else { - client.post_json(&path, &body)?; + client + .api() + .repo_create_pull_review_requests(owner, name, idx, body) + .send()?; println!("review requested: {} on #{}", args.user, args.number); } Ok(()) diff --git a/hive-forge/src/verbs/pr_commits.rs b/hive-forge/src/verbs/pr_commits.rs index fe22184d..7db9c5c8 100644 --- a/hive-forge/src/verbs/pr_commits.rs +++ b/hive-forge/src/verbs/pr_commits.rs @@ -10,15 +10,19 @@ use anyhow::Result; use clap::Args as ClapArgs; +use forgejo_api::structs::{Commit, RepoGetPullRequestCommitsQuery}; use serde_json::json; -use crate::client::Client; -use crate::verbs::print_json; +use crate::client::{Client, index}; +use crate::verbs::{print_json, rfc3339}; /// Page cap for the commit list. Forgejo serves up to 50 commits per /// page; 40 pages (2000 commits) is far beyond any real PR. const MAX_PAGES: u32 = 40; +/// Page size on the commit list endpoint (Forgejo's cap). +const PAGE_SIZE: u32 = 50; + #[derive(ClapArgs)] pub struct Args { /// PR number. @@ -26,22 +30,35 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let commits = client.get_json_all( - &format!("/repos/{repo}/pulls/{}/commits", args.number), - MAX_PAGES, - )?; + let (owner, name) = client.owner_repo()?; + let idx = index(args.number)?; + let mut commits: Vec = Vec::new(); + for page in 1..=MAX_PAGES { + let (_, batch) = client + .api() + .repo_get_pull_request_commits( + owner, + name, + idx, + RepoGetPullRequestCommitsQuery::default(), + ) + .page(page) + .page_size(PAGE_SIZE) + .send()?; + let short = batch.len() < PAGE_SIZE as usize; + commits.extend(batch); + if short { + break; + } + } let trimmed: Vec<_> = commits .iter() .map(|c| { - let commit = c.get("commit"); json!({ - "sha": c.get("sha"), - "message": commit.and_then(|x| x.get("message")), - "author_date": commit - .and_then(|x| x.get("author")) - .and_then(|a| a.get("date")), - "author": c.get("author").and_then(|u| u.get("login")), + "sha": c.sha, + "message": c.commit.as_ref().and_then(|x| x.message.as_deref()), + "author_date": rfc3339(c.commit.as_ref().and_then(|x| x.author.as_ref()).and_then(|a| a.date)), + "author": c.author.as_ref().and_then(|u| u.login.as_deref()), }) }) .collect(); diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index c6cd21eb..2d1f8d49 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -25,10 +25,10 @@ use std::process::{Command, Stdio}; use anyhow::{Context, Result}; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::{CreatePullRequestOption, EditIssueOption}; use crate::body; -use crate::client::Client; +use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct Args { @@ -97,17 +97,27 @@ pub fn run(client: &Client, args: Args) -> Result<()> { if args.push { push_branch(remote, head)?; } - let repo = client.repo(); - let payload = json!({ - "title": args.title, - "head": head, - "base": args.base, - "body": body, - "draft": args.draft, - "allow_maintainer_edit": true, - }); - let resp = client.post_json(&format!("/repos/{repo}/pulls"), &payload)?; - if let Some(url) = resp.get("html_url").and_then(Value::as_str) { + let (owner, name) = client.owner_repo()?; + // Note: Forgejo's CreatePullRequestOption has no `draft` / + // `allow_maintainer_edit` fields (verified against the instance's + // swagger) — the raw client used to send both and the server + // silently dropped them, so omitting them here changes nothing. + let payload = CreatePullRequestOption { + assignee: None, + assignees: None, + base: Some(args.base.clone()), + body: Some(body), + due_date: None, + head: Some(head.to_owned()), + labels: None, + milestone: None, + title: Some(args.title.clone()), + }; + let resp = client + .api() + .repo_create_pull_request(owner, name, payload) + .send()?; + if let Some(url) = resp.html_url { println!("{url}"); } Ok(()) @@ -182,12 +192,23 @@ fn agit_create(client: &Client, args: &Args, body: &str) -> Result<()> { }; if deferred_body { if let Some(number) = pr_number_from_url(&url) { - let repo = client.repo(); + let (owner, name) = client.owner_repo()?; + let payload = EditIssueOption { + assignee: None, + assignees: None, + body: Some(body.to_owned()), + due_date: None, + milestone: None, + r#ref: None, + state: None, + title: None, + unset_due_date: None, + updated_at: None, + }; client - .patch_json( - &format!("/repos/{repo}/issues/{number}"), - &json!({ "body": body }), - ) + .api() + .issue_edit_issue(owner, name, index(number)?, payload) + .send() .with_context(|| format!("set body on AGit PR #{number}"))?; } else { eprintln!( diff --git a/hive-forge/src/verbs/pr_merge.rs b/hive-forge/src/verbs/pr_merge.rs index e7c20558..8038c446 100644 --- a/hive-forge/src/verbs/pr_merge.rs +++ b/hive-forge/src/verbs/pr_merge.rs @@ -14,9 +14,11 @@ use anyhow::{Result, bail}; use clap::{Args as ClapArgs, ValueEnum}; -use serde_json::{Value, json}; +use forgejo_api::structs::{ + CommitStatusState, MergePullRequestOption, MergePullRequestOptionDo, PullRequest, StateType, +}; -use crate::client::Client; +use crate::client::{Client, index, split_repo}; /// Merge strategy. Squash is deliberately omitted (hive convention: keep the /// per-commit history, so a squash option isn't exposed). @@ -29,13 +31,22 @@ pub enum Method { } impl Method { - /// The Forgejo `Do` field value for this strategy. + /// The Forgejo `Do` field value for this strategy (used for the + /// confirmation message). fn forgejo_do(self) -> &'static str { match self { Method::Merge => "merge", Method::Rebase => "rebase", } } + + /// The typed `Do` enum variant for the merge request body. + fn as_option_do(self) -> MergePullRequestOptionDo { + match self { + Method::Merge => MergePullRequestOptionDo::Merge, + Method::Rebase => MergePullRequestOptionDo::Rebase, + } + } } #[derive(ClapArgs)] @@ -65,12 +76,17 @@ pub struct Args { /// be merged). pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); - let pull = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?; + let (owner, name) = client.owner_repo()?; + let idx = index(args.number)?; + let pull = client + .api() + .repo_get_pull_request(owner, name, idx) + .send()?; - if pull.get("merged").and_then(Value::as_bool).unwrap_or(false) { + if pull.merged.unwrap_or(false) { bail!("pr-merge: PR #{} is already merged", args.number); } - if pull.get("state").and_then(Value::as_str) == Some("closed") { + if pull.state == Some(StateType::Closed) { bail!("pr-merge: PR #{} is closed", args.number); } @@ -78,15 +94,20 @@ pub fn run(client: &Client, args: Args) -> Result<()> { check_ready(client, repo, args.number, &pull)?; } - let payload = json!({ - "Do": args.method.forgejo_do(), - "delete_branch_after_merge": !args.keep_branch, - "force_merge": args.force, - }); - client.post_no_content( - &format!("/repos/{repo}/pulls/{}/merge", args.number), - &payload, - )?; + let payload = MergePullRequestOption { + r#do: args.method.as_option_do(), + merge_commit_id: None, + merge_message_field: None, + merge_title_field: None, + delete_branch_after_merge: Some(!args.keep_branch), + force_merge: Some(args.force), + head_commit_id: None, + merge_when_checks_succeed: None, + }; + client + .api() + .repo_merge_pull_request(owner, name, idx, payload) + .send()?; let deleted = if args.keep_branch { "" @@ -105,8 +126,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> { /// mergeable, CI must not be red/pending, and no review may request changes. /// Bails with an actionable message (pointing at `--force`) on the first /// failure. -fn check_ready(client: &Client, repo: &str, number: u64, pull: &Value) -> Result<()> { - match pull.get("mergeable").and_then(Value::as_bool) { +fn check_ready(client: &Client, repo: &str, number: u64, pull: &PullRequest) -> Result<()> { + match pull.mergeable { Some(true) => {} Some(false) => bail!( "pr-merge: PR #{number} is not mergeable (conflicts). Rebase it, or pass --force." @@ -116,20 +137,18 @@ fn check_ready(client: &Client, repo: &str, number: u64, pull: &Value) -> Result ), } - if let Some(sha) = pull - .get("head") - .and_then(|h| h.get("sha")) - .and_then(Value::as_str) - { - let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?; - let state = combined.get("state").and_then(Value::as_str).unwrap_or(""); - let has_statuses = combined - .get("statuses") - .and_then(Value::as_array) - .is_some_and(|a| !a.is_empty()); + if let Some(sha) = pull.head.as_ref().and_then(|h| h.sha.as_deref()) { + let (owner, name) = split_repo(repo)?; + let (_, combined) = client + .api() + .repo_get_combined_status_by_ref(owner, name, sha) + .send()?; + let state = combined.state; + let has_statuses = combined.statuses.as_ref().is_some_and(|a| !a.is_empty()); // An empty status set means no CI is configured — not a blocker. // Anything other than success once CI exists blocks the merge. - if has_statuses && state != "success" { + if has_statuses && state != Some(CommitStatusState::Success) { + let state = super::pr_status::status_state_str(state); bail!( "pr-merge: PR #{number} CI is not green (state: {state}). Wait for green, or pass --force." ); @@ -167,13 +186,20 @@ mod tests { #[test] fn merge_payload_shape() { // delete-by-default: keep_branch=false → delete_branch_after_merge=true. - let payload = json!({ - "Do": Method::Merge.forgejo_do(), - "delete_branch_after_merge": true, - "force_merge": false, - }); - assert_eq!(payload["Do"], "merge"); - assert_eq!(payload["delete_branch_after_merge"], true); - assert_eq!(payload["force_merge"], false); + // Pin the wire shape of the typed body — `Do` casing included. + let payload = MergePullRequestOption { + r#do: Method::Merge.as_option_do(), + merge_commit_id: None, + merge_message_field: None, + merge_title_field: None, + delete_branch_after_merge: Some(true), + force_merge: Some(false), + head_commit_id: None, + merge_when_checks_succeed: None, + }; + let wire = serde_json::to_value(&payload).unwrap(); + assert_eq!(wire["Do"], "merge"); + assert_eq!(wire["delete_branch_after_merge"], true); + assert_eq!(wire["force_merge"], false); } } diff --git a/hive-forge/src/verbs/pr_reviews.rs b/hive-forge/src/verbs/pr_reviews.rs index a30aa9be..1069d65b 100644 --- a/hive-forge/src/verbs/pr_reviews.rs +++ b/hive-forge/src/verbs/pr_reviews.rs @@ -3,9 +3,10 @@ use anyhow::{Result, bail}; use clap::Args as ClapArgs; +use forgejo_api::structs::CreatePullReviewOptions; use serde_json::{Value, json}; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -55,53 +56,69 @@ pub fn run(client: &Client, args: Args) -> Result<()> { /// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`) and print /// a compact summary of the created review. fn submit_review(client: &Client, number: u64, event: &str, body: Option) -> Result<()> { - let repo = client.repo(); - let payload = json!({ - "event": event, - "body": body.unwrap_or_default(), - }); - let v = client.post_json(&format!("/repos/{repo}/pulls/{number}/reviews"), &payload)?; + let (owner, name) = client.owner_repo()?; + let payload = CreatePullReviewOptions { + body: Some(body.unwrap_or_default()), + comments: None, + commit_id: None, + event: Some(event.to_owned()), + }; + let review = client + .api() + .repo_create_pull_review(owner, name, index(number)?, payload) + .send()?; print_json(&json!({ - "id": v.get("id"), - "state": v.get("state"), - "user": v.get("user").and_then(|u| u.get("login")), + "id": review.id, + "state": review.state, + "user": review.user.as_ref().and_then(|u| u.login.as_deref()), })) } -/// Fetch inline diff comments for a single review. Returns an empty vec on -/// any error (missing review, network failure) so callers can degrade -/// gracefully. -fn fetch_inline_comments(client: &Client, repo: &str, pr: u64, review_id: u64) -> Vec { +/// Fetch inline diff comments for a single review, serialized back to +/// the API's JSON shape. Returns an empty vec on any error (missing +/// review, network failure) so callers can degrade gracefully. +fn fetch_inline_comments(client: &Client, pr: u64, review_id: i64) -> Vec { + let Ok((owner, name)) = client.owner_repo() else { + return Vec::new(); + }; + let Ok(idx) = index(pr) else { + return Vec::new(); + }; client - .get_json(&format!( - "/repos/{repo}/pulls/{pr}/reviews/{review_id}/comments" - )) + .api() + .repo_get_pull_review_comments(owner, name, idx, review_id) + .send() .ok() + .and_then(|comments| serde_json::to_value(comments).ok()) .and_then(|v| v.as_array().cloned()) .unwrap_or_default() } /// List all reviews for a PR, dispatching to the appropriate output mode. fn list_reviews(client: &Client, number: u64) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))?; - let reviews = v.as_array().cloned().unwrap_or_default(); + let (owner, name) = client.owner_repo()?; + let (_, reviews) = client + .api() + .repo_list_pull_reviews(owner, name, index(number)?) + .send()?; + let reviews = serde_json::to_value(reviews)?; + let reviews = reviews.as_array().cloned().unwrap_or_default(); if client.json_mode() { - list_reviews_json(client, repo, number, &reviews) + list_reviews_json(client, number, &reviews) } else { - list_reviews_text(client, repo, number, &reviews); + list_reviews_text(client, number, &reviews); Ok(()) } } /// JSON output: one object per review, with an inline `comments` array. -fn list_reviews_json(client: &Client, repo: &str, number: u64, reviews: &[Value]) -> Result<()> { +fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<()> { let trimmed: Vec = reviews .iter() .map(|r| { - let id = r.get("id").and_then(Value::as_u64).unwrap_or(0); + let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let inline: Vec = if id > 0 { - fetch_inline_comments(client, repo, number, id) + fetch_inline_comments(client, number, id) .iter() .map(|c| { json!({ @@ -130,13 +147,13 @@ fn list_reviews_json(client: &Client, repo: &str, number: u64, reviews: &[Value] /// Human-readable output: Markdown-style heading per review, inline /// comments as `[path:line] body` (line omitted for PR-level comments). -fn list_reviews_text(client: &Client, repo: &str, number: u64, reviews: &[Value]) { +fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) { if reviews.is_empty() { println!("(no reviews)"); return; } for r in reviews { - let id = r.get("id").and_then(Value::as_u64).unwrap_or(0); + let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let user = r .get("user") .and_then(|u| u.get("login")) @@ -149,7 +166,7 @@ fn list_reviews_text(client: &Client, repo: &str, number: u64, reviews: &[Value] println!("{body}"); } if id > 0 { - for c in &fetch_inline_comments(client, repo, number, id) { + for c in &fetch_inline_comments(client, number, id) { let path = c.get("path").and_then(Value::as_str).unwrap_or("?"); let cbody = c.get("body").and_then(Value::as_str).unwrap_or("").trim(); // PR-level comments have no line; omit `:line` when absent. diff --git a/hive-forge/src/verbs/pr_status.rs b/hive-forge/src/verbs/pr_status.rs index c947cc01..c67b4439 100644 --- a/hive-forge/src/verbs/pr_status.rs +++ b/hive-forge/src/verbs/pr_status.rs @@ -11,10 +11,11 @@ use anyhow::{Context, Result, bail}; use clap::Args as ClapArgs; +use forgejo_api::structs::{CommitStatusState, IssueGetCommentsQuery}; use serde_json::Value; -use crate::client::Client; -use crate::verbs::print_json; +use crate::client::{Client, index}; +use crate::verbs::{print_json, rfc3339}; #[derive(ClapArgs)] pub struct Args { @@ -38,14 +39,29 @@ pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); match (args.pr, args.sha) { (Some(pr), _) => pr_status(client, repo, pr), - (None, Some(sha)) => sha_status(client, repo, &sha), + (None, Some(sha)) => sha_status(client, &sha), (None, None) => bail!("pr-status: pass one of --pr or --sha "), } } +/// The wire string for a combined/per-context CI status state, matching +/// what the raw API emitted (`""` when absent). Shared with `pr-merge` +/// for its "CI is not green" message. +pub(crate) fn status_state_str(state: Option) -> &'static str { + match state { + Some(CommitStatusState::Pending) => "pending", + Some(CommitStatusState::Success) => "success", + Some(CommitStatusState::Error) => "error", + Some(CommitStatusState::Failure) => "failure", + Some(CommitStatusState::Warning) => "warning", + Some(CommitStatusState::Skipped) => "skipped", + None => "", + } +} + /// CI-only path for an explicit commit. Exit code mirrors the CI verdict. -fn sha_status(client: &Client, repo: &str, sha: &str) -> Result<()> { - let (state, statuses) = fetch_combined(client, repo, sha)?; +fn sha_status(client: &Client, sha: &str) -> Result<()> { + let (state, statuses) = fetch_combined(client, sha)?; if client.json_mode() { print_json(&combined_json(sha, &state, &statuses))?; } else { @@ -60,33 +76,36 @@ fn sha_status(client: &Client, repo: &str, sha: &str) -> Result<()> { /// Full PR health view. Exit code is a merge-readiness verdict. fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> { - let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; - let title = pull.get("title").and_then(Value::as_str).unwrap_or(""); - let state = pull.get("state").and_then(Value::as_str).unwrap_or("?"); - let merged = pull.get("merged").and_then(Value::as_bool).unwrap_or(false); + let (owner, name) = client.owner_repo()?; + let pull = client + .api() + .repo_get_pull_request(owner, name, index(pr)?) + .send()?; + let title = pull.title.as_deref().unwrap_or(""); + let state = pull.state.map_or("?", |s| match s { + forgejo_api::structs::StateType::Open => "open", + forgejo_api::structs::StateType::Closed => "closed", + }); + let merged = pull.merged.unwrap_or(false); // `mergeable` is `null` while the forge is still computing it. - let mergeable = pull.get("mergeable").and_then(Value::as_bool); + let mergeable = pull.mergeable; let sha = pull - .get("head") - .and_then(|h| h.get("sha")) - .and_then(Value::as_str) - .map(str::to_owned) + .head + .as_ref() + .and_then(|h| h.sha.clone()) .with_context(|| format!("pr-status: PR #{pr} has no head.sha"))?; - let requested = pull - .get("requested_reviewers") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|u| u.get("login").and_then(Value::as_str)) - .map(str::to_owned) - .collect::>() - }) - .unwrap_or_default(); + let requested: Vec = pull + .requested_reviewers + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|u| u.login.clone()) + .collect(); - let (ci_state, ci_statuses) = fetch_combined(client, repo, &sha)?; + let (ci_state, ci_statuses) = fetch_combined(client, &sha)?; let reviews = super::latest_reviews(client, repo, pr)?; - let last = last_comment(client, repo, pr)?; + let last = last_comment(client, pr)?; if client.json_mode() { print_json(&serde_json::json!({ @@ -136,37 +155,56 @@ fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> { } /// Fetch the combined commit status: `(overall_state, statuses[])`. -fn fetch_combined(client: &Client, repo: &str, sha: &str) -> Result<(String, Vec)> { - let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?; - let state = combined - .get("state") - .and_then(Value::as_str) - .unwrap_or("") - .to_owned(); +/// Statuses ride as their serialized (API-shape) JSON so the render +/// helpers stay pure `Value` walkers. +fn fetch_combined(client: &Client, sha: &str) -> Result<(String, Vec)> { + let (owner, name) = client.owner_repo()?; + let (_, combined) = client + .api() + .repo_get_combined_status_by_ref(owner, name, sha) + .send()?; + let state = status_state_str(combined.state).to_owned(); let statuses = combined - .get("statuses") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + .statuses + .unwrap_or_default() + .iter() + .map(serde_json::to_value) + .collect::, _>>()?; Ok((state, statuses)) } /// The most recent issue comment on the PR, as `(login, created_at)`. /// Comments page oldest-first; we drain (bounded) and take the max /// timestamp so a long thread still reports the genuinely-latest one. -fn last_comment(client: &Client, repo: &str, pr: u64) -> Result> { - let comments = client.get_json_all(&format!("/repos/{repo}/issues/{pr}/comments"), 20)?; - let last = comments - .iter() - .filter_map(|c| { - let login = c - .get("user") - .and_then(|u| u.get("login")) - .and_then(Value::as_str)?; - let created = c.get("created_at").and_then(Value::as_str)?; - Some((login.to_owned(), created.to_owned())) - }) - .max_by(|a, b| a.1.cmp(&b.1)); +fn last_comment(client: &Client, pr: u64) -> Result> { + const PAGE_SIZE: u32 = 50; + const MAX_PAGES: u32 = 20; + let (owner, name) = client.owner_repo()?; + let idx = index(pr)?; + let mut last: Option<(String, String)> = None; + for page in 1..=MAX_PAGES { + let (_, comments) = client + .api() + .issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default()) + .page(page) + .page_size(PAGE_SIZE) + .send()?; + let short = comments.len() < PAGE_SIZE as usize; + for c in &comments { + let Some(login) = c.user.as_ref().and_then(|u| u.login.clone()) else { + continue; + }; + let Some(created) = rfc3339(c.created_at) else { + continue; + }; + if last.as_ref().is_none_or(|(_, t)| created > *t) { + last = Some((login, created)); + } + } + if short { + break; + } + } Ok(last) } @@ -323,6 +361,19 @@ mod tests { assert_eq!(status_mark("weird"), "•"); } + #[test] + fn status_state_str_matches_wire_names() { + assert_eq!( + status_state_str(Some(CommitStatusState::Success)), + "success" + ); + assert_eq!( + status_state_str(Some(CommitStatusState::Failure)), + "failure" + ); + assert_eq!(status_state_str(None), ""); + } + #[test] fn combined_json_shape() { let v = combined_json("abc", "success", &[]); diff --git a/hive-forge/src/verbs/reopen.rs b/hive-forge/src/verbs/reopen.rs index 68208375..86edce9b 100644 --- a/hive-forge/src/verbs/reopen.rs +++ b/hive-forge/src/verbs/reopen.rs @@ -8,7 +8,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use serde_json::json; -use crate::client::Client; +use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] @@ -20,16 +20,21 @@ pub struct Args { /// # Errors /// /// Returns an error when the `PATCH /repos/{repo}/issues/{number}` request -/// fails (network / non-success status from `patch_json`) or when emitting -/// the JSON summary via `print_json` fails. +/// fails (network / non-success status) or when emitting the JSON summary +/// via `print_json` fails. pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "state": "open" }), - )?; + let (owner, name) = client.owner_repo()?; + let resp = client + .api() + .issue_edit_issue( + owner, + name, + index(args.number)?, + super::close::state_edit("open"), + ) + .send()?; print_json(&json!({ - "number": resp.get("number"), - "state": resp.get("state"), + "number": resp.number, + "state": resp.state, })) } diff --git a/hive-forge/src/verbs/repo_add_collaborator.rs b/hive-forge/src/verbs/repo_add_collaborator.rs index 7fa24068..ffd8cd21 100644 --- a/hive-forge/src/verbs/repo_add_collaborator.rs +++ b/hive-forge/src/verbs/repo_add_collaborator.rs @@ -9,7 +9,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use clap::ValueEnum; -use serde_json::json; +use forgejo_api::structs::{AddCollaboratorOption, AddCollaboratorOptionPermission}; use crate::client::Client; @@ -25,7 +25,8 @@ pub enum Permission { } impl Permission { - /// The wire string Forgejo's API expects. + /// The wire string Forgejo's API expects (used for the printed + /// confirmation). fn as_api(self) -> &'static str { match self { Permission::Read => "read", @@ -33,6 +34,15 @@ impl Permission { Permission::Admin => "admin", } } + + /// The typed permission for the request body. + fn as_option(self) -> AddCollaboratorOptionPermission { + match self { + Permission::Read => AddCollaboratorOptionPermission::Read, + Permission::Write => AddCollaboratorOptionPermission::Write, + Permission::Admin => AddCollaboratorOptionPermission::Admin, + } + } } #[derive(ClapArgs)] @@ -53,11 +63,19 @@ pub struct Args { /// writing the confirmation to stdout. pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); + let (owner, name) = client.owner_repo()?; let perm = args.permission.as_api(); - client.put_no_content( - &format!("/repos/{repo}/collaborators/{}", args.user), - &json!({ "permission": perm }), - )?; + client + .api() + .repo_add_collaborator( + owner, + name, + &args.user, + AddCollaboratorOption { + permission: Some(args.permission.as_option()), + }, + ) + .send()?; println!("added {} to {repo} ({perm})", args.user); Ok(()) } diff --git a/hive-forge/src/verbs/repo_create.rs b/hive-forge/src/verbs/repo_create.rs index edaef007..9ce580e8 100644 --- a/hive-forge/src/verbs/repo_create.rs +++ b/hive-forge/src/verbs/repo_create.rs @@ -11,7 +11,7 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::CreateRepoOption; use crate::client::Client; use crate::verbs::print_json; @@ -48,29 +48,31 @@ pub struct Args { /// permission on the target namespace, token missing/invalid) and any /// I/O error from writing the result to stdout. pub fn run(client: &Client, args: Args) -> Result<()> { - let mut payload = json!({ - "name": args.name, - "private": args.private, - "auto_init": args.auto_init, - }); - if let Some(d) = args.description { - payload["description"] = json!(d); - } - if let Some(b) = args.default_branch { - payload["default_branch"] = json!(b); - } + let payload = CreateRepoOption { + auto_init: Some(args.auto_init), + default_branch: args.default_branch, + description: args.description, + gitignores: None, + issue_labels: None, + license: None, + name: args.name, + object_format_name: None, + private: Some(args.private), + readme: None, + template: None, + trust_model: None, + }; - let path = match args.org.as_deref() { - Some(org) => format!("/orgs/{org}/repos"), - None => "/user/repos".to_owned(), + let resp = match args.org.as_deref() { + Some(org) => client.api().create_org_repo(org, payload).send()?, + None => client.api().create_current_user_repo(payload).send()?, }; - let resp = client.post_json(&path, &payload)?; if client.json_mode() { - return print_json(&resp); + return print_json(&serde_json::to_value(&resp)?); } // Default human path: print the web URL, like issue-create / pr-create. - if let Some(url) = resp.get("html_url").and_then(Value::as_str) { + if let Some(url) = resp.html_url { println!("{url}"); } Ok(()) diff --git a/hive-forge/src/verbs/repo_labels.rs b/hive-forge/src/verbs/repo_labels.rs index 0681ad00..b0d1de48 100644 --- a/hive-forge/src/verbs/repo_labels.rs +++ b/hive-forge/src/verbs/repo_labels.rs @@ -5,11 +5,17 @@ use anyhow::Result; use clap::Args as ClapArgs; -use serde_json::{Value, json}; +use forgejo_api::structs::{IssueListLabelsQuery, Label}; use crate::client::Client; use crate::verbs::print_json; +/// Page size on the label list endpoint. +const PAGE_SIZE: u32 = 50; + +/// Runaway cap on label pagination — same ceiling the raw client used. +const MAX_PAGES: u32 = 10; + #[derive(ClapArgs)] pub struct Args { /// Substring pattern to filter label names (case-sensitive). @@ -17,34 +23,46 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); + let (owner, name) = client.owner_repo()?; // Repos can carry more than one page of labels; paginate so the list // is complete rather than capped at the first page. - let labels = client.get_json_all(&format!("/repos/{repo}/labels"), 10)?; - let filtered: Vec<&Value> = labels + let mut labels: Vec