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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/issues.rs

//! Issues, proxied to gunnar's `Forge` service.
//!
//! An issue is the OTHER half of the forge from a pull request's review, and it
//! is thinner here for the same reason the review half is: gunnar owns the
//! evidence. What people said — and, for an issue, that it was opened and closed
//! at all — is a log of `sshsig`-signed events, and this layer only translates
//! JSON to a signed `Forge.Append` and folds `Forge.GetIssue`/`ListIssues` back
//! to the browser. It keeps no state of its own; the state IS the fold.
//!
//! # In whose name
//!
//! The person's (thin layer, owner 2026-10-04; GHUNT-19): an issue opened,
//! commented or closed from the console is sent AS THE PERSON from their own
//! session, unsigned, and gunnar signs it in their name after a fresh touch of
//! their passkey (`[stepup]` otherwise, answered in place). The console has no
//! access to a human's ssh key and must not invent one; to put your own ssh key
//! on an issue, use `gunnar forge issue`. The page says so.

use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::api::prs::console_principal;
use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// An issue as the console shows it — nothing but gunnar's fold.
#[derive(Debug, Clone, Serialize)]
pub struct Issue {
    pub number: u64,
    pub title: String,
    pub body: String,
    /// The identity-table principal that opened it — the opening event's signer.
    pub author: String,
    pub open: bool,
    pub comments: u64,
    pub opened_at_unix_ms: i64,
}

fn render(state: pb::IssueState) -> Issue {
    Issue {
        number: state.number,
        title: state.title,
        body: state.body,
        author: state.author,
        open: state.open,
        comments: state.comments,
        opened_at_unix_ms: state.opened_at_unix_ms,
    }
}

/// The body of an open.
#[derive(Debug, Clone, Deserialize)]
pub struct NewIssue {
    pub title: String,
    #[serde(default)]
    pub body: String,
}

/// The body of a comment.
#[derive(Debug, Clone, Deserialize)]
pub struct NewComment {
    pub body: String,
}

/// The body of a close.
#[derive(Debug, Clone, Deserialize)]
pub struct CloseIssue {
    #[serde(default)]
    pub reason: String,
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or_default()
}

/// One forge event AS THE PERSON, appended (thin layer, owner 2026-10-04):
/// the event names their principal and goes from their own session, unsigned,
/// for gunnar to attest (GHUNT-19), so the forge log says who filed it and
/// gunnar's Write on the repository decides. One writer for all three mutating
/// verbs.
async fn sign_and_append(
    actor: &crate::api::Actor,
    event: crate::forge::ForgeEvent,
) -> Result<(), ApiError> {
    let signed = actor.gunnar.sign_forge_event(&event)?;
    actor
        .gunnar
        .forge()
        .append(pb::AppendEventRequest {
            event: Some(signed),
        })
        .await
        .map_err(ApiError::from)?;
    Ok(())
}

