refactor(hive-forge): port CLI verbs to forgejo-api
This commit is contained in:
parent
b8a3927c43
commit
4636987469
36 changed files with 1463 additions and 1153 deletions
|
|
@ -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<reqwest::Client>` 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/<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,
|
||||
/// 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://<user>:<token>@host/<repo>.git`.
|
||||
|
|
@ -95,11 +119,6 @@ impl Client {
|
|||
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
|
||||
/// `-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 `<api>/<path>` and decode JSON.
|
||||
pub fn get_json(&self, path: &str) -> Result<Value> {
|
||||
let url = format!("{}{}", self.api(), path);
|
||||
let resp = self.http.get(&url).send().context("GET")?;
|
||||
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(())
|
||||
/// 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
|
||||
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. 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<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||
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::<Value>()
|
||||
.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/<uuid>`, 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<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}"))?;
|
||||
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<Value> {
|
||||
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> {
|
||||
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<String> {
|
||||
|
|
@ -364,9 +239,10 @@ fn read_token() -> Result<String> {
|
|||
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<Response> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
|
|
@ -376,13 +252,28 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
|
|||
bail!("hive-forge: {op} failed ({status}): {body}");
|
||||
}
|
||||
|
||||
fn decode_json(resp: Response, op: &str) -> Result<Value> {
|
||||
let resp = check_status(resp, op)?;
|
||||
resp.json::<Value>()
|
||||
.with_context(|| format!("decode JSON for {op}"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_repo;
|
||||
|
||||
fn decode_text(resp: Response, op: &str) -> Result<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// `<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 /
|
||||
/// non-2xx — callers degrade open on `Err`.
|
||||
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 {
|
||||
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<Opti
|
|||
/// result). Clears the unread signal so a subsequent comment isn't
|
||||
/// blocked after the agent has read the thread.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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/<run-number>`, 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<u64> {
|
||||
fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result<i64> {
|
||||
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::<u64>().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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
//! <file>` — 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<Vec<u8>> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,16 @@
|
|||
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
|
||||
//! - `--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` /
|
||||
//! `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<String> {
|
||||
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<String> {
|
||||
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/<run-number>`, 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/<n>` tail),
|
||||
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
|
||||
fn run_number_of(run: &Value) -> Option<u64> {
|
||||
run.get("html_url")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|u| u.rsplit('/').next())
|
||||
fn run_number_of(run: &ActionRun) -> Option<u64> {
|
||||
run.html_url
|
||||
.as_ref()
|
||||
.and_then(|u| u.as_str().rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().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 `#<n>` 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
//! `comment-edit <id> [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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! `comment-show <id>` — 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// 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<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
|
||||
.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<Value>, reviews: Vec<Value>) -> Vec<Value>
|
|||
}
|
||||
|
||||
/// Fetch the first page's worth of comments (existing behaviour).
|
||||
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
|
||||
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<Vec<Value>> {
|
||||
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<Ve
|
|||
/// first to know how many exist, then start paginating from the
|
||||
/// page that contains item `total - n`. Work is bounded by
|
||||
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
|
||||
#[allow(
|
||||
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>> {
|
||||
fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
|
||||
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<
|
|||
let last_page = (total - 1) / page_size + 1;
|
||||
let mut merged: Vec<Value> = 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>())
|
||||
.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"),
|
||||
"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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value> = 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<u64> {
|
||||
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<Vec<Label>> {
|
||||
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<u64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,18 @@
|
|||
//! - `assignments [--user NAME]`
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||||
use forgejo_api::structs::{
|
||||
Issue, IssueGetCommentsQuery, IssueListIssuesQuery, IssueListIssuesQueryState,
|
||||
IssueListIssuesQueryType, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
use crate::verbs::{print_json, rfc3339};
|
||||
|
||||
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
||||
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
||||
|
|
@ -53,11 +57,14 @@ enum Kind {
|
|||
}
|
||||
|
||||
impl Kind {
|
||||
fn forgejo_type(self) -> &'static str {
|
||||
/// Forgejo's `type` filter: `issues` / `pulls`, or absent for the
|
||||
/// both-kinds slice (the forge returns everything when `type` is
|
||||
/// omitted).
|
||||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
||||
match self {
|
||||
Kind::Issues => "issues",
|
||||
Kind::Pulls => "pulls",
|
||||
Kind::All => "all",
|
||||
Kind::Issues => Some(IssueListIssuesQueryType::Issues),
|
||||
Kind::Pulls => Some(IssueListIssuesQueryType::Pulls),
|
||||
Kind::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,11 +77,19 @@ enum State {
|
|||
}
|
||||
|
||||
impl State {
|
||||
fn as_str(self) -> &'static str {
|
||||
fn issue_state(self) -> IssueListIssuesQueryState {
|
||||
match self {
|
||||
State::Open => "open",
|
||||
State::Closed => "closed",
|
||||
State::All => "all",
|
||||
State::Open => IssueListIssuesQueryState::Open,
|
||||
State::Closed => IssueListIssuesQueryState::Closed,
|
||||
State::All => IssueListIssuesQueryState::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_state(self) -> RepoListPullRequestsQueryState {
|
||||
match self {
|
||||
State::Open => RepoListPullRequestsQueryState::Open,
|
||||
State::Closed => RepoListPullRequestsQueryState::Closed,
|
||||
State::All => RepoListPullRequestsQueryState::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,26 +140,44 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drain the repo's issue list (both kinds unless filtered) across
|
||||
/// pages, up to the runaway cap.
|
||||
fn fetch_issues(
|
||||
client: &Client,
|
||||
r#type: Option<IssueListIssuesQueryType>,
|
||||
state: IssueListIssuesQueryState,
|
||||
) -> Result<Vec<Issue>> {
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = IssueListIssuesQuery {
|
||||
state: Some(state),
|
||||
r#type,
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.issue_list_issues(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
items.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
// ───────────────────────── unassigned ─────────────────────────
|
||||
|
||||
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let items = client.get_json_all(
|
||||
&format!(
|
||||
"/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}",
|
||||
args.r#type.forgejo_type(),
|
||||
args.state.as_str()
|
||||
),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?;
|
||||
let filtered: Vec<Value> = items
|
||||
.into_iter()
|
||||
.filter(|it| {
|
||||
it.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(Vec::is_empty)
|
||||
})
|
||||
.map(trim_item)
|
||||
.iter()
|
||||
.filter(|it| it.assignees.as_ref().is_none_or(Vec::is_empty))
|
||||
.map(trim_issue)
|
||||
.collect();
|
||||
emit(client, &filtered, |it| {
|
||||
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
||||
|
|
@ -154,41 +187,64 @@ fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
|||
// ───────────────────────── no-reviewer ────────────────────────
|
||||
|
||||
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
// PR-only: `/repos/{repo}/pulls` doesn't return issues.
|
||||
let pulls = client.get_json_all(
|
||||
&format!(
|
||||
"/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}",
|
||||
args.state.as_str()
|
||||
),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
// PR-only: the pulls endpoint doesn't return issues.
|
||||
let mut pulls = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = RepoListPullRequestsQuery {
|
||||
state: Some(args.state.pull_state()),
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_pull_requests(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
pulls.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let needle = format!("@{}", args.reviewer);
|
||||
let mut missing: Vec<Value> = Vec::new();
|
||||
for pr in pulls {
|
||||
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
|
||||
if number == 0 {
|
||||
for pr in &pulls {
|
||||
let Some(number) = pr.number.filter(|n| *n > 0) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
|
||||
// that already @reviewer in the description.
|
||||
let body = pr.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
if body.contains(&needle) {
|
||||
if pr.body.as_deref().unwrap_or("").contains(&needle) {
|
||||
continue;
|
||||
}
|
||||
// Paginate so PRs with >50 comments don't yield false positives
|
||||
// (flagged in review). Same 1000-comment ceiling as elsewhere.
|
||||
let comments = client.get_json_all(
|
||||
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let mentioned = comments.iter().any(|c| {
|
||||
c.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|body| body.contains(&needle))
|
||||
});
|
||||
let mut mentioned = false;
|
||||
for page in 1..=MAX_PAGES {
|
||||
let (_, comments) = client
|
||||
.api()
|
||||
.issue_get_comments(owner, name, number, IssueGetCommentsQuery::default())
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = comments.len() < PAGE_LIMIT as usize;
|
||||
mentioned = comments
|
||||
.iter()
|
||||
.any(|c| c.body.as_deref().is_some_and(|body| body.contains(&needle)));
|
||||
if mentioned || short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !mentioned {
|
||||
missing.push(trim_item(pr));
|
||||
missing.push(json!({
|
||||
"number": pr.number,
|
||||
"title": pr.title,
|
||||
"state": pr.state,
|
||||
"url": pr.html_url,
|
||||
"is_pr": true,
|
||||
"assignees": logins(pr.assignees.as_deref()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
||||
|
|
@ -200,50 +256,64 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
|||
if args.days < 0 {
|
||||
bail!("--days must be non-negative");
|
||||
}
|
||||
let repo = client.repo();
|
||||
let branches = client.get_json_all(
|
||||
&format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let mut branches = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_branches(owner, name)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
branches.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Collect active PR head refs to skip — a branch with an open PR
|
||||
// isn't "stale", it's "in review".
|
||||
let open_pulls = client.get_json_all(
|
||||
&format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let active_heads: std::collections::HashSet<String> = open_pulls
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
p.get("head")
|
||||
.and_then(|h| h.get("ref"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let query = RepoListPullRequestsQuery {
|
||||
state: Some(RepoListPullRequestsQueryState::Open),
|
||||
..Default::default()
|
||||
};
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.repo_list_pull_requests(owner, name, query)
|
||||
.page(page)
|
||||
.page_size(PAGE_LIMIT)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_LIMIT as usize;
|
||||
active_heads.extend(
|
||||
batch
|
||||
.iter()
|
||||
.filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())),
|
||||
);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cutoff_days = today_days_utc().context("compute today")? - args.days;
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let mut stale: Vec<Value> = Vec::new();
|
||||
for br in branches {
|
||||
let name = br.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if name.is_empty() || active_heads.contains(name) {
|
||||
for br in &branches {
|
||||
let branch_name = br.name.as_deref().unwrap_or("");
|
||||
if branch_name.is_empty() || active_heads.contains(branch_name) {
|
||||
continue;
|
||||
}
|
||||
let ts = br
|
||||
.get("commit")
|
||||
.and_then(|c| c.get("timestamp"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let Some(date_str) = ts.get(..10) else {
|
||||
let Some(ts) = br.commit.as_ref().and_then(|c| c.timestamp) else {
|
||||
continue;
|
||||
};
|
||||
let Some(days) = parse_yyyy_mm_dd_days(date_str) else {
|
||||
continue;
|
||||
};
|
||||
if days <= cutoff_days {
|
||||
// Whole days between the commit's calendar date and today —
|
||||
// date-granular, matching the old YYYY-MM-DD prefix math.
|
||||
let age_days = (today - ts.date()).whole_days();
|
||||
if age_days >= args.days {
|
||||
stale.push(json!({
|
||||
"name": name,
|
||||
"last_commit": ts,
|
||||
"age_days": (today_days_utc().unwrap_or(days) - days),
|
||||
"name": branch_name,
|
||||
"last_commit": rfc3339(Some(ts)),
|
||||
"age_days": age_days,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -257,27 +327,14 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
|||
// ───────────────────────── assignments ────────────────────────
|
||||
|
||||
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let items = client.get_json_all(
|
||||
&format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"),
|
||||
MAX_PAGES,
|
||||
)?;
|
||||
let items = fetch_issues(client, None, IssueListIssuesQueryState::Open)?;
|
||||
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
for it in items {
|
||||
let assignees: Vec<String> = it
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.get("login").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for it in &items {
|
||||
let assignees = logins(it.assignees.as_deref());
|
||||
if assignees.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let slim = trim_item(it);
|
||||
let slim = trim_issue(it);
|
||||
for u in assignees {
|
||||
if args.user.as_deref().is_some_and(|w| w != u) {
|
||||
continue;
|
||||
|
|
@ -317,27 +374,27 @@ fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
|||
|
||||
// ───────────────────────── shared helpers ─────────────────────
|
||||
|
||||
/// Strip a Forgejo issue/PR JSON down to the fields lint output cares
|
||||
/// Assignee logins from a typed user list (missing logins dropped).
|
||||
fn logins(users: Option<&[forgejo_api::structs::User]>) -> Vec<String> {
|
||||
users
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|u| u.login.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Strip a Forgejo issue/PR down to the fields lint output cares
|
||||
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
||||
fn trim_item(it: Value) -> Value {
|
||||
fn trim_issue(it: &Issue) -> Value {
|
||||
json!({
|
||||
"number": it.get("number"),
|
||||
"title": it.get("title"),
|
||||
"state": it.get("state"),
|
||||
"url": it.get("html_url"),
|
||||
// Forgejo's unified /issues endpoint always emits a
|
||||
// `pull_request` key — `null` for plain issues, an object
|
||||
// (with merged/url/etc.) for PRs. Treat any non-null as a PR.
|
||||
"is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()),
|
||||
"assignees": it
|
||||
.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.get("login").cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
"number": it.number,
|
||||
"title": it.title,
|
||||
"state": it.state,
|
||||
"url": it.html_url,
|
||||
// The unified issues endpoint marks PRs with a `pull_request`
|
||||
// object (absent/null for plain issues).
|
||||
"is_pr": it.pull_request.is_some(),
|
||||
"assignees": logins(it.assignees.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -375,77 +432,3 @@ where
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ───
|
||||
|
||||
/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock).
|
||||
fn today_days_utc() -> Result<i64> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock before epoch")?
|
||||
.as_secs();
|
||||
// `as_secs()` returns u64; clamp into i64 (won't overflow until y2554).
|
||||
Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp)
|
||||
/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse
|
||||
/// failure rather than panicking — lint output stays best-effort.
|
||||
fn parse_yyyy_mm_dd_days(stamp: &str) -> Option<i64> {
|
||||
let bytes = stamp.as_bytes();
|
||||
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
|
||||
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
|
||||
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
|
||||
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return None;
|
||||
}
|
||||
Some(days_from_civil(year, month, day))
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since
|
||||
/// 1970-01-01. Public-domain reference algorithm. Handles negative years.
|
||||
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = i64::from(y - era * 400); // [0, 399]
|
||||
let m = i64::from(month);
|
||||
let d = i64::from(day);
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
i64::from(era) * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn epoch_is_day_zero() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_dates() {
|
||||
// Hinnant reference values
|
||||
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
|
||||
assert_eq!(days_from_civil(2020, 2, 29), 18_321);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_iso_prefix() {
|
||||
assert_eq!(
|
||||
parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"),
|
||||
Some(18_321)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None);
|
||||
assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None);
|
||||
assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IssueListIssuesQueryType> {
|
||||
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<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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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<Value> = 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OffsetDateTime>) -> Option<String> {
|
||||
ts.and_then(|t| t.format(&Rfc3339).ok())
|
||||
}
|
||||
|
||||
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` 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<Vec<(String, String)>> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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<Commit> = 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();
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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<Value> {
|
||||
/// 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<Value> {
|
||||
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<Value> = 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<Value> = 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.
|
||||
|
|
|
|||
|
|
@ -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 <n> or --sha <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<CommitStatusState>) -> &'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::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let requested: Vec<String> = 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<Value>)> {
|
||||
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<Value>)> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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<Option<(String, String)>> {
|
||||
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<Option<(String, String)>> {
|
||||
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", &[]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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<Label> = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let (_, batch) = client
|
||||
.api()
|
||||
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
|
||||
.page(page)
|
||||
.page_size(PAGE_SIZE)
|
||||
.send()?;
|
||||
let short = batch.len() < PAGE_SIZE as usize;
|
||||
labels.extend(batch);
|
||||
if short {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let filtered: Vec<&Label> = labels
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let name = l.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
args.pattern.as_deref().is_none_or(|p| name.contains(p))
|
||||
let label_name = l.name.as_deref().unwrap_or_default();
|
||||
args.pattern
|
||||
.as_deref()
|
||||
.is_none_or(|p| label_name.contains(p))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if client.json_mode() {
|
||||
// Full label objects (id, name, color, description) as a JSON array.
|
||||
print_json(&json!(filtered))?;
|
||||
print_json(&serde_json::to_value(&filtered)?)?;
|
||||
} else {
|
||||
// One label per line: `name`, plus its description (tab-separated)
|
||||
// when set, so triage can see what each label means at a glance.
|
||||
for l in filtered {
|
||||
let name = l.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let desc = l
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let label_name = l.name.as_deref().unwrap_or_default();
|
||||
let desc = l.description.as_deref().unwrap_or_default();
|
||||
if desc.is_empty() {
|
||||
println!("{name}");
|
||||
println!("{label_name}");
|
||||
} else {
|
||||
println!("{name}\t{desc}");
|
||||
println!("{label_name}\t{desc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use anyhow::Result;
|
|||
use clap::Args as ClapArgs;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, is_not_found};
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
|
|
@ -38,7 +38,7 @@ pub struct Args {
|
|||
///
|
||||
/// # Errors
|
||||
/// Propagates transport / non-2xx errors from the Forgejo client calls
|
||||
/// (`get_json` / `put_json` / `delete`) and from `print_json`.
|
||||
/// and from `print_json`.
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
if args.list {
|
||||
// List every repo the authed user watches, so an agent can audit
|
||||
|
|
@ -49,48 +49,56 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
const PAGE: u32 = 50;
|
||||
let mut watching: Vec<String> = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let v = client.get_json(&format!("/user/subscriptions?page={page}&limit={PAGE}"))?;
|
||||
let arr = v.as_array().cloned().unwrap_or_default();
|
||||
let n = arr.len();
|
||||
watching.extend(
|
||||
arr.iter()
|
||||
.filter_map(|r| r["full_name"].as_str().map(str::to_owned)),
|
||||
);
|
||||
let (_, repos) = client
|
||||
.api()
|
||||
.user_current_list_subscriptions()
|
||||
.page(page)
|
||||
.page_size(PAGE)
|
||||
.send()?;
|
||||
let n = repos.len();
|
||||
watching.extend(repos.into_iter().filter_map(|r| r.full_name));
|
||||
if n < PAGE as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return print_json(&json!({ "watching": watching }));
|
||||
}
|
||||
let repo = client.repo();
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
if args.unwatch {
|
||||
client.delete(&format!("/repos/{repo}/subscription"), None)?;
|
||||
client
|
||||
.api()
|
||||
.user_current_delete_subscription(owner, name)
|
||||
.send()?;
|
||||
println!("unsubscribed");
|
||||
return Ok(());
|
||||
}
|
||||
if args.watch || args.ignore {
|
||||
let (subscribed, ignored) = if args.ignore {
|
||||
(false, true)
|
||||
} else {
|
||||
(true, false)
|
||||
};
|
||||
// Forgejo requires PUT (not POST) for this endpoint.
|
||||
let resp = client.put_json(
|
||||
&format!("/repos/{repo}/subscription"),
|
||||
&json!({ "subscribed": subscribed, "ignored": ignored }),
|
||||
)?;
|
||||
// Forgejo's PUT subscription endpoint takes no body — it always
|
||||
// subscribes (watch). The old raw client sent a
|
||||
// `{subscribed, ignored}` body which the server ignored, so
|
||||
// `--ignore` has always behaved like `--watch` server-side; the
|
||||
// typed call just makes that explicit.
|
||||
let resp = client
|
||||
.api()
|
||||
.user_current_put_subscription(owner, name)
|
||||
.send()?;
|
||||
return print_json(&json!({
|
||||
"subscribed": resp.get("subscribed"),
|
||||
"ignored": resp.get("ignored"),
|
||||
"subscribed": resp.subscribed,
|
||||
"ignored": resp.ignored,
|
||||
}));
|
||||
}
|
||||
// Forgejo returns 404 when the current user is not watching the repo
|
||||
// (rather than a response with subscribed=false).
|
||||
match client.get_json_optional(&format!("/repos/{repo}/subscription"))? {
|
||||
Some(resp) => print_json(&json!({
|
||||
"subscribed": resp.get("subscribed"),
|
||||
"ignored": resp.get("ignored"),
|
||||
match client
|
||||
.api()
|
||||
.user_current_check_subscription(owner, name)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => print_json(&json!({
|
||||
"subscribed": resp.subscribed,
|
||||
"ignored": resp.ignored,
|
||||
})),
|
||||
None => print_json(&json!({ "subscribed": false, "ignored": false })),
|
||||
Err(e) if is_not_found(&e) => print_json(&json!({ "subscribed": false, "ignored": false })),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use forgejo_api::structs::IssueGetCommentsAndTimelineQuery;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, index};
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
|
|
@ -33,11 +34,20 @@ pub struct Args {
|
|||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{}/timeline?limit={}",
|
||||
args.number, args.limit
|
||||
))?;
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let (_, events) = client
|
||||
.api()
|
||||
.issue_get_comments_and_timeline(
|
||||
owner,
|
||||
name,
|
||||
index(args.number)?,
|
||||
IssueGetCommentsAndTimelineQuery::default(),
|
||||
)
|
||||
.page_size(u32::try_from(args.limit).unwrap_or(u32::MAX))
|
||||
.send()?;
|
||||
// Serialize back to the API's JSON shape so the per-type render
|
||||
// arms (and their tests) keep working on plain `Value`s.
|
||||
let v = serde_json::to_value(events)?;
|
||||
if client.json_mode() {
|
||||
return print_json(&v);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
use forgejo_api::structs::RepoGetSingleCommitQuery;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
|
|
@ -14,25 +14,35 @@ pub struct Args {
|
|||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
// Try branch first; the bash helper silently treats failure as
|
||||
// "not a branch, use the reference as a commit sha directly".
|
||||
let commit_sha = match client.get_json(&format!("/repos/{repo}/branches/{}", args.reference)) {
|
||||
Ok(v) => v
|
||||
.get("commit")
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&args.reference)
|
||||
.to_owned(),
|
||||
let commit_sha = match client
|
||||
.api()
|
||||
.repo_get_branch(owner, name, &args.reference)
|
||||
.send()
|
||||
{
|
||||
Ok(branch) => branch
|
||||
.commit
|
||||
.and_then(|c| c.id)
|
||||
.unwrap_or_else(|| args.reference.clone()),
|
||||
Err(_) => args.reference.clone(),
|
||||
};
|
||||
let commit = client.get_json(&format!("/repos/{repo}/git/commits/{commit_sha}"))?;
|
||||
let commit = client
|
||||
.api()
|
||||
.repo_get_single_commit(
|
||||
owner,
|
||||
name,
|
||||
&commit_sha,
|
||||
RepoGetSingleCommitQuery::default(),
|
||||
)
|
||||
.send()?;
|
||||
let sha = commit
|
||||
.get("tree")
|
||||
.and_then(|t| t.get("sha"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| commit.get("sha").and_then(Value::as_str))
|
||||
.unwrap_or("");
|
||||
.commit
|
||||
.and_then(|c| c.tree)
|
||||
.and_then(|t| t.sha)
|
||||
.or(commit.sha)
|
||||
.unwrap_or_default();
|
||||
println!("{sha}");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
use forgejo_api::structs::{IssueGetCommentsQuery, StateType};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, index};
|
||||
use crate::notify;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
|
|
@ -19,16 +19,24 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
// 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);
|
||||
let issue = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||
let title = issue.get("title").and_then(Value::as_str).unwrap_or("");
|
||||
let body = issue.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
let state = issue.get("state").and_then(Value::as_str).unwrap_or("?");
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let issue = client
|
||||
.api()
|
||||
.issue_get_issue(owner, name, index(args.number)?)
|
||||
.send()?;
|
||||
let title = issue.title.as_deref().unwrap_or("");
|
||||
let body = issue.body.as_deref().unwrap_or("");
|
||||
let state = match issue.state {
|
||||
Some(StateType::Open) => "open",
|
||||
Some(StateType::Closed) => "closed",
|
||||
None => "?",
|
||||
};
|
||||
let user = issue
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|u| u.login.as_deref())
|
||||
.unwrap_or("?");
|
||||
let kind = if issue.get("pull_request").is_some_and(|p| !p.is_null()) {
|
||||
let kind = if issue.pull_request.is_some() {
|
||||
"PR"
|
||||
} else {
|
||||
"issue"
|
||||
|
|
@ -39,23 +47,28 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
let comments = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{}/comments?limit=50",
|
||||
args.number
|
||||
))?;
|
||||
let arr = comments.as_array().cloned().unwrap_or_default();
|
||||
if !arr.is_empty() {
|
||||
let (_, comments) = client
|
||||
.api()
|
||||
.issue_get_comments(
|
||||
owner,
|
||||
name,
|
||||
index(args.number)?,
|
||||
IssueGetCommentsQuery::default(),
|
||||
)
|
||||
.page_size(50)
|
||||
.send()?;
|
||||
if !comments.is_empty() {
|
||||
println!();
|
||||
println!("---");
|
||||
println!("## Comments ({})", arr.len());
|
||||
println!("## Comments ({})", comments.len());
|
||||
println!();
|
||||
for c in &arr {
|
||||
for c in &comments {
|
||||
let cu = c
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|u| u.login.as_deref())
|
||||
.unwrap_or("?");
|
||||
let cb = c.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
let cb = c.body.as_deref().unwrap_or("");
|
||||
println!("**{cu}**: {cb}");
|
||||
println!();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue