fabrica

hanna/fabrica

26400 bytes
Raw
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 access_for, assignee_form, comment_box, comment_card, deps_section, edit_issue_form, issue_url,
29 label_form, locked_note, repo_of_issue, respond_redirect, state_badge, 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.
96#[allow(clippy::too_many_lines)] // Cohesive view: header, thread, merge panel, diff.
97pub(crate) async fn view(
98 state: &AppState,
99 viewer: Option<User>,
100 jar: CookieJar,
101 uri: &Uri,
102 ctx: RepoCtx,
103 number: i64,
104) -> AppResult<Response> {
105 if !ctx.repo.pulls_enabled {
106 return Err(AppError::NotFound);
107 }
108 let Some(issue) = state
109 .store
110 .issue_by_number(&ctx.repo.id, number, true)
111 .await?
112 else {
113 // A `#n` reference to a plain issue lands here — redirect to it.
114 if ctx.repo.issues_enabled
115 && state
116 .store
117 .issue_by_number(&ctx.repo.id, number, false)
118 .await?
119 .is_some()
120 {
121 let dest = format!(
122 "/{}/{}/-/issues/{number}",
123 ctx.owner.username, ctx.repo.path
124 );
125 return Ok(Redirect::to(&dest).into_response());
126 }
127 return Err(AppError::NotFound);
128 };
129 let pr = state
130 .store
131 .pull_by_issue(&issue.id)
132 .await?
133 .ok_or(AppError::NotFound)?;
134
135 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
136 let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id);
137 let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone());
138 let branches = branch_names(state, &ctx.repo.id).await;
139 let Loaded {
140 author,
141 comment_rows,
142 issue_labels,
143 repo_labels,
144 assignee,
145 commits,
146 files,
147 mergeable,
148 deps,
149 dependents,
150 blocked,
151 } = gather(state, &ctx, &issue, &pr, can_write).await?;
152
153 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
154 let csrf = chrome.csrf.clone();
155 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
156 // Paginate long comment threads (the pager only shows past one page).
157 let paging = crate::repo::Pagination::from_query(uri.query());
158 let (comment_rows, comments_next) = crate::repo::page_slice(&comment_rows, paging);
159 let body = html! {
160 (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
161 div class="issue-view" {
162 header class="issue-header" {
163 div class="issue-title-line" {
164 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
165 @if can_write || is_author {
166 (edit_issue_form(&issue, &csrf))
167 }
168 }
169 div class="issue-sub" {
170 (pr_state_badge(&issue, &pr))
171 span class="muted" {
172 " " (author) " wants to merge "
173 code { (pr.head_ref) } " into " code { (pr.base_ref) }
174 }
175 }
176 }
177 div class="issue-main" {
178 article class="issue-thread" {
179 @let body_action = format!("/issue/{}/edit-body", issue.id);
180 (comment_card(&author, &repo_base, &issue.body, issue.created_at,
181 (can_write || is_author).then_some((body_action.as_str(), csrf.as_str()))))
182 @for (c, cauthor) in &comment_rows {
183 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
184 @let action = format!("/comment/{}/edit", c.id);
185 (comment_card(cauthor, &repo_base, &c.body, c.created_at,
186 editable.then_some((action.as_str(), csrf.as_str()))))
187 }
188 @if comments_next || paging.page > 1 {
189 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
190 }
191 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
192 @if issue.locked() { (locked_note(can_write)) }
193 @if can_write || (is_author && !issue.locked()) {
194 (comment_box(&issue, &csrf, can_write))
195 }
196 }
197 aside class="issue-side" {
198 section {
199 h3 { "Assignee" }
200 @if can_write {
201 (assignee_form(&issue, &ctx, state, &csrf).await)
202 } @else if let Some(a) = &assignee {
203 p { (a) }
204 } @else {
205 p class="muted" { "No one" }
206 }
207 }
208 section {
209 h3 { "Labels" }
210 @if can_write && !repo_labels.is_empty() {
211 (label_form(&issue, &repo_labels, &issue_labels, &csrf))
212 } @else if issue_labels.is_empty() {
213 p class="muted" { "None" }
214 } @else {
215 div class="label-set" { @for l in &issue_labels { (label_chip(l)) } }
216 }
217 }
218 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))
219 }
220 }
221 @if issue.state == model::IssueState::Open {
222 (pr_changes(&commits, &files))
223 }
224 }
225 };
226 let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path);
227 Ok((jar, page(&chrome, &title, body)).into_response())
228}
229
230/// Everything the PR view needs beyond the issue/PR rows themselves.
231struct Loaded {
232 /// The author's display name.
233 author: String,
234 /// Each comment paired with its author's name.
235 comment_rows: Vec<(model::Comment, String)>,
236 /// Labels applied to this PR.
237 issue_labels: Vec<model::Label>,
238 /// All labels defined on the repo (for the picker).
239 repo_labels: Vec<model::Label>,
240 /// The assignee's name, if any.
241 assignee: Option<String>,
242 /// The commits the PR contributes (empty once closed).
243 commits: Vec<git::CommitSummary>,
244 /// The rendered three-dot diff (empty once closed).
245 files: Vec<crate::diff::RenderedFile>,
246 /// Mergeability, computed only for an open PR the viewer can merge.
247 mergeable: Option<git::merge::Mergeability>,
248 /// The issues/PRs this PR is blocked by.
249 deps: Vec<model::Issue>,
250 /// The issues/PRs blocked by this PR.
251 dependents: Vec<model::Issue>,
252 /// Whether any dependency is still open (blocking the merge).
253 blocked: bool,
254}
255
256/// Load the comments, labels, assignee, diff, and mergeability for the PR view.
257async fn gather(
258 state: &AppState,
259 ctx: &RepoCtx,
260 issue: &model::Issue,
261 pr: &model::PullRequest,
262 can_write: bool,
263) -> AppResult<Loaded> {
264 let author = username(state, &issue.author_id).await;
265 let comments = state.store.list_comments(&issue.id).await?;
266 let mut comment_rows = Vec::with_capacity(comments.len());
267 for c in comments {
268 let name = username(state, &c.author_id).await;
269 comment_rows.push((c, name));
270 }
271 let issue_labels = state.store.issue_labels(&issue.id).await?;
272 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
273 let assignee = match &issue.assignee_id {
274 Some(id) => Some(username(state, id).await),
275 None => None,
276 };
277
278 let open = issue.state == model::IssueState::Open;
279 // Three-dot commits/diff; best-effort so a deleted branch does not 500.
280 let (commits, files) = if open {
281 crate::repo::load_range_diff(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref)
282 .await
283 .unwrap_or_default()
284 } else {
285 (Vec::new(), Vec::new())
286 };
287 let mergeable = if open && pr.merged_at.is_none() && can_write {
288 analyze(state, &ctx.repo.id, &pr.base_ref, &pr.head_ref).await
289 } else {
290 None
291 };
292 let deps = state.store.dependencies_of(&issue.id).await?;
293 let dependents = state.store.dependents_of(&issue.id).await?;
294 let blocked = deps.iter().any(|d| d.state == model::IssueState::Open);
295 Ok(Loaded {
296 author,
297 comment_rows,
298 issue_labels,
299 repo_labels,
300 assignee,
301 commits,
302 files,
303 mergeable,
304 deps,
305 dependents,
306 blocked,
307 })
308}
309
310// ---- render helpers ----
311
312/// The commits + diff a pull request contributes.
313fn pr_changes(commits: &[git::CommitSummary], files: &[crate::diff::RenderedFile]) -> Markup {
314 let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| {
315 let (fa, fd) = f.stats();
316 (a + fa, d + fd)
317 });
318 html! {
319 section class="pr-changes" {
320 h2 { (commits.len()) " commits · " (files.len()) " files changed "
321 span class="muted" {
322 span style="color:var(--fb-diff-add-fg)" { "+" (adds) }
323 " "
324 span style="color:var(--fb-diff-del-fg)" { "−" (dels) }
325 }
326 }
327 @if !commits.is_empty() {
328 ul class="entry-list card pr-commits" {
329 @for c in commits {
330 li class="entry-row" {
331 div class="entry-row-body" {
332 span class="entry-row-title" { (c.summary) }
333 div class="entry-row-meta muted mono" { (c.oid.short()) }
334 }
335 }
336 }
337 }
338 }
339 @for (i, f) in files.iter().enumerate() {
340 details open id=(format!("file-{i}")) class="diff-file" {
341 summary {
342 @let (fa, fd) = f.stats();
343 span class="mono" { (f.path()) }
344 " " span class="muted" { "+" (fa) " −" (fd) }
345 }
346 @if f.diff.is_binary {
347 p class="muted" style="padding:0.5rem" { "Binary file not shown." }
348 } @else if f.diff.hunks.is_empty() {
349 p class="muted" style="padding:0.5rem" { "No textual changes." }
350 } @else {
351 (diff::unified(f, None))
352 }
353 }
354 }
355 }
356 }
357}
358
359/// The state badge for a PR, distinguishing a merged PR from a plain closed one.
360fn pr_state_badge(issue: &model::Issue, pr: &model::PullRequest) -> Markup {
361 if pr.merged_at.is_some() {
362 return html! { span class="badge badge-state-merged" { "Merged" } };
363 }
364 state_badge(issue.state, true)
365}
366
367/// The merge panel: shows mergeability and, when clean and unblocked, the
368/// strategy buttons. Open dependencies block the merge.
369fn merge_panel(
370 issue: &model::Issue,
371 pr: &model::PullRequest,
372 mergeable: Option<git::merge::Mergeability>,
373 blocked: bool,
374 csrf: &str,
375) -> Markup {
376 use git::merge::Mergeability;
377 if let Some(sha) = &pr.merge_sha {
378 return html! {
379 div class="card merge-panel merged" {
380 p { "This pull request was merged" }
381 @if let Some(s) = &pr.merge_strategy { " " span class="muted" { "(" (s) ")" } }
382 p class="mono muted" { (sha) }
383 }
384 };
385 }
386 if issue.state != model::IssueState::Open {
387 return html! {};
388 }
389 // A merge is blocked until every dependency is closed.
390 if blocked && mergeable.is_some() {
391 return html! {
392 div class="card merge-panel conflicts" {
393 p { "This pull request is blocked by open dependencies and cannot be merged yet." }
394 }
395 };
396 }
397 match mergeable {
398 None => html! {},
399 Some(Mergeability::AlreadyMerged) => html! {
400 div class="card merge-panel" {
401 p class="muted" { "The base branch already contains these commits — nothing to merge." }
402 }
403 },
404 Some(Mergeability::Conflicts) => html! {
405 div class="card merge-panel conflicts" {
406 p { "This branch has conflicts that must be resolved before it can be merged." }
407 }
408 },
409 Some(Mergeability::Clean) => html! {
410 div class="card merge-panel clean" {
411 p { "This branch has no conflicts with the base branch." }
412 form method="post" action=(format!("/pull/{}/merge", issue.id)) class="merge-form" {
413 input type="hidden" name="_csrf" value=(csrf);
414 label for="strategy" { "Method " }
415 select id="strategy" name="strategy" {
416 option value="merge" { "Create a merge commit" }
417 option value="squash" { "Squash and merge" }
418 option value="rebase" { "Rebase and merge" }
419 }
420 button class="btn btn-primary inline-btn" type="submit" { "Merge pull request" }
421 }
422 }
423 },
424 }
425}
426
427/// Run the (blocking) mergeability analysis on the blocking pool.
428async fn analyze(
429 state: &AppState,
430 repo_id: &str,
431 base_ref: &str,
432 head_ref: &str,
433) -> Option<git::merge::Mergeability> {
434 let binary = state.config.git.binary.clone();
435 let home = state.config.storage.data_dir.join("tmp");
436 let repo_path = git::repo_path(&state.config.storage.repo_dir, repo_id).ok()?;
437 let base = base_ref.to_string();
438 let head = head_ref.to_string();
439 tokio::task::spawn_blocking(move || {
440 git::merge::analyze(&binary, &repo_path, &home, &base, &head).ok()
441 })
442 .await
443 .ok()
444 .flatten()
445}
446
447// ---- POST handlers ----
448
449/// `?base_ref=&head_ref=` for pre-filling the compare form.
450fn compare_query(uri: &Uri) -> (Option<String>, Option<String>) {
451 let mut base = None;
452 let mut head = None;
453 if let Some(q) = uri.query() {
454 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
455 match k.as_ref() {
456 "base_ref" => base = Some(v.into_owned()),
457 "head_ref" => head = Some(v.into_owned()),
458 _ => {}
459 }
460 }
461 }
462 (base, head)
463}
464
465/// New-pull-request form fields.
466#[derive(Debug, Deserialize)]
467pub struct NewPullForm {
468 /// The base branch (merged into).
469 #[serde(default)]
470 base_ref: String,
471 /// The head branch (source of the changes).
472 #[serde(default)]
473 head_ref: String,
474 /// Title.
475 #[serde(default)]
476 title: String,
477 /// Markdown body.
478 #[serde(default)]
479 body: String,
480 /// CSRF token.
481 #[serde(rename = "_csrf")]
482 csrf: String,
483}
484
485/// `POST /pull-new/{repo_id}` — open a pull request.
486pub async fn create(
487 State(state): State<AppState>,
488 MaybeUser(viewer): MaybeUser,
489 jar: CookieJar,
490 headers: HeaderMap,
491 Path(repo_id): Path<String>,
492 axum::Form(form): axum::Form<NewPullForm>,
493) -> Response {
494 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
495 return AppError::Forbidden.into_response();
496 }
497 let result = async {
498 let user = viewer.ok_or(AppError::Forbidden)?;
499 let repo = state
500 .store
501 .repo_by_id(&repo_id)
502 .await?
503 .ok_or(AppError::NotFound)?;
504 if !repo.pulls_enabled {
505 return Err(AppError::NotFound);
506 }
507 // Read access is enough to open a PR.
508 if access_for(&state, Some(&user), &repo).await? < auth::Access::Read {
509 return Err(AppError::NotFound);
510 }
511 let title = form.title.trim();
512 let base = form.base_ref.trim();
513 let head = form.head_ref.trim();
514 if title.is_empty() {
515 return Err(AppError::BadRequest("A title is required.".to_string()));
516 }
517 if base.is_empty() || head.is_empty() || base == head {
518 return Err(AppError::BadRequest(
519 "Choose two different branches to compare.".to_string(),
520 ));
521 }
522 // Both refs must exist.
523 let names = branch_names(&state, &repo.id).await;
524 if !names.iter().any(|b| b == base) || !names.iter().any(|b| b == head) {
525 return Err(AppError::BadRequest("Unknown branch.".to_string()));
526 }
527 let issue = state
528 .store
529 .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head)
530 .await?;
531 crate::issues::notify_mentions(&state, &user.id, &repo, &issue.id, form.body.trim()).await;
532 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
533 }
534 .await;
535 respond_redirect(result)
536}
537
538/// Merge form: the chosen strategy.
539#[derive(Debug, Deserialize)]
540pub struct MergeForm {
541 /// `merge` | `squash` | `rebase`.
542 #[serde(default)]
543 strategy: String,
544 /// CSRF token.
545 #[serde(rename = "_csrf")]
546 csrf: String,
547}
548
549/// `POST /pull/{id}/merge` — merge a pull request (Write access).
550pub async fn merge(
551 State(state): State<AppState>,
552 MaybeUser(viewer): MaybeUser,
553 jar: CookieJar,
554 headers: HeaderMap,
555 Path(id): Path<String>,
556 axum::Form(form): axum::Form<MergeForm>,
557) -> Response {
558 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
559 return AppError::Forbidden.into_response();
560 }
561 let result = async {
562 let user = viewer.ok_or(AppError::Forbidden)?;
563 let issue = state
564 .store
565 .issue_by_id(&id)
566 .await?
567 .ok_or(AppError::NotFound)?;
568 if !issue.is_pull {
569 return Err(AppError::NotFound);
570 }
571 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
572 if access < auth::Access::Write {
573 return Err(AppError::Forbidden);
574 }
575 let pr = state
576 .store
577 .pull_by_issue(&issue.id)
578 .await?
579 .ok_or(AppError::NotFound)?;
580 if pr.merged_at.is_some() || issue.state != model::IssueState::Open {
581 return Err(AppError::BadRequest(
582 "This pull request is already closed.".to_string(),
583 ));
584 }
585 // Refuse to merge while any dependency is still open.
586 if state.store.open_dependency_count(&issue.id).await? > 0 {
587 return Err(AppError::BadRequest(
588 "This pull request is blocked by open dependencies.".to_string(),
589 ));
590 }
591
592 let strategy = git::merge::MergeStrategy::from_token(&form.strategy);
593 let sha = run_merge(&state, &repo, &pr, &user, strategy, &issue).await?;
594 state
595 .store
596 .mark_pull_merged(&issue.id, &user.id, &sha, strategy.as_str())
597 .await?;
598 // Close any issues the PR body says it closes ("closes #1", "fixes #2").
599 for number in closing_refs(&issue.body) {
600 if let Some(target) = state.store.issue_by_number(&repo.id, number, false).await?
601 && target.state == model::IssueState::Open
602 {
603 let _ = state
604 .store
605 .set_issue_state(&target.id, model::IssueState::Closed)
606 .await;
607 }
608 }
609 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
610 }
611 .await;
612 respond_redirect(result)
613}
614
615/// Parse GitHub-style closing keywords (`closes #1`, `fixes #2`, `resolves #3`)
616/// from a PR body, returning the referenced issue numbers.
617fn closing_refs(text: &str) -> Vec<i64> {
618 const KEYWORDS: &[&str] = &[
619 "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
620 ];
621 let lower = text.to_lowercase();
622 let words: Vec<&str> = lower.split_whitespace().collect();
623 let mut out = Vec::new();
624 for pair in words.windows(2) {
625 if KEYWORDS.contains(&pair[0])
626 && let Some(rest) = pair[1].strip_prefix('#')
627 {
628 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
629 if let Ok(n) = digits.parse::<i64>() {
630 out.push(n);
631 }
632 }
633 }
634 out.sort_unstable();
635 out.dedup();
636 out
637}
638
639/// Run the blocking server-side merge on the blocking pool.
640async fn run_merge(
641 state: &AppState,
642 repo: &model::Repo,
643 pr: &model::PullRequest,
644 user: &User,
645 strategy: git::merge::MergeStrategy,
646 issue: &model::Issue,
647) -> AppResult<String> {
648 let binary = state.config.git.binary.clone();
649 let home: PathBuf = state.config.storage.data_dir.join("tmp");
650 let repo_path = git::repo_path(&state.config.storage.repo_dir, &repo.id)?;
651 let workdir = home.join(format!("merge-{}", issue.id));
652 let base = pr.base_ref.clone();
653 let head = pr.head_ref.clone();
654 let name = user
655 .display_name
656 .clone()
657 .unwrap_or_else(|| user.username.clone());
658 let email = user.email.clone();
659 let message = format!("Merge pull request #{} — {}", issue.number, issue.title);
660
661 let sha = tokio::task::spawn_blocking(move || {
662 let req = git::merge::MergeRequest {
663 binary: &binary,
664 repo_path: &repo_path,
665 workdir: &workdir,
666 base_branch: &base,
667 head_ref: &head,
668 strategy,
669 message: &message,
670 identity: git::merge::MergeIdentity {
671 home: &home,
672 name: &name,
673 email: &email,
674 },
675 };
676 git::merge::merge(&req)
677 })
678 .await
679 .map_err(AppError::internal)?;
680
681 sha.map_err(|e| match e {
682 git::GitError::MergeConflict(detail) if detail.is_empty() => {
683 AppError::BadRequest("The branch could not be merged cleanly.".to_string())
684 }
685 git::GitError::MergeConflict(detail) => {
686 AppError::BadRequest(format!("The branch could not be merged cleanly: {detail}"))
687 }
688 other => AppError::from(other),
689 })
690}
691
692#[cfg(test)]
693mod tests {
694 use super::closing_refs;
695
696 #[test]
697 fn parses_closing_keywords() {
698 assert_eq!(closing_refs("Closes #1 and fixes #2."), vec![1, 2]);
699 assert_eq!(closing_refs("resolves #10"), vec![10]);
700 // No keyword, or a plain mention, does not close.
701 assert!(closing_refs("see #3").is_empty());
702 assert!(closing_refs("closesnt #4").is_empty());
703 // Deduped and sorted.
704 assert_eq!(closing_refs("fix #5 fix #5 close #2"), vec![2, 5]);
705 }
706}