Watch
0
0
Fork
You've already forked hyperhive
0

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

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

View file

@ -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);
}

View file

@ -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,
}))
}

View file

@ -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}");
}
}

View file

@ -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}");
}

View file

@ -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);
}
}

View file

@ -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,
}
}

View file

@ -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,
}))
}

View file

@ -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,
}))
}

View file

@ -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(())
}

View file

@ -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

View file

@ -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 {

View file

@ -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)
}

View file

@ -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(())

View file

@ -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()),
}))
}

View file

@ -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));
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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,
}))
}
}

View file

@ -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");

View file

@ -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)
}

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