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