// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Pull requests: open (branch compare), view (conversation + commits + diff), //! and merge. //! //! PRs share the `issues` table and much rendering with [`crate::issues`] — the //! conversation thread, state button, assignee, and labels are the same helpers, //! and comment/state/assignee/label mutations reuse the `/issue/{id}/…` routes. //! What is PR-specific lives here: the compare form, the three-dot diff, and the //! server-side merge, which shells out through [`git::merge`]. use std::path::PathBuf; use axum::extract::{Path, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use maud::{Markup, html}; use model::User; use serde::Deserialize; use crate::AppState; use crate::diff; use crate::error::{AppError, AppResult}; use crate::issues::{ access_for, assignee_form, comment_box, comment_card, deps_section, edit_issue_form, issue_url, label_form, locked_note, repo_of_issue, respond_redirect, state_badge, username, }; use crate::layout::page; use crate::pages::build_chrome; use crate::repo::{RepoCtx, Tab, branch_names, label_chip, repo_header}; use crate::session::{MaybeUser, verify_csrf}; /// `GET …/-/pulls/new` — the branch-compare form for opening a pull request. pub(crate) async fn new_form( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { if !ctx.repo.pulls_enabled { return Err(AppError::NotFound); } if viewer.is_none() { return Ok(Redirect::to("/login").into_response()); } let branches = branch_names(state, &ctx.repo.id).await; // Pre-select the base/head from the query when arriving from a branch view. let q = compare_query(uri); let base_sel = q.0.unwrap_or_else(|| ctx.repo.default_branch.clone()); let head_sel = q.1.unwrap_or_default(); let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let body = html! { (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) h2 { "New pull request" } div class="card" { form method="post" action=(format!("/pull-new/{}", ctx.repo.id)) { input type="hidden" name="_csrf" value=(chrome.csrf); div class="compare-refs" { label { "Base " select name="base_ref" aria-label="Base branch" { @for b in &branches { option value=(b) selected[*b == base_sel] { (b) } } } } span class="compare-arrow" { "←" } label { "Compare " select name="head_ref" aria-label="Compare branch" { @for b in &branches { option value=(b) selected[*b == head_sel] { (b) } } } } } label for="title" { "Title" } input type="text" id="title" name="title" required autofocus; label for="body" { "Description" } textarea id="body" name="body" rows="6" placeholder="Markdown supported" {} button class="btn btn-primary" type="submit" { "Create pull request" } } } }; let title = format!( "New pull request · {}/{}", ctx.owner.username, ctx.repo.path ); Ok((jar, page(&chrome, &title, body)).into_response()) } /// `GET …/-/pulls/{n}` — a pull request: conversation, commits, and diff. #[allow(clippy::too_many_lines)] // Cohesive view: header, thread, merge panel, diff. pub(crate) async fn view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, number: i64, ) -> AppResult { if !ctx.repo.pulls_enabled { return Err(AppError::NotFound); } let Some(issue) = state .store .issue_by_number(&ctx.repo.id, number, true) .await? else { // A `#n` reference to a plain issue lands here — redirect to it. if ctx.repo.issues_enabled && state .store .issue_by_number(&ctx.repo.id, number, false) .await? .is_some() { let dest = format!( "/{}/{}/-/issues/{number}", ctx.owner.username, ctx.repo.path ); return Ok(Redirect::to(&dest).into_response()); } return Err(AppError::NotFound); }; let pr = state .store .pull_by_issue(&issue.id) .await? .ok_or(AppError::NotFound)?; let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); let viewer_id: Option = viewer.as_ref().map(|u| u.id.clone()); let branches = branch_names(state, &ctx.repo.id).await; let Loaded { author, comment_rows, issue_labels, repo_labels, assignee, commits, files, mergeable, deps, dependents, blocked, } = gather(state, &ctx, &issue, &pr, can_write).await?; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let csrf = chrome.csrf.clone(); let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); // Paginate long comment threads (the pager only shows past one page). let paging = crate::repo::Pagination::from_query(uri.query()); let (comment_rows, comments_next) = crate::repo::page_slice(&comment_rows, paging); let body = html! { (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) div class="issue-view" { header class="issue-header" { div class="issue-title-line" { h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } @if can_write || is_author { (edit_issue_form(&issue, &csrf)) } } div class="issue-sub" { (pr_state_badge(&issue, &pr)) span class="muted" { " " (author) " wants to merge " code { (pr.head_ref) } " into " code { (pr.base_ref) } } } } div class="issue-main" { article class="issue-thread" { @let body_action = format!("/issue/{}/edit-body", issue.id); (comment_card(&author, &repo_base, &issue.body, issue.created_at, (can_write || is_author).then_some((body_action.as_str(), csrf.as_str())))) @for (c, cauthor) in &comment_rows { @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); @let action = format!("/comment/{}/edit", c.id); (comment_card(cauthor, &repo_base, &c.body, c.created_at, editable.then_some((action.as_str(), csrf.as_str())))) } @if comments_next || paging.page > 1 { (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next)) } (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) @if issue.locked() { (locked_note(can_write)) } @if can_write || (is_author && !issue.locked()) { (comment_box(&issue, &csrf, can_write)) } } aside class="issue-side" { section { h3 { "Assignee" } @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) } @else if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } } } section { h3 { "Labels" } @if can_write && !repo_labels.is_empty() { (label_form(&issue, &repo_labels, &issue_labels, &csrf)) } @else if issue_labels.is_empty() { p class="muted" { "None" } } @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } } } (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write)) } } @if issue.state == model::IssueState::Open { (pr_changes(&commits, &files)) } } }; let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } /// Everything the PR view needs beyond the issue/PR rows themselves. struct Loaded { /// The author's display name. author: String, /// Each comment paired with its author's name. comment_rows: Vec<(model::Comment, String)>, /// Labels applied to this PR. issue_labels: Vec, /// All labels defined on the repo (for the picker). repo_labels: Vec, /// The assignee's name, if any. assignee: Option, /// The commits the PR contributes (empty once closed). commits: Vec, /// The rendered three-dot diff (empty once closed). files: Vec, /// Mergeability, computed only for an open PR the viewer can merge. mergeable: Option, /// The issues/PRs this PR is blocked by. deps: Vec, /// The issues/PRs blocked by this PR. dependents: Vec, /// Whether any dependency is still open (blocking the merge). blocked: bool, } /// Load the comments, labels, assignee, diff, and mergeability for the PR view. async fn gather( state: &AppState, ctx: &RepoCtx, issue: &model::Issue, pr: &model::PullRequest, can_write: bool, ) -> AppResult { let author = username(state, &issue.author_id).await; let comments = state.store.list_comments(&issue.id).await?; let mut comment_rows = Vec::with_capacity(comments.len()); for c in comments { let name = username(state, &c.author_id).await; comment_rows.push((c, name)); } let issue_labels = state.store.issue_labels(&issue.id).await?; let repo_labels = state.store.list_labels(&ctx.repo.id).await?; let assignee = match &issue.assignee_id { Some(id) => Some(username(state, id).await), None => None, }; let open = issue.state == model::IssueState::Open; // Three-dot commits/diff; best-effort so a deleted branch does not 500. let (commits, files) = if open { crate::repo::load_range_diff(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref) .await .unwrap_or_default() } else { (Vec::new(), Vec::new()) }; let mergeable = if open && pr.merged_at.is_none() && can_write { analyze(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref).await } else { None }; let deps = state.store.dependencies_of(&issue.id).await?; let dependents = state.store.dependents_of(&issue.id).await?; let blocked = deps.iter().any(|d| d.state == model::IssueState::Open); Ok(Loaded { author, comment_rows, issue_labels, repo_labels, assignee, commits, files, mergeable, deps, dependents, blocked, }) } // ---- render helpers ---- /// The commits + diff a pull request contributes. fn pr_changes(commits: &[git::CommitSummary], files: &[crate::diff::RenderedFile]) -> Markup { let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| { let (fa, fd) = f.stats(); (a + fa, d + fd) }); html! { section class="pr-changes" { h2 { (commits.len()) " commits · " (files.len()) " files changed " span class="muted" { span style="color:var(--fb-diff-add-fg)" { "+" (adds) } " " span style="color:var(--fb-diff-del-fg)" { "−" (dels) } } } @if !commits.is_empty() { ul class="entry-list card pr-commits" { @for c in commits { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { (c.summary) } div class="entry-row-meta muted mono" { (c.oid.short()) } } } } } } @for (i, f) in files.iter().enumerate() { details open id=(format!("file-{i}")) class="diff-file" { summary { @let (fa, fd) = f.stats(); span class="mono" { (f.path()) } " " span class="muted" { "+" (fa) " −" (fd) } } @if f.diff.is_binary { p class="muted" style="padding:0.5rem" { "Binary file not shown." } } @else if f.diff.hunks.is_empty() { p class="muted" style="padding:0.5rem" { "No textual changes." } } @else { (diff::unified(f, None)) } } } } } } /// The state badge for a PR, distinguishing a merged PR from a plain closed one. fn pr_state_badge(issue: &model::Issue, pr: &model::PullRequest) -> Markup { if pr.merged_at.is_some() { return html! { span class="badge badge-state-merged" { "Merged" } }; } state_badge(issue.state, true) } /// The merge panel: shows mergeability and, when clean and unblocked, the /// strategy buttons. Open dependencies block the merge. fn merge_panel( issue: &model::Issue, pr: &model::PullRequest, mergeable: Option, blocked: bool, csrf: &str, ) -> Markup { use git::merge::Mergeability; if let Some(sha) = &pr.merge_sha { return html! { div class="card merge-panel merged" { p { "This pull request was merged" } @if let Some(s) = &pr.merge_strategy { " " span class="muted" { "(" (s) ")" } } p class="mono muted" { (sha) } } }; } if issue.state != model::IssueState::Open { return html! {}; } // A merge is blocked until every dependency is closed. if blocked && mergeable.is_some() { return html! { div class="card merge-panel conflicts" { p { "This pull request is blocked by open dependencies and cannot be merged yet." } } }; } match mergeable { None => html! {}, Some(Mergeability::AlreadyMerged) => html! { div class="card merge-panel" { p class="muted" { "The base branch already contains these commits — nothing to merge." } } }, Some(Mergeability::Conflicts) => html! { div class="card merge-panel conflicts" { p { "This branch has conflicts that must be resolved before it can be merged." } } }, Some(Mergeability::Clean) => html! { div class="card merge-panel clean" { p { "This branch has no conflicts with the base branch." } form method="post" action=(format!("/pull/{}/merge", issue.id)) class="merge-form" { input type="hidden" name="_csrf" value=(csrf); label for="strategy" { "Method " } select id="strategy" name="strategy" { option value="merge" { "Create a merge commit" } option value="squash" { "Squash and merge" } option value="rebase" { "Rebase and merge" } } button class="btn btn-primary inline-btn" type="submit" { "Merge pull request" } } } }, } } /// Run the (blocking) mergeability analysis on the blocking pool. async fn analyze( state: &AppState, repo_id: &str, base_ref: &str, head_ref: &str, ) -> Option { let binary = state.config.git.binary.clone(); let home = state.config.storage.data_dir.join("tmp"); let repo_path = git::repo_path(&state.config.storage.repo_dir, repo_id).ok()?; let base = base_ref.to_string(); let head = head_ref.to_string(); tokio::task::spawn_blocking(move || { git::merge::analyze(&binary, &repo_path, &home, &base, &head).ok() }) .await .ok() .flatten() } // ---- POST handlers ---- /// `?base_ref=&head_ref=` for pre-filling the compare form. fn compare_query(uri: &Uri) -> (Option, Option) { let mut base = None; let mut head = None; if let Some(q) = uri.query() { for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { match k.as_ref() { "base_ref" => base = Some(v.into_owned()), "head_ref" => head = Some(v.into_owned()), _ => {} } } } (base, head) } /// New-pull-request form fields. #[derive(Debug, Deserialize)] pub struct NewPullForm { /// The base branch (merged into). #[serde(default)] base_ref: String, /// The head branch (source of the changes). #[serde(default)] head_ref: String, /// Title. #[serde(default)] title: String, /// Markdown body. #[serde(default)] body: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /pull-new/{repo_id}` — open a pull request. pub async fn create( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(repo_id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let repo = state .store .repo_by_id(&repo_id) .await? .ok_or(AppError::NotFound)?; if !repo.pulls_enabled { return Err(AppError::NotFound); } // Read access is enough to open a PR. if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { return Err(AppError::NotFound); } let title = form.title.trim(); let base = form.base_ref.trim(); let head = form.head_ref.trim(); if title.is_empty() { return Err(AppError::BadRequest("A title is required.".to_string())); } if base.is_empty() || head.is_empty() || base == head { return Err(AppError::BadRequest( "Choose two different branches to compare.".to_string(), )); } // Both refs must exist. let names = branch_names(&state, &repo.id).await; if !names.iter().any(|b| b == base) || !names.iter().any(|b| b == head) { return Err(AppError::BadRequest("Unknown branch.".to_string())); } let issue = state .store .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head) .await?; crate::issues::notify_mentions(&state, &user.id, &repo, &issue.id, form.body.trim()).await; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Merge form: the chosen strategy. #[derive(Debug, Deserialize)] pub struct MergeForm { /// `merge` | `squash` | `rebase`. #[serde(default)] strategy: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /pull/{id}/merge` — merge a pull request (Write access). pub async fn merge( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; if !issue.is_pull { return Err(AppError::NotFound); } let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } let pr = state .store .pull_by_issue(&issue.id) .await? .ok_or(AppError::NotFound)?; if pr.merged_at.is_some() || issue.state != model::IssueState::Open { return Err(AppError::BadRequest( "This pull request is already closed.".to_string(), )); } // Refuse to merge while any dependency is still open. if state.store.open_dependency_count(&issue.id).await? > 0 { return Err(AppError::BadRequest( "This pull request is blocked by open dependencies.".to_string(), )); } let strategy = git::merge::MergeStrategy::from_token(&form.strategy); let sha = run_merge(&state, &repo, &pr, &user, strategy, &issue).await?; state .store .mark_pull_merged(&issue.id, &user.id, &sha, strategy.as_str()) .await?; // Close any issues the PR body says it closes ("closes #1", "fixes #2"). for number in closing_refs(&issue.body) { if let Some(target) = state.store.issue_by_number(&repo.id, number, false).await? && target.state == model::IssueState::Open { let _ = state .store .set_issue_state(&target.id, model::IssueState::Closed) .await; } } Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Parse GitHub-style closing keywords (`closes #1`, `fixes #2`, `resolves #3`) /// from a PR body, returning the referenced issue numbers. fn closing_refs(text: &str) -> Vec { const KEYWORDS: &[&str] = &[ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", ]; let lower = text.to_lowercase(); let words: Vec<&str> = lower.split_whitespace().collect(); let mut out = Vec::new(); for pair in words.windows(2) { if KEYWORDS.contains(&pair[0]) && let Some(rest) = pair[1].strip_prefix('#') { let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); if let Ok(n) = digits.parse::() { out.push(n); } } } out.sort_unstable(); out.dedup(); out } /// Run the blocking server-side merge on the blocking pool. async fn run_merge( state: &AppState, repo: &model::Repo, pr: &model::PullRequest, user: &User, strategy: git::merge::MergeStrategy, issue: &model::Issue, ) -> AppResult { let binary = state.config.git.binary.clone(); let home: PathBuf = state.config.storage.data_dir.join("tmp"); let repo_path = git::repo_path(&state.config.storage.repo_dir, &repo.id)?; let workdir = home.join(format!("merge-{}", issue.id)); let base = pr.base_ref.clone(); let head = pr.head_ref.clone(); let name = user .display_name .clone() .unwrap_or_else(|| user.username.clone()); let email = user.email.clone(); let message = format!("Merge pull request #{} — {}", issue.number, issue.title); let sha = tokio::task::spawn_blocking(move || { let req = git::merge::MergeRequest { binary: &binary, repo_path: &repo_path, workdir: &workdir, base_branch: &base, head_ref: &head, strategy, message: &message, identity: git::merge::MergeIdentity { home: &home, name: &name, email: &email, }, }; git::merge::merge(&req) }) .await .map_err(AppError::internal)?; sha.map_err(|e| match e { git::GitError::MergeConflict(detail) if detail.is_empty() => { AppError::BadRequest("The branch could not be merged cleanly.".to_string()) } git::GitError::MergeConflict(detail) => { AppError::BadRequest(format!("The branch could not be merged cleanly: {detail}")) } other => AppError::from(other), }) } #[cfg(test)] mod tests { use super::closing_refs; #[test] fn parses_closing_keywords() { assert_eq!(closing_refs("Closes #1 and fixes #2."), vec![1, 2]); assert_eq!(closing_refs("resolves #10"), vec![10]); // No keyword, or a plain mention, does not close. assert!(closing_refs("see #3").is_empty()); assert!(closing_refs("closesnt #4").is_empty()); // Deduped and sorted. assert_eq!(closing_refs("fix #5 fix #5 close #2"), vec![2, 5]); } }