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