crates/gunnar-ui-server/src/api/prs.rs
//! Pull requests, proxied to gunnar's `PullRequests` service.
//!
//! Thin on purpose: records and the merge's ref move live in gunnar, behind
//! the same grant checks the git door uses. This layer translates JSON to
//! gRPC and back and adds nothing — the one place it edits the story is the
//! state names, which travel as the lowercase words the console shows.
//!
//! Two services, not one. The record is `PullRequests`; the review — comments
//! and signed approvals — is `Forge`, and the merge refuses without a verified
//! approval of the tip. See the second half of this file.
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{repo_path, ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;
/// A pull request as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Pull {
pub number: u64,
pub title: String,
pub description: String,
pub source_ref: String,
pub target_ref: String,
/// `open`, `merged`, `closed` — or `unspecified` if gunnar ever sends it.
pub state: &'static str,
pub author: String,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
pub source_oid: String,
/// Empty unless merged.
pub merge_oid: String,
/// **The description as HTML**, from the server's own markdown renderer and its
/// sanitiser (`readme::plain_html`), so the page draws it and decides nothing
/// (GUN-FIELD-TEST, 2026-11-01). Empty for an empty description.
pub description_html: String,
}
fn render(pull: pb::PullRequest) -> Pull {
Pull {
number: pull.number,
title: pull.title,
description: pull.description.clone(),
source_ref: pull.source_ref,
target_ref: pull.target_ref,
state: state_name(pull.state),
author: pull.author,
created_at_unix_ms: pull.created_at_unix_ms,
updated_at_unix_ms: pull.updated_at_unix_ms,
source_oid: pull.source_oid,
merge_oid: pull.merge_oid,
description_html: if pull.description.trim().is_empty() {
String::new()
} else {
crate::readme::plain_html(&pull.description)
},
}
}
/// **May this person merge it now, how, and if not, why** — gunnar's `CheckMerge`, read
/// as one answer for the page (GUN-FIELD-TEST, GUN-MERGE-ENGINE 2026-11-01). `checked:
/// false` is an older gunnar without the check: the page then offers Merge and gunnar
/// decides on the press, as before. Every word here is gunnar's: the strategy it chose,
/// the target tip it checked, the paths that conflict.
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct Mergeable {
pub checked: bool,
pub allowed: bool,
/// gunnar's own sentence when it would refuse; empty otherwise.
pub reason: String,
/// How gunnar would merge: `fast-forward`, `merge` (a merge commit) or `squash`;
/// empty when it would not, or an older gunnar did not say.
pub strategy: &'static str,
/// The target tip gunnar checked. The page sends it back with the press, so a target
/// that moved in between is checked again by gunnar, never merged on this answer.
pub target: String,
/// Every path that conflicts, as gunnar words it (`path (why)`). Non-empty: no Merge.
pub conflicts: Vec<String>,
/// The head is already in the target, merged outside Gunnar: the commit that contains
/// it (GUN-PR-FIELD-2026-11-01 item 3). The press marks the pull request merged there.
pub contained_in: String,
}
/// The word the page uses for gunnar's strategy.
fn strategy_word(value: i32) -> &'static str {
match pb::MergeStrategy::try_from(value) {
Ok(pb::MergeStrategy::FastForward) => "fast-forward",
Ok(pb::MergeStrategy::MergeCommit) => "merge",
Ok(pb::MergeStrategy::Squash) => "squash",
_ => "",
}
}
/// Pure: gunnar's answer to `CheckMerge` as a [`Mergeable`]. A refusal on the state of
/// the world (`FAILED_PRECONDITION`) is an answer, and so are conflicts; anything else is
/// passed on.
pub fn mergeable_of(
answer: Result<pb::MergePullRequestResponse, tonic::Status>,
) -> Result<Mergeable, tonic::Status> {
match answer {
Ok(response) => {
let plan = response.plan.unwrap_or_default();
Ok(Mergeable {
checked: true,
allowed: plan.conflicts.is_empty(),
reason: String::new(),
strategy: strategy_word(plan.strategy),
target: plan.target_oid,
conflicts: plan.conflicts,
contained_in: plan.contained_in,
})
}
Err(status) if status.code() == tonic::Code::FailedPrecondition => Ok(Mergeable {
checked: true,
allowed: false,
reason: status.message().trim_start_matches("failed precondition: ").to_owned(),
..Mergeable::default()
}),
Err(status) if status.code() == tonic::Code::Unimplemented => Ok(Mergeable {
checked: false,
allowed: true,
..Mergeable::default()
}),
Err(status) => Err(status),
}
}
/// `GET /api/repos/{namespace}/{name}/pulls/{number}/mergeable`: gunnar's `CheckMerge`,
/// asked as the person (it needs Write, as the merge does). Nothing moves.
pub async fn mergeable(
actor: crate::api::Actor,
Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Mergeable> {
let answer = actor
.gunnar
.pulls()
.check_merge(pb::MergePullRequestRequest {
repo: Some(repo_path(&namespace, &name)),
number,
..Default::default()
})
.await
.map(tonic::Response::into_inner);
Ok(Json(mergeable_of(answer).map_err(ApiError::from)?))
}
fn state_name(value: i32) -> &'static str {
match pb::PullRequestState::try_from(value) {
Ok(pb::PullRequestState::Open) => "open",
Ok(pb::PullRequestState::Merged) => "merged",
Ok(pb::PullRequestState::Closed) => "closed",
_ => "unspecified",
}
}
fn state_from_name(name: &str) -> pb::PullRequestState {
match name {
"open" => pb::PullRequestState::Open,
"merged" => pb::PullRequestState::Merged,
"closed" => pb::PullRequestState::Closed,
_ => pb::PullRequestState::Unspecified,
}
}
/// `?state=` on the list.
#[derive(Debug, Clone, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub state: Option<String>,
}
/// The body of a create.
#[derive(Debug, Clone, Deserialize)]
pub struct NewPull {
pub title: String,
#[serde(default)]
pub description: String,
pub source_ref: String,
pub target_ref: String,
}
/// `GET /api/repos/{namespace}/{name}/pulls?state=`
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers
/// `PullRequests.List` under Read on the repository, so a private repository's
/// pull requests are refused to another account's session by gunnar itself.
/// Until this line the console asked with its own server-wide admin key.
pub async fn list(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
Query(query): Query<ListQuery>,
) -> ApiResult<Vec<Pull>> {
let response = reader
.gunnar
.pulls()
.list(pb::ListPullRequestsRequest {
repo: Some(repo_path(&namespace, &name)),
state: query
.state
.as_deref()
.map(state_from_name)
.unwrap_or(pb::PullRequestState::Unspecified) as i32,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(response.pulls.into_iter().map(render).collect()))
}
/// `POST /api/repos/{namespace}/{name}/pulls`
///
/// Authored by the PERSON's own principal (their own key, thin layer
/// 2026-10-04), not the console's. gunnar sets a pull request's author from the caller on
/// the connection, so a console that created every pull request under its own
/// key made every browser pull request the console's — which is not only wrong
/// in the log but makes `forbid_self_approval` (author versus approver) unable
/// to fire, since the author was never the human. Acting as the reviewer here
/// makes both true. Without a passkey session there is no human key to act
/// with, so it falls back to the console — the loopback/break-glass posture.
pub async fn create(
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
headers: axum::http::HeaderMap,
Json(new): Json<NewPull>,
) -> ApiResult<Pull> {
let reviewer = reviewer_identity(&state, &headers).await?;
let acting = reviewer
.as_ref()
.map(|r| &r.gunnar)
.unwrap_or(&state.gunnar);
let response = acting
.pulls()
.create(pb::CreatePullRequestRequest {
repo: Some(repo_path(&namespace, &name)),
title: new.title,
description: new.description,
source_ref: new.source_ref,
target_ref: new.target_ref,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render(response.pull.unwrap_or_default())))
}
/// `GET /api/repos/{namespace}/{name}/pulls/{number}` — asked as the reader.
pub async fn get(
reader: Reader,
Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Pull> {
let response = reader
.gunnar
.pulls()
.get(pb::GetPullRequestRequest {
repo: Some(repo_path(&namespace, &name)),
number,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render(response.pull.unwrap_or_default())))
}
/// `?target=` on a merge: the target tip the page's check answered.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MergeQuery {
#[serde(default)]
pub target: Option<String>,
}
/// `POST /api/repos/{namespace}/{name}/pulls/{number}/merge[?target=<oid>]`. gunnar picks
/// the strategy; `target` is the tip the page's check saw, so a target that moved since is
/// checked again by gunnar.
pub async fn merge(
actor: crate::api::Actor,
Path((namespace, name, number)): Path<(String, String, u64)>,
Query(query): Query<MergeQuery>,
) -> ApiResult<Pull> {
let response = actor
.gunnar
.pulls()
.merge(pb::MergePullRequestRequest {
repo: Some(repo_path(&namespace, &name)),
number,
expected_target_oid: query.target.unwrap_or_default(),
..Default::default()
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render(response.pull.unwrap_or_default())))
}
/// `POST /api/repos/{namespace}/{name}/pulls/{number}/close`
pub async fn close(
actor: crate::api::Actor,
Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Pull> {
let response = actor
.gunnar
.pulls()
.close(pb::ClosePullRequestRequest {
repo: Some(repo_path(&namespace, &name)),
number,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render(response.pull.unwrap_or_default())))
}
// ── the review half ───────────────────────────────────────────────────────
//
// A pull request has two halves with different authorities, and the four
// handlers above are only the first. The record — number, refs, state, merge
// oid — is gunnar's under a compare-and-swap. What people SAID about it is a
// log of `sshsig`-signed events, because the value of "someone approved commit
// abc" is that a third party can check it without trusting any server.
//
// `PullRequests.Merge` is where the two meet: it refuses unless a **verified**
// approval names the commit the source branch is at RIGHT NOW. Until these
// handlers existed the console could open and merge pull requests and had no
// way to record an approval at all, so every merge from the web answered
// "Nobody has approved this pull request" and the button was decorative.
/// One approval, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Approval {
/// The identity-table principal the approval is attributed to.
pub actor: String,
/// The commit that was approved — never "this pull request".
pub commit: String,
/// True only when a registered key verified the signature, re-checked by
/// gunnar at the moment of asking.
pub verified: bool,
/// `verified`, `UNKNOWN KEY`, `NO EVENT`, or the problem in words.
pub verdict: String,
/// True when this approval names [`Review::head`].
///
/// An approval of an earlier tip is a real approval of a real commit and
/// is **not** an approval of this one, so the page has to show the two
/// apart rather than rendering a tick.
pub covers_head: bool,
}
/// The review on one pull request.
#[derive(Debug, Clone, Serialize)]
pub struct Review {
pub pull: u64,
/// **Where the source branch is now** — the commit a merge would move to.
///
/// Not `PullRequest.source_oid`, which is the tip at CREATION: the branch
/// may have advanced since, and the whole gate is about those two coming
/// apart. Empty when the source branch is gone.
pub head: String,
pub approvals: Vec<Approval>,
pub comments: u64,
/// **What the merge gate will decide**, from the same two facts it uses: is
/// there a verified approval naming `head`.
///
/// A display, not an authority. gunnar decides at merge time against the
/// key table as it is then, and the console never pre-empts a refusal — the
/// Merge button stays live and gunnar's own words are what an operator
/// reads if it says no.
pub head_is_approved: bool,
}
/// The body of an approval.
#[derive(Debug, Clone, Deserialize)]
pub struct NewApproval {
/// **The commit being approved.** Required, and never defaulted to the
/// current tip: an approval that means "whatever is there now" is the
/// mutable `approved: bool` this design exists to avoid, and it would let a
/// reviewer sign off on a commit that arrived after they looked.
pub commit: String,
}
/// Where the pull request's source branch points right now.
///
/// `""` when the branch is gone — which is a fact the page shows rather than an
/// error, because a merge refuses on it separately and says so better.
async fn source_head(
gunnar: &crate::grpc::Gunnar,
namespace: &str,
name: &str,
source_ref: &str,
) -> Result<String, ApiError> {
let response = gunnar
.refs()
.list(pb::ListRefsRequest {
repo: Some(repo_path(namespace, name)),
prefix: source_ref.to_owned(),
peel: false,
..Default::default()
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(response
.refs
.into_iter()
.find(|r| r.ref_name == source_ref)
.map(|r| r.oid)
.unwrap_or_default())
}
/// Ask gunnar for the folded review and shape it for the browser.
async fn read_review(
gunnar: &crate::grpc::Gunnar,
namespace: &str,
name: &str,
number: u64,
) -> Result<Review, ApiError> {
let pull = gunnar
.pulls()
.get(pb::GetPullRequestRequest {
repo: Some(repo_path(namespace, name)),
number,
})
.await
.map_err(ApiError::from)?
.into_inner()
.pull
.unwrap_or_default();
let head = source_head(gunnar, namespace, name, &pull.source_ref).await?;
let response = gunnar
.forge()
.get_review(pb::GetReviewRequest {
repo: format!("{namespace}/{name}"),
pull: number,
})
.await
.map_err(ApiError::from)?
.into_inner();
let folded = response.state.unwrap_or_default();
let approvals: Vec<Approval> = folded
.approvals
.into_iter()
.map(|a| Approval {
covers_head: !head.is_empty() && a.commit == head,
actor: a.actor,
commit: a.commit,
verified: a.verified,
verdict: a.verdict,
})
.collect();
Ok(Review {
pull: number,
head_is_approved: approvals.iter().any(|a| a.verified && a.covers_head),
head,
approvals,
comments: folded.comments,
})
}
/// The identity-table principal gunnar knows the console's own key by.
///
/// `Forge.Append` refuses an event attributed to anyone but the caller — "a
/// forge event is evidence about who did something, so it may not be filed in
/// another principal's name" — and the console's principal is assigned at
/// `gunnar-ui-server enroll` time and lives only in gunnar's key table.
///
/// Asked fresh rather than cached at startup: a key that has been moved to a
/// different principal must sign as the principal it is on NOW, and an approval
/// is a deliberate act a human takes by hand, so one extra call costs nothing.
pub(crate) async fn console_principal(state: &AppState) -> Result<String, ApiError> {
let fingerprint = state.gunnar.fingerprint().to_owned();
let response = state
.gunnar
.access()
.list_keys(pb::ListKeysRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
response
.keys
.into_iter()
.find(|key| key.fingerprint == fingerprint)
.map(|key| key.principal_id)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
ApiError(Fail {
code: "failedprecondition".to_owned(),
message: format!(
"Gunnar has no principal for the console's own key ({fingerprint}), so an \
approval could not be attributed to anybody. Re-run `gunnar-ui-server \
enroll`."
),
})
})
}
/// `GET /api/repos/{namespace}/{name}/pulls/{number}/review`
///
/// Asked as the reader (thin layer, owner 2026-10-04): gunnar's Read on the
/// repository decides who may see a review, not the console's own key.
pub async fn review(
reader: Reader,
Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Review> {
Ok(Json(read_review(&reader.gunnar, &namespace, &name, number).await?))
}
/// **The person on this request, as a signing identity** — or `None` when
/// nobody is (the open-on-loopback posture) or the break-glass admin token is
/// (a machine credential, nobody at gunnar).
///
/// Thin layer (owner 2026-10-04): it is the person's OWN principal and key —
/// the [`crate::api::Actor`] every other call of theirs is signed with — so a
/// pull request, an approval and a comment are theirs in gunnar's log, and
/// gunnar's grants decide whether they may (Write on the repository to file a
/// review event, as for a push). Until then the console minted a second
/// principal per reviewer (`console-<user>`), held its private key, and granted
/// it server-wide Write on its own authority.
pub(crate) struct Person {
pub gunnar: crate::grpc::Gunnar,
pub principal_id: String,
}
async fn reviewer_identity(
state: &AppState,
headers: &axum::http::HeaderMap,
) -> Result<Option<Person>, ApiError> {
let Some(caller @ crate::auth::middleware::Caller::Operator(_)) =
crate::auth::middleware::resolve(state, headers).await
else {
return Ok(None);
};
let actor = crate::api::Actor::of(state, Some(&caller)).await?;
Ok(match (actor.console, actor.principal) {
(false, Some(principal_id)) => Some(Person {
gunnar: actor.gunnar,
principal_id,
}),
// A test fixture's stand-in for a person holding a server-wide grant
// signs with the console's key; it is the console's fallback below.
_ => None,
})
}
/// `POST /api/repos/{namespace}/{name}/pulls/{number}/approve`
///
/// # What this signs, and in whose name
///
/// **A person's approval is THEIRS** (GHUNT-19): the console sends it AS
/// THEM, from their own identity-core session ([`crate::api::Actor`]), and
/// UNSIGNED — a passkey session holds no key of theirs, and the console must
/// not sign in their name. Gunnar attests it: when the session stepped up (a
/// fresh, user-verified touch of their own passkey) it signs the event with its
/// own key, the actor named and "attested for session step-up at <time>" in
/// the event and the audit row; otherwise it answers `[stepup]`, which reaches
/// the browser as `"code":"stepup"` and the web answers with the touch in place.
/// `PullRequests.Merge` then counts it as *theirs* — what the old console
/// (signing every web approval with its own key) could not give. It is also
/// what makes `forbid_self_approval` real:
/// the approval now carries the human's principal, so an author's own web
/// approval is refused at merge where before every web approval was the
/// console's and the rule was dead.
///
/// **The honest fallback.** With no passkey session — the open-on-loopback
/// posture, or the break-glass admin token — there is no reviewer key to sign
/// with, and the console does NOT silently put a human's name to an approval.
/// It signs with its own key, attributed to its own principal, and writes a
/// comment SAYING SO ([`crate::forge::attribution`]). That is the console's
/// word about who asked, checkable only as far as the console is trusted, and
/// it never pretends to be the human's.
///
/// A viewer cannot reach this: `role_floor` puts every mutating `/api/` route
/// at [`crate::role::Role::Operator`].
///
/// **The self-approval and required-approvals rules live in gunnar**, not
/// here: since 2026-08-31 a `ProtectionRule` may carry `forbid_self_approval`
/// and `required_approvals`, and `PullRequests.Merge` enforces both. This
/// route deliberately adds no rule of its own — a rule in the console that
/// the CLI and every other client would not share is the definition of a gate
/// that does not hold. The console's job is to relay gunnar's refusal, which
/// names the rule.
pub async fn approve(
State(state): State<AppState>,
Path((namespace, name, number)): Path<(String, String, u64)>,
headers: axum::http::HeaderMap,
Json(new): Json<NewApproval>,
) -> ApiResult<Review> {
let commit = new.commit.trim().to_owned();
if commit.is_empty() {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: "an approval has to name the commit it approves: Gunnar merges on an \
approval of a commit, never on an approval of a pull request, so an \
empty one would approve nothing."
.to_owned(),
}));
}
let repo = format!("{namespace}/{name}");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default();
match reviewer_identity(&state, &headers).await? {
// ── THE FIX: the reviewer's own session, gunnar's attestation ──────
//
// Sent from the reviewer's own session, unsigned
// (`Gunnar::sign_forge_event` on a session client): `Forge.Append`
// sees their principal on the connection and their step-up, and gunnar
// signs the event in their name — which a console-key call never could,
// and the approval is theirs at merge. No console "who asked" comment
// here: the approval names them itself, so a comment claiming the
// console's word about it would be a second, weaker, and false story.
Some(reviewer) => {
let approval = crate::forge::ForgeEvent {
repo,
pull: number,
actor: reviewer.principal_id.clone(),
at_unix_ms: now,
event: crate::forge::Event::Approved { commit },
};
let signed = reviewer.gunnar.sign_forge_event(&approval)?;
reviewer
.gunnar
.forge()
.append(pb::AppendEventRequest {
event: Some(signed),
})
.await
.map_err(ApiError::from)?;
}
// ── THE HONEST FALLBACK: the console signs, and says so ─────────────
None => {
let actor = console_principal(&state).await?;
// Name whoever the fallback IS: the break-glass admin token, or —
// when even that is absent — the unlocked loopback posture, which
// has genuinely no session to name.
let (user, role) = match crate::auth::middleware::resolve(&state, &headers).await {
Some(caller) => (caller.name().to_owned(), caller.role().to_string()),
None => (
"an unauthenticated session".to_owned(),
"the console is running with no passkey login configured".to_owned(),
),
};
let attribution = crate::forge::ForgeEvent {
repo: repo.clone(),
pull: number,
actor: actor.clone(),
at_unix_ms: now,
event: crate::forge::Event::Commented {
body: crate::forge::attribution(&user, &role, &commit),
// A per-pull attribution comment, not an inline one.
path: String::new(),
line: 0,
blob_oid: String::new(),
},
};
// Deliberately BEFORE the approval, and a refusal here stops the
// approval happening at all. The two orderings fail differently and
// only one fails safely: this way a failure leaves "somebody asked"
// and no approval, which is true and harmless. The other way round
// leaves an approval with nothing saying who asked for it.
let signed = state.gunnar.sign_forge_event(&attribution)?;
state
.gunnar
.forge()
.append(pb::AppendEventRequest {
event: Some(signed),
})
.await
.map_err(ApiError::from)?;
let approval = crate::forge::ForgeEvent {
repo,
pull: number,
actor,
at_unix_ms: now,
event: crate::forge::Event::Approved { commit },
};
let signed = state.gunnar.sign_forge_event(&approval)?;
state
.gunnar
.forge()
.append(pb::AppendEventRequest {
event: Some(signed),
})
.await
.map_err(ApiError::from)?;
}
}
// Read back with the same reader the page has: the person when there is
// one, the console's key on the fallback.
let reader = match reviewer_identity(&state, &headers).await? {
Some(person) => person.gunnar,
None => state.gunnar.clone(),
};
Ok(Json(read_review(&reader, &namespace, &name, number).await?))
}
// ── inline comments ────────────────────────────────────────────────────────
//
// A review comment can be about the pull request as a whole, or about ONE line
// of ONE file version. The second kind carries an anchor — `(path, line,
// blob_oid)` — inside the same signed `Commented` event, and the blob is what
// makes it follow the code: a comment made on a blob stays on that blob's line
// across a rebase that does not touch the file, and falls off a file that was
// rewritten, because a rewritten file has a new blob oid. The diff page renders
// each comment on the line whose new-side blob and number the anchor names.
/// One inline comment, as the diff page consumes it.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct InlineComment {
/// The identity-table principal who wrote it.
pub actor: String,
/// The file it is on.
pub path: String,
/// The one-based line within `blob_oid`.
pub line: u32,
/// The blob the commented line lived in. The diff page matches this against
/// a file's new-side oid — the comment follows the blob, not the pull.
pub blob_oid: String,
/// What was said.
pub body: String,
pub at_unix_ms: i64,
}
/// Pull the inline comments out of a folded review's raw events.
///
/// A `Commented` with an empty `path` is a per-pull comment and is skipped — it
/// has no line to sit on. Pure, so the extraction is testable off a synthetic
/// event list.
fn inline_from_events(events: Vec<pb::SignedForgeEvent>) -> Vec<InlineComment> {
let mut out = Vec::new();
for signed in events {
let Some(event) = signed.event else { continue };
if let Some(pb::forge_event::Kind::Commented(comment)) = event.kind {
if !comment.path.is_empty() {
out.push(InlineComment {
actor: event.actor,
path: comment.path,
line: comment.line,
blob_oid: comment.blob_oid,
body: comment.body,
at_unix_ms: event.at_unix_ms,
});
}
}
}
out
}
/// `GET /api/repos/{namespace}/{name}/pulls/{number}/comments`
///
/// Every inline comment on the pull request, folded fresh from the forge log.
///
/// Asked as the reader: gunnar's Read on the repository decides.
pub async fn comments(
reader: Reader,
Path((namespace, name, number)): Path<(String, String, u64)>,
) -> ApiResult<Vec<InlineComment>> {
Ok(Json(read_comments(&reader.gunnar, &namespace, &name, number).await?))
}
/// The inline comments, asked with `gunnar`.
async fn read_comments(
gunnar: &crate::grpc::Gunnar,
namespace: &str,
name: &str,
number: u64,
) -> Result<Vec<InlineComment>, ApiError> {
let response = gunnar
.forge()
.get_review(pb::GetReviewRequest {
repo: format!("{namespace}/{name}"),
pull: number,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(inline_from_events(response.events))
}
/// The body of a new inline comment.
#[derive(Debug, Clone, Deserialize)]
pub struct NewComment {
/// The file the comment is on.
pub path: String,
/// The one-based line within `blob_oid`.
pub line: u32,
/// The blob the commented line lives in — taken from the diff the reviewer
/// is looking at, so the comment anchors to exactly the version on screen.
pub blob_oid: String,
/// What is being said.
pub body: String,
}
/// `POST /api/repos/{namespace}/{name}/pulls/{number}/comments`
///
/// Files one inline comment, like an approval: from the REVIEWER's own session
/// when there is a passkey session (gunnar signs it in their name after a fresh
/// touch, GHUNT-19), and by the console's own key — attributed to its own
/// principal — in the open-on-loopback fallback. Either way the anchor is inside
/// the signature, so the comment cannot be moved to another line or file after
/// the fact.
pub async fn comment(
State(state): State<AppState>,
Path((namespace, name, number)): Path<(String, String, u64)>,
headers: axum::http::HeaderMap,
Json(new): Json<NewComment>,
) -> ApiResult<Vec<InlineComment>> {
let path = new.path.trim().to_owned();
let blob_oid = new.blob_oid.trim().to_owned();
if path.is_empty() || blob_oid.is_empty() || new.body.trim().is_empty() {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: "an inline comment needs a file path, the blob oid of the line it is on, \
and a body: without the blob it could not follow the code across a rebase, \
which is the whole point of anchoring it."
.to_owned(),
}));
}
let reviewer = reviewer_identity(&state, &headers).await?;
let acting = reviewer
.as_ref()
.map(|r| &r.gunnar)
.unwrap_or(&state.gunnar);
let actor = match &reviewer {
Some(identity) => identity.principal_id.clone(),
None => console_principal(&state).await?,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default();
let event = crate::forge::ForgeEvent {
repo: format!("{namespace}/{name}"),
pull: number,
actor,
at_unix_ms: now,
event: crate::forge::Event::Commented {
body: new.body,
path,
line: new.line,
blob_oid,
},
};
let signed = acting.sign_forge_event(&event)?;
acting
.forge()
.append(pb::AppendEventRequest {
event: Some(signed),
})
.await
.map_err(ApiError::from)?;
Ok(Json(read_comments(acting, &namespace, &name, number).await?))
}
#[cfg(test)]
mod inline_tests {
use super::*;
/// ★ **A pull request's description is markdown, drawn as such** (GUN-FIELD-TEST,
/// 2026-11-01): the console showed the backticks and ran the paragraphs together.
/// The server renders it with the README's own renderer and sanitiser.
#[test]
fn a_pull_description_is_rendered_markdown_and_sanitised() {
let pull = render(pb::PullRequest {
description: "Opened by the field test.\n\nIt adds `topic.md`.<script>alert(1)</script>".into(),
..Default::default()
});
let html = &pull.description_html;
assert!(html.contains("<code>topic.md</code>"), "inline code: {html}");
assert_eq!(html.matches("<p>").count(), 2, "two paragraphs: {html}");
assert!(!html.contains("<script"), "sanitised: {html}");
assert_eq!(pull.description, "Opened by the field test.\n\nIt adds `topic.md`.<script>alert(1)</script>", "the source stays");
assert_eq!(render(pb::PullRequest::default()).description_html, "", "nothing to draw");
}
/// ★ **Merge is offered when gunnar would take it, how gunnar would make it, and
/// otherwise gunnar's reason or its conflict list** (GUN-FIELD-TEST, GUN-MERGE-ENGINE
/// 2026-11-01).
#[test]
fn the_merge_check_answers_yes_or_gunnars_own_refusal() {
let plan = |strategy: pb::MergeStrategy, conflicts: &[&str]| pb::MergePullRequestResponse {
pull: None,
plan: Some(pb::MergePlan {
strategy: strategy as i32,
target_oid: "3ba904a9056c".into(),
conflicts: conflicts.iter().map(|c| (*c).to_owned()).collect(),
..Default::default()
}),
};
assert_eq!(
mergeable_of(Ok(plan(pb::MergeStrategy::FastForward, &[]))).unwrap(),
Mergeable {
checked: true,
allowed: true,
strategy: "fast-forward",
target: "3ba904a9056c".into(),
..Mergeable::default()
}
);
assert_eq!(mergeable_of(Ok(plan(pb::MergeStrategy::MergeCommit, &[]))).unwrap().strategy, "merge");
assert_eq!(mergeable_of(Ok(plan(pb::MergeStrategy::Squash, &[]))).unwrap().strategy, "squash");
let outside = mergeable_of(Ok(pb::MergePullRequestResponse {
pull: None,
plan: Some(pb::MergePlan { contained_in: "0123456789ab".into(), ..Default::default() }),
}))
.unwrap();
assert_eq!(outside.contained_in, "0123456789ab", "merged outside Gunnar: the containing commit");
assert!(outside.allowed, "the press marks it merged");
let conflicted = mergeable_of(Ok(plan(pb::MergeStrategy::MergeCommit, &["a.txt (both sides changed the same lines)"]))).unwrap();
assert!(!conflicted.allowed, "conflicts: no Merge");
assert_eq!(conflicted.conflicts, vec!["a.txt (both sides changed the same lines)".to_owned()]);
let refused = mergeable_of(Err(tonic::Status::failed_precondition("no verified approval names 3ba904a9056c"))).unwrap();
assert_eq!(
refused,
Mergeable {
checked: true,
allowed: false,
reason: "no verified approval names 3ba904a9056c".into(),
..Mergeable::default()
}
);
// gunnar's control plane words a refusal "failed precondition: …"; the page says
// "Not mergeable yet: …" itself, so the reason is the sentence alone.
let worded = mergeable_of(Err(tonic::Status::failed_precondition("failed precondition: no verified approval names 3ba904a9056c"))).unwrap();
assert_eq!(worded.reason, "no verified approval names 3ba904a9056c");
assert_eq!(
mergeable_of(Err(tonic::Status::unimplemented("PullRequests.CheckMerge"))).unwrap(),
Mergeable { checked: false, allowed: true, ..Mergeable::default() },
"an older gunnar: offer Merge, gunnar decides on the press"
);
assert_eq!(
mergeable_of(Err(tonic::Status::permission_denied("no write"))).unwrap_err().code(),
tonic::Code::PermissionDenied,
"any other answer is passed on"
);
}
fn commented(
actor: &str,
path: &str,
line: u32,
blob: &str,
body: &str,
) -> pb::SignedForgeEvent {
pb::SignedForgeEvent {
event: Some(pb::ForgeEvent {
repo: "team/repo".into(),
pull: 1,
actor: actor.into(),
at_unix_ms: 10,
kind: Some(pb::forge_event::Kind::Commented(pb::Commented {
body: body.into(),
path: path.into(),
line,
blob_oid: blob.into(),
})),
}),
key_fingerprint: String::new(),
signature: String::new(),
attested: None,
}
}
/// **Only anchored comments come back as inline ones**, each carrying its
/// exact blob and line — a per-pull comment (empty path) is not one.
#[test]
fn only_anchored_comments_are_inline() {
let events = vec![
commented("alice", "src/lib.rs", 42, &"a".repeat(40), "off-by-one"),
// A per-pull comment: empty path.
commented("bob", "", 0, "", "LGTM"),
];
let inline = inline_from_events(events);
assert_eq!(
inline.len(),
1,
"the per-pull comment must not appear inline"
);
assert_eq!(inline[0].path, "src/lib.rs");
assert_eq!(inline[0].line, 42);
assert_eq!(inline[0].blob_oid, "a".repeat(40));
assert_eq!(inline[0].actor, "alice");
assert_eq!(inline[0].body, "off-by-one");
}
}