/// `GET /api/repos/{namespace}/{name}/issues`
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers
/// `Forge.ListIssues` under Read on the repository, so another account's
/// private issues are refused by gunnar on the person's own key.
pub async fn list(
    reader: crate::api::Reader,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Vec<Issue>> {
    let response = reader
        .gunnar
        .forge()
        .list_issues(pb::ListIssuesRequest {
            repo: format!("{namespace}/{name}"),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(response.issues.into_iter().map(render).map(|i| for_reader(&reader, i)).collect()))
}

/// **Nobody is named to a guest** (GUN-GUEST, owner 2026-10-30, as the public pages never
/// named one): a visitor with no session reads an issue without its filer's principal.
fn for_reader(reader: &crate::api::Reader, issue: Issue) -> Issue {
    if reader.anonymous {
        Issue { author: String::new(), ..issue }
    } else {
        issue
    }
}

/// `GET /api/repos/{namespace}/{name}/issues/{number}` — asked as the reader.
pub async fn get(
    reader: crate::api::Reader,
    Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Issue> {
    let issue = read_issue(&reader.gunnar, &namespace, &name, number).await?;
    Ok(Json(for_reader(&reader, issue)))
}

/// One issue, folded, asked with `gunnar`.
async fn read_issue(
    gunnar: &crate::grpc::Gunnar,
    namespace: &str,
    name: &str,
    number: u64,
) -> Result<Issue, ApiError> {
    let response = gunnar
        .forge()
        .get_issue(pb::GetIssueRequest {
            repo: format!("{namespace}/{name}"),
            number,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let state = response.state.ok_or_else(|| {
        ApiError(Fail {
            code: "internal".to_owned(),
            message: "Gunnar returned an issue with no state".to_owned(),
        })
    })?;
    Ok(render(state))
}

/// `POST /api/repos/{namespace}/{name}/issues`
///
/// A viewer cannot reach this: `role_floor` puts every mutating `/api/` route
/// at operator. The issue is signed by the console's key and attributed to the
/// console's principal — see the module note.
/// The principal a forge event from `actor` names: the person's, or — for
/// the break-glass token and the open console — the console's own.
async fn actor_principal(state: &AppState, actor: &crate::api::Actor) -> Result<String, ApiError> {
    match &actor.principal {
        Some(principal) => Ok(principal.clone()),
        None => console_principal(state).await,
    }
}

pub async fn open(
    who: crate::api::Actor,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Json(new): Json<NewIssue>,
) -> ApiResult<Issue> {
    let title = new.title.trim().to_owned();
    if title.is_empty() {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "an issue needs a title".to_owned(),
        }));
    }
    let actor = actor_principal(&state, &who).await?;
    let repo = format!("{namespace}/{name}");
    sign_and_append(
        &who,
        crate::forge::ForgeEvent {
            repo: repo.clone(),
            // An opening has no number yet: the fold assigns it.
            pull: 0,
            actor,
            at_unix_ms: now_ms(),
            event: crate::forge::Event::IssueOpened {
                title,
                body: new.body,
            },
        },
    )
    .await?;
    // The number the fold now gives it is the highest — read the list back and
    // return the freshly opened one, so the page can navigate straight to it.
    let issues = who
        .gunnar
        .forge()
        .list_issues(pb::ListIssuesRequest { repo })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .issues;
    let opened = issues
        .into_iter()
        .max_by_key(|i| i.number)
        .map(render)
        .ok_or_else(|| {
            ApiError(Fail {
                code: "internal".to_owned(),
                message: "the issue was appended but does not fold back".to_owned(),
            })
        })?;
    Ok(Json(opened))
}

/// `POST /api/repos/{namespace}/{name}/issues/{number}/comment`
pub async fn comment(
    who: crate::api::Actor,
    State(state): State<AppState>,
    Path((namespace, name, number)): Path<(String, String, u64)>,
    Json(new): Json<NewComment>,
) -> ApiResult<Issue> {
    let body = new.body.trim().to_owned();
    if body.is_empty() {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "an empty comment says nothing".to_owned(),
        }));
    }
    let actor = actor_principal(&state, &who).await?;
    let repo = format!("{namespace}/{name}");
    sign_and_append(
        &who,
        crate::forge::ForgeEvent {
            repo: repo.clone(),
            pull: number,
            actor,
            at_unix_ms: now_ms(),
            event: crate::forge::Event::IssueCommented { body },
        },
    )
    .await?;
    Ok(Json(read_issue(&who.gunnar, &namespace, &name, number).await?))
}

/// `POST /api/repos/{namespace}/{name}/issues/{number}/close`
pub async fn close(
    who: crate::api::Actor,
    State(state): State<AppState>,
    Path((namespace, name, number)): Path<(String, String, u64)>,
    Json(new): Json<CloseIssue>,
) -> ApiResult<Issue> {
    let actor = actor_principal(&state, &who).await?;
    let repo = format!("{namespace}/{name}");
    sign_and_append(
        &who,
        crate::forge::ForgeEvent {
            repo: repo.clone(),
            pull: number,
            actor,
            at_unix_ms: now_ms(),
            event: crate::forge::Event::IssueClosed { reason: new.reason },
        },
    )
    .await?;
    Ok(Json(read_issue(&who.gunnar, &namespace, &name, number).await?))
}