crates/gunnar-ui-web/src/pages/pulls.rs
//! Pull requests: the list, the create form, and one pull's page.
//!
//! The console's half is thin on purpose — records, grants and the merge's
//! ref move all live in gunnar. What this page adds is the review surface:
//! the compare view for an open pull IS the existing diff page, because a
//! pull request's diff and a ref-to-ref diff are the same question.
//!
//! # The review, and why the page shows a commit rather than a tick
//!
//! gunnar refuses a merge unless a **verified** approval names the commit the
//! source branch is at right now. An approval of an earlier tip is a real
//! approval of a real commit and is not an approval of this one, so a page that
//! rendered "approved ✓" would be lying the moment the branch moved — which is
//! exactly the case the whole design exists for. Every approval below is shown
//! with the commit it names and whether that commit is the current head.
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map};
use serde::Deserialize;
use crate::api::{segment, Fail, Page};
use crate::fmt;
use crate::pages::repo::RefTarget;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, Empty, FailBox, Hint, PageHeader, Panel, Tone};
/// One approval on a pull request, as the BFF sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Approval {
pub actor: String,
/// The commit this approval names. Never "the pull request".
pub commit: String,
pub verified: bool,
/// `verified`, `UNKNOWN KEY`, `NO EVENT`, or the problem in words.
pub verdict: String,
/// True when `commit` is where the source branch is now.
pub covers_head: bool,
}
/// The review on one pull request.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Review {
pub pull: u64,
/// Where the source branch points now — the commit a merge would move to.
pub head: String,
pub approvals: Vec<Approval>,
pub comments: u64,
/// What gunnar's merge gate will decide, from the same two facts.
pub head_is_approved: bool,
}
/// A pull request, as the BFF sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Pull {
pub number: u64,
pub title: String,
pub description: String,
pub source_ref: String,
pub target_ref: String,
pub state: String,
pub author: String,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
pub source_oid: String,
pub merge_oid: String,
/// The description as the server rendered and sanitised it (empty from an older one).
#[serde(default)]
pub description_html: String,
}
fn state_tone(state: &str) -> Tone {
match state {
"open" => Tone::Live,
"merged" => Tone::Pass,
"closed" => Tone::Plain,
_ => Tone::Warn,
}
}
/// `/:namespace/:name/pulls` — the list and the create form.
#[component]
pub fn PullsList() -> impl IntoView {
// Whether anybody is signed in: a guest's 401 is not a lost session (GUN-FIELD-TEST).
let session = crate::app::session();
let params = use_params_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let path = Memo::new(move |_| format!("{}/{}", namespace.get(), name.get()));
let reload = RwSignal::new(0u32);
let pulls = LocalResource::new(move || {
reload.track();
let url = format!(
"/api/repos/{}/{}/pulls",
segment(&namespace.get()),
segment(&name.get())
);
async move { crate::api::get::<Vec<Pull>>(&url).await }
});
// Every branch, page after page (Henrik, 2026-10-31: the first page alone
// lost every branch after the `fix/...` ones, `main` among them).
let refs = LocalResource::new(move || {
let (ns, repo) = (segment(&namespace.get()), segment(&name.get()));
async move {
let mut all: Vec<RefTarget> = Vec::new();
let mut after: Option<String> = None;
loop {
let url = crate::branches::refs_url(&ns, &repo, after.as_deref());
let page = crate::api::get::<Page<RefTarget>>(&url).await?;
let next = crate::branches::next_after(
page.truncated,
page.items.last().map(|t| t.ref_name.as_str()),
after.as_deref(),
);
all.extend(page.items);
match next {
Some(n) => after = Some(n),
None => return Ok(all),
}
}
}
});
// The create form's state.
let title = RwSignal::new(String::new());
let source = RwSignal::new(String::new());
let target = RwSignal::new(String::new());
let create_fail = RwSignal::new(None::<Fail>);
let busy = RwSignal::new(false);
// gunnar's per-repository answer (Write on it), never the console role.
let rights = crate::pages::repo_rights();
let may_write = Memo::new(move |_| rights.get().may_work());
let create = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
create_fail.set(None);
let url = format!(
"/api/repos/{}/{}/pulls",
segment(&namespace.get_untracked()),
segment(&name.get_untracked())
);
let body = serde_json::json!({
"title": title.get_untracked(),
"source_ref": source.get_untracked(),
"target_ref": target.get_untracked(),
});
leptos::task::spawn_local(async move {
match crate::api::post::<_, Pull>(&url, &body).await {
Ok(_) => {
title.set(String::new());
reload.update(|n| *n += 1);
}
Err(refusal) => create_fail.set(Some(refusal)),
}
busy.set(false);
});
};
let repo_href = move || {
format!(
"/{}/{}",
segment(&namespace.get()),
segment(&name.get())
)
};
view! {
<PageHeader
title=move || format!("{} — pull requests", path.get())
>
<a class="btn" href=repo_href>"Repository"</a>
</PageHeader>
<crate::pages::repo::RepoTabs/>
<crate::pages::Writes what="open a pull request" may=may_write>
<Panel title="Open a pull request">
<div class="panel-body">
<div class="form-row">
<div class="field" style="flex:2;min-width:14rem">
<label for="pr-title">"Title"</label>
<input
id="pr-title"
type="text"
prop:value=move || title.get()
on:input=move |ev| title.set(event_target_value(&ev))
/>
</div>
<div class="field" style="flex:1;min-width:11rem">
<label for="pr-source">"Source branch"</label>
{branch_select("pr-source", refs, source)}
</div>
<div class="field" style="flex:1;min-width:11rem">
<label for="pr-target">"Target branch"</label>
{branch_select("pr-target", refs, target)}
</div>
<button
class="btn"
style="align-self:flex-end"
disabled=move || busy.get()
on:click=create
>
{move || if busy.get() { "Opening …" } else { "Open pull request" }}
</button>
</div>
{move || create_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
</Panel>
</crate::pages::Writes>
<Panel title="Pull requests">
{move || match pulls.get() {
Some(Err(f)) if crate::pull_view::guest_refused(f.status, session.get().map(|s| s.authenticated)) => view! {
<div class="panel-body"><p class="hint">{crate::pull_view::GUEST_LINE}" "<a href="/login" rel="external">"Log in"</a></p></div>
}.into_any(),
state => resolved(state, None, |items: Vec<Pull>| {
if items.is_empty() {
return view! {
<Empty
title="No pull requests"
detail="Open one from a pushed branch, and its diff is one click away."
/>
}
.into_any();
}
let now = now_ms();
let ns = segment(&namespace.get_untracked());
let nm = segment(&name.get_untracked());
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Pull requests"</caption>
<thead>
<tr>
<th scope="col">"#"</th>
<th scope="col">"Title"</th>
<th scope="col">"State"</th>
<th scope="col">"Branches"</th>
<th scope="col">"Author"</th>
<th scope="col">"Updated"</th>
</tr>
</thead>
<tbody>
{items
.into_iter()
.map(|pull| {
let href = format!(
"/{ns}/{nm}/pulls/{}",
pull.number
);
let branches = format!(
"{} → {}",
fmt::short_ref(&pull.source_ref),
fmt::short_ref(&pull.target_ref)
);
view! {
<tr>
<td class="num">{pull.number}</td>
<td><a href=href>{pull.title.clone()}</a></td>
<td>
<Badge
tone=state_tone(&pull.state)
label=pull.state.clone()
/>
</td>
<td class="oid">{branches}</td>
<td>{pull.author.clone()}</td>
<td class="num">
{fmt::since(pull.updated_at_unix_ms, now)}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
}),
}}
</Panel>
}
}
/// A `<select>` over the repository's branches. Tags are not offered: a pull
/// request merges branches.
fn branch_select(
id: &'static str,
refs: LocalResource<Result<Vec<RefTarget>, Fail>>,
chosen: RwSignal<String>,
) -> impl IntoView {
view! {
<select id=id on:change=move |ev| chosen.set(event_target_value(&ev))>
<option value="">"— choose —"</option>
{move || {
refs.get()
.and_then(|r| r.ok())
.map(|branches| {
branches
.into_iter()
.filter(|t| t.ref_name.starts_with("refs/heads/"))
.map(|t| {
let label = fmt::short_ref(&t.ref_name).to_owned();
view! { <option value=t.ref_name.clone()>{label}</option> }
})
.collect_view()
})
}}
</select>
}
}
/// `/:namespace/:name/pulls/:number` — one pull request.
#[component]
pub fn PullDetail() -> impl IntoView {
let params = use_params_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let number = Memo::new(move |_| params.read().get("number").unwrap_or_default());
let reload = RwSignal::new(0u32);
let pull = LocalResource::new(move || {
reload.track();
let url = format!(
"/api/repos/{}/{}/pulls/{}",
segment(&namespace.get()),
segment(&name.get()),
number.get()
);
async move { crate::api::get::<Pull>(&url).await }
});
let review = LocalResource::new(move || {
reload.track();
let url = format!(
"/api/repos/{}/{}/pulls/{}/review",
segment(&namespace.get()),
segment(&name.get()),
number.get()
);
async move { crate::api::get::<Review>(&url).await }
});
// May this person merge it now: gunnar's own check, asked ahead (GUN-FIELD-TEST).
let mergeable = LocalResource::new(move || {
reload.track();
let url = format!(
"/api/repos/{}/{}/pulls/{}/mergeable",
segment(&namespace.get()),
segment(&name.get()),
number.get()
);
async move { crate::api::get::<crate::pull_view::Mergeable>(&url).await }
});
let action_fail = RwSignal::new(None::<Fail>);
let busy = RwSignal::new(false);
let session = crate::app::session();
// gunnar's per-repository answer (Write on it), never the console role.
let rights = crate::pages::repo_rights();
let may_write = Memo::new(move |_| rights.get().may_work());
let navigate = use_navigate();
// `query` is the merge's `?target=` (the tip gunnar's check answered), empty for close.
let act = move |verb: &'static str, query: String| {
if busy.get_untracked() {
return;
}
busy.set(true);
action_fail.set(None);
let url = format!(
"/api/repos/{}/{}/pulls/{}/{verb}{query}",
segment(&namespace.get_untracked()),
segment(&name.get_untracked()),
number.get_untracked()
);
leptos::task::spawn_local(async move {
match crate::api::post_empty::<Pull>(&url).await {
Ok(_) => reload.update(|n| *n += 1),
Err(refusal) => action_fail.set(Some(refusal)),
}
busy.set(false);
});
};
// **The commit is a parameter, not a default.** The button sends the head
// the page is showing, so approving means approving what was on screen; a
// BFF that filled it in from the current tip would let a reviewer sign off
// on a commit that landed while they were reading.
let approve = move |commit: String| {
if busy.get_untracked() {
return;
}
busy.set(true);
action_fail.set(None);
let url = format!(
"/api/repos/{}/{}/pulls/{}/approve",
segment(&namespace.get_untracked()),
segment(&name.get_untracked()),
number.get_untracked()
);
let body = serde_json::json!({ "commit": commit });
leptos::task::spawn_local(async move {
match crate::api::post::<_, Review>(&url, &body).await {
Ok(_) => reload.update(|n| *n += 1),
Err(refusal) => action_fail.set(Some(refusal)),
}
busy.set(false);
});
};
let list_href = move || {
format!(
"/{}/{}/pulls",
segment(&namespace.get()),
segment(&name.get())
)
};
view! {
<PageHeader
title=move || format!("{}/{} — pull request #{}", namespace.get(), name.get(), number.get())
>
<a class="btn" href=list_href>"All pull requests"</a>
</PageHeader>
<crate::pages::repo::RepoTabs/>
{move || match pull.get() {
Some(Err(f)) if crate::pull_view::guest_refused(f.status, session.get().map(|s| s.authenticated)) => view! {
<div class="panel-body"><p class="hint">{crate::pull_view::GUEST_LINE}" "<a href="/login" rel="external">"Log in"</a></p></div>
}.into_any(),
state => resolved(state, None, |pull: Pull| {
let now = now_ms();
let ns = segment(&namespace.get_untracked());
let nm = segment(&name.get_untracked());
let open = pull.state == "open";
// `pull=` brings this pull request's inline comments onto the diff,
// each on the line its anchor names.
let diff_href = format!(
"/{ns}/{nm}/diff?from={}&to={}&pull={}",
pull.target_ref, pull.source_ref, pull.number
);
let opened = format!(
"{} opened this {} · updated {}",
pull.author,
fmt::since(pull.created_at_unix_ms, now),
fmt::since(pull.updated_at_unix_ms, now),
);
let branches = format!(
"{} → {}",
fmt::short_ref(&pull.source_ref),
fmt::short_ref(&pull.target_ref)
);
let merged_note = (pull.state == "merged").then(|| {
format!(
"Merged: {} moved to {}",
fmt::short_ref(&pull.target_ref),
fmt::short_oid(&pull.merge_oid)
)
});
let title = pull.title.clone();
let state = pull.state.clone();
let description = pull.description.clone();
let description_html = pull.description_html.clone();
let source_label = fmt::short_ref(&pull.source_ref).to_owned();
let _ = &navigate;
view! {
<Panel title=title>
<div class="panel-body">
<p>
<Badge tone=state_tone(&state) label=state.clone()/>
" "
<span class="oid">{branches}</span>
</p>
<p class="truncation">{opened}</p>
{if !description_html.is_empty() {
Some(view! { <div class="pull-description readme" inner_html=description_html.clone()></div> }.into_any())
} else {
(!description.is_empty()).then(|| view! { <p>{description.clone()}</p> }.into_any())
}}
{merged_note.map(|note| view! { <p class="truncation">{note}</p> })}
<div class="form-row pull-actions">
{open.then(|| view! {
<a class="btn" href=diff_href.clone()>"View the diff"</a>
})}
// `move ||` is load-bearing, not style: `may_write`
// is a Memo over the session, and the session is
// fetched asynchronously after the app mounts. On a
// fresh navigation the pull resolves BEFORE
// `/auth/status` does, so an unwrapped
// `(open && may_write.get())` here was evaluated once
// with `may_write` still false and the Merge and
// Close buttons NEVER appeared for an admin — the
// "View the diff" link beside them, gated only on
// `open`, showed all the same. Wrapping the gate in a
// reactive block re-runs it when the session lands,
// the same way `PullsList` already gates its create
// control. Measured before this: the merge button was
// absent ~40% of fresh loads.
{open.then(|| view! {
<crate::pages::Writes what="merge or close this pull request" may=may_write>
{move || match crate::pull_view::offer(
mergeable.get().and_then(|r| r.ok()).as_ref(),
) {
// gunnar's strategy is the label: "Fast-forward"
// or "Merge" (a merge commit). The press sends the
// target tip the check saw; gunnar re-checks a
// moved one (GUN-MERGE-ENGINE).
crate::pull_view::Offer::Merge(label) => view! {
<button
class="btn"
disabled=move || busy.get()
on:click=move |_| act(
"merge",
crate::pull_view::merge_query(
mergeable.get_untracked().and_then(|r| r.ok()).as_ref(),
),
)
>
{move || if busy.get() { "Merging …" } else { label }}
</button>
}.into_any(),
crate::pull_view::Offer::Refused(reason) => view! {
<p class="hint-line merge-refused">{format!("Not mergeable yet: {reason}")}</p>
}.into_any(),
// GUN-PR-FIELD-2026-11-01 item 3: the head is already in
// the target. gunnar names the containing commit; the
// press records it (no ref moves, no approval asked).
crate::pull_view::Offer::MergedOutside(commit) => view! {
<p class="hint-line merge-refused">
{crate::pull_view::merged_outside_line(&commit)}
</p>
<button
class="btn"
disabled=move || busy.get()
on:click=move |_| act("merge", String::new())
>
{move || if busy.get() { "Marking …" } else { "Mark as merged" }}
</button>
}.into_any(),
crate::pull_view::Offer::Conflicts(paths) => view! {
<div class="merge-conflicts">
<p class="hint-line merge-refused">
{crate::pull_view::conflicts_line(paths.len())}
</p>
<ul class="conflict-list">
{paths.into_iter().map(|p| view! { <li><code>{p}</code></li> }).collect_view()}
</ul>
</div>
}.into_any(),
crate::pull_view::Offer::Wait => ().into_any(),
}}
<button
class="btn quiet"
disabled=move || busy.get()
on:click=move |_| act("close", String::new())
>
"Close without merging"
</button>
</crate::pages::Writes>
})}
</div>
{move || action_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
</Panel>
// GUN-PR-CLOSED-REVIEW: a closed or merged pull request has nothing left to
// approve, so the Review panel (approvals, Approve) is not drawn at all.
{crate::pull_view::shows_review(&pull.state).then(|| view! {
<Panel title="Review">
{move || resolved(review.get(), None, {
let source_label = source_label.clone();
move |review: Review| {
let head = review.head.clone();
let approved = review.head_is_approved;
let where_it_is = if head.is_empty() {
format!("{source_label} is gone")
} else {
format!("{source_label} is at {}", fmt::short_oid(&head))
};
let rows = review.approvals.clone();
let nothing_yet = rows.is_empty();
let comments = review.comments;
let for_button = head.clone();
let offer = open && may_write.get() && !head.is_empty();
view! {
<div class="panel-body">
<p>
<Badge
tone=if approved { Tone::Pass } else { Tone::Warn }
label=if approved {
"approved at this commit"
} else {
"not approved at this commit"
}
/>
" "
<span class="oid">{where_it_is}</span>
</p>
<Hint
line="Gunnar merges on an approval of a COMMIT, never of a pull request."
more="If the branch moves after a review, the approval still names \
what was reviewed and the merge is refused."
/>
{move || match session.get() {
// A signed-in passkey reviewer's approval is THEIRS
// (GHUNT-19): the console sends it from their own
// session, and Gunnar signs it in their name after
// a fresh touch of their passkey — so it verifies as
// THEM at merge, not as the console, and
// forbid-self-approval can tell them from the
// author. The old console-signs-everything note
// lives on only in the loopback fallback below.
Some(s) if s.authenticated && !s.open && may_write.get() => view! {
<Hint
line="An approval you make here is yours: a fresh touch of your own key (your passkey), not the console's."
more="Gunnar records the approval under your name and signs it \
for you after the touch, saying so in the review. It \
counts as yours at merge. It is not the console's."
/>
}
.into_any(),
_ => view! {
<Hint
line="Sign in with your passkey to approve under your own name."
more="Nobody is signed in with a passkey here, so an approval made \
now would be recorded under this site's own name, not yours, \
and the record says so."
/>
}
.into_any(),
}}
{(comments > 0).then(|| view! {
<p class="truncation">
{format!("{comments} comment(s) in the forge log")}
</p>
})}
{nothing_yet.then(|| view! {
<Empty
title="Nobody has approved this yet"
detail="A merge will be refused until a verified \
approval names the commit above."
/>
})}
{(!nothing_yet).then(move || view! {
<div class="table-wrap">
<table>
<caption class="vh">"Approvals"</caption>
<thead>
<tr>
<th scope="col">"Actor"</th>
<th scope="col">"Commit"</th>
<th scope="col">"Checked now"</th>
<th scope="col">"Covers the head"</th>
</tr>
</thead>
<tbody>
{rows
.into_iter()
.map(|a| view! {
<tr>
<td>{a.actor.clone()}</td>
<td class="oid">
{fmt::short_oid(&a.commit)}
</td>
<td>
<Badge
tone=if a.verified {
Tone::Pass
} else {
Tone::Bad
}
label=a.verdict.clone()
/>
</td>
<td>
<Badge
tone=if a.covers_head {
Tone::Pass
} else {
Tone::Warn
}
label=if a.covers_head {
"yes"
} else {
"an earlier commit"
}
/>
</td>
</tr>
})
.collect_view()}
</tbody>
</table>
</div>
})}
{offer.then(move || view! {
<div class="form-row">
<button
class="btn"
disabled=move || busy.get()
on:click=move |_| approve(for_button.clone())
>
{move || if busy.get() {
"Approving …"
} else {
"Approve this commit"
}}
</button>
</div>
})}
</div>
}
}
})}
</Panel>
})}
}
.into_any()
}),
}}
}
}