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.
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! `timeline <number> [--limit N]` — list timeline events on an
|
|
//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept
|
|
//! falling back to curl for "who closed this?" / "when was this
|
|
//! labelled?" archaeology). Composes naturally with `view <n>` /
|
|
//! `comments <n>` — separate verb keeps the existing shapes stable.
|
|
//!
|
|
//! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual
|
|
//! comments AND the event entries (label, assignee, close, reopen,
|
|
//! `pull_push`, etc.) in chronological order. We render each row in
|
|
//! a human-readable form by default; pass the global `--json` flag
|
|
//! for the raw API shape.
|
|
//!
|
|
//! `--tail N` is a follow-up (the timeline endpoint doesn't expose a
|
|
//! total-count field so we can't use the count-then-page trick that
|
|
//! `comments --tail` lands in #770; future shape probably mirrors
|
|
//! `comments --tail` once Forgejo grows a `count` query or we accept
|
|
//! the trailing-slice cost).
|
|
|
|
use anyhow::Result;
|
|
use clap::Args as ClapArgs;
|
|
use serde_json::Value;
|
|
|
|
use crate::client::Client;
|
|
use crate::verbs::print_json;
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// Issue or PR number.
|
|
number: u64,
|
|
/// Page size (Forgejo caps at 50). Returns the first `N` events.
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: u64,
|
|
}
|
|
|
|
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
|
|
))?;
|
|
if client.json_mode() {
|
|
return print_json(&v);
|
|
}
|
|
let Some(events) = v.as_array() else {
|
|
return print_json(&v);
|
|
};
|
|
for ev in events {
|
|
print_event(ev);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Render one timeline event as a single `**actor @ ts**: summary`
|
|
/// line. Comment rows inline their full body; structured event types
|
|
/// (label, assignees, close, etc.) get a one-line human summary
|
|
/// derived from the per-type fields the API populates. Unknown /
|
|
/// future types fall through to a `[<type>]` placeholder so a forge
|
|
/// schema bump doesn't panic the verb — operator still sees that the
|
|
/// event existed, with timestamp + actor.
|
|
///
|
|
/// Pure function (no I/O) so the tests below can pin the formatted
|
|
/// output for every supported event type without re-implementing the
|
|
/// per-arm dispatch. `print_event` is the only caller that adds the
|
|
/// terminating newline.
|
|
#[allow(clippy::too_many_lines)]
|
|
fn format_event(ev: &Value) -> String {
|
|
let event_type = ev.get("type").and_then(Value::as_str).unwrap_or("?");
|
|
let user = ev
|
|
.get("user")
|
|
.and_then(|u| u.get("login"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
let ts = ev.get("created_at").and_then(Value::as_str).unwrap_or("?");
|
|
let summary = match event_type {
|
|
"comment" => {
|
|
// Comments get the full body inlined — matches `comments`
|
|
// verb shape so the operator sees the same line they'd
|
|
// get from the head-of-thread listing.
|
|
ev.get("body")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_owned()
|
|
}
|
|
"label" => {
|
|
// Forgejo encodes label add/remove via `body = "1"` (added)
|
|
// or `body = "0"` (removed). Quirky but stable.
|
|
let action = match ev.get("body").and_then(Value::as_str).unwrap_or("") {
|
|
"1" => "added",
|
|
"0" => "removed",
|
|
_ => "changed",
|
|
};
|
|
let label = ev
|
|
.get("label")
|
|
.and_then(|l| l.get("name"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
format!("{action} label `{label}`")
|
|
}
|
|
"assignees" => {
|
|
let assignee = ev
|
|
.get("assignee")
|
|
.and_then(|a| a.get("login"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
let removed = ev
|
|
.get("removed_assignee")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
if removed {
|
|
format!("unassigned @{assignee}")
|
|
} else {
|
|
format!("assigned @{assignee}")
|
|
}
|
|
}
|
|
"review_request" => {
|
|
let reviewer = ev
|
|
.get("assignee")
|
|
.and_then(|a| a.get("login"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
let removed = ev
|
|
.get("removed_assignee")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
if removed {
|
|
format!("removed review request from @{reviewer}")
|
|
} else {
|
|
format!("requested review from @{reviewer}")
|
|
}
|
|
}
|
|
"close" => "closed".to_owned(),
|
|
"reopen" => "reopened".to_owned(),
|
|
"merge" => "merged".to_owned(),
|
|
"milestone" => {
|
|
let title = ev
|
|
.get("milestone")
|
|
.and_then(|x| x.get("title"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
format!("added to milestone `{title}`")
|
|
}
|
|
"demilestone" => {
|
|
let title = ev
|
|
.get("old_milestone")
|
|
.and_then(|x| x.get("title"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
format!("removed from milestone `{title}`")
|
|
}
|
|
"pull_push" => {
|
|
// Body is JSON: `{"is_force_push":bool,"commit_ids":[...]}`.
|
|
// Defensive parse — fall through to a no-detail summary if
|
|
// the shape ever drifts.
|
|
let body_str = ev.get("body").and_then(Value::as_str).unwrap_or("");
|
|
let parsed: Option<Value> = serde_json::from_str(body_str).ok();
|
|
let n = parsed
|
|
.as_ref()
|
|
.and_then(|v| v.get("commit_ids"))
|
|
.and_then(Value::as_array)
|
|
.map_or(0, Vec::len);
|
|
let force = parsed
|
|
.as_ref()
|
|
.and_then(|v| v.get("is_force_push"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
if force {
|
|
format!("force-pushed {n} commit(s)")
|
|
} else {
|
|
format!("pushed {n} commit(s)")
|
|
}
|
|
}
|
|
"commit_ref" => {
|
|
let sha = ev
|
|
.get("ref_commit_sha")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let short: String = sha.chars().take(7).collect();
|
|
if short.is_empty() {
|
|
"referenced from a commit".to_owned()
|
|
} else {
|
|
format!("referenced from commit {short}")
|
|
}
|
|
}
|
|
"comment_ref" | "issue_ref" => "referenced from another issue/PR".to_owned(),
|
|
"changed_target_branch" => "changed target branch".to_owned(),
|
|
"review" => "submitted a review".to_owned(),
|
|
"lock" => "locked the conversation".to_owned(),
|
|
"unlock" => "unlocked the conversation".to_owned(),
|
|
// Future / unknown types: surface the raw label so we don't
|
|
// pretend nothing happened. Operator sees `[deploy_status]` or
|
|
// whatever new event a forge bump invents.
|
|
other => format!("[{other}]"),
|
|
};
|
|
format!("**{user} @ {ts}**: {summary}")
|
|
}
|
|
|
|
/// Print one event followed by a blank line spacer. Thin wrapper
|
|
/// around `format_event` so the tests can pin per-arm output without
|
|
/// duplicating the dispatch.
|
|
fn print_event(ev: &Value) {
|
|
println!("{}", format_event(ev));
|
|
println!();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
//! Tests call `format_event` directly so any new event-type arm
|
|
//! added in `print_event`'s dispatch is automatically covered by
|
|
//! the rendering path (no parallel test-side dispatch to keep in
|
|
//! sync). Argus on PR #798 🟡: "extract a `format_event(ev) ->
|
|
//! String` helper and test that function directly instead of
|
|
//! duplicating the logic" — addressed.
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn comment_renders_body_inline() {
|
|
let ev = serde_json::json!({
|
|
"type": "comment",
|
|
"user": { "login": "iris" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"body": "looks good to me",
|
|
});
|
|
assert_eq!(
|
|
format_event(&ev),
|
|
"**iris @ 2026-05-31T12:00:00Z**: looks good to me"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn label_added_renders_action_and_name() {
|
|
let ev = serde_json::json!({
|
|
"type": "label",
|
|
"user": { "login": "triage" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"body": "1",
|
|
"label": { "name": "area:harness" },
|
|
});
|
|
assert_eq!(
|
|
format_event(&ev),
|
|
"**triage @ 2026-05-31T12:00:00Z**: added label `area:harness`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn label_removed_renders_removed_action() {
|
|
let ev = serde_json::json!({
|
|
"type": "label",
|
|
"user": { "login": "mara" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"body": "0",
|
|
"label": { "name": "needs-review" },
|
|
});
|
|
assert_eq!(
|
|
format_event(&ev),
|
|
"**mara @ 2026-05-31T12:00:00Z**: removed label `needs-review`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn close_event_renders_one_word_summary() {
|
|
let ev = serde_json::json!({
|
|
"type": "close",
|
|
"user": { "login": "mara" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
});
|
|
assert_eq!(format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: closed");
|
|
}
|
|
|
|
#[test]
|
|
fn assignees_added_and_removed() {
|
|
let added = serde_json::json!({
|
|
"type": "assignees",
|
|
"user": { "login": "triage" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"assignee": { "login": "damocles" },
|
|
"removed_assignee": false,
|
|
});
|
|
assert_eq!(
|
|
format_event(&added),
|
|
"**triage @ 2026-05-31T12:00:00Z**: assigned @damocles"
|
|
);
|
|
let removed = serde_json::json!({
|
|
"type": "assignees",
|
|
"user": { "login": "triage" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"assignee": { "login": "damocles" },
|
|
"removed_assignee": true,
|
|
});
|
|
assert_eq!(
|
|
format_event(&removed),
|
|
"**triage @ 2026-05-31T12:00:00Z**: unassigned @damocles"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pull_push_counts_commits_and_marks_force() {
|
|
let normal = serde_json::json!({
|
|
"type": "pull_push",
|
|
"user": { "login": "damocles" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"body": r#"{"is_force_push":false,"commit_ids":["a","b","c"]}"#,
|
|
});
|
|
assert_eq!(
|
|
format_event(&normal),
|
|
"**damocles @ 2026-05-31T12:00:00Z**: pushed 3 commit(s)"
|
|
);
|
|
let forced = serde_json::json!({
|
|
"type": "pull_push",
|
|
"user": { "login": "damocles" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"body": r#"{"is_force_push":true,"commit_ids":["a"]}"#,
|
|
});
|
|
assert_eq!(
|
|
format_event(&forced),
|
|
"**damocles @ 2026-05-31T12:00:00Z**: force-pushed 1 commit(s)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_ref_truncates_sha_to_seven() {
|
|
let ev = serde_json::json!({
|
|
"type": "commit_ref",
|
|
"user": { "login": "damocles" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
"ref_commit_sha": "abcdef0123456789abcdef0123456789abcdef01",
|
|
});
|
|
assert_eq!(
|
|
format_event(&ev),
|
|
"**damocles @ 2026-05-31T12:00:00Z**: referenced from commit abcdef0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_event_type_renders_bracketed_placeholder() {
|
|
// Future-proofing: a forge schema bump that adds a new event
|
|
// type shouldn't silently swallow the row.
|
|
let ev = serde_json::json!({
|
|
"type": "deploy_status",
|
|
"user": { "login": "ci-bot" },
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
});
|
|
assert_eq!(
|
|
format_event(&ev),
|
|
"**ci-bot @ 2026-05-31T12:00:00Z**: [deploy_status]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_user_falls_back_to_placeholder() {
|
|
// Defensive: forge has been known to omit `user` on bot events.
|
|
let ev = serde_json::json!({
|
|
"type": "close",
|
|
"created_at": "2026-05-31T12:00:00Z",
|
|
});
|
|
assert_eq!(format_event(&ev), "**? @ 2026-05-31T12:00:00Z**: closed");
|
|
}
|
|
}
|