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/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()
        }),
            }}
    }
}