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-web/src/pull_view.rs

//! **What a pull request's page offers** (GUN-FIELD-TEST, 2026-11-01), on the host side
//! of the wasm boundary. The console offered "Merge (fast-forward)" on a pull request
//! nobody had approved, and the press was refused. gunnar's `CheckMerge` (through
//! `GET …/pulls/{n}/mergeable`) answers ahead; the page offers Merge only on a yes and
//! shows gunnar's own sentence on a no. Since GUN-MERGE-ENGINE (2026-11-01) the answer also
//! says HOW gunnar merges — the button reads "Fast-forward" or "Merge" (a merge commit) —
//! and lists the conflicting paths instead of a button. The server decides; this only
//! reads its answer.

/// The server's answer, as the page reads it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct Mergeable {
    pub checked: bool,
    pub allowed: bool,
    #[serde(default)]
    pub reason: String,
    /// gunnar's strategy: `fast-forward`, `merge`, `squash`, or empty (an older gunnar).
    #[serde(default)]
    pub strategy: String,
    /// The target tip gunnar checked; sent back with the press (GUN-MERGE-ENGINE).
    #[serde(default)]
    pub target: String,
    /// Every path that conflicts, in gunnar's words.
    #[serde(default)]
    pub conflicts: Vec<String>,
    /// The head is already in the target (merged outside Gunnar): the commit that holds it.
    #[serde(default)]
    pub contained_in: String,
}

/// What the page draws where the Merge button goes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Offer {
    /// The button, with gunnar's strategy as its label (see [`button_label`]).
    Merge(&'static str),
    /// gunnar's reason, and no button.
    Refused(String),
    /// The paths that conflict, and no button (GUN-MERGE-ENGINE, 2026-11-01).
    Conflicts(Vec<String>),
    /// The head is already in the target, merged outside Gunnar: the containing commit,
    /// and a button that marks the pull request merged (GUN-PR-FIELD-2026-11-01 item 3).
    MergedOutside(String),
    /// Nothing yet (the check has not answered).
    Wait,
}

/// Pure: the button's label for gunnar's strategy. "Fast-forward" moves the branch to the
/// approved head; "Merge" writes a merge commit; an older gunnar that did not say gets
/// "Merge" and decides on the press.
pub fn button_label(strategy: &str) -> &'static str {
    match strategy {
        "fast-forward" => "Fast-forward",
        "squash" => "Squash and merge",
        _ => "Merge",
    }
}

/// Pure: does the page draw the Review panel (the approvals and the Approve button)?
/// Only on an OPEN pull request: a closed or merged one has nothing left to approve
/// (GUN-PR-CLOSED-REVIEW).
pub fn shows_review(state: &str) -> bool {
    state == "open"
}

/// Pure: the line for a head merged outside Gunnar.
pub fn merged_outside_line(commit: &str) -> String {
    format!(
        "Merged outside Gunnar: {} on the target already contains this head. Nothing is left \
         to merge; marking it merged moves nothing.",
        &commit[..12.min(commit.len())]
    )
}

/// Pure: the line above a conflict list.
pub fn conflicts_line(count: usize) -> String {
    format!(
        "Gunnar cannot merge this: {count} {} changed on both sides. Merge the target into \
         the branch locally, resolve, push, and have the new head reviewed.",
        if count == 1 { "path is" } else { "paths are" }
    )
}

/// Pure: the merge URL's query — the target tip the check answered, so a target that
/// moved in between is checked again by gunnar.
pub fn merge_query(answer: Option<&Mergeable>) -> String {
    match answer {
        Some(m) if !m.target.is_empty() && m.target.bytes().all(|b| b.is_ascii_hexdigit()) => {
            format!("?target={}", m.target)
        }
        _ => String::new(),
    }
}

/// **A guest's pull request pages** (GUN-FIELD-TEST, 2026-11-01): pull requests are read
/// with a session only (`auth::middleware::is_anonymous_read` leaves `pulls` out), and a
/// guest on `/{ns}/{name}/pulls` was told "Your session is no longer valid" about a session
/// they never had. A 401 to somebody who is not signed in is this line instead.
pub const GUEST_LINE: &str = "Pull requests here are read signed in.";

/// Pure: is this refusal the guest's (a 401 to somebody not signed in)?
pub fn guest_refused(status: u16, signed_in: Option<bool>) -> bool {
    status == 401 && signed_in == Some(false)
}

