crates/gunnar-cli/src/control/forge.rs
//! **The forge's online verbs** — `gunnar control whoami|pr|issue|review|events`
//! (owner 2026-10-04: the console is a thin layer, so what it can do the CLI can
//! do; both only CALL gunnar).
//!
//! Every verb here is one or two control-plane calls signed with the caller's
//! own key, and every decision is gunnar's. The events this files
//! (`Forge.Append`) are signed HERE with the same key, by the same
//! `gunnar_core::forge::sign_event` the offline `gunnar forge` uses, and
//! attributed to the principal gunnar names for that key (`Access.WhoAmI`) — so
//! nobody types an `--actor` that gunnar would refuse anyway.
use std::path::Path;
use anyhow::{bail, Context, Result};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::events_client::EventsClient;
use gunnar_proto::forge_client::ForgeClient;
use gunnar_proto::pull_requests_client::PullRequestsClient;
use crate::cli::{
ControlEventsCommand, ControlInviteCommand, ControlIssueCommand, ControlPrCommand,
ControlReviewCommand,
};
use crate::control::{authed, connect, explain, private_key, repo_path, runtime};
fn failed(status: tonic::Status) -> anyhow::Error {
anyhow::anyhow!("{}", explain(&status))
}
/// `<ns>/<repo>`, validated, as the string the forge log is keyed by.
fn forge_repo(repo: &str) -> Result<String> {
let path = repo_path(repo)?;
Ok(format!("{}/{}", path.namespace, path.repo))
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn level_word(level: i32) -> &'static str {
match gunnar_proto::GrantLevel::try_from(level) {
Ok(gunnar_proto::GrantLevel::Admin) => "admin",
Ok(gunnar_proto::GrantLevel::Write) => "write",
Ok(gunnar_proto::GrantLevel::Read) => "read",
_ => "none",
}
}
/// `gunnar control whoami`.
pub fn whoami(at: &str, identity: Option<&Path>) -> Result<()> {
let key = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let me = rt.block_on(async {
let mut client = AccessClient::new(channel);
client
.who_am_i(authed(&key, gunnar_proto::WhoAmIRequest {})?)
.await
.map(|r| r.into_inner())
.map_err(failed)
})?;
for line in whoami_lines(&me) {
println!("{line}");
}
Ok(())
}
/// `whoami`'s lines, out where a test can read them.
pub(crate) fn whoami_lines(me: &gunnar_proto::WhoAmIResponse) -> Vec<String> {
let principal = me.principal.clone().unwrap_or_default();
let mut lines = vec![
format!("principal {} ({})", principal.id, principal.name),
format!("key {}", me.fingerprint),
format!("server level {}", level_word(me.server_level)),
];
// **Whose space this person uses** (members never buy, 2026-10-07): the
// account, their role in it, and whom to ask for room or Write.
if !me.account.trim().is_empty() {
let role = if me.account_role.trim().is_empty() {
"-"
} else {
me.account_role.trim()
};
lines.push(format!("account {} ({role})", me.account.trim()));
if role == "employee" && !me.account_admin.trim().is_empty() {
lines.push(format!(
" room and Write are the account's: ask {}",
me.account_admin.trim()
));
}
// An appointed admin decides for the account's repositories and never buys.
if role == "admin-appointed" && !me.account_admin.trim().is_empty() {
lines.push(format!(
" room is the account's: ask {}",
me.account_admin.trim()
));
}
}
lines
}
/// The principal gunnar says this key is — the actor every event is filed as.
fn actor(rt: &tokio::runtime::Runtime, at: &str, key: &ssh_key::PrivateKey) -> Result<String> {
let channel = connect(rt, at)?;
rt.block_on(async {
let mut client = AccessClient::new(channel);
let me = client
.who_am_i(authed(key, gunnar_proto::WhoAmIRequest {})?)
.await
.map_err(failed)?
.into_inner();
me.principal
.map(|p| p.id)
.filter(|id| !id.is_empty())
.context("gunnar named no principal for this key")
})
}
/// Sign `event` with the caller's key, as the principal gunnar names, and
/// append it.
fn append(
rt: &tokio::runtime::Runtime,
at: &str,
key: &ssh_key::PrivateKey,
repo: &str,
subject: u64,
event: gunnar_core::forge::Event,
) -> Result<String> {
let actor = actor(rt, at, key)?;
let signed = gunnar_core::forge::sign_event(
key,
gunnar_core::forge::ForgeEvent {
repo: repo.to_owned(),
pull: subject,
actor: actor.clone(),
at_unix_ms: now_ms(),
event,
},
)
.context("signing the event with your key")?;
let channel = connect(rt, at)?;
rt.block_on(async {
let mut client = ForgeClient::new(channel);
client
.append(authed(
key,
gunnar_proto::AppendEventRequest {
event: Some(wire(signed)),
},
)?)
.await
.map_err(failed)
})?;
Ok(actor)
}
/// A signed event as the wire carries it.
fn wire(signed: gunnar_core::forge::SignedEvent) -> gunnar_proto::SignedForgeEvent {
use gunnar_core::forge::Event;
use gunnar_proto::forge_event::Kind;
let kind = match signed.event.event {
Event::Commented {
body,
path,
line,
blob_oid,
} => Kind::Commented(gunnar_proto::Commented {
body,
path,
line,
blob_oid,
}),
Event::Approved { commit } => Kind::Approved(gunnar_proto::Approved { commit }),
Event::Rejected { commit, reason } => {
Kind::Rejected(gunnar_proto::Rejected { commit, reason })
}
Event::IssueOpened { title, body } => {
Kind::IssueOpened(gunnar_proto::IssueOpened { title, body })
}
Event::IssueCommented { body } => {
Kind::IssueCommented(gunnar_proto::IssueCommented { body })
}
Event::IssueClosed { reason } => Kind::IssueClosed(gunnar_proto::IssueClosed { reason }),
};
gunnar_proto::SignedForgeEvent {
event: Some(gunnar_proto::ForgeEvent {
repo: signed.event.repo,
pull: signed.event.pull,
actor: signed.event.actor,
at_unix_ms: signed.event.at_unix_ms,
kind: Some(kind),
}),
key_fingerprint: signed.key_fingerprint,
signature: signed.signature,
attested: None,
}
}
fn pull_state(state: i32) -> &'static str {
match gunnar_proto::PullRequestState::try_from(state) {
Ok(gunnar_proto::PullRequestState::Open) => "open",
Ok(gunnar_proto::PullRequestState::Merged) => "merged",
Ok(gunnar_proto::PullRequestState::Closed) => "closed",
_ => "unknown",
}
}
fn print_pull(pull: &gunnar_proto::PullRequest) {
println!(
"#{} {} [{}]",
pull.number,
pull.title,
pull_state(pull.state)
);
println!("{} → {}", pull.source_ref, pull.target_ref);
println!("author {}", pull.author);
if !pull.source_oid.is_empty() {
println!("source {}", pull.source_oid);
}
if !pull.merge_oid.is_empty() {
println!("merged {}", pull.merge_oid);
}
if !pull.description.is_empty() {
println!();
println!("{}", pull.description);
}
}
/// `gunnar control pr …`.
pub fn pr(command: ControlPrCommand) -> Result<()> {
match command {
ControlPrCommand::Create {
repo,
source,
target,
title,
body,
identity,
at,
} => {
let path = repo_path(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let pull = rt.block_on(async {
let mut client = PullRequestsClient::new(channel);
client
.create(authed(
&key,
gunnar_proto::CreatePullRequestRequest {
repo: Some(path),
title,
description: body,
source_ref: source,
target_ref: target,
},
)?)
.await
.map_err(failed)
})?;
let pull = pull
.into_inner()
.pull
.context("gunnar returned no pull request")?;
println!("opened {repo}#{}", pull.number);
Ok(())
}
ControlPrCommand::List {
repo,
state,
identity,
at,
} => {
let state = match state.as_str() {
"all" => gunnar_proto::PullRequestState::Unspecified,
"open" => gunnar_proto::PullRequestState::Open,
"merged" => gunnar_proto::PullRequestState::Merged,
"closed" => gunnar_proto::PullRequestState::Closed,
other => bail!("--state {other:?}: open, merged, closed or all"),
};
let path = repo_path(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let pulls = rt.block_on(async {
let mut client = PullRequestsClient::new(channel);
client
.list(authed(
&key,
gunnar_proto::ListPullRequestsRequest {
repo: Some(path),
state: state as i32,
},
)?)
.await
.map_err(failed)
})?;
let pulls = pulls.into_inner().pulls;
if pulls.is_empty() {
println!("no pull requests");
}
for pull in &pulls {
println!(
"#{:<4} {:<7} {} ({} → {})",
pull.number,
pull_state(pull.state),
pull.title,
pull.source_ref,
pull.target_ref
);
}
Ok(())
}
ControlPrCommand::Get {
repo,
number,
identity,
at,
} => {
let path = repo_path(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let pull = rt.block_on(async {
let mut client = PullRequestsClient::new(channel);
client
.get(authed(
&key,
gunnar_proto::GetPullRequestRequest {
repo: Some(path),
number,
},
)?)
.await
.map_err(failed)
})?;
let pull = pull
.into_inner()
.pull
.context("gunnar returned no pull request")?;
print_pull(&pull);
Ok(())
}
ControlPrCommand::Close {
repo,
number,
identity,
at,
} => {
let path = repo_path(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let pull = rt.block_on(async {
let mut client = PullRequestsClient::new(channel);
client
.close(authed(
&key,
gunnar_proto::ClosePullRequestRequest {
repo: Some(path),
number,
},
)?)
.await
.map_err(failed)
})?;
let pull = pull.into_inner().pull.unwrap_or_default();
println!("{repo}#{number} is {}", pull_state(pull.state));
Ok(())
}
}
}
fn list_issues(
rt: &tokio::runtime::Runtime,
at: &str,
key: &ssh_key::PrivateKey,
repo: &str,
) -> Result<Vec<gunnar_proto::IssueState>> {
let channel = connect(rt, at)?;
rt.block_on(async {
let mut client = ForgeClient::new(channel);
client
.list_issues(authed(
key,
gunnar_proto::ListIssuesRequest {
repo: repo.to_owned(),
},
)?)
.await
.map(|r| r.into_inner().issues)
.map_err(failed)
})
}
fn issue_state(open: bool) -> &'static str {
if open {
"open"
} else {
"closed"
}
}
/// `gunnar control issue …`.
pub fn issue(command: ControlIssueCommand) -> Result<()> {
match command {
ControlIssueCommand::Open {
repo,
title,
body,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let before = list_issues(&rt, &at, &key, &repo)?
.iter()
.map(|i| i.number)
.max()
.unwrap_or(0);
let actor = append(
&rt,
&at,
&key,
&repo,
0,
gunnar_core::forge::Event::IssueOpened {
title: title.clone(),
body,
},
)?;
// The number is the fold's to assign; read it back rather than
// guess it.
let number = list_issues(&rt, &at, &key, &repo)?
.into_iter()
.filter(|i| i.number > before && i.author == actor && i.title == title)
.map(|i| i.number)
.min()
.context("the issue was appended and the fold does not show it")?;
println!("opened {repo}#{number}");
Ok(())
}
ControlIssueCommand::Comment {
repo,
number,
body,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
append(
&rt,
&at,
&key,
&repo,
number,
gunnar_core::forge::Event::IssueCommented { body },
)?;
println!("commented on {repo}#{number}");
Ok(())
}
ControlIssueCommand::Close {
repo,
number,
reason,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
append(
&rt,
&at,
&key,
&repo,
number,
gunnar_core::forge::Event::IssueClosed { reason },
)?;
println!("closed {repo}#{number}");
Ok(())
}
ControlIssueCommand::List { repo, identity, at } => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let issues = list_issues(&rt, &at, &key, &repo)?;
if issues.is_empty() {
println!("no issues");
}
for issue in &issues {
println!(
"#{:<4} {:<6} {} ({}, {} comments)",
issue.number,
issue_state(issue.open),
issue.title,
issue.author,
issue.comments
);
}
Ok(())
}
ControlIssueCommand::Get {
repo,
number,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let response = rt.block_on(async {
let mut client = ForgeClient::new(channel);
client
.get_issue(authed(
&key,
gunnar_proto::GetIssueRequest {
repo: repo.clone(),
number,
},
)?)
.await
.map(|r| r.into_inner())
.map_err(failed)
})?;
let state = response
.state
.context("gunnar returned an issue with no state")?;
println!(
"#{} {} [{}]",
state.number,
state.title,
issue_state(state.open)
);
println!("opened by {}", state.author);
if !state.body.is_empty() {
println!();
println!("{}", state.body);
}
for signed in &response.events {
let Some(event) = &signed.event else { continue };
use gunnar_proto::forge_event::Kind;
match &event.kind {
Some(Kind::IssueCommented(c)) => {
println!();
println!("{} commented:", event.actor);
println!("{}", c.body);
}
Some(Kind::IssueClosed(c)) => {
println!();
if c.reason.is_empty() {
println!("{} closed it", event.actor);
} else {
println!("{} closed it: {}", event.actor, c.reason);
}
}
_ => {}
}
}
Ok(())
}
}
}
/// `gunnar control review …`.
pub fn review(command: ControlReviewCommand) -> Result<()> {
match command {
ControlReviewCommand::Get {
repo,
pull,
identity,
at,
} => crate::control::show(&at, identity.as_deref(), &repo, pull),
ControlReviewCommand::Comment {
repo,
pull,
body,
path,
line,
blob,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let anchored = path.is_some();
append(
&rt,
&at,
&key,
&repo,
pull,
gunnar_core::forge::Event::Commented {
body,
path: path.unwrap_or_default(),
line: line.unwrap_or_default(),
blob_oid: blob.unwrap_or_default(),
},
)?;
if anchored {
println!("commented on a line of {repo}#{pull}");
} else {
println!("commented on {repo}#{pull}");
}
Ok(())
}
ControlReviewCommand::Approve {
repo,
pull,
commit,
identity,
at,
} => {
let repo = forge_repo(&repo)?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let actor = append(
&rt,
&at,
&key,
&repo,
pull,
gunnar_core::forge::Event::Approved {
commit: commit.clone(),
},
)?;
println!("{actor} approved {commit} on {repo}#{pull}");
Ok(())
}
}
}
/// An event kind as the flag spells it: `ref-updated`, `ref_updated` or
/// `REF_UPDATED` all name `EVENT_KIND_REF_UPDATED`.
fn event_kind(word: &str) -> Result<i32> {
let wanted = format!(
"EVENT_KIND_{}",
word.trim().replace('-', "_").to_ascii_uppercase()
);
gunnar_proto::EventKind::from_str_name(&wanted)
.filter(|k| *k != gunnar_proto::EventKind::Unspecified)
.map(|k| k as i32)
.with_context(|| format!("--kind {word:?} is not an event kind gunnar sends"))
}
/// One line for one event.
fn event_line(event: &gunnar_proto::Event) -> String {
use gunnar_proto::event::Payload;
let repo = event
.repo_path()
.map(|p| format!("{}/{}", p.namespace, p.repo))
.unwrap_or_default();
let short = |oid: &str| oid.chars().take(12).collect::<String>();
let what = match event.payload.as_ref() {
Some(Payload::RefUpdated(e)) => format!(
"{} {} → {} by {}",
e.ref_name,
short(&e.old_oid),
short(&e.new_oid),
e.principal_id
),
Some(Payload::RefDeleted(e)) => format!("{} deleted by {}", e.ref_name, e.principal_id),
Some(Payload::PushRejected(e)) => format!("push rejected: {}", e.reason),
Some(Payload::RepoCreated(e)) => format!("created by {}", e.principal_id),
Some(Payload::RepoDeleted(e)) => format!("deleted by {}", e.principal_id),
Some(Payload::MirrorSynced(_)) => "mirror synced".to_owned(),
Some(Payload::KeyUsed(e)) => format!(
"{} used {} ({})",
e.principal_id, e.fingerprint, e.operation
),
Some(Payload::PushAttestation(_)) => "push attested".to_owned(),
Some(Payload::Gap(e)) => format!("GAP: {} events lost", e.dropped),
Some(Payload::ReplicationFailed(e)) => format!("replication to {} failed", e.target),
Some(Payload::PullRequestOpened(e)) => format!("pull #{} opened: {}", e.number, e.title),
Some(Payload::PullRequestMerged(e)) => {
format!("pull #{} merged into {}", e.number, e.target_ref)
}
Some(Payload::PullRequestClosed(e)) => format!("pull #{} closed", e.number),
Some(Payload::StreamOpened(e)) => format!("stream opened (epoch {})", e.epoch),
Some(Payload::VisibilityChanged(e)) => format!("visibility {} → {}", e.from, e.to),
Some(Payload::EncryptedChanged(e)) => format!("encrypted {} → {}", e.from, e.to),
None => "(empty)".to_owned(),
};
let kind = gunnar_proto::EventKind::try_from(event.kind)
.map(|k| {
k.as_str_name()
.trim_start_matches("EVENT_KIND_")
.to_ascii_lowercase()
.replace('_', "-")
})
.unwrap_or_else(|_| event.kind.to_string());
format!("{:>8} {kind:<20} {repo} {what}", event.seq)
}
/// `gunnar control events …`.
pub fn events(command: ControlEventsCommand) -> Result<()> {
match command {
ControlEventsCommand::Watch {
namespace,
repo,
kind,
count,
from_seq,
identity,
at,
} => {
let repos = repo
.iter()
.map(|r| repo_path(r))
.collect::<Result<Vec<_>>>()?;
let kinds = kind
.iter()
.map(|k| event_kind(k))
.collect::<Result<Vec<_>>>()?;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
rt.block_on(async {
let mut client = EventsClient::new(channel);
let mut stream = client
.watch(authed(
&key,
gunnar_proto::WatchRequest {
namespaces: namespace,
repos,
kinds,
include_attestation_object_ids: false,
from_seq,
},
)?)
.await
.map_err(failed)?
.into_inner();
let mut seen = 0u64;
use std::io::Write as _;
while let Some(event) = stream.message().await.map_err(failed)? {
let opened = event.kind == gunnar_proto::EventKind::StreamOpened as i32;
println!("{}", event_line(&event));
let _ = std::io::stdout().flush();
if !opened {
seen += 1;
if count.is_some_and(|n| seen >= n) {
break;
}
}
}
Ok::<_, anyhow::Error>(())
})
}
}
}
/// `gunnar control invite …` — a first-login link onto an existing person,
/// minted by the identity core (`Access.EnrolmentLink`, IDENTITY-HOST): a
/// server admin's. The person lists and withdraws their own links in the
/// console (the core's `IdentityService`).
pub fn invite(command: ControlInviteCommand) -> Result<()> {
use gunnar_proto::access_client::AccessClient as Access;
match command {
ControlInviteCommand::Create {
principal,
expires_in_days,
first_only,
identity,
at,
} => {
if !(1..=30).contains(&expires_in_days) {
bail!("--expires-in-days {expires_in_days}: 1 to 30");
}
let expires = now_ms() + i64::from(expires_in_days) * 86_400_000;
let key = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let made = rt.block_on(async {
let mut client = Access::new(channel);
client
.enrolment_link(authed(
&key,
gunnar_proto::EnrolmentLinkRequest {
principal_id: principal.clone(),
first_only,
expires_at_unix_ms: expires,
},
)?)
.await
.map(|r| r.into_inner())
.map_err(failed)
})?;
println!("invite {}", made.invite_id);
println!("for {principal}");
println!("expires {} (unix ms)", made.expires_at_unix_ms);
println!("secret {}", made.secret);
println!("(the secret is shown once; gunnar keeps only its hash)");
Ok(())
}
}
}
#[cfg(test)]
mod whoami_tests {
/// **`gunnar control whoami` says whose space a person uses** (wiring
/// tracer B 2026-10-08; members never buy): the account, the person's
/// role in it and its admin, as Access.WhoAmI answers them.
#[test]
fn whoami_names_the_account_the_role_and_whom_to_ask() {
let me = gunnar_proto::WhoAmIResponse {
account: "acme".into(),
account_role: "employee".into(),
account_admin: "acme".into(),
..Default::default()
};
let lines = super::whoami_lines(&me);
assert!(
lines
.iter()
.any(|l| l.starts_with("account") && l.contains("acme") && l.contains("employee")),
"{lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("ask acme")),
"a member is told whom to ask: {lines:?}"
);
let alone = gunnar_proto::WhoAmIResponse::default();
assert!(
!super::whoami_lines(&alone)
.iter()
.any(|l| l.starts_with("account")),
"no account, no line"
);
}
}