fabrica

hanna/fabrica

feat(web): pull requests — compare, diff, conversation, and merge

f2d1b78 · hanna committed on 2026-07-25

Add the pulls store methods (create_pull with shared numbering + refs,
pull_by_issue, mark_pull_merged closing the issue atomically) and the web
pulls module: a Pulls tab (per-repo toggle) reusing the issue list, a branch-
compare form, and a PR view with the conversation thread, a three-dot commits/
diff (via git merge-base + commits_between), and a merge panel. Merging runs
git::merge on the blocking pool, attributes the commit to the acting user, and
records the result. Conversation mutations reuse the /issue/{id}/* routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +1130 −11UnifiedSplit
Cargo.lock +2 −0
@@ -5611,6 +5611,7 @@ dependencies = [
56115611 "config",
56125612 "flate2",
56135613 "git",
5614+ "git2",
56145615 "grep",
56155616 "highlight",
56165617 "mail",
@@ -5621,6 +5622,7 @@ dependencies = [
56215622 "serde",
56225623 "serde_json",
56235624 "store",
5625+ "tempfile",
56245626 "thiserror 2.0.19",
56255627 "time",
56265628 "tokio",
assets/base.css +53 −0
@@ -357,6 +357,59 @@ body.drawer-open .drawer-backdrop {
357357 color: var(--fb-danger);
358358 border-color: var(--fb-danger);
359359 }
360+.badge-state-merged {
361+ color: var(--fb-accent);
362+ border-color: var(--fb-accent);
363+}
364+
365+/* Pull requests: compare form, merge panel, and change list. */
366+.compare-refs {
367+ display: flex;
368+ align-items: center;
369+ gap: 0.75rem;
370+ flex-wrap: wrap;
371+ margin-bottom: 1rem;
372+}
373+.compare-refs label {
374+ display: inline-flex;
375+ align-items: center;
376+ gap: 0.4rem;
377+}
378+.compare-refs select {
379+ width: auto;
380+}
381+.compare-arrow {
382+ font-size: 1.25rem;
383+ color: var(--fb-muted);
384+}
385+.merge-panel {
386+ border-left: 3px solid var(--fb-border);
387+}
388+.merge-panel.clean {
389+ border-left-color: var(--fb-success);
390+}
391+.merge-panel.conflicts {
392+ border-left-color: var(--fb-danger);
393+}
394+.merge-panel.merged {
395+ border-left-color: var(--fb-accent);
396+}
397+.merge-form {
398+ display: flex;
399+ align-items: center;
400+ gap: 0.5rem;
401+ flex-wrap: wrap;
402+ margin-top: 0.5rem;
403+}
404+.merge-form select {
405+ width: auto;
406+}
407+.pr-changes {
408+ margin-top: 1.5rem;
409+}
410+.pr-commits {
411+ margin-bottom: 1rem;
412+}
360413 .issue-header {
361414 margin-bottom: 1rem;
362415 padding-bottom: 0.75rem;
crates/git/src/repo.rs +45 −0
@@ -307,6 +307,51 @@ impl Repo {
307307 Ok(out)
308308 }
309309
310+ /// The best common ancestor of two commits (the merge base), or `None` if they
311+ /// have unrelated histories.
312+ ///
313+ /// # Errors
314+ ///
315+ /// Returns [`GitError::Libgit2`] on a lookup failure.
316+ pub fn merge_base(&self, a: &Oid, b: &Oid) -> Result<Option<Oid>, GitError> {
317+ let a = git2::Oid::from_str(a.as_str())?;
318+ let b = git2::Oid::from_str(b.as_str())?;
319+ match self.inner.merge_base(a, b) {
320+ Ok(oid) => Ok(Some(oid_of(oid))),
321+ Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
322+ Err(e) => Err(e.into()),
323+ }
324+ }
325+
326+ /// The commits reachable from `head` but not `base`, newest first — the commits
327+ /// a pull request would contribute. Capped at `limit`.
328+ ///
329+ /// # Errors
330+ ///
331+ /// Returns [`GitError::Libgit2`] on a revwalk failure.
332+ pub fn commits_between(
333+ &self,
334+ base: &Oid,
335+ head: &Oid,
336+ limit: usize,
337+ ) -> Result<Vec<CommitSummary>, GitError> {
338+ let base = git2::Oid::from_str(base.as_str())?;
339+ let head = git2::Oid::from_str(head.as_str())?;
340+ let mut walk = self.inner.revwalk()?;
341+ walk.set_sorting(Sort::TIME)?;
342+ walk.push(head)?;
343+ walk.hide(base)?;
344+ let mut out = Vec::with_capacity(limit.min(64));
345+ for oid in walk {
346+ let commit = self.inner.find_commit(oid?)?;
347+ out.push(commit_summary(&commit));
348+ if out.len() >= limit {
349+ break;
350+ }
351+ }
352+ Ok(out)
353+ }
354+
310355 /// Fetch a single commit in full.
311356 ///
312357 /// # Errors
crates/store/src/issues.rs +140 −1
@@ -4,7 +4,7 @@
44
55 //! Issues and pull requests (shared `issues` table), comments, and issue labels.
66
7-use model::{Comment, Issue, IssueState, Label};
7+use model::{Comment, Issue, IssueState, Label, PullRequest};
88 use sqlx::any::AnyRow;
99 use sqlx::{AssertSqlSafe, Row};
1010
@@ -387,6 +387,145 @@ impl Store {
387387 Ok(())
388388 }
389389
390+ // ---- Pull requests ----
391+
392+ /// Open a pull request: allocate the shared number, insert the `issues` row
393+ /// (`is_pull = true`), and record its base/head refs, all in one transaction.
394+ ///
395+ /// # Errors
396+ ///
397+ /// Returns [`StoreError::Query`] on a database failure.
398+ pub async fn create_pull(
399+ &self,
400+ repo_id: &str,
401+ author_id: &str,
402+ title: &str,
403+ body: &str,
404+ base_ref: &str,
405+ head_ref: &str,
406+ ) -> Result<Issue, StoreError> {
407+ let mut tx = self.pool.begin().await?;
408+ let row = sqlx::query("SELECT next_iid FROM repos WHERE id = $1")
409+ .bind(repo_id)
410+ .fetch_one(&mut *tx)
411+ .await?;
412+ let number: i64 = row.try_get("next_iid")?;
413+ sqlx::query("UPDATE repos SET next_iid = $1 WHERE id = $2")
414+ .bind(number + 1)
415+ .bind(repo_id)
416+ .execute(&mut *tx)
417+ .await?;
418+
419+ let now = now_ms();
420+ let issue = Issue {
421+ id: new_id(),
422+ repo_id: repo_id.to_string(),
423+ number,
424+ author_id: author_id.to_string(),
425+ title: title.to_string(),
426+ body: body.to_string(),
427+ state: IssueState::Open,
428+ is_pull: true,
429+ assignee_id: None,
430+ created_at: now,
431+ updated_at: now,
432+ closed_at: None,
433+ };
434+ sqlx::query(
435+ "INSERT INTO issues \
436+ (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \
437+ created_at, updated_at, closed_at) \
438+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
439+ )
440+ .bind(&issue.id)
441+ .bind(&issue.repo_id)
442+ .bind(issue.number)
443+ .bind(&issue.author_id)
444+ .bind(&issue.title)
445+ .bind(&issue.body)
446+ .bind(issue.state.as_str())
447+ .bind(issue.is_pull)
448+ .bind(&issue.assignee_id)
449+ .bind(issue.created_at)
450+ .bind(issue.updated_at)
451+ .bind(issue.closed_at)
452+ .execute(&mut *tx)
453+ .await?;
454+ sqlx::query("INSERT INTO pull_requests (issue_id, base_ref, head_ref) VALUES ($1, $2, $3)")
455+ .bind(&issue.id)
456+ .bind(base_ref)
457+ .bind(head_ref)
458+ .execute(&mut *tx)
459+ .await?;
460+ tx.commit().await?;
461+ Ok(issue)
462+ }
463+
464+ /// Fetch the pull-request row for an issue id, if it is a PR.
465+ ///
466+ /// # Errors
467+ ///
468+ /// Returns [`StoreError::Query`] on a database failure.
469+ pub async fn pull_by_issue(&self, issue_id: &str) -> Result<Option<PullRequest>, StoreError> {
470+ let row = sqlx::query(
471+ "SELECT issue_id, base_ref, head_ref, merged_at, merged_by, merge_sha, merge_strategy \
472+ FROM pull_requests WHERE issue_id = $1",
473+ )
474+ .bind(issue_id)
475+ .fetch_optional(&self.pool)
476+ .await?;
477+ row.map(|r| {
478+ Ok(PullRequest {
479+ issue_id: r.try_get("issue_id")?,
480+ base_ref: r.try_get("base_ref")?,
481+ head_ref: r.try_get("head_ref")?,
482+ merged_at: r.try_get("merged_at")?,
483+ merged_by: r.try_get("merged_by")?,
484+ merge_sha: r.try_get("merge_sha")?,
485+ merge_strategy: r.try_get("merge_strategy")?,
486+ })
487+ })
488+ .transpose()
489+ .map_err(|e: sqlx::Error| e.into())
490+ }
491+
492+ /// Record a merge: stamp the pull-request row and close the issue, atomically.
493+ ///
494+ /// # Errors
495+ ///
496+ /// Returns [`StoreError::Query`] on a database failure.
497+ pub async fn mark_pull_merged(
498+ &self,
499+ issue_id: &str,
500+ merged_by: &str,
501+ merge_sha: &str,
502+ strategy: &str,
503+ ) -> Result<(), StoreError> {
504+ let now = now_ms();
505+ let mut tx = self.pool.begin().await?;
506+ sqlx::query(
507+ "UPDATE pull_requests \
508+ SET merged_at = $1, merged_by = $2, merge_sha = $3, merge_strategy = $4 \
509+ WHERE issue_id = $5",
510+ )
511+ .bind(now)
512+ .bind(merged_by)
513+ .bind(merge_sha)
514+ .bind(strategy)
515+ .bind(issue_id)
516+ .execute(&mut *tx)
517+ .await?;
518+ sqlx::query(
519+ "UPDATE issues SET state = 'closed', closed_at = $1, updated_at = $1 WHERE id = $2",
520+ )
521+ .bind(now)
522+ .bind(issue_id)
523+ .execute(&mut *tx)
524+ .await?;
525+ tx.commit().await?;
526+ Ok(())
527+ }
528+
390529 /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`].
391530 fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> {
392531 Ok(Issue {
crates/web/Cargo.toml +2 −0
@@ -42,6 +42,8 @@ tracing-subscriber = { workspace = true }
4242
4343 [dev-dependencies]
4444 tower = { workspace = true, features = ["util"] }
45+tempfile = { workspace = true }
46+git2 = { workspace = true }
4547
4648 [lints]
4749 workspace = true
crates/web/src/issues.rs +30 −10
@@ -49,6 +49,16 @@ impl Kind {
4949 tab: Tab::Issues,
5050 }
5151 }
52+
53+ /// The pull-requests kind.
54+ pub(crate) fn pulls() -> Self {
55+ Self {
56+ is_pull: true,
57+ seg: "pulls",
58+ noun: "pull request",
59+ tab: Tab::Pulls,
60+ }
61+ }
5262 }
5363
5464 /// `GET …/-/issues` — the issue list with open/closed tabs.
@@ -271,7 +281,7 @@ pub(crate) async fn view(
271281 // ---- shared render helpers ----
272282
273283 /// A single comment card (also used for the issue body).
274-fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {
284+pub(crate) fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {
275285 html! {
276286 div class="card comment" {
277287 div class="comment-head muted" {
@@ -295,7 +305,7 @@ fn state_icon(st: IssueState, is_pull: bool) -> Markup {
295305 }
296306
297307 /// The open/closed state badge.
298-fn state_badge(st: IssueState, is_pull: bool) -> Markup {
308+pub(crate) fn state_badge(st: IssueState, is_pull: bool) -> Markup {
299309 let (class, label) = match st {
300310 IssueState::Open => ("badge badge-state-open", "Open"),
301311 IssueState::Closed => ("badge badge-state-closed", "Closed"),
@@ -308,7 +318,7 @@ fn state_badge(st: IssueState, is_pull: bool) -> Markup {
308318 }
309319
310320 /// The close/reopen button (posts to the state endpoint).
311-fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
321+pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
312322 let (target, label) = match issue.state {
313323 IssueState::Open => ("closed", format!("Close {}", kind.noun)),
314324 IssueState::Closed => ("open", format!("Reopen {}", kind.noun)),
@@ -323,7 +333,12 @@ fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
323333 }
324334
325335 /// The assignee select form (owner + collaborators).
326-async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &str) -> Markup {
336+pub(crate) async fn assignee_form(
337+ issue: &Issue,
338+ ctx: &RepoCtx,
339+ state: &AppState,
340+ csrf: &str,
341+) -> Markup {
327342 let mut options: Vec<(String, String)> =
328343 vec![(ctx.owner.id.clone(), ctx.owner.username.clone())];
329344 if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await {
@@ -346,7 +361,12 @@ async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &st
346361 }
347362
348363 /// The label-toggle form: checkboxes for each repo label.
349-fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &str) -> Markup {
364+pub(crate) fn label_form(
365+ issue: &Issue,
366+ repo_labels: &[Label],
367+ applied: &[Label],
368+ csrf: &str,
369+) -> Markup {
350370 let is_on = |id: &str| applied.iter().any(|l| l.id == id);
351371 html! {
352372 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" {
@@ -364,7 +384,7 @@ fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &st
364384 }
365385
366386 /// Resolve a username for display, falling back to the id.
367-async fn username(state: &AppState, user_id: &str) -> String {
387+pub(crate) async fn username(state: &AppState, user_id: &str) -> String {
368388 state
369389 .store
370390 .user_by_id(user_id)
@@ -377,7 +397,7 @@ async fn username(state: &AppState, user_id: &str) -> String {
377397 // ---- POST handlers (fixed-prefix routes, keyed by id) ----
378398
379399 /// Load an issue's repo and the viewer's access to it.
380-async fn repo_of_issue(
400+pub(crate) async fn repo_of_issue(
381401 state: &AppState,
382402 viewer: Option<&User>,
383403 issue: &Issue,
@@ -392,7 +412,7 @@ async fn repo_of_issue(
392412 }
393413
394414 /// Compute the viewer's access to a repo.
395-async fn access_for(
415+pub(crate) async fn access_for(
396416 state: &AppState,
397417 viewer: Option<&User>,
398418 repo: &model::Repo,
@@ -418,7 +438,7 @@ async fn access_for(
418438 }
419439
420440 /// The path to an issue/PR.
421-async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {
441+pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {
422442 let owner = username(state, &repo.owner_id).await;
423443 let seg = if issue.is_pull { "pulls" } else { "issues" };
424444 format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)
@@ -679,7 +699,7 @@ pub async fn set_labels(
679699 }
680700
681701 /// Redirect on success, render the error otherwise.
682-fn respond_redirect(result: AppResult<String>) -> Response {
702+pub(crate) fn respond_redirect(result: AppResult<String>) -> Response {
683703 match result {
684704 Ok(dest) => Redirect::to(&dest).into_response(),
685705 Err(err) => err.into_response(),
crates/web/src/lib.rs +3 −0
@@ -21,6 +21,7 @@ mod issues;
2121 mod layout;
2222 mod markdown;
2323 mod pages;
24+mod pulls;
2425 mod repo;
2526 mod search;
2627 #[path = "serve.rs"]
@@ -183,6 +184,8 @@ pub fn build_router(state: AppState) -> Router {
183184 .route("/issue/{id}/state", post(issues::set_state))
184185 .route("/issue/{id}/assignee", post(issues::set_assignee))
185186 .route("/issue/{id}/labels", post(issues::set_labels))
187+ .route("/pull-new/{repo_id}", post(pulls::create))
188+ .route("/pull/{id}/merge", post(pulls::merge))
186189 .route("/healthz", get(serve_assets::healthz))
187190 .route("/version", get(serve_assets::version))
188191 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/pulls.rs +587 −0
@@ -0,0 +1,587 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Pull requests: open (branch compare), view (conversation + commits + diff),
6+//! and merge.
7+//!
8+//! PRs share the `issues` table and much rendering with [`crate::issues`] — the
9+//! conversation thread, state button, assignee, and labels are the same helpers,
10+//! and comment/state/assignee/label mutations reuse the `/issue/{id}/…` routes.
11+//! What is PR-specific lives here: the compare form, the three-dot diff, and the
12+//! server-side merge, which shells out through [`git::merge`].
13+
14+use std::path::PathBuf;
15+
16+use axum::extract::{Path, State};
17+use axum::http::{HeaderMap, Uri};
18+use axum::response::{IntoResponse, Redirect, Response};
19+use axum_extra::extract::cookie::CookieJar;
20+use maud::{Markup, html};
21+use model::User;
22+use serde::Deserialize;
23+
24+use crate::AppState;
25+use crate::diff;
26+use crate::error::{AppError, AppResult};
27+use crate::issues::{
28+ Kind, access_for, assignee_form, comment_card, issue_url, label_form, repo_of_issue,
29+ respond_redirect, state_badge, state_button, username,
30+};
31+use crate::layout::page;
32+use crate::pages::build_chrome;
33+use crate::repo::{RepoCtx, Tab, branch_names, label_chip, repo_header};
34+use crate::session::{MaybeUser, verify_csrf};
35+
36+/// `GET …/-/pulls/new` — the branch-compare form for opening a pull request.
37+pub(crate) async fn new_form(
38+ state: &AppState,
39+ viewer: Option<User>,
40+ jar: CookieJar,
41+ uri: &Uri,
42+ ctx: RepoCtx,
43+) -> AppResult<Response> {
44+ if !ctx.repo.pulls_enabled {
45+ return Err(AppError::NotFound);
46+ }
47+ if viewer.is_none() {
48+ return Ok(Redirect::to("/login").into_response());
49+ }
50+ let branches = branch_names(state, &ctx.repo.id).await;
51+ // Pre-select the base/head from the query when arriving from a branch view.
52+ let q = compare_query(uri);
53+ let base_sel = q.0.unwrap_or_else(|| ctx.repo.default_branch.clone());
54+ let head_sel = q.1.unwrap_or_default();
55+
56+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
57+ let body = html! {
58+ (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
59+ h2 { "New pull request" }
60+ div class="card" {
61+ form method="post" action=(format!("/pull-new/{}", ctx.repo.id)) {
62+ input type="hidden" name="_csrf" value=(chrome.csrf);
63+ div class="compare-refs" {
64+ label { "Base "
65+ select name="base_ref" aria-label="Base branch" {
66+ @for b in &branches {
67+ option value=(b) selected[*b == base_sel] { (b) }
68+ }
69+ }
70+ }
71+ span class="compare-arrow" { "←" }
72+ label { "Compare "
73+ select name="head_ref" aria-label="Compare branch" {
74+ @for b in &branches {
75+ option value=(b) selected[*b == head_sel] { (b) }
76+ }
77+ }
78+ }
79+ }
80+ label for="title" { "Title" }
81+ input type="text" id="title" name="title" required autofocus;
82+ label for="body" { "Description" }
83+ textarea id="body" name="body" rows="6" placeholder="Markdown supported" {}
84+ button class="btn btn-primary" type="submit" { "Create pull request" }
85+ }
86+ }
87+ };
88+ let title = format!(
89+ "New pull request · {}/{}",
90+ ctx.owner.username, ctx.repo.path
91+ );
92+ Ok((jar, page(&chrome, &title, body)).into_response())
93+}
94+
95+/// `GET …/-/pulls/{n}` — a pull request: conversation, commits, and diff.
96+pub(crate) async fn view(
97+ state: &AppState,
98+ viewer: Option<User>,
99+ jar: CookieJar,
100+ uri: &Uri,
101+ ctx: RepoCtx,
102+ number: i64,
103+) -> AppResult<Response> {
104+ if !ctx.repo.pulls_enabled {
105+ return Err(AppError::NotFound);
106+ }
107+ let issue = state
108+ .store
109+ .issue_by_number(&ctx.repo.id, number, true)
110+ .await?
111+ .ok_or(AppError::NotFound)?;
112+ let pr = state
113+ .store
114+ .pull_by_issue(&issue.id)
115+ .await?
116+ .ok_or(AppError::NotFound)?;
117+
118+ let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
119+ let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id);
120+ let branches = branch_names(state, &ctx.repo.id).await;
121+ let Loaded {
122+ author,
123+ comment_rows,
124+ issue_labels,
125+ repo_labels,
126+ assignee,
127+ commits,
128+ files,
129+ mergeable,
130+ } = gather(state, &ctx, &issue, &pr, can_write).await?;
131+
132+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
133+ let csrf = chrome.csrf.clone();
134+ let body = html! {
135+ (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
136+ div class="issue-view" {
137+ header class="issue-header" {
138+ h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
139+ div class="issue-sub" {
140+ (pr_state_badge(&issue, &pr))
141+ span class="muted" {
142+ " " (author) " wants to merge "
143+ code { (pr.head_ref) } " into " code { (pr.base_ref) }
144+ }
145+ }
146+ }
147+ div class="issue-main" {
148+ article class="issue-thread" {
149+ (comment_card(&author, &issue.body, issue.created_at))
150+ @for (c, cauthor) in &comment_rows {
151+ (comment_card(cauthor, &c.body, c.created_at))
152+ }
153+ (merge_panel(&issue, &pr, mergeable, &csrf))
154+ @if can_write {
155+ div class="card comment-form" {
156+ form method="post" action=(format!("/issue/{}/comment", issue.id)) {
157+ input type="hidden" name="_csrf" value=(csrf);
158+ textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {}
159+ div class="comment-actions" {
160+ @if can_write || is_author {
161+ (state_button(&issue, &csrf, Kind::pulls()))
162+ }
163+ button class="btn btn-primary" type="submit" { "Comment" }
164+ }
165+ }
166+ }
167+ }
168+ }
169+ aside class="issue-side" {
170+ section {
171+ h3 { "Assignee" }
172+ @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } }
173+ @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) }
174+ }
175+ section {
176+ h3 { "Labels" }
177+ @if issue_labels.is_empty() { p class="muted" { "None" } }
178+ @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } }
179+ @if can_write && !repo_labels.is_empty() {
180+ (label_form(&issue, &repo_labels, &issue_labels, &csrf))
181+ }
182+ }
183+ }
184+ }
185+ @if issue.state == model::IssueState::Open {
186+ (pr_changes(&commits, &files))
187+ }
188+ }
189+ };
190+ let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path);
191+ Ok((jar, page(&chrome, &title, body)).into_response())
192+}
193+
194+/// Everything the PR view needs beyond the issue/PR rows themselves.
195+struct Loaded {
196+ /// The author's display name.
197+ author: String,
198+ /// Each comment paired with its author's name.
199+ comment_rows: Vec<(model::Comment, String)>,
200+ /// Labels applied to this PR.
201+ issue_labels: Vec<model::Label>,
202+ /// All labels defined on the repo (for the picker).
203+ repo_labels: Vec<model::Label>,
204+ /// The assignee's name, if any.
205+ assignee: Option<String>,
206+ /// The commits the PR contributes (empty once closed).
207+ commits: Vec<git::CommitSummary>,
208+ /// The rendered three-dot diff (empty once closed).
209+ files: Vec<crate::diff::RenderedFile>,
210+ /// Mergeability, computed only for an open PR the viewer can merge.
211+ mergeable: Option<git::merge::Mergeability>,
212+}
213+
214+/// Load the comments, labels, assignee, diff, and mergeability for the PR view.
215+async fn gather(
216+ state: &AppState,
217+ ctx: &RepoCtx,
218+ issue: &model::Issue,
219+ pr: &model::PullRequest,
220+ can_write: bool,
221+) -> AppResult<Loaded> {
222+ let author = username(state, &issue.author_id).await;
223+ let comments = state.store.list_comments(&issue.id).await?;
224+ let mut comment_rows = Vec::with_capacity(comments.len());
225+ for c in comments {
226+ let name = username(state, &c.author_id).await;
227+ comment_rows.push((c, name));
228+ }
229+ let issue_labels = state.store.issue_labels(&issue.id).await?;
230+ let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
231+ let assignee = match &issue.assignee_id {
232+ Some(id) => Some(username(state, id).await),
233+ None => None,
234+ };
235+
236+ let open = issue.state == model::IssueState::Open;
237+ // Three-dot commits/diff; best-effort so a deleted branch does not 500.
238+ let (commits, files) = if open {
239+ crate::repo::load_range_diff(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref)
240+ .await
241+ .unwrap_or_default()
242+ } else {
243+ (Vec::new(), Vec::new())
244+ };
245+ let mergeable = if open && pr.merged_at.is_none() && can_write {
246+ analyze(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref).await
247+ } else {
248+ None
249+ };
250+ Ok(Loaded {
251+ author,
252+ comment_rows,
253+ issue_labels,
254+ repo_labels,
255+ assignee,
256+ commits,
257+ files,
258+ mergeable,
259+ })
260+}
261+
262+// ---- render helpers ----
263+
264+/// The commits + diff a pull request contributes.
265+fn pr_changes(commits: &[git::CommitSummary], files: &[crate::diff::RenderedFile]) -> Markup {
266+ let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| {
267+ let (fa, fd) = f.stats();
268+ (a + fa, d + fd)
269+ });
270+ html! {
271+ section class="pr-changes" {
272+ h2 { (commits.len()) " commits · " (files.len()) " files changed "
273+ span class="muted" {
274+ span style="color:var(--fb-diff-add-fg)" { "+" (adds) }
275+ " "
276+ span style="color:var(--fb-diff-del-fg)" { "−" (dels) }
277+ }
278+ }
279+ @if !commits.is_empty() {
280+ ul class="entry-list card pr-commits" {
281+ @for c in commits {
282+ li class="entry-row" {
283+ div class="entry-row-body" {
284+ span class="entry-row-title" { (c.summary) }
285+ div class="entry-row-meta muted mono" { (c.oid.short()) }
286+ }
287+ }
288+ }
289+ }
290+ }
291+ @for (i, f) in files.iter().enumerate() {
292+ details open id=(format!("file-{i}")) class="diff-file" {
293+ summary {
294+ @let (fa, fd) = f.stats();
295+ span class="mono" { (f.path()) }
296+ " " span class="muted" { "+" (fa) " −" (fd) }
297+ }
298+ @if f.diff.is_binary {
299+ p class="muted" style="padding:0.5rem" { "Binary file not shown." }
300+ } @else if f.diff.hunks.is_empty() {
301+ p class="muted" style="padding:0.5rem" { "No textual changes." }
302+ } @else {
303+ (diff::unified(f, None))
304+ }
305+ }
306+ }
307+ }
308+ }
309+}
310+
311+/// The state badge for a PR, distinguishing a merged PR from a plain closed one.
312+fn pr_state_badge(issue: &model::Issue, pr: &model::PullRequest) -> Markup {
313+ if pr.merged_at.is_some() {
314+ return html! { span class="badge badge-state-merged" { "Merged" } };
315+ }
316+ state_badge(issue.state, true)
317+}
318+
319+/// The merge panel: shows mergeability and, when clean, the strategy buttons.
320+fn merge_panel(
321+ issue: &model::Issue,
322+ pr: &model::PullRequest,
323+ mergeable: Option<git::merge::Mergeability>,
324+ csrf: &str,
325+) -> Markup {
326+ use git::merge::Mergeability;
327+ if let Some(sha) = &pr.merge_sha {
328+ return html! {
329+ div class="card merge-panel merged" {
330+ p { "This pull request was merged" }
331+ @if let Some(s) = &pr.merge_strategy { " " span class="muted" { "(" (s) ")" } }
332+ p class="mono muted" { (sha) }
333+ }
334+ };
335+ }
336+ if issue.state != model::IssueState::Open {
337+ return html! {};
338+ }
339+ match mergeable {
340+ None => html! {},
341+ Some(Mergeability::AlreadyMerged) => html! {
342+ div class="card merge-panel" {
343+ p class="muted" { "The base branch already contains these commits — nothing to merge." }
344+ }
345+ },
346+ Some(Mergeability::Conflicts) => html! {
347+ div class="card merge-panel conflicts" {
348+ p { "This branch has conflicts that must be resolved before it can be merged." }
349+ }
350+ },
351+ Some(Mergeability::Clean) => html! {
352+ div class="card merge-panel clean" {
353+ p { "This branch has no conflicts with the base branch." }
354+ form method="post" action=(format!("/pull/{}/merge", issue.id)) class="merge-form" {
355+ input type="hidden" name="_csrf" value=(csrf);
356+ label for="strategy" { "Method " }
357+ select id="strategy" name="strategy" {
358+ option value="merge" { "Create a merge commit" }
359+ option value="squash" { "Squash and merge" }
360+ option value="rebase" { "Rebase and merge" }
361+ }
362+ button class="btn btn-primary" type="submit" { "Merge pull request" }
363+ }
364+ }
365+ },
366+ }
367+}
368+
369+/// Run the (blocking) mergeability analysis on the blocking pool.
370+async fn analyze(
371+ state: &AppState,
372+ repo_id: &str,
373+ base_ref: &str,
374+ head_ref: &str,
375+) -> Option<git::merge::Mergeability> {
376+ let binary = state.config.git.binary.clone();
377+ let home = state.config.storage.data_dir.join("tmp");
378+ let repo_path = git::repo_path(&state.config.storage.repo_dir, repo_id).ok()?;
379+ let base = base_ref.to_string();
380+ let head = head_ref.to_string();
381+ tokio::task::spawn_blocking(move || {
382+ git::merge::analyze(&binary, &repo_path, &home, &base, &head).ok()
383+ })
384+ .await
385+ .ok()
386+ .flatten()
387+}
388+
389+// ---- POST handlers ----
390+
391+/// `?base_ref=&head_ref=` for pre-filling the compare form.
392+fn compare_query(uri: &Uri) -> (Option<String>, Option<String>) {
393+ let mut base = None;
394+ let mut head = None;
395+ if let Some(q) = uri.query() {
396+ for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
397+ match k.as_ref() {
398+ "base_ref" => base = Some(v.into_owned()),
399+ "head_ref" => head = Some(v.into_owned()),
400+ _ => {}
401+ }
402+ }
403+ }
404+ (base, head)
405+}
406+
407+/// New-pull-request form fields.
408+#[derive(Debug, Deserialize)]
409+pub struct NewPullForm {
410+ /// The base branch (merged into).
411+ #[serde(default)]
412+ base_ref: String,
413+ /// The head branch (source of the changes).
414+ #[serde(default)]
415+ head_ref: String,
416+ /// Title.
417+ #[serde(default)]
418+ title: String,
419+ /// Markdown body.
420+ #[serde(default)]
421+ body: String,
422+ /// CSRF token.
423+ #[serde(rename = "_csrf")]
424+ csrf: String,
425+}
426+
427+/// `POST /pull-new/{repo_id}` — open a pull request.
428+pub async fn create(
429+ State(state): State<AppState>,
430+ MaybeUser(viewer): MaybeUser,
431+ jar: CookieJar,
432+ headers: HeaderMap,
433+ Path(repo_id): Path<String>,
434+ axum::Form(form): axum::Form<NewPullForm>,
435+) -> Response {
436+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
437+ return AppError::Forbidden.into_response();
438+ }
439+ let result = async {
440+ let user = viewer.ok_or(AppError::Forbidden)?;
441+ let repo = state
442+ .store
443+ .repo_by_id(&repo_id)
444+ .await?
445+ .ok_or(AppError::NotFound)?;
446+ if !repo.pulls_enabled {
447+ return Err(AppError::NotFound);
448+ }
449+ // Read access is enough to open a PR.
450+ if access_for(&state, Some(&user), &repo).await? < auth::Access::Read {
451+ return Err(AppError::NotFound);
452+ }
453+ let title = form.title.trim();
454+ let base = form.base_ref.trim();
455+ let head = form.head_ref.trim();
456+ if title.is_empty() {
457+ return Err(AppError::BadRequest("A title is required.".to_string()));
458+ }
459+ if base.is_empty() || head.is_empty() || base == head {
460+ return Err(AppError::BadRequest(
461+ "Choose two different branches to compare.".to_string(),
462+ ));
463+ }
464+ // Both refs must exist.
465+ let names = branch_names(&state, &repo.id).await;
466+ if !names.iter().any(|b| b == base) || !names.iter().any(|b| b == head) {
467+ return Err(AppError::BadRequest("Unknown branch.".to_string()));
468+ }
469+ let issue = state
470+ .store
471+ .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head)
472+ .await?;
473+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
474+ }
475+ .await;
476+ respond_redirect(result)
477+}
478+
479+/// Merge form: the chosen strategy.
480+#[derive(Debug, Deserialize)]
481+pub struct MergeForm {
482+ /// `merge` | `squash` | `rebase`.
483+ #[serde(default)]
484+ strategy: String,
485+ /// CSRF token.
486+ #[serde(rename = "_csrf")]
487+ csrf: String,
488+}
489+
490+/// `POST /pull/{id}/merge` — merge a pull request (Write access).
491+pub async fn merge(
492+ State(state): State<AppState>,
493+ MaybeUser(viewer): MaybeUser,
494+ jar: CookieJar,
495+ headers: HeaderMap,
496+ Path(id): Path<String>,
497+ axum::Form(form): axum::Form<MergeForm>,
498+) -> Response {
499+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
500+ return AppError::Forbidden.into_response();
501+ }
502+ let result = async {
503+ let user = viewer.ok_or(AppError::Forbidden)?;
504+ let issue = state
505+ .store
506+ .issue_by_id(&id)
507+ .await?
508+ .ok_or(AppError::NotFound)?;
509+ if !issue.is_pull {
510+ return Err(AppError::NotFound);
511+ }
512+ let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
513+ if access < auth::Access::Write {
514+ return Err(AppError::Forbidden);
515+ }
516+ let pr = state
517+ .store
518+ .pull_by_issue(&issue.id)
519+ .await?
520+ .ok_or(AppError::NotFound)?;
521+ if pr.merged_at.is_some() || issue.state != model::IssueState::Open {
522+ return Err(AppError::BadRequest(
523+ "This pull request is already closed.".to_string(),
524+ ));
525+ }
526+
527+ let strategy = git::merge::MergeStrategy::from_token(&form.strategy);
528+ let sha = run_merge(&state, &repo, &pr, &user, strategy, &issue).await?;
529+ state
530+ .store
531+ .mark_pull_merged(&issue.id, &user.id, &sha, strategy.as_str())
532+ .await?;
533+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
534+ }
535+ .await;
536+ respond_redirect(result)
537+}
538+
539+/// Run the blocking server-side merge on the blocking pool.
540+async fn run_merge(
541+ state: &AppState,
542+ repo: &model::Repo,
543+ pr: &model::PullRequest,
544+ user: &User,
545+ strategy: git::merge::MergeStrategy,
546+ issue: &model::Issue,
547+) -> AppResult<String> {
548+ let binary = state.config.git.binary.clone();
549+ let home: PathBuf = state.config.storage.data_dir.join("tmp");
550+ let repo_path = git::repo_path(&state.config.storage.repo_dir, &repo.id)?;
551+ let workdir = home.join(format!("merge-{}", issue.id));
552+ let base = pr.base_ref.clone();
553+ let head = pr.head_ref.clone();
554+ let name = user
555+ .display_name
556+ .clone()
557+ .unwrap_or_else(|| user.username.clone());
558+ let email = user.email.clone();
559+ let message = format!("Merge pull request #{} — {}", issue.number, issue.title);
560+
561+ let sha = tokio::task::spawn_blocking(move || {
562+ let req = git::merge::MergeRequest {
563+ binary: &binary,
564+ repo_path: &repo_path,
565+ workdir: &workdir,
566+ base_branch: &base,
567+ head_ref: &head,
568+ strategy,
569+ message: &message,
570+ identity: git::merge::MergeIdentity {
571+ home: &home,
572+ name: &name,
573+ email: &email,
574+ },
575+ };
576+ git::merge::merge(&req)
577+ })
578+ .await
579+ .map_err(AppError::internal)?;
580+
581+ sha.map_err(|e| match e {
582+ git::GitError::MergeConflict => {
583+ AppError::BadRequest("The branch could not be merged cleanly.".to_string())
584+ }
585+ other => AppError::from(other),
586+ })
587+}
crates/web/src/repo.rs +62 −0
@@ -305,6 +305,20 @@ async fn dispatch_view(
305305 },
306306 }
307307 }
308+ "pulls" => {
309+ let kind = crate::issues::Kind::pulls();
310+ match rest {
311+ "" => {
312+ let closed = uri.query().is_some_and(|q| q.contains("state=closed"));
313+ crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await
314+ }
315+ "new" => crate::pulls::new_form(state, viewer, jar, uri, ctx).await,
316+ n => match n.parse::<i64>() {
317+ Ok(num) => crate::pulls::view(state, viewer, jar, uri, ctx, num).await,
318+ Err(_) => Err(AppError::NotFound),
319+ },
320+ }
321+ }
308322 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
309323 // Reserved `runs` slot returns 404 until built.
310324 _ => Err(AppError::NotFound),
@@ -1538,6 +1552,54 @@ async fn load_commit(
15381552 .await
15391553 }
15401554
1555+/// The commits and rendered diff a pull request contributes: the head-only
1556+/// commit list (newest first) and the three-dot diff (merge-base → head).
1557+pub(crate) type RangeDiff = (Vec<git::CommitSummary>, Vec<RenderedFile>);
1558+
1559+/// Load a pull request's commits and diff. `base` and `head` are branch names (or
1560+/// revs); the diff is three-dot (against their merge base) so unrelated base
1561+/// commits do not appear as changes.
1562+pub(crate) async fn load_range_diff(
1563+ state: &AppState,
1564+ repo_id: &str,
1565+ base: &str,
1566+ head: &str,
1567+) -> AppResult<RangeDiff> {
1568+ let context_lines = state.config.ui.diff_context_lines;
1569+ let base = base.to_string();
1570+ let head = head.to_string();
1571+ git_read(state, repo_id, move |repo| {
1572+ let base_oid = repo.resolve_ref(&base)?.oid;
1573+ let head_oid = repo.resolve_ref(&head)?.oid;
1574+ // Three-dot: diff from the merge base, so only the head's own work shows.
1575+ let merge_base = repo.merge_base(&base_oid, &head_oid)?.unwrap_or(base_oid);
1576+ let commits = repo.commits_between(&merge_base, &head_oid, 200)?;
1577+ let diffs = repo.diff(
1578+ Some(&merge_base),
1579+ &head_oid,
1580+ git::DiffOpts { context_lines },
1581+ )?;
1582+
1583+ let head_rev = git::Resolved {
1584+ oid: head_oid.clone(),
1585+ kind: git::RefKind::Commit,
1586+ name: head.clone(),
1587+ };
1588+ let base_rev = git::Resolved {
1589+ oid: merge_base.clone(),
1590+ kind: git::RefKind::Commit,
1591+ name: merge_base.to_string(),
1592+ };
1593+ let rendered = diffs
1594+ .files
1595+ .into_iter()
1596+ .map(|file| render_one_file(repo, &head_rev, Some(&base_rev), file))
1597+ .collect();
1598+ Ok((commits, rendered))
1599+ })
1600+ .await
1601+}
1602+
15411603 /// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole
15421604 /// document.
15431605 fn render_one_file(
crates/web/src/tests.rs +206 −0
@@ -621,6 +621,212 @@ async fn issues_404_when_disabled() {
621621 assert_eq!(res.status(), StatusCode::NOT_FOUND);
622622 }
623623
624+/// Build an app whose storage lives under a temp dir, so real bare repositories
625+/// can be created on disk — needed for pull-request diffs and merges. The temp
626+/// dir is returned so it outlives the test.
627+async fn app_with_git() -> (AppState, Router, tempfile::TempDir) {
628+ let store = Store::connect("sqlite::memory:", 1).await.unwrap();
629+ store.migrate().await.unwrap();
630+ let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap();
631+ store
632+ .create_user(NewUser {
633+ username: "ada".to_string(),
634+ email: "ada@example.com".to_string(),
635+ display_name: Some("Ada".to_string()),
636+ password_hash: Some(hash),
637+ is_admin: false,
638+ must_change_password: false,
639+ })
640+ .await
641+ .unwrap();
642+
643+ let dir = tempfile::tempdir().unwrap();
644+ let mut config = Config::default();
645+ config.instance.name = "Forge".to_string();
646+ config.auth.cookie_secure = false;
647+ config.storage.repo_dir = dir.path().join("repos");
648+ config.storage.data_dir = dir.path().to_path_buf();
649+ std::fs::create_dir_all(config.storage.data_dir.join("tmp")).unwrap();
650+
651+ let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
652+ let router = crate::build_router(state.clone());
653+ (state, router, dir)
654+}
655+
656+/// Commit `files` onto `branch` in a bare repo via libgit2, returning the new oid.
657+fn seed_commit(
658+ raw: &git2::Repository,
659+ branch: &str,
660+ parents: &[git2::Oid],
661+ files: &[(&str, &str)],
662+ message: &str,
663+ t: i64,
664+) -> git2::Oid {
665+ let mut root = raw.treebuilder(None).unwrap();
666+ for (name, content) in files {
667+ let blob = raw.blob(content.as_bytes()).unwrap();
668+ root.insert(name, blob, 0o100_644).unwrap();
669+ }
670+ let tree = raw.find_tree(root.write().unwrap()).unwrap();
671+ let sig = git2::Signature::new("Ada", "ada@example.com", &git2::Time::new(t, 0)).unwrap();
672+ let parent_commits: Vec<_> = parents
673+ .iter()
674+ .map(|p| raw.find_commit(*p).unwrap())
675+ .collect();
676+ let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
677+ raw.commit(
678+ Some(&format!("refs/heads/{branch}")),
679+ &sig,
680+ &sig,
681+ message,
682+ &tree,
683+ &parent_refs,
684+ )
685+ .unwrap()
686+}
687+
688+#[tokio::test]
689+async fn pull_request_create_view_and_merge() {
690+ // Merge shells out to `git`; skip cleanly where it is absent.
691+ if std::process::Command::new("git")
692+ .arg("--version")
693+ .output()
694+ .is_err()
695+ {
696+ return;
697+ }
698+
699+ let (state, app, _dir) = app_with_git().await;
700+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
701+ let repo = state
702+ .store
703+ .create_repo(NewRepo {
704+ owner_id: ada.id,
705+ group_id: None,
706+ name: "proj".to_string(),
707+ path: "proj".to_string(),
708+ description: None,
709+ visibility: model::Visibility::Public,
710+ default_branch: "main".to_string(),
711+ })
712+ .await
713+ .unwrap();
714+
715+ // Create the bare repo on disk with a mergeable feature branch.
716+ let hook = state.config.storage.data_dir.join("fabrica");
717+ git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
718+ let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
719+ let raw = git2::Repository::open_bare(&path).unwrap();
720+ let base = seed_commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1);
721+ seed_commit(
722+ &raw,
723+ "feature",
724+ &[base],
725+ &[("README.md", "hello\n"), ("feature.txt", "new\n")],
726+ "add feature",
727+ 2,
728+ );
729+
730+ let cookie = login(&app).await;
731+ let token = cookie
732+ .split("fabrica_csrf=")
733+ .nth(1)
734+ .and_then(|s| s.split(';').next())
735+ .unwrap()
736+ .to_string();
737+
738+ // Open a pull request comparing feature → main.
739+ let req = Request::builder()
740+ .method("POST")
741+ .uri(format!("/pull-new/{}", repo.id))
742+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
743+ .header(header::COOKIE, cookie.clone())
744+ .body(Body::from(format!(
745+ "base_ref=main&head_ref=feature&title=Add+feature&body=please&_csrf={token}"
746+ )))
747+ .unwrap();
748+ let res = app.clone().oneshot(req).await.unwrap();
749+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
750+ assert_eq!(
751+ res.headers().get(header::LOCATION).unwrap(),
752+ "/ada/proj/-/pulls/1"
753+ );
754+
755+ // The PR page shows the compare summary, the diff, and a clean merge panel.
756+ let req = Request::builder()
757+ .uri("/ada/proj/-/pulls/1")
758+ .header(header::COOKIE, cookie.clone())
759+ .body(Body::empty())
760+ .unwrap();
761+ let res = app.clone().oneshot(req).await.unwrap();
762+ assert_eq!(res.status(), StatusCode::OK);
763+ let html = body_string(res).await;
764+ assert!(html.contains("wants to merge"), "compare summary shown");
765+ assert!(html.contains("feature.txt"), "diff lists the new file");
766+ assert!(html.contains("Merge pull request"), "merge button shown");
767+
768+ // Merge it (default merge-commit strategy).
769+ let issue = state
770+ .store
771+ .issue_by_number(&repo.id, 1, true)
772+ .await
773+ .unwrap()
774+ .unwrap();
775+ let req = Request::builder()
776+ .method("POST")
777+ .uri(format!("/pull/{}/merge", issue.id))
778+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
779+ .header(header::COOKIE, cookie)
780+ .body(Body::from(format!("strategy=merge&_csrf={token}")))
781+ .unwrap();
782+ let res = app.oneshot(req).await.unwrap();
783+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
784+
785+ // The PR is now merged and closed, and main carries the feature file.
786+ let pr = state.store.pull_by_issue(&issue.id).await.unwrap().unwrap();
787+ assert!(pr.merged_at.is_some(), "merge recorded");
788+ assert!(pr.merge_sha.is_some(), "merge sha stored");
789+ let closed = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
790+ assert_eq!(closed.state, model::IssueState::Closed, "PR auto-closed");
791+
792+ let main_tip = raw
793+ .find_reference("refs/heads/main")
794+ .unwrap()
795+ .peel_to_commit()
796+ .unwrap();
797+ assert!(
798+ main_tip.tree().unwrap().get_name("feature.txt").is_some(),
799+ "main now contains the merged file"
800+ );
801+ assert_eq!(main_tip.parent_count(), 2, "a merge commit");
802+}
803+
804+#[tokio::test]
805+async fn pulls_404_when_disabled() {
806+ let (state, app) = app().await;
807+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
808+ let repo = state
809+ .store
810+ .create_repo(NewRepo {
811+ owner_id: ada.id,
812+ group_id: None,
813+ name: "nopr".to_string(),
814+ path: "nopr".to_string(),
815+ description: None,
816+ visibility: model::Visibility::Public,
817+ default_branch: "main".to_string(),
818+ })
819+ .await
820+ .unwrap();
821+ state
822+ .store
823+ .set_feature_enabled(&repo.id, "pulls", false)
824+ .await
825+ .unwrap();
826+ let res = app.oneshot(get("/ada/nopr/-/pulls")).await.unwrap();
827+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
828+}
829+
624830 #[tokio::test]
625831 async fn settings_requires_authentication() {
626832 let (_state, app) = app().await;