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 = [
5611 "config",5611 "config",
5612 "flate2",5612 "flate2",
5613 "git",5613 "git",
5614 "git2",
5614 "grep",5615 "grep",
5615 "highlight",5616 "highlight",
5616 "mail",5617 "mail",
@@ -5621,6 +5622,7 @@ dependencies = [
5621 "serde",5622 "serde",
5622 "serde_json",5623 "serde_json",
5623 "store",5624 "store",
5625 "tempfile",
5624 "thiserror 2.0.19",5626 "thiserror 2.0.19",
5625 "time",5627 "time",
5626 "tokio",5628 "tokio",
assets/base.css +53 −0
@@ -357,6 +357,59 @@ body.drawer-open .drawer-backdrop {
357 color: var(--fb-danger);357 color: var(--fb-danger);
358 border-color: var(--fb-danger);358 border-color: var(--fb-danger);
359}359}
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}
360.issue-header {413.issue-header {
361 margin-bottom: 1rem;414 margin-bottom: 1rem;
362 padding-bottom: 0.75rem;415 padding-bottom: 0.75rem;
crates/git/src/repo.rs +45 −0
@@ -307,6 +307,51 @@ impl Repo {
307 Ok(out)307 Ok(out)
308 }308 }
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
310 /// Fetch a single commit in full.355 /// Fetch a single commit in full.
311 ///356 ///
312 /// # Errors357 /// # Errors
crates/store/src/issues.rs +140 −1
@@ -4,7 +4,7 @@
44
5//! Issues and pull requests (shared `issues` table), comments, and issue labels.5//! Issues and pull requests (shared `issues` table), comments, and issue labels.
66
7use model::{Comment, Issue, IssueState, Label};7use model::{Comment, Issue, IssueState, Label, PullRequest};
8use sqlx::any::AnyRow;8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};9use sqlx::{AssertSqlSafe, Row};
1010
@@ -387,6 +387,145 @@ impl Store {
387 Ok(())387 Ok(())
388 }388 }
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
390 /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`].529 /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`].
391 fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> {530 fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> {
392 Ok(Issue {531 Ok(Issue {
crates/web/Cargo.toml +2 −0
@@ -42,6 +42,8 @@ tracing-subscriber = { workspace = true }
4242
43[dev-dependencies]43[dev-dependencies]
44tower = { workspace = true, features = ["util"] }44tower = { workspace = true, features = ["util"] }
45tempfile = { workspace = true }
46git2 = { workspace = true }
4547
46[lints]48[lints]
47workspace = true49workspace = true
crates/web/src/issues.rs +30 −10
@@ -49,6 +49,16 @@ impl Kind {
49 tab: Tab::Issues,49 tab: Tab::Issues,
50 }50 }
51 }51 }
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 }
52}62}
5363
54/// `GET …/-/issues` — the issue list with open/closed tabs.64/// `GET …/-/issues` — the issue list with open/closed tabs.
@@ -271,7 +281,7 @@ pub(crate) async fn view(
271// ---- shared render helpers ----281// ---- shared render helpers ----
272282
273/// A single comment card (also used for the issue body).283/// A single comment card (also used for the issue body).
274fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {284pub(crate) fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {
275 html! {285 html! {
276 div class="card comment" {286 div class="card comment" {
277 div class="comment-head muted" {287 div class="comment-head muted" {
@@ -295,7 +305,7 @@ fn state_icon(st: IssueState, is_pull: bool) -> Markup {
295}305}
296306
297/// The open/closed state badge.307/// The open/closed state badge.
298fn state_badge(st: IssueState, is_pull: bool) -> Markup {308pub(crate) fn state_badge(st: IssueState, is_pull: bool) -> Markup {
299 let (class, label) = match st {309 let (class, label) = match st {
300 IssueState::Open => ("badge badge-state-open", "Open"),310 IssueState::Open => ("badge badge-state-open", "Open"),
301 IssueState::Closed => ("badge badge-state-closed", "Closed"),311 IssueState::Closed => ("badge badge-state-closed", "Closed"),
@@ -308,7 +318,7 @@ fn state_badge(st: IssueState, is_pull: bool) -> Markup {
308}318}
309319
310/// The close/reopen button (posts to the state endpoint).320/// The close/reopen button (posts to the state endpoint).
311fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {321pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
312 let (target, label) = match issue.state {322 let (target, label) = match issue.state {
313 IssueState::Open => ("closed", format!("Close {}", kind.noun)),323 IssueState::Open => ("closed", format!("Close {}", kind.noun)),
314 IssueState::Closed => ("open", format!("Reopen {}", kind.noun)),324 IssueState::Closed => ("open", format!("Reopen {}", kind.noun)),
@@ -323,7 +333,12 @@ fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
323}333}
324334
325/// The assignee select form (owner + collaborators).335/// The assignee select form (owner + collaborators).
326async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &str) -> Markup {336pub(crate) async fn assignee_form(
337 issue: &Issue,
338 ctx: &RepoCtx,
339 state: &AppState,
340 csrf: &str,
341) -> Markup {
327 let mut options: Vec<(String, String)> =342 let mut options: Vec<(String, String)> =
328 vec![(ctx.owner.id.clone(), ctx.owner.username.clone())];343 vec![(ctx.owner.id.clone(), ctx.owner.username.clone())];
329 if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await {344 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
346}361}
347362
348/// The label-toggle form: checkboxes for each repo label.363/// The label-toggle form: checkboxes for each repo label.
349fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &str) -> Markup {364pub(crate) fn label_form(
365 issue: &Issue,
366 repo_labels: &[Label],
367 applied: &[Label],
368 csrf: &str,
369) -> Markup {
350 let is_on = |id: &str| applied.iter().any(|l| l.id == id);370 let is_on = |id: &str| applied.iter().any(|l| l.id == id);
351 html! {371 html! {
352 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" {372 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
364}384}
365385
366/// Resolve a username for display, falling back to the id.386/// Resolve a username for display, falling back to the id.
367async fn username(state: &AppState, user_id: &str) -> String {387pub(crate) async fn username(state: &AppState, user_id: &str) -> String {
368 state388 state
369 .store389 .store
370 .user_by_id(user_id)390 .user_by_id(user_id)
@@ -377,7 +397,7 @@ async fn username(state: &AppState, user_id: &str) -> String {
377// ---- POST handlers (fixed-prefix routes, keyed by id) ----397// ---- POST handlers (fixed-prefix routes, keyed by id) ----
378398
379/// Load an issue's repo and the viewer's access to it.399/// Load an issue's repo and the viewer's access to it.
380async fn repo_of_issue(400pub(crate) async fn repo_of_issue(
381 state: &AppState,401 state: &AppState,
382 viewer: Option<&User>,402 viewer: Option<&User>,
383 issue: &Issue,403 issue: &Issue,
@@ -392,7 +412,7 @@ async fn repo_of_issue(
392}412}
393413
394/// Compute the viewer's access to a repo.414/// Compute the viewer's access to a repo.
395async fn access_for(415pub(crate) async fn access_for(
396 state: &AppState,416 state: &AppState,
397 viewer: Option<&User>,417 viewer: Option<&User>,
398 repo: &model::Repo,418 repo: &model::Repo,
@@ -418,7 +438,7 @@ async fn access_for(
418}438}
419439
420/// The path to an issue/PR.440/// The path to an issue/PR.
421async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {441pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {
422 let owner = username(state, &repo.owner_id).await;442 let owner = username(state, &repo.owner_id).await;
423 let seg = if issue.is_pull { "pulls" } else { "issues" };443 let seg = if issue.is_pull { "pulls" } else { "issues" };
424 format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)444 format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)
@@ -679,7 +699,7 @@ pub async fn set_labels(
679}699}
680700
681/// Redirect on success, render the error otherwise.701/// Redirect on success, render the error otherwise.
682fn respond_redirect(result: AppResult<String>) -> Response {702pub(crate) fn respond_redirect(result: AppResult<String>) -> Response {
683 match result {703 match result {
684 Ok(dest) => Redirect::to(&dest).into_response(),704 Ok(dest) => Redirect::to(&dest).into_response(),
685 Err(err) => err.into_response(),705 Err(err) => err.into_response(),
crates/web/src/lib.rs +3 −0
@@ -21,6 +21,7 @@ mod issues;
21mod layout;21mod layout;
22mod markdown;22mod markdown;
23mod pages;23mod pages;
24mod pulls;
24mod repo;25mod repo;
25mod search;26mod search;
26#[path = "serve.rs"]27#[path = "serve.rs"]
@@ -183,6 +184,8 @@ pub fn build_router(state: AppState) -> Router {
183 .route("/issue/{id}/state", post(issues::set_state))184 .route("/issue/{id}/state", post(issues::set_state))
184 .route("/issue/{id}/assignee", post(issues::set_assignee))185 .route("/issue/{id}/assignee", post(issues::set_assignee))
185 .route("/issue/{id}/labels", post(issues::set_labels))186 .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))
186 .route("/healthz", get(serve_assets::healthz))189 .route("/healthz", get(serve_assets::healthz))
187 .route("/version", get(serve_assets::version))190 .route("/version", get(serve_assets::version))
188 .route("/theme.css", get(serve_assets::theme_default))191 .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
14use std::path::PathBuf;
15
16use axum::extract::{Path, State};
17use axum::http::{HeaderMap, Uri};
18use axum::response::{IntoResponse, Redirect, Response};
19use axum_extra::extract::cookie::CookieJar;
20use maud::{Markup, html};
21use model::User;
22use serde::Deserialize;
23
24use crate::AppState;
25use crate::diff;
26use crate::error::{AppError, AppResult};
27use 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};
31use crate::layout::page;
32use crate::pages::build_chrome;
33use crate::repo::{RepoCtx, Tab, branch_names, label_chip, repo_header};
34use crate::session::{MaybeUser, verify_csrf};
35
36/// `GET …/-/pulls/new` — the branch-compare form for opening a pull request.
37pub(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.
96pub(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.
195struct 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.
215async 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.
265fn 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.
312fn 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.
320fn 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.
370async 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.
392fn 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)]
409pub 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.
428pub 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)]
481pub 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).
491pub 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.
540async 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(
305 },305 },
306 }306 }
307 }307 }
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 }
308 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,322 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
309 // Reserved `runs` slot returns 404 until built.323 // Reserved `runs` slot returns 404 until built.
310 _ => Err(AppError::NotFound),324 _ => Err(AppError::NotFound),
@@ -1538,6 +1552,54 @@ async fn load_commit(
1538 .await1552 .await
1539}1553}
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).
1557pub(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.
1562pub(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
1541/// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole1603/// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole
1542/// document.1604/// document.
1543fn render_one_file(1605fn render_one_file(
crates/web/src/tests.rs +206 −0
@@ -621,6 +621,212 @@ async fn issues_404_when_disabled() {
621 assert_eq!(res.status(), StatusCode::NOT_FOUND);621 assert_eq!(res.status(), StatusCode::NOT_FOUND);
622}622}
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.
627async 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.
657fn 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]
689async 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]
805async 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
624#[tokio::test]830#[tokio::test]
625async fn settings_requires_authentication() {831async fn settings_requires_authentication() {
626 let (_state, app) = app().await;832 let (_state, app) = app().await;