hyperhive/hive-forge/src/verbs/list.rs
atlas 4bff450343 feat(gateway): hivectl gateway user management + fix htpasswdFile assertion
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.

Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.

Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
2026-06-01 23:25:28 +02:00

215 lines
7.2 KiB
Rust

//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee
//! <user>] [--author <user>] [--label <name>] [--limit N]` — list
//! issues / PRs with filters. Pretty `#NNN [author] title` output by
//! default; `--json` for piping.
//!
//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string
//! filters one-for-one so the mental model carries over. Closes the
//! second of the four #694 gaps (read-side; 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 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.
#[derive(Copy, Clone, Debug, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum Kind {
/// Issues only (excludes PRs).
Issue,
/// Pull requests only.
Pr,
/// Issues + pull requests (default; matches the forge UI's
/// "Issues" tab when no type filter is applied).
Both,
}
impl Kind {
fn api_value(self) -> &'static str {
match self {
Self::Issue => "issues",
Self::Pr => "pulls",
Self::Both => "all",
}
}
}
/// State filter. Matches Forgejo's `state` query parameter
/// (`open` / `closed` / `all`).
#[derive(Copy, Clone, Debug, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum State {
Open,
Closed,
All,
}
impl State {
fn api_value(self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
Self::All => "all",
}
}
}
#[derive(ClapArgs)]
pub struct Args {
/// What to return: issues, PRs, or both (default: both).
#[arg(long, value_enum, default_value_t = Kind::Both)]
kind: Kind,
/// Issue/PR state (default: open).
#[arg(long, value_enum, default_value_t = State::Open)]
state: State,
/// Filter to items assigned to this user (single login).
#[arg(long)]
assignee: Option<String>,
/// Filter to items authored by this user (single login).
#[arg(long)]
author: Option<String>,
/// Filter to items mentioning this user.
#[arg(long)]
mention: Option<String>,
/// Filter to items carrying any of these label names. Repeatable.
#[arg(long = "label")]
labels: Vec<String>,
/// Max items to return (default: 30; forge's per-page cap applies).
#[arg(long, default_value_t = 30)]
limit: u64,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let mut path = format!(
"/repos/{repo}/issues?type={}&state={}&limit={}",
args.kind.api_value(),
args.state.api_value(),
args.limit
);
if let Some(u) = args.assignee.as_deref()
&& !u.is_empty()
{
write!(path, "&assigned_by={}", pct_encode(u)).unwrap();
}
if let Some(u) = args.author.as_deref()
&& !u.is_empty()
{
write!(path, "&created_by={}", pct_encode(u)).unwrap();
}
if let Some(u) = args.mention.as_deref()
&& !u.is_empty()
{
write!(path, "&mentioned_by={}", 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| pct_encode(l)).collect();
write!(path, "&labels={}", encoded.join(",")).unwrap();
}
let resp = client.get_json(&path)?;
if client.json_mode() {
return print_json(&resp);
}
let Some(items) = resp.as_array() else {
// Forge returned something other than an array — most likely
// an error envelope; fall back to JSON-dumping so the user
// can see what came back.
return print_json(&resp);
};
for item in items {
print_row(item);
}
Ok(())
}
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the
/// subset of characters that show up in usernames + label names
/// (spaces in labels are the realistic non-ASCII case) without
/// pulling in a fresh workspace dep. Username regex is gitea-style
/// `[a-zA-Z0-9_-]` so the no-op fast path covers all of them.
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
write!(out, "%{b:02X}").unwrap();
}
}
out
}
/// Render one issue/PR as a single `#NNN [author] title` line.
/// Defensive: missing fields drop to placeholders so a partial
/// response from a future API change still produces readable output
/// instead of panicking on `unwrap`.
fn print_row(item: &Value) {
let number = item.get("number").and_then(Value::as_u64).unwrap_or(0);
let title = item.get("title").and_then(Value::as_str).unwrap_or("");
let author = item
.get("user")
.and_then(|u| u.get("login"))
.and_then(Value::as_str)
.unwrap_or("?");
// Tag PRs visually so a mixed-kind result stays scannable. The
// forge always populates `pull_request` but sets it to `null` for
// issues; we check non-null specifically rather than just-present
// (which would render every issue as a PR).
let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
let kind = if is_pr { "PR" } else { " " };
println!("#{number:>4} {kind} [{author}] {title}");
}
#[cfg(test)]
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
// 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");
}
#[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");
}
#[test]
fn pct_encode_passes_unreserved_through() {
// Usernames + plain label 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");
}
#[test]
fn pct_encode_escapes_spaces_and_specials() {
// Forgejo labels can contain spaces ("good first issue" is a
// canonical example); the comma separator gets encoded too
// when it appears INSIDE a label name (we re-add the joined
// form unencoded above as the field separator).
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
assert_eq!(pct_encode("a&b"), "a%26b");
assert_eq!(pct_encode("a/b"), "a%2Fb");
}
}