Watch
0
0
Fork
You've already forked hyperhive
0

refactor(hive-forge): port CLI verbs to forgejo-api

This commit is contained in:
müde 2026-07-07 09:24:53 +02:00
commit 4636987469
36 changed files with 1463 additions and 1153 deletions

View file

@ -1,11 +1,14 @@
//! Blocking HTTP client for the Forgejo REST API. Identity is the //! App-level Forgejo client wrapper. Identity is the per-agent token
//! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All //! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through
//! verbs go through this client so error surfaces, header set, and //! the typed [`forgejo_api::sync::Forgejo`] client (exposed via
//! 4xx/5xx body unwrapping stay consistent. //! [`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 std::path::PathBuf;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use forgejo_api::{Auth, ForgejoError};
use reqwest::blocking::{Client as HttpClient, Response}; use reqwest::blocking::{Client as HttpClient, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::Serialize; use serde::Serialize;
@ -18,14 +21,23 @@ const DEFAULT_URL: &str = "http://localhost:3000";
/// take a repo override. /// take a repo override.
const DEFAULT_REPO: &str = "hyperhive/hyperhive"; const DEFAULT_REPO: &str = "hyperhive/hyperhive";
/// Blocking Forgejo API client. Cheap to clone (wraps an /// Forgejo client pair: the typed `/api/v1` client plus a raw
/// `Arc<reqwest::Client>` internally). /// `reqwest` client for web-router-only routes.
pub struct Client { 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/<uuid>`,
/// 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, base: String,
/// Per-agent forge token. Kept alongside the pre-built auth header /// Per-agent forge token. Kept so verbs that shell out to `git`
/// so verbs that shell out to `git` (e.g. `clone`) can assemble /// (e.g. `clone`) can assemble an authenticated push URL without
/// an authenticated push URL without re-reading the token file. /// re-reading the token file.
token: String, token: String,
/// Default repo used when a verb doesn't carry an explicit /// Default repo used when a verb doesn't carry an explicit
/// `[repo]` override. /// `[repo]` override.
@ -49,19 +61,24 @@ impl Client {
.unwrap_or_else(|| DEFAULT_REPO.to_owned()); .unwrap_or_else(|| DEFAULT_REPO.to_owned());
let token = read_token().context("read forge-token")?; 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 mut headers = HeaderMap::new();
let auth = format!("token {token}"); let auth = format!("token {token}");
let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?; let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?;
auth_val.set_sensitive(true); auth_val.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_val); headers.insert(AUTHORIZATION, auth_val);
headers.insert(ACCEPT, HeaderValue::from_static("application/json")); headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let web = HttpClient::builder()
let http = HttpClient::builder()
.default_headers(headers) .default_headers(headers)
.build() .build()
.context("build reqwest client")?; .context("build reqwest client")?;
Ok(Self { Ok(Self {
http, api,
web,
base, base,
token, token,
default_repo, 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. /// Assemble an authenticated git URL for `repo` (e.g.
/// `internal/knowledge`) by injecting the agent's forge user + /// `internal/knowledge`) by injecting the agent's forge user +
/// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`. /// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`.
@ -95,11 +119,6 @@ impl Client {
self.json_mode self.json_mode
} }
/// Resolve the API base path (`<base>/api/v1`).
fn api(&self) -> String {
format!("{}/api/v1", self.base)
}
/// Return the active repo. `from_env` already folded the /// Return the active repo. `from_env` already folded the
/// `-r/--repo` override into `default_repo`, so verbs just read /// `-r/--repo` override into `default_repo`, so verbs just read
/// it as-is — no per-verb override plumbing. /// it as-is — no per-verb override plumbing.
@ -108,177 +127,10 @@ impl Client {
&self.default_repo &self.default_repo
} }
/// GET `<api>/<path>` and decode JSON. /// The active repo split into `(owner, name)` for the typed
pub fn get_json(&self, path: &str) -> Result<Value> { /// client's per-segment path arguments.
let url = format!("{}{}", self.api(), path); pub fn owner_repo(&self) -> Result<(&str, &str)> {
let resp = self.http.get(&url).send().context("GET")?; split_repo(self.repo())
decode_json(resp, &format!("GET {url}"))
}
/// GET `<api>/<path>` 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<Option<Value>> {
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<Vec<Value>> {
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 `<api>/<path>` 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<String> {
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 `<api>/<path>` and decode the response.
pub fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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 `<api>/<path>` and decode the response.
pub fn patch_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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 `<api>/<path>` and decode the response.
pub fn put_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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 `<api>/<path>` 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<B: Serialize>(&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 `<api>/<path>` 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 `<api>/<path>` 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<B: Serialize>(&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 `<api>/<path>`. 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(())
} }
/// Build the full URL for a Forgejo attachment by UUID. /// 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 /// POST a JSON body to a base-relative *web* path (NOT under
/// `/api/v1/`) and decode the JSON response. Used for endpoints /// `/api/v1/`, so no `forgejo-api` method exists) and decode the
/// Forgejo only serves through its web router — e.g. the Actions /// JSON response. Used for endpoints Forgejo only serves through
/// run-view log streamer at /// its web router — e.g. the Actions run-view log streamer at
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The /// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
/// agent's `Authorization: token` header is sent as usual; the /// agent's `Authorization: token` header is sent as usual; the
/// web router accepts a token-authed doer and, because the handler /// web router accepts a token-authed doer and, because the handler
@ -312,44 +164,67 @@ impl Client {
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> { pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
let url = self.web_url(path); let url = self.web_url(path);
let resp = self let resp = self
.http .web
.post(&url) .post(&url)
.header(CONTENT_TYPE, "application/json") .header(CONTENT_TYPE, "application/json")
.json(body) .json(body)
.send() .send()
.context("POST")?; .context("POST")?;
decode_json(resp, &format!("POST {url}")) let resp = check_status(resp, &format!("POST {url}"))?;
resp.json::<Value>()
.with_context(|| format!("decode JSON for POST {url}"))
} }
/// GET a raw (non-API) URL and return the response body as bytes. /// GET a raw (non-API) URL and return the response body as bytes.
/// The client's auth headers are still sent — Forgejo requires them /// Stays on the raw `reqwest` client because these are *web-router*
/// for private attachment downloads. Uses the full URL as-is; the /// routes (`/attachments/<uuid>`, Actions artifact / persisted-log
/// caller is responsible for constructing it (see `attachment_url`). /// 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<Vec<u8>> { pub fn get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
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}"))?; let resp = check_status(resp, &format!("GET {url}"))?;
resp.bytes() resp.bytes()
.map(|b| b.to_vec()) .map(|b| b.to_vec())
.with_context(|| format!("read bytes for GET {url}")) .with_context(|| format!("read bytes for GET {url}"))
} }
}
/// POST a multipart file upload, returning the parsed response. /// Split an `owner/name` repo string into its two path segments for
/// Used by `attach-issue` / `attach-comment`. /// the typed client. Errors on anything that isn't exactly
pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result<Value> { /// `owner/name` with both halves non-empty.
let url = format!("{}{}", self.api(), path); pub fn split_repo(repo: &str) -> Result<(&str, &str)> {
let form = reqwest::blocking::multipart::Form::new() match repo.split_once('/') {
.file("attachment", file) Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => {
.with_context(|| format!("read {}", file.display()))?; Ok((owner, name))
let resp = self }
.http _ => bail!("hive-forge: repo must be of the form owner/name, got {repo:?}"),
.post(&url)
.multipart(form)
.send()
.context("POST")?;
decode_json(resp, &format!("POST {url}"))
} }
} }
/// 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> {
i64::try_from(n).with_context(|| format!("number {n} out of range"))
}
/// Locate and read the forge token. Falls back to `$PWD/forge-token` /// Locate and read the forge token. Falls back to `$PWD/forge-token`
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. /// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
fn read_token() -> Result<String> { fn read_token() -> Result<String> {
@ -364,9 +239,10 @@ fn read_token() -> Result<String> {
Ok(raw.trim().to_owned()) Ok(raw.trim().to_owned())
} }
/// Surface non-2xx HTTP responses as anyhow errors with the response /// Surface non-2xx HTTP responses on the raw web routes as anyhow
/// body included (matches `curl --fail-with-body`) — turns /// errors with the response body included (matches `curl
/// silent failures into errors with a clear message. /// --fail-with-body`) — turns silent failures into errors with a
/// clear message.
fn check_status(resp: Response, op: &str) -> Result<Response> { fn check_status(resp: Response, op: &str) -> Result<Response> {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
@ -376,13 +252,28 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
bail!("hive-forge: {op} failed ({status}): {body}"); bail!("hive-forge: {op} failed ({status}): {body}");
} }
fn decode_json(resp: Response, op: &str) -> Result<Value> { #[cfg(test)]
let resp = check_status(resp, op)?; mod tests {
resp.json::<Value>() use super::split_repo;
.with_context(|| format!("decode JSON for {op}"))
}
fn decode_text(resp: Response, op: &str) -> Result<String> { #[test]
let resp = check_status(resp, op)?; fn split_repo_accepts_owner_name() {
resp.text().with_context(|| format!("decode text for {op}")) 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());
}
} }

