Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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