/// Pure: the offer for an answer.
pub fn offer(answer: Option<&Mergeable>) -> Offer {
    match answer {
        None => Offer::Wait,
        Some(m) if m.checked && !m.contained_in.is_empty() => {
            Offer::MergedOutside(m.contained_in.clone())
        }
        Some(m) if m.checked && !m.conflicts.is_empty() => Offer::Conflicts(m.conflicts.clone()),
        Some(m) if m.checked && !m.allowed => Offer::Refused(m.reason.clone()),
        Some(m) => Offer::Merge(button_label(&m.strategy)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn m(checked: bool, allowed: bool, reason: &str) -> Mergeable {
        Mergeable {
            checked,
            allowed,
            reason: reason.into(),
            strategy: String::new(),
            target: String::new(),
            conflicts: Vec::new(),
            contained_in: String::new(),
        }
    }

    fn how(strategy: &str, target: &str, conflicts: &[&str]) -> Mergeable {
        Mergeable {
            strategy: strategy.into(),
            target: target.into(),
            conflicts: conflicts.iter().map(|c| (*c).to_owned()).collect(),
            allowed: conflicts.is_empty(),
            ..m(true, true, "")
        }
    }

    /// ★ Merge only on a yes; gunnar's words on a no; an older gunnar keeps the button.
    #[test]
    fn merge_is_offered_only_when_gunnar_would_take_it() {
        assert_eq!(offer(Some(&m(true, true, ""))), Offer::Merge("Merge"));
        assert_eq!(
            offer(Some(&m(true, false, "no verified approval names 3ba904a9056c"))),
            Offer::Refused("no verified approval names 3ba904a9056c".into())
        );
        assert_eq!(offer(Some(&m(false, true, ""))), Offer::Merge("Merge"), "an older gunnar decides on the press");
        assert_eq!(offer(None), Offer::Wait, "no button before the answer");
    }

    /// ★ A closed or merged pull request draws no Review panel: nothing is left to
    /// approve, and an Approve button there invited a signature on a dead branch
    /// (GUN-PR-CLOSED-REVIEW).
    #[test]
    fn the_review_panel_is_drawn_only_on_an_open_pull_request() {
        assert!(shows_review("open"));
        assert!(!shows_review("closed"));
        assert!(!shows_review("merged"));
        let page = crate::code::of(include_str!("pages/pulls.rs"));
        assert!(
            page.contains("crate::pull_view::shows_review("),
            "the page asks shows_review before drawing the Review panel"
        );
    }

    /// ★ A head already in the target is "merged outside Gunnar", naming the commit that
    /// contains it, with a button that marks the pull request merged (GUN-PR-FIELD-2026-11-01
    /// item 3).
    #[test]
    fn a_head_already_in_the_target_reads_merged_outside_gunnar() {
        let outside = Mergeable { contained_in: "0123456789abcdef0123".into(), ..how("", "0123456789abcdef0123", &[]) };
        assert_eq!(offer(Some(&outside)), Offer::MergedOutside("0123456789abcdef0123".into()));
        let line = merged_outside_line("0123456789abcdef0123");
        assert!(line.starts_with("Merged outside Gunnar"), "{line}");
        assert!(line.contains("0123456789ab"), "the containing commit is named: {line}");
        let page = crate::code::of(include_str!("pages/pulls.rs"));
        assert!(page.contains("crate::pull_view::Offer::MergedOutside("), "the page draws it");
        assert!(page.contains("Mark as merged"), "and offers to record it");
    }

    /// ★ The button says what gunnar will do — "Fast-forward" or "Merge" (a merge
    /// commit) — and a conflict is a list of paths with no button (GUN-MERGE-ENGINE).
    #[test]
    fn the_button_says_how_gunnar_merges_and_conflicts_are_listed() {
        assert_eq!(offer(Some(&how("fast-forward", "ab12", &[]))), Offer::Merge("Fast-forward"));
        assert_eq!(offer(Some(&how("merge", "ab12", &[]))), Offer::Merge("Merge"));
        assert_eq!(offer(Some(&how("squash", "ab12", &[]))), Offer::Merge("Squash and merge"));
        assert_eq!(
            offer(Some(&how("merge", "ab12", &["a.txt (both sides changed the same lines)"]))),
            Offer::Conflicts(vec!["a.txt (both sides changed the same lines)".into()])
        );
        assert!(conflicts_line(1).contains("1 path is changed on both sides"));
        assert!(conflicts_line(2).contains("2 paths are"));
        assert_eq!(merge_query(Some(&how("merge", "ab12", &[]))), "?target=ab12");
        assert_eq!(merge_query(Some(&how("merge", "", &[]))), "", "nothing checked, nothing sent");
        assert_eq!(merge_query(Some(&how("merge", "x&y=1", &[]))), "", "only an oid is sent");
        assert_eq!(merge_query(None), "");
        let page = crate::code::of(include_str!("pages/pulls.rs"));
        assert!(page.contains("crate::pull_view::Offer::Conflicts("), "the page draws the conflict list");
        assert!(page.contains("crate::pull_view::merge_query("), "the press sends the checked target");
        assert!(!page.contains("Merge (fast-forward)"), "the label is gunnar's strategy now");
    }

    /// ★ The page draws the server's HTML description, asks the check, uses [`offer`],
    /// and spaces its buttons.
    #[test]
    fn the_pull_request_page_draws_the_description_and_the_offer() {
        let page = crate::code::of(include_str!("pages/pulls.rs"));
        assert!(page.contains("inner_html=description_html"), "the description is the server's HTML");
        assert!(page.contains("/mergeable"), "the page asks gunnar ahead");
        assert!(page.contains("crate::pull_view::offer("), "the page draws the offer");
        assert!(page.contains("class=\"form-row pull-actions\""), "the buttons sit in a spaced row");
        let css = include_str!("../styles/console.css");
        let rule = css.split(".pull-actions {").nth(1).and_then(|r| r.split('}').next()).unwrap_or("");
        assert!(rule.contains("gap:") && rule.contains("flex-wrap: wrap"), "the row is spaced and wraps: {rule:?}");
    }

    /// ★ A guest's 401 is the guest line, not a lost session; a signed-in person's 401
    /// still is one.
    #[test]
    fn a_guest_is_not_told_their_session_ended() {
        assert!(guest_refused(401, Some(false)));
        assert!(!guest_refused(401, Some(true)), "a real lost session");
        assert!(!guest_refused(401, None), "the session has not answered yet");
        assert!(!guest_refused(404, Some(false)));
        let page = crate::code::of(include_str!("pages/pulls.rs"));
        assert!(page.matches("crate::pull_view::guest_refused(").count() >= 2, "the list and one pull request");
    }
}