View file

@ -13,12 +13,12 @@
//! `forge cli: agents keep commenting without reading prev comments`.) //! `forge cli: agents keep commenting without reading prev comments`.)
use anyhow::Result; 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. /// 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. /// How many pages of unread notifications to scan for the thread.
/// Notifications come newest-first and a thread the caller is about to /// 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 /// 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 /// firehose), at the cost of not detecting a match buried past
/// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for /// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for
/// a courtesy guard). /// a courtesy guard).
const MAX_PAGES: u64 = 5; const MAX_PAGES: u32 = 5;
/// The notification thread id of an UNREAD notification on /// The notification thread id of an UNREAD notification on
/// `<repo>#<number>`, or `None` when the thread has no unread /// `<repo>#<number>`, 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 / /// number can't false-match another repo. Errors only on transport /
/// non-2xx — callers degrade open on `Err`. /// non-2xx — callers degrade open on `Err`.
pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Option<u64>> { pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Option<u64>> {
let (owner, name) = split_repo(repo)?;
for page in 1..=MAX_PAGES { for page in 1..=MAX_PAGES {
let v = client.get_json(&format!( let query = NotifyGetRepoListQuery {
"/repos/{repo}/notifications?all=false&page={page}&limit={PAGE_SIZE}" all: Some(false),
))?; ..Default::default()
let arr = v.as_array().cloned().unwrap_or_default(); };
let len = arr.len() as u64; let (_, threads) = client
for n in &arr { .api()
.notify_get_repo_list(owner, name, query)
.page(page)
.page_size(PAGE_SIZE)
.send()?;
for n in &threads {
let subject_url = n let subject_url = n
.get("subject") .subject
.and_then(|s| s.get("url")) .as_ref()
.and_then(Value::as_str) .and_then(|s| s.url.as_ref())
.unwrap_or(""); .map_or("", url::Url::as_str);
if subject_matches(subject_url, number) { 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. // Last (short) page reached — stop.
if len < PAGE_SIZE { if threads.len() < PAGE_SIZE as usize {
break; break;
} }
} }
@ -62,7 +68,11 @@ pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Opti
/// result). Clears the unread signal so a subsequent comment isn't /// result). Clears the unread signal so a subsequent comment isn't
/// blocked after the agent has read the thread. /// blocked after the agent has read the thread.
pub fn mark_thread_read(client: &Client, thread_id: u64) -> Result<()> { pub fn mark_thread_read(client: &Client, thread_id: u64) -> 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: /// Reading a thread (`comments` / `view`) is the "I've seen it" signal:

View file

@ -23,7 +23,7 @@ use std::path::PathBuf;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use serde_json::Value; use forgejo_api::structs::ListActionRunsQuery;
use crate::client::Client; use crate::client::Client;
@ -45,28 +45,29 @@ pub struct Args {
/// Pages over the REST runs list (newest-first) to find the run whose /// Pages over the REST runs list (newest-first) to find the run whose
/// run-page `html_url` ends in `/runs/<run-number>`, returning its global /// run-page `html_url` ends in `/runs/<run-number>`, returning its global
/// run id — the identifier the web artifact-download route requires. /// run id — the identifier the web artifact-download route requires.
fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result<u64> { fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result<i64> {
const PER_PAGE: u32 = 50; const PER_PAGE: u32 = 50;
const MAX_PAGES: u32 = 40; const MAX_PAGES: u32 = 40;
let (owner, name) = client.owner_repo()?;
for page in 1..=MAX_PAGES { for page in 1..=MAX_PAGES {
let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); let body = client
let body = client.get_json(&path)?; .api()
let runs = body .list_action_runs(owner, name, ListActionRunsQuery::default())
.get("workflow_runs") .page(page)
.and_then(Value::as_array) .page_size(PER_PAGE)
.cloned() .send()?;
.unwrap_or_default(); let runs = body.workflow_runs.unwrap_or_default();
if runs.is_empty() { if runs.is_empty() {
break; break;
} }
for run in &runs { for run in &runs {
let tail = run let tail = run
.get("html_url") .html_url
.and_then(Value::as_str) .as_ref()
.and_then(|u| u.rsplit('/').next()) .and_then(|u| u.as_str().rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok()); .and_then(|s| s.parse::<u64>().ok());
if tail == Some(run_number) if tail == Some(run_number)
&& let Some(id) = run.get("id").and_then(Value::as_u64) && let Some(id) = run.id
{ {
return Ok(id); return Ok(id);
} }

View file

@ -5,9 +5,10 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; 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; use crate::verbs::print_json;
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -22,37 +23,45 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; let idx = index(args.number)?;
let current = client.api().issue_get_issue(owner, name, idx).send()?;
let mut assignees: Vec<String> = current let mut assignees: Vec<String> = current
.get("assignees") .assignees
.and_then(Value::as_array) .unwrap_or_default()
.map(|a| { .into_iter()
a.iter() .filter_map(|u| u.login)
.filter_map(|u| u.get("login").and_then(Value::as_str).map(str::to_owned)) .collect();
.collect()
})
.unwrap_or_default();
if args.remove { if args.remove {
assignees.retain(|u| u != &args.user); assignees.retain(|u| u != &args.user);
} else if !assignees.contains(&args.user) { } else if !assignees.contains(&args.user) {
assignees.push(args.user.clone()); assignees.push(args.user.clone());
} }
let resp = client.patch_json( let payload = EditIssueOption {
&format!("/repos/{repo}/issues/{}", args.number), assignee: None,
&json!({ "assignees": assignees }), 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 let logins: Vec<&str> = resp
.get("assignees") .assignees
.and_then(Value::as_array) .as_deref()
.map(|a| { .unwrap_or_default()
a.iter() .iter()
.filter_map(|u| u.get("login").and_then(Value::as_str)) .filter_map(|u| u.login.as_deref())
.collect() .collect();
})
.unwrap_or_default();
print_json(&json!({ print_json(&json!({
"number": resp.get("number"), "number": resp.number,
"assignees": logins, "assignees": logins,
})) }))
} }

View file

@ -2,13 +2,15 @@
//! <file>` — upload a file as an attachment. Prints the browser //! <file>` — upload a file as an attachment. Prints the browser
//! download URL. //! 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 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)] #[derive(ClapArgs)]
pub struct IssueArgs { pub struct IssueArgs {
@ -39,11 +41,16 @@ pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
args.file.display() args.file.display()
); );
} }
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let resp = client.post_multipart_file( let bytes = read_file(&args.file)?;
&format!("/repos/{repo}/issues/{}/assets", args.number), let query = IssueCreateIssueAttachmentQuery {
&args.file, 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); print_url(&resp);
Ok(()) Ok(())
} }
@ -61,17 +68,33 @@ pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> {
args.file.display() args.file.display()
); );
} }
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let resp = client.post_multipart_file( let bytes = read_file(&args.file)?;
&format!("/repos/{repo}/issues/comments/{}/assets", args.id), let query = IssueCreateIssueCommentAttachmentQuery {
&args.file, 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); print_url(&resp);
Ok(()) Ok(())
} }
fn print_url(v: &Value) { fn read_file(path: &Path) -> Result<Vec<u8>> {
if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) { 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<String> {
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}"); println!("{url}");
} }
} }

View file

@ -2,7 +2,6 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use serde_json::Value;
use crate::client::Client; use crate::client::Client;
@ -13,17 +12,16 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?; let (_, branches) = client
let names: Vec<&str> = v .api()
.as_array() .repo_list_branches(owner, name)
.map(|a| { .page_size(100)
a.iter() .send()?;
.filter_map(|b| b.get("name").and_then(Value::as_str)) for branch in &branches {
.collect() let Some(n) = branch.name.as_deref() else {
}) continue;
.unwrap_or_default(); };
for n in names {
if args.pattern.as_deref().is_none_or(|p| n.contains(p)) { if args.pattern.as_deref().is_none_or(|p| n.contains(p)) {
println!("{n}"); println!("{n}");
} }

View file

@ -22,16 +22,16 @@
//! - `--branch <name>` → dispatches `--workflow` on that branch directly. //! - `--branch <name>` → dispatches `--workflow` on that branch directly.
//! - `--run <n>` → looks the run up in the Actions runs list (by the //! - `--run <n>` → looks the run up in the Actions runs list (by the
//! `runs/<n>` tail of its `html_url`, same convention as `ci-log` / //! `runs/<n>` tail of its `html_url`, same convention as `ci-log` /
//! `artifact-get`) and dispatches the SAME workflow on the SAME branch the //! `artifact-get`) and dispatches the SAME workflow on the SAME ref the
//! run used. //! run used (the run record's `prettyref` + `workflow_id`).
//! //!
//! Dispatch re-runs the whole workflow, so there is no single-job variant. //! Dispatch re-runs the whole workflow, so there is no single-job variant.
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use clap::Args as ClapArgs; 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)] #[derive(ClapArgs)]
pub struct Args { pub struct Args {
@ -60,12 +60,12 @@ pub struct Args {
/// ///
/// Returns an error if none of `--pr` / `--run` / `--branch` is given, if a /// 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 /// `--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). /// such as `404` for an unknown workflow file or branch).
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
let (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) { 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(run), _) => resolve_run(client, repo, run, &args.workflow)?,
(_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()), (_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()),
(None, None, None) => { (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 client
.post_no_content(&path, &json!({ "ref": branch })) .api()
.dispatch_workflow(owner, name, &workflow, body)
.send()
.with_context(|| { .with_context(|| {
format!( format!(
"dispatch workflow {workflow} on {branch} ({repo}) — the workflow \ "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 /// 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. /// pushes to, which is the ref we dispatch the workflow on.
fn branch_for_pr(client: &Client, repo: &str, pr: u64) -> Result<String> { fn branch_for_pr(client: &Client, pr: u64) -> Result<String> {
let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; let (owner, name) = client.owner_repo()?;
pull.get("head") let pull = client
.and_then(|h| h.get("ref")) .api()
.and_then(Value::as_str) .repo_get_pull_request(owner, name, index(pr)?)
.map(str::to_string) .send()?;
pull.head
.and_then(|h| h.r#ref)
.with_context(|| format!("ci-rerun: PR #{pr} has no head.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 /// Page the Actions runs list (newest-first) to find the run whose run-page
/// `html_url` ends in `/runs/<run-number>`, returning the `(workflow, branch)` /// `html_url` ends in `/runs/<run-number>`, returning the `(workflow, branch)`
/// to dispatch a fresh run of it. `fallback_workflow` is used when the run /// 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( fn resolve_run(
client: &Client, client: &Client,
repo: &str, repo: &str,
@ -110,21 +119,22 @@ fn resolve_run(
) -> Result<(String, String)> { ) -> Result<(String, String)> {
const PER_PAGE: u32 = 50; const PER_PAGE: u32 = 50;
const MAX_PAGES: u32 = 40; const MAX_PAGES: u32 = 40;
let (owner, name) = client.owner_repo()?;
for page in 1..=MAX_PAGES { for page in 1..=MAX_PAGES {
let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); let body = client
let body = client.get_json(&path)?; .api()
let runs = body .list_action_runs(owner, name, ListActionRunsQuery::default())
.get("workflow_runs") .page(page)
.and_then(Value::as_array) .page_size(PER_PAGE)
.cloned() .send()?;
.unwrap_or_default(); let runs = body.workflow_runs.unwrap_or_default();
if runs.is_empty() { if runs.is_empty() {
break; break;
} }
for run in &runs { for run in &runs {
if run_number_of(run) == Some(run_number) { if run_number_of(run) == Some(run_number) {
return run_dispatch_target(run, fallback_workflow) 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/<n>` tail), /// The per-repo run NUMBER from a run object's `html_url` (`…/runs/<n>` tail),
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces. /// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
fn run_number_of(run: &Value) -> Option<u64> { fn run_number_of(run: &ActionRun) -> Option<u64> {
run.get("html_url") run.html_url
.and_then(Value::as_str) .as_ref()
.and_then(|u| u.rsplit('/').next()) .and_then(|u| u.as_str().rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
} }
/// Pull the `(workflow-file, branch)` dispatch target out of a run object: /// Pull the `(workflow-file, ref)` dispatch target out of a run record:
/// `head_branch` is the branch, and the workflow file is the basename of the /// `prettyref` is the ref the run ran on (the branch name for push /
/// run's `path` (e.g. `.forgejo/workflows/ci.yml` → `ci.yml`), falling back to /// dispatch runs — PR-event runs carry a `#<n>` pseudo-ref the dispatch
/// `fallback_workflow` when the run carries no usable `path`. `None` only when /// endpoint will reject with a clear 404), and `workflow_id` is the
/// the run has no `head_branch`. /// workflow file name (e.g. `ci.yml`), falling back to
fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String, String)> { /// `fallback_workflow` when absent. `None` only when the run has no ref.
let branch = run.get("head_branch").and_then(Value::as_str)?; 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 let workflow = run
.get("path") .workflow_id
.and_then(Value::as_str) .as_deref()
.and_then(|p| p.rsplit('/').next())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or(fallback_workflow); .unwrap_or(fallback_workflow);
Some((workflow.to_string(), branch.to_string())) Some((workflow.to_string(), branch.to_string()))
@ -158,28 +168,41 @@ fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{run_dispatch_target, run_number_of}; use super::{ActionRun, run_dispatch_target, run_number_of};
use serde_json::json; 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] #[test]
fn parses_run_number_from_html_url() { 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)); 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(&run), Some(42));
assert_eq!( 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 None
); );
assert_eq!(run_number_of(&json!({})), None); assert_eq!(run_number_of(&run_from(json!({}))), None);
} }
#[test] #[test]
fn extracts_workflow_and_branch() { fn extracts_workflow_and_branch() {
let run = json!({ let run = run_from(json!({
"head_branch": "atlas/foo", "prettyref": "atlas/foo",
"path": ".forgejo/workflows/ci.yml", "workflow_id": "ci.yml",
}); }));
assert_eq!( assert_eq!(
run_dispatch_target(&run, "fallback.yml"), run_dispatch_target(&run, "fallback.yml"),
Some(("ci.yml".to_string(), "atlas/foo".to_string())) Some(("ci.yml".to_string(), "atlas/foo".to_string()))
@ -187,8 +210,8 @@ mod tests {
} }
#[test] #[test]
fn falls_back_to_default_workflow_without_path() { fn falls_back_to_default_workflow_without_file() {
let run = json!({ "head_branch": "b" }); let run = run_from(json!({ "prettyref": "b" }));
assert_eq!( assert_eq!(
run_dispatch_target(&run, "fallback.yml"), run_dispatch_target(&run, "fallback.yml"),
Some(("fallback.yml".to_string(), "b".to_string())) Some(("fallback.yml".to_string(), "b".to_string()))
@ -196,7 +219,7 @@ mod tests {
} }
#[test] #[test]
fn no_branch_means_no_target() { fn no_ref_means_no_target() {
assert_eq!(run_dispatch_target(&json!({}), "ci.yml"), None); assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None);
} }
} }

View file

@ -4,7 +4,7 @@ use anyhow::Result;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use serde_json::json; use serde_json::json;
use crate::client::Client; use crate::client::{Client, index};
use crate::verbs::print_json; use crate::verbs::print_json;
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -14,13 +14,31 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let resp = client.patch_json( let resp = client
&format!("/repos/{repo}/issues/{}", args.number), .api()
&json!({ "state": "closed" }), .issue_edit_issue(owner, name, index(args.number)?, state_edit("closed"))
)?; .send()?;
print_json(&json!({ print_json(&json!({
"number": resp.get("number"), "number": resp.number,
"state": resp.get("state"), "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,
}
}

View file

@ -11,10 +11,11 @@
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use forgejo_api::structs::CreateIssueCommentOption;
use serde_json::json; use serde_json::json;
use crate::body; use crate::body;
use crate::client::Client; use crate::client::{Client, index};
use crate::notify; use crate::notify;
use crate::verbs::print_json; use crate::verbs::print_json;
@ -63,12 +64,21 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
} }
} }
let resp = client.post_json( let (owner, name) = client.owner_repo()?;
&format!("/repos/{repo}/issues/{}/comments", args.number), let resp = client
&json!({ "body": body }), .api()
)?; .issue_create_comment(
owner,
name,
index(args.number)?,
CreateIssueCommentOption {
body,
updated_at: None,
},
)
.send()?;
print_json(&json!({ print_json(&json!({
"id": resp.get("id"), "id": resp.id,
"url": resp.get("html_url"), "url": resp.html_url,
})) }))
} }

View file

@ -1,12 +1,13 @@
//! `comment-edit <id> [body sources] [repo]` — edit an existing //! `comment-edit <id> [body sources] [repo]` — edit an existing
//! comment by id. //! comment by id.
use anyhow::Result; use anyhow::{Result, bail};
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use forgejo_api::structs::EditIssueCommentOption;
use serde_json::json; use serde_json::json;
use crate::body; use crate::body;
use crate::client::Client; use crate::client::{Client, index};
use crate::verbs::print_json; use crate::verbs::print_json;
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -33,14 +34,26 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
args.body_file.as_deref(), args.body_file.as_deref(),
"comment-edit", "comment-edit",
)?; )?;
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let resp = client.patch_json( let Some(resp) = client
&format!("/repos/{repo}/issues/comments/{}", args.id), .api()
&json!({ "body": body }), .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!({ print_json(&json!({
"id": resp.get("id"), "id": resp.id,
"user": resp.get("user").and_then(|u| u.get("login")), "user": resp.user.as_ref().and_then(|u| u.login.as_deref()),
"url": resp.get("html_url"), "url": resp.html_url,
})) }))
} }

View file

@ -1,12 +1,12 @@
//! `comment-show <id>` — print the body (or full JSON envelope //! `comment-show <id>` — print the body (or full JSON envelope
//! when `--json` is set globally) of a single comment by id. //! when `--json` is set globally) of a single comment by id.
use anyhow::Result; use anyhow::{Result, bail};
use clap::Args as ClapArgs; 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; use crate::verbs::{print_json, rfc3339};
#[derive(ClapArgs)] #[derive(ClapArgs)]
pub struct Args { pub struct Args {
@ -15,20 +15,26 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?; 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() { if client.json_mode() {
let trimmed = json!({ let trimmed = json!({
"id": v.get("id"), "id": c.id,
"user": v.get("user").and_then(|u| u.get("login")), "user": c.user.as_ref().and_then(|u| u.login.as_deref()),
"created_at": v.get("created_at"), "created_at": rfc3339(c.created_at),
"updated_at": v.get("updated_at"), "updated_at": rfc3339(c.updated_at),
"body": v.get("body"), "body": c.body,
"url": v.get("html_url"), "url": c.html_url,
}); });
print_json(&trimmed) print_json(&trimmed)
} else { } else {
let body = v.get("body").and_then(Value::as_str).unwrap_or(""); let body = c.body.as_deref().unwrap_or("");
println!("{body}"); println!("{body}");
Ok(()) Ok(())
} }

View file

@ -28,11 +28,12 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use forgejo_api::structs::IssueGetCommentsQuery;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::client::Client; use crate::client::{Client, index};
use crate::notify; 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 /// 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 /// 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<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
let thread = match args.tail { let thread = match args.tail {
Some(n) => fetch_tail(client, repo, args.number, n)?, Some(n) => fetch_tail(client, args.number, n)?,
None => fetch_head(client, repo, args.number, args.limit)?, None => fetch_head(client, args.number, args.limit)?,
}; };
// Merge in PR review bodies (empty for issues — degrades to a // Merge in PR review bodies (empty for issues — degrades to a
// no-op) so review feedback isn't silently dropped. // 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 // Reading the thread clears its unread notification so the
// read-before-comment guard (in `comment`) lets a reply through. // read-before-comment guard (in `comment`) lets a reply through.
notify::mark_read_best_effort(client, repo, args.number); notify::mark_read_best_effort(client, repo, args.number);
@ -106,6 +107,16 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
} }
} }
/// Serialize a typed comment page back to the JSON `Value` shape the
/// merge + render pipeline works on (the structs serialize to the API
/// wire shape, so downstream field access is unchanged).
fn to_values<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
items
.into_iter()
.map(|c| serde_json::to_value(&c).map_err(Into::into))
.collect()
}
/// Fetch a PR's review *bodies* and normalise them to the comment /// Fetch a PR's review *bodies* and normalise them to the comment
/// shape so they merge alongside issue-thread comments. /// 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 /// `created_at` is synthesised from the review's `submitted_at` so
/// the chronological merge sorts uniformly; `kind:"review"` + the /// the chronological merge sorts uniformly; `kind:"review"` + the
/// review `state` tag the entry for display. /// review `state` tag the entry for display.
fn fetch_review_bodies(client: &Client, repo: &str, number: u64) -> Vec<Value> { fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
let Ok((owner, name)) = client.owner_repo() else {
return Vec::new();
};
let Ok(idx) = index(number) else {
return Vec::new();
};
let reviews = client let reviews = client
.get_json(&format!("/repos/{repo}/pulls/{number}/reviews")) .api()
.ok() .repo_list_pull_reviews(owner, name, idx)
.and_then(|v| v.as_array().cloned()) .send()
.map(|(_, reviews)| reviews)
.unwrap_or_default(); .unwrap_or_default();
reviews reviews
.into_iter() .into_iter()
.filter_map(|r| { .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" { if state == "PENDING" {
return None; return None;
} }
if r.get("body") if r.body.as_deref().unwrap_or("").trim().is_empty() {
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.is_empty()
{
return None; return None;
} }
let submitted = rfc3339(r.submitted_at);
Some(json!({ Some(json!({
"id": r.get("id"), "id": r.id,
"user": r.get("user"), "user": r.user,
"created_at": r.get("submitted_at"), "created_at": submitted,
"updated_at": r.get("submitted_at"), "updated_at": submitted,
"body": r.get("body"), "body": r.body,
"html_url": r.get("html_url"), "html_url": r.html_url,
"kind": "review", "kind": "review",
"state": state, "state": state,
})) }))
@ -170,11 +184,19 @@ fn merge_chronological(mut items: Vec<Value>, reviews: Vec<Value>) -> Vec<Value>
} }
/// Fetch the first page's worth of comments (existing behaviour). /// Fetch the first page's worth of comments (existing behaviour).
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> { fn fetch_head(client: &Client, number: u64, limit: u64) -> Result<Vec<Value>> {
let v = client.get_json(&format!( let (owner, name) = client.owner_repo()?;
"/repos/{repo}/issues/{number}/comments?limit={limit}" let (_, comments) = client
))?; .api()
Ok(v.as_array().cloned().unwrap_or_default()) .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. /// Fetch the last `n` comments on an issue/PR in chronological order.
@ -186,16 +208,17 @@ fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Ve
/// first to know how many exist, then start paginating from the /// first to know how many exist, then start paginating from the
/// page that contains item `total - n`. Work is bounded by /// page that contains item `total - n`. Work is bounded by
/// `ceil(n/50) + 1` page fetches, regardless of thread length. /// `ceil(n/50) + 1` page fetches, regardless of thread length.
#[allow( fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
clippy::cast_possible_truncation,
reason = "the forge `comments` count cast to usize is a small issue-thread length, never anywhere near usize::MAX even on a 32-bit target, so it cannot truncate in practice"
)]
fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<Value>> {
if n == 0 { if n == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?; let (owner, name) = client.owner_repo()?;
let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize; 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 { if total == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -210,17 +233,19 @@ fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<
let last_page = (total - 1) / page_size + 1; let last_page = (total - 1) / page_size + 1;
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size); let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
for page in start_page..=last_page { for page in start_page..=last_page {
let v = client.get_json(&format!( let (_, arr) = client
"/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}" .api()
))?; .issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default())
let arr = v.as_array().cloned().unwrap_or_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() { if arr.is_empty() {
// Page came back empty — either we miscounted (comments // Page came back empty — either we miscounted (comments
// deleted between the issue GET and now) or upstream's // deleted between the issue GET and now) or upstream's
// playing tricks. Stop rather than spin. // playing tricks. Stop rather than spin.
break; break;
} }
merged.extend(arr); merged.extend(to_values(arr)?);
} }
// The first fetched page contains items from `start_page` × 50 // The first fetched page contains items from `start_page` × 50
// back; we overshoot by `start_idx % 50` items. Slice the tail // back; we overshoot by `start_idx % 50` items. Slice the tail

View file

@ -15,8 +15,9 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery;
use crate::client::Client; use crate::client::{Client, index};
#[derive(ClapArgs)] #[derive(ClapArgs)]
pub struct Args { pub struct Args {
@ -31,11 +32,17 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let diff = client.get_text( let diff = client
&format!("/repos/{repo}/pulls/{}.diff", args.number), .api()
"text/plain", .repo_download_pull_diff_or_patch(
)?; owner,
name,
index(args.number)?,
"diff",
RepoDownloadPullDiffOrPatchQuery::default(),
)
.send()?;
let out = if args.full { let out = if args.full {
diff diff
} else { } else {

View file

@ -2,9 +2,9 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; 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; use crate::verbs::print_json;
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -14,22 +14,33 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; 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!({ let trimmed = json!({
"number": v.get("number"), "number": issue.number,
"title": v.get("title"), "title": issue.title,
"state": v.get("state"), "state": issue.state,
"user": v.get("user").and_then(|u| u.get("login")), "user": issue.user.as_ref().and_then(|u| u.login.as_deref()),
"assignees": v.get("assignees") "assignees": assignees,
.and_then(Value::as_array) "labels": labels,
.map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::<Vec<_>>()) "body": issue.body,
.unwrap_or_default(),
"labels": v.get("labels")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::<Vec<_>>())
.unwrap_or_default(),
"body": v.get("body"),
}); });
print_json(&trimmed) print_json(&trimmed)
} }

View file

@ -3,7 +3,7 @@
use anyhow::Result; use anyhow::Result;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use serde_json::{Value, json}; use forgejo_api::structs::CreateIssueOption;
use crate::body; use crate::body;
use crate::client::Client; use crate::client::Client;
@ -32,13 +32,23 @@ pub struct Args {
/// I/O error from writing the issue URL to stdout. /// I/O error from writing the issue URL to stdout.
pub fn run(client: &Client, args: Args) -> Result<()> { 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 body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let mut payload = json!({ "title": args.title, "body": body }); let payload = CreateIssueOption {
if let Some(a) = args.assignee { assignee: None,
payload["assignees"] = json!([a]); assignees: args.assignee.map(|a| vec![a]),
} body: Some(body),
let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?; closed: None,
if let Some(url) = resp.get("html_url").and_then(Value::as_str) { 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}"); println!("{url}");
} }
Ok(()) Ok(())

View file

@ -4,10 +4,11 @@
use anyhow::Result; use anyhow::Result;
use clap::{Args as ClapArgs, ValueEnum}; 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::body;
use crate::client::Client; use crate::client::{Client, index};
use crate::verbs::print_json; use crate::verbs::print_json;
#[derive(Copy, Clone, ValueEnum)] #[derive(Copy, Clone, ValueEnum)]
@ -55,7 +56,9 @@ pub struct Args {
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
// Body is partial: only update the body field if a source was // Body is partial: only update the body field if a source was
// actually given. Piped stdin without --body/--body-file leaves // 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_explicit = args.body.is_some() || args.body_file.is_some();
let body = if body_explicit { let body = if body_explicit {
body::resolve(args.body.as_deref(), args.body_file.as_deref())? body::resolve(args.body.as_deref(), args.body_file.as_deref())?
@ -63,29 +66,28 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
None None
}; };
let mut payload = Map::new(); let payload = EditIssueOption {
if let Some(t) = args.title { assignee: None,
payload.insert("title".into(), Value::String(t)); assignees: None,
} body,
if let Some(b) = body { due_date: None,
payload.insert("body".into(), Value::String(b)); milestone: args.milestone.map(index).transpose()?,
} r#ref: None,
if let Some(s) = args.state { state: args.state.map(|s| s.as_str().to_owned()),
payload.insert("state".into(), Value::String(s.as_str().to_owned())); title: args.title,
} unset_due_date: None,
if let Some(m) = args.milestone { updated_at: None,
payload.insert("milestone".into(), Value::Number(m.into())); };
}
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let resp = client.patch_json( let resp = client
&format!("/repos/{repo}/issues/{}", args.number), .api()
&Value::Object(payload), .issue_edit_issue(owner, name, index(args.number)?, payload)
)?; .send()?;
print_json(&json!({ print_json(&json!({
"number": resp.get("number"), "number": resp.number,
"title": resp.get("title"), "title": resp.title,
"state": resp.get("state"), "state": resp.state,
"milestone": resp.get("milestone").and_then(|m| m.get("title")), "milestone": resp.milestone.as_ref().and_then(|m| m.title.as_deref()),
})) }))
} }

View file

@ -3,9 +3,10 @@
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Args as ClapArgs, Subcommand}; 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; use crate::verbs::print_json;
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -33,85 +34,84 @@ enum Action {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { 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) { match args.action.unwrap_or(Action::List) {
Action::List => { Action::List => {
let labels = let labels = client.api().issue_get_labels(owner, name, idx).send()?;
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels); print_label_names(&labels);
} }
Action::Add { labels } => { Action::Add { labels } => {
if labels.is_empty() { if labels.is_empty() {
bail!("hive-forge labels add: pass at least one label name"); bail!("hive-forge labels add: pass at least one label name");
} }
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; let all = repo_labels(client)?;
let ids = resolve_ids(&all, &labels); let ids: Vec<serde_json::Value> = resolve_ids(&all, &labels)
let resp = client.post_json( .into_iter()
&format!("/repos/{repo}/issues/{}/labels", args.number), .map(|id| json!(id))
&json!({ "labels": ids }), .collect();
)?; let resp = client
.api()
.issue_add_label(
owner,
name,
idx,
IssueLabelsOption {
labels: Some(ids),
updated_at: None,
},
)
.send()?;
print_label_names(&resp); print_label_names(&resp);
} }
Action::Remove { labels } => { Action::Remove { labels } => {
if labels.is_empty() { if labels.is_empty() {
bail!("hive-forge labels remove: pass at least one label name"); bail!("hive-forge labels remove: pass at least one label name");
} }
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; let all = repo_labels(client)?;
for name in &labels { for label in &labels {
if let Some(id) = lookup_id(&all, name) { if let Some(id) = lookup_id(&all, label) {
let _ = client.delete( let _ = client
&format!("/repos/{repo}/issues/{}/labels/{id}", args.number), .api()
None, .issue_remove_label(
); owner,
name,
idx,
&id.to_string(),
DeleteLabelsOption { updated_at: None },
)
.send();
} }
} }
let labels = let labels = client.api().issue_get_labels(owner, name, idx).send()?;
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels); print_label_names(&labels);
} }
} }
Ok(()) Ok(())
} }
fn resolve_ids(all: &Value, names: &[String]) -> Vec<u64> { /// First page (100) of the repo's label set, for name → id resolution.
let Some(arr) = all.as_array() else { fn repo_labels(client: &Client) -> Result<Vec<Label>> {
return Vec::new(); let (owner, name) = client.owner_repo()?;
}; let (_, labels) = client
names .api()
.iter() .issue_list_labels(owner, name, IssueListLabelsQuery::default())
.filter_map(|n| { .page_size(100)
arr.iter().find_map(|l| { .send()?;
let lname = l.get("name").and_then(Value::as_str)?; Ok(labels)
if lname == n {
l.get("id").and_then(Value::as_u64)
} else {
None
}
})
})
.collect()
} }
fn lookup_id(all: &Value, name: &str) -> Option<u64> { fn resolve_ids(all: &[Label], names: &[String]) -> Vec<i64> {
let arr = all.as_array()?; names.iter().filter_map(|n| lookup_id(all, n)).collect()
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 print_label_names(v: &Value) { fn lookup_id(all: &[Label], name: &str) -> Option<i64> {
let names: Vec<&str> = v all.iter()
.as_array() .find(|l| l.name.as_deref() == Some(name))
.map(|a| { .and_then(|l| l.id)
a.iter() }
.filter_map(|l| l.get("name").and_then(Value::as_str))
.collect() fn print_label_names(labels: &[Label]) {
}) let names: Vec<&str> = labels.iter().filter_map(|l| l.name.as_deref()).collect();
.unwrap_or_default();
let _ = print_json(&json!(names)); let _ = print_json(&json!(names));
} }

View file

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

View file

@ -10,19 +10,20 @@
//! read-side curl-fallback gap (no boundary concerns — //! read-side curl-fallback gap (no boundary concerns —
//! every agent + the operator queries the issue tracker constantly). //! every agent + the operator queries the issue tracker constantly).
use std::fmt::Write as _;
use anyhow::Result; use anyhow::Result;
use clap::{Args as ClapArgs, ValueEnum}; use clap::{Args as ClapArgs, ValueEnum};
use forgejo_api::structs::{
IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
};
use serde_json::Value; use serde_json::Value;
use crate::client::Client; use crate::client::Client;
use crate::verbs::print_json; use crate::verbs::print_json;
/// What kind of items to return. Matches Forgejo's `type` query /// What kind of items to return. Maps onto Forgejo's `type` query
/// parameter values verbatim (`issues` / `pulls` / `all`) so the /// parameter: `issues` / `pulls`, or no filter at all for `both`
/// mapping is one-for-one and a future enum addition upstream /// (Forgejo returns issues + PRs when `type` is absent — same slice
/// stays trivially supportable. /// the forge UI's "Issues" tab shows without a type filter).
#[derive(Copy, Clone, Debug, ValueEnum)] #[derive(Copy, Clone, Debug, ValueEnum)]
#[clap(rename_all = "kebab-case")] #[clap(rename_all = "kebab-case")]
pub enum Kind { pub enum Kind {
@ -30,17 +31,16 @@ pub enum Kind {
Issue, Issue,
/// Pull requests only. /// Pull requests only.
Pr, Pr,
/// Issues + pull requests (default; matches the forge UI's /// Issues + pull requests (default).
/// "Issues" tab when no type filter is applied).
Both, Both,
} }
impl Kind { impl Kind {
fn api_value(self) -> &'static str { fn query_type(self) -> Option<IssueListIssuesQueryType> {
match self { match self {
Self::Issue => "issues", Self::Issue => Some(IssueListIssuesQueryType::Issues),
Self::Pr => "pulls", Self::Pr => Some(IssueListIssuesQueryType::Pulls),
Self::Both => "all", Self::Both => None,
} }
} }
} }
@ -56,11 +56,11 @@ pub enum State {
} }
impl State { impl State {
fn api_value(self) -> &'static str { fn query_state(self) -> IssueListIssuesQueryState {
match self { match self {
Self::Open => "open", Self::Open => IssueListIssuesQueryState::Open,
Self::Closed => "closed", Self::Closed => IssueListIssuesQueryState::Closed,
Self::All => "all", Self::All => IssueListIssuesQueryState::All,
} }
} }
} }
@ -100,54 +100,40 @@ pub struct Args {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let (owner, name) = client.owner_repo()?;
let mut path = format!( let query = IssueListIssuesQuery {
"/repos/{repo}/issues?type={}&state={}&limit={}&page={}", state: Some(args.state.query_state()),
args.kind.api_value(), // The forge parses `labels` as a comma-separated list of names.
args.state.api_value(), labels: (!args.labels.is_empty()).then(|| args.labels.join(",")),
args.limit, q: None,
args.page r#type: args.kind.query_type(),
); milestones: None,
if let Some(u) = args.assignee.as_deref() since: None,
&& !u.is_empty() before: None,
{ created_by: args.author.clone().filter(|s| !s.is_empty()),
write!(path, "&assigned_by={}", super::pct_encode(u)).unwrap(); assigned_by: args.assignee.clone().filter(|s| !s.is_empty()),
} mentioned_by: args.mention.clone().filter(|s| !s.is_empty()),
if let Some(u) = args.author.as_deref() sort: None,
&& !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<String> = 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);
}; };
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); print_row(item);
} }
// When the returned page is exactly `--limit` items, more pages // When the returned page is exactly `--limit` items, more pages
// may exist. Print a hint to stderr so the caller knows to fetch // may exist. Print a hint to stderr so the caller knows to fetch
// the next page rather than assuming the result is complete. Only // the next page rather than assuming the result is complete. Only
// fires on a full page — a short or empty page signals the end. // 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!( eprintln!(
"… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).", "… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).",
args.limit, args.limit,
@ -184,19 +170,25 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn kind_api_values_match_forgejo_enum() { fn kind_query_types_match_forgejo_enum() {
// The forge accepts only `issues` / `pulls` / `all` for the // The forge accepts only `issues` / `pulls` for the `type`
// `type` parameter — pin the wire mapping so a clap rename // parameter (absent = both) — pin the mapping so a clap rename
// doesn't silently start returning the wrong slice. // doesn't silently start returning the wrong slice.
assert_eq!(Kind::Issue.api_value(), "issues"); assert_eq!(
assert_eq!(Kind::Pr.api_value(), "pulls"); Kind::Issue.query_type(),
assert_eq!(Kind::Both.api_value(), "all"); Some(IssueListIssuesQueryType::Issues)
);
assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls));
assert_eq!(Kind::Both.query_type(), None);
} }
#[test] #[test]
fn state_api_values_match_forgejo_enum() { fn state_query_states_match_forgejo_enum() {
assert_eq!(State::Open.api_value(), "open"); assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open);
assert_eq!(State::Closed.api_value(), "closed"); assert_eq!(
assert_eq!(State::All.api_value(), "all"); State::Closed.query_state(),
IssueListIssuesQueryState::Closed
);
assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All);
} }
} }

