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?))
}