| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 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 | 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 | }; |
| 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 | |
| 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 | |
| 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 | |
| 96 | #[allow(clippy::too_many_lines)] |
| 97 | pub(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 | |
| 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 | |
| 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 | |
| 231 | struct Loaded { |
| 232 | |
| 233 | author: String, |
| 234 | |
| 235 | comment_rows: Vec<(model::Comment, String)>, |
| 236 | |
| 237 | issue_labels: Vec<model::Label>, |
| 238 | |
| 239 | repo_labels: Vec<model::Label>, |
| 240 | |
| 241 | assignee: Option<String>, |
| 242 | |
| 243 | commits: Vec<git::CommitSummary>, |
| 244 | |
| 245 | files: Vec<crate::diff::RenderedFile>, |
| 246 | |
| 247 | mergeable: Option<git::merge::Mergeability>, |
| 248 | |
| 249 | deps: Vec<model::Issue>, |
| 250 | |
| 251 | dependents: Vec<model::Issue>, |
| 252 | |
| 253 | blocked: bool, |
| 254 | } |
| 255 | |
| 256 | |
| 257 | async 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 | |
| 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 | |
| 311 | |
| 312 | |
| 313 | fn 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 | |
| 360 | fn 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 | |
| 368 | |
| 369 | fn 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 | |
| 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 | |
| 428 | async 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 | |
| 448 | |
| 449 | |
| 450 | fn 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 | |
| 466 | #[derive(Debug, Deserialize)] |
| 467 | pub struct NewPullForm { |
| 468 | |
| 469 | #[serde(default)] |
| 470 | base_ref: String, |
| 471 | |
| 472 | #[serde(default)] |
| 473 | head_ref: String, |
| 474 | |
| 475 | #[serde(default)] |
| 476 | title: String, |
| 477 | |
| 478 | #[serde(default)] |
| 479 | body: String, |
| 480 | |
| 481 | #[serde(rename = "_csrf")] |
| 482 | csrf: String, |
| 483 | } |
| 484 | |
| 485 | |
| 486 | pub 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 | |
| 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 | |
| 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 | |
| 539 | #[derive(Debug, Deserialize)] |
| 540 | pub struct MergeForm { |
| 541 | |
| 542 | #[serde(default)] |
| 543 | strategy: String, |
| 544 | |
| 545 | #[serde(rename = "_csrf")] |
| 546 | csrf: String, |
| 547 | } |
| 548 | |
| 549 | |
| 550 | pub 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 | |
| 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 | |
| 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 | |
| 616 | |
| 617 | fn 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 | |
| 640 | async 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)] |
| 693 | mod 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 | |
| 701 | assert!(closing_refs("see #3").is_empty()); |
| 702 | assert!(closing_refs("closesnt #4").is_empty()); |
| 703 | |
| 704 | assert_eq!(closing_refs("fix #5 fix #5 close #2"), vec![2, 5]); |
| 705 | } |
| 706 | } |