View file

@ -1,12 +1,17 @@
//! `milestone list|create|close` — manage milestones. Default action: //! `milestone list|create|close` — manage milestones. Default action:
//! list. //! list.
use anyhow::Result; use anyhow::{Context, Result};
use clap::{Args as ClapArgs, Subcommand}; use clap::{Args as ClapArgs, Subcommand};
use forgejo_api::structs::{
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery,
};
use serde_json::{Value, json}; use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::client::Client; use crate::client::{Client, index};
use crate::verbs::print_json; use crate::verbs::{print_json, rfc3339};
#[derive(ClapArgs)] #[derive(ClapArgs)]
pub struct Args { pub struct Args {
@ -38,52 +43,71 @@ enum Action {
} }
pub fn run(client: &Client, args: Args) -> Result<()> { 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) { match args.action.unwrap_or(Action::List) {
Action::List => { Action::List => {
let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?; let query = IssueGetMilestonesListQuery {
let trimmed: Vec<Value> = v state: Some("open".to_owned()),
.as_array() name: None,
.map(|a| { };
a.iter() let (_, milestones) = client
.map(|m| { .api()
json!({ .issue_get_milestones_list(owner, name, query)
"id": m.get("id"), .page_size(50)
"title": m.get("title"), .send()?;
"open_issues": m.get("open_issues"), let trimmed: Vec<Value> = milestones
"closed_issues": m.get("closed_issues"), .iter()
"due_on": m.get("due_on"), .map(|m| {
"description": m.get("description"), json!({
}) "id": m.id,
}) "title": m.title,
.collect() "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)) print_json(&Value::Array(trimmed))
} }
Action::Create { title, desc, due } => { Action::Create { title, desc, due } => {
let mut payload = json!({ "title": title }); let due_on = due
if let Some(d) = desc.filter(|s| !s.is_empty()) { .filter(|s| !s.is_empty())
payload["description"] = Value::String(d); .map(|d| {
} OffsetDateTime::parse(&format!("{d}T00:00:00Z"), &Rfc3339)
if let Some(d) = due.filter(|s| !s.is_empty()) { .with_context(|| format!("milestone create: bad --due date {d:?}"))
payload["due_on"] = Value::String(format!("{d}T00:00:00Z")); })
} .transpose()?;
let resp = client.post_json(&format!("/repos/{repo}/milestones"), &payload)?; 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!({ print_json(&json!({
"id": resp.get("id"), "id": resp.id,
"title": resp.get("title"), "title": resp.title,
})) }))
} }
Action::Close { id } => { Action::Close { id } => {
let resp = client.patch_json( let payload = EditMilestoneOption {
&format!("/repos/{repo}/milestones/{id}"), description: None,
&json!({ "state": "closed" }), 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!({ print_json(&json!({
"id": resp.get("id"), "id": resp.id,
"title": resp.get("title"), "title": resp.title,
"state": resp.get("state"), "state": resp.state,
})) }))
} }
} }

Some files were not shown because too many files have changed in this diff Show more