Sam Jeston
History
703 lines·
22.1 KB
·Rust
| 1 | //! Pull requests: compare, open, discuss, review changes, merge and close. |
| 2 | |
| 3 | use askama::Template; |
| 4 | use axum::Form; |
| 5 | use axum::extract::{Path, Query, State}; |
| 6 | use git2::{Oid, Repository}; |
| 7 | use serde::Deserialize; |
| 8 | |
| 9 | use super::fmt::urlpath; |
| 10 | use super::repo::{RepoCtx, load_repo}; |
| 11 | use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render}; |
| 12 | use crate::db::{self, PrEvent, PullRequest}; |
| 13 | use crate::git::{self, CommitInfo, DiffSummary, MergeMethod, MergeRequest, Mergeability}; |
| 14 | use crate::markdown; |
| 15 | use crate::state::AppState; |
| 16 | use crate::sync; |
| 17 | |
| 18 | // --------------------------------------------------------------------------- |
| 19 | // Compare |
| 20 | // --------------------------------------------------------------------------- |
| 21 | |
| 22 | pub struct CompareView { |
| 23 | pub base: String, |
| 24 | pub head: String, |
| 25 | pub branches: Vec<String>, |
| 26 | pub commits: Vec<CommitInfo>, |
| 27 | pub diff: Option<DiffSummary>, |
| 28 | pub mergeability: Option<Mergeability>, |
| 29 | pub existing: Option<i64>, |
| 30 | pub title: String, |
| 31 | pub notice: Option<String>, |
| 32 | } |
| 33 | |
| 34 | impl CompareView { |
| 35 | pub fn can_open(&self) -> bool { |
| 36 | self.existing.is_none() && !self.commits.is_empty() |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | #[derive(Template)] |
| 41 | #[template(path = "repo/compare.html")] |
| 42 | struct ComparePage { |
| 43 | ctx: Ctx, |
| 44 | r: RepoCtx, |
| 45 | tab: &'static str, |
| 46 | view: CompareView, |
| 47 | } |
| 48 | |
| 49 | #[derive(Deserialize)] |
| 50 | pub struct CompareQuery { |
| 51 | base: Option<String>, |
| 52 | head: Option<String>, |
| 53 | } |
| 54 | |
| 55 | pub async fn compare_default( |
| 56 | State(state): State<AppState>, |
| 57 | ctx: Ctx, |
| 58 | Path((owner, name)): Path<(String, String)>, |
| 59 | Query(q): Query<CompareQuery>, |
| 60 | ) -> WebResult { |
| 61 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 62 | let base = q.base.unwrap_or_else(|| r.repo.default_branch.clone()); |
| 63 | match q.head.filter(|h| !h.is_empty()) { |
| 64 | Some(head) => Ok(redirect(&format!( |
| 65 | "{}/compare/{}...{}", |
| 66 | r.base(), |
| 67 | urlpath(&base), |
| 68 | urlpath(&head) |
| 69 | ))), |
| 70 | None => compare_page(&state, ctx, r, base, String::new()).await, |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | pub async fn compare( |
| 75 | State(state): State<AppState>, |
| 76 | ctx: Ctx, |
| 77 | Path((owner, name, spec)): Path<(String, String, String)>, |
| 78 | ) -> WebResult { |
| 79 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 80 | let (base, head) = match spec.split_once("...") { |
| 81 | Some((b, h)) => (b.to_string(), h.to_string()), |
| 82 | None => (r.repo.default_branch.clone(), spec.clone()), |
| 83 | }; |
| 84 | compare_page(&state, ctx, r, base, head).await |
| 85 | } |
| 86 | |
| 87 | async fn compare_page( |
| 88 | state: &AppState, |
| 89 | ctx: Ctx, |
| 90 | r: RepoCtx, |
| 91 | base: String, |
| 92 | head: String, |
| 93 | ) -> WebResult { |
| 94 | let existing = if head.is_empty() { |
| 95 | None |
| 96 | } else { |
| 97 | db::find_open_pull(&state.db, r.repo.id, &base, &head).await? |
| 98 | }; |
| 99 | let path = r.path.clone(); |
| 100 | let view = blocking(move || { |
| 101 | let repo = git::open(&path)?; |
| 102 | let branches = git::branch_names(&repo)?; |
| 103 | let mut view = CompareView { |
| 104 | base: base.clone(), |
| 105 | head: head.clone(), |
| 106 | branches, |
| 107 | commits: Vec::new(), |
| 108 | diff: None, |
| 109 | mergeability: None, |
| 110 | existing, |
| 111 | title: String::new(), |
| 112 | notice: None, |
| 113 | }; |
| 114 | if head.is_empty() { |
| 115 | view.notice = Some("Choose a branch to compare against the base.".into()); |
| 116 | return Ok(view); |
| 117 | } |
| 118 | let (Some(base_tip), Some(head_tip)) = |
| 119 | (git::branch_tip(&repo, &base), git::branch_tip(&repo, &head)) |
| 120 | else { |
| 121 | view.notice = Some("One of the selected branches does not exist.".into()); |
| 122 | return Ok(view); |
| 123 | }; |
| 124 | if base == head { |
| 125 | view.notice = Some("Choose two different branches to see what changed.".into()); |
| 126 | return Ok(view); |
| 127 | } |
| 128 | let Some(merge_base) = git::merge_base(&repo, base_tip, head_tip) else { |
| 129 | view.notice = Some(format!( |
| 130 | "{base} and {head} have entirely different histories." |
| 131 | )); |
| 132 | return Ok(view); |
| 133 | }; |
| 134 | view.commits = git::commits_between(&repo, base_tip, head_tip)?; |
| 135 | if view.commits.is_empty() { |
| 136 | view.notice = Some(format!( |
| 137 | "{base} is up to date with all commits from {head}." |
| 138 | )); |
| 139 | return Ok(view); |
| 140 | } |
| 141 | view.title = match view.commits.as_slice() { |
| 142 | [only] => only.summary.clone(), |
| 143 | _ => humanize_branch(&head), |
| 144 | }; |
| 145 | view.mergeability = Some(git::mergeability(&repo, base_tip, head_tip)?); |
| 146 | view.diff = Some(git::range_diff(&repo, merge_base, head_tip)?); |
| 147 | Ok(view) |
| 148 | }) |
| 149 | .await?; |
| 150 | render(&ComparePage { |
| 151 | ctx, |
| 152 | r, |
| 153 | tab: "pulls", |
| 154 | view, |
| 155 | }) |
| 156 | } |
| 157 | |
| 158 | /// `feature/add-login` → `Feature/add login` |
| 159 | fn humanize_branch(branch: &str) -> String { |
| 160 | let s = branch.replace(['-', '_'], " "); |
| 161 | let mut chars = s.chars(); |
| 162 | match chars.next() { |
| 163 | Some(first) => first.to_uppercase().chain(chars).collect(), |
| 164 | None => s, |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | #[derive(Deserialize)] |
| 169 | pub struct CreateForm { |
| 170 | base: String, |
| 171 | head: String, |
| 172 | title: String, |
| 173 | #[serde(default)] |
| 174 | body: String, |
| 175 | } |
| 176 | |
| 177 | pub async fn create( |
| 178 | State(state): State<AppState>, |
| 179 | ctx: Ctx, |
| 180 | Path((owner, name)): Path<(String, String)>, |
| 181 | Form(f): Form<CreateForm>, |
| 182 | ) -> WebResult { |
| 183 | let user = ctx.require_user()?.clone(); |
| 184 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 185 | let title = f.title.trim().chars().take(256).collect::<String>(); |
| 186 | if title.is_empty() { |
| 187 | return Err(AppError::BadRequest("A pull request needs a title.".into())); |
| 188 | } |
| 189 | if let Some(number) = db::find_open_pull(&state.db, r.repo.id, &f.base, &f.head).await? { |
| 190 | return Ok(redirect(&format!("{}/pull/{number}", r.base()))); |
| 191 | } |
| 192 | let path = r.path.clone(); |
| 193 | let (base, head) = (f.base.clone(), f.head.clone()); |
| 194 | let (head_tip, merge_base) = blocking(move || { |
| 195 | let repo = git::open(&path)?; |
| 196 | let (Some(base_tip), Some(head_tip)) = |
| 197 | (git::branch_tip(&repo, &base), git::branch_tip(&repo, &head)) |
| 198 | else { |
| 199 | return Err(AppError::BadRequest("Both branches must exist.".into())); |
| 200 | }; |
| 201 | if base == head || git::is_ancestor(&repo, head_tip, base_tip) { |
| 202 | return Err(AppError::BadRequest( |
| 203 | "There is nothing to merge between these branches.".into(), |
| 204 | )); |
| 205 | } |
| 206 | let merge_base = git::merge_base(&repo, base_tip, head_tip).ok_or_else(|| { |
| 207 | AppError::BadRequest("These branches have entirely different histories.".into()) |
| 208 | })?; |
| 209 | Ok((head_tip, merge_base)) |
| 210 | }) |
| 211 | .await?; |
| 212 | let number = db::create_pull( |
| 213 | &state.db, |
| 214 | r.repo.id, |
| 215 | user.id, |
| 216 | &title, |
| 217 | f.body.trim(), |
| 218 | &f.base, |
| 219 | &f.head, |
| 220 | &head_tip.to_string(), |
| 221 | &merge_base.to_string(), |
| 222 | ) |
| 223 | .await?; |
| 224 | let path = r.path.clone(); |
| 225 | blocking(move || Ok(git::set_pull_ref(&git::open(&path)?, number, head_tip)?)).await?; |
| 226 | Ok(redirect(&format!("{}/pull/{number}", r.base()))) |
| 227 | } |
| 228 | |
| 229 | // --------------------------------------------------------------------------- |
| 230 | // List |
| 231 | // --------------------------------------------------------------------------- |
| 232 | |
| 233 | #[derive(Template)] |
| 234 | #[template(path = "repo/pulls.html")] |
| 235 | struct PullsPage { |
| 236 | ctx: Ctx, |
| 237 | r: RepoCtx, |
| 238 | tab: &'static str, |
| 239 | pulls: Vec<PullRequest>, |
| 240 | showing_open: bool, |
| 241 | open_count: i64, |
| 242 | closed_count: i64, |
| 243 | } |
| 244 | |
| 245 | #[derive(Deserialize)] |
| 246 | pub struct ListQuery { |
| 247 | state: Option<String>, |
| 248 | } |
| 249 | |
| 250 | pub async fn list( |
| 251 | State(state): State<AppState>, |
| 252 | ctx: Ctx, |
| 253 | Path((owner, name)): Path<(String, String)>, |
| 254 | Query(q): Query<ListQuery>, |
| 255 | ) -> WebResult { |
| 256 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 257 | let showing_open = q.state.as_deref() != Some("closed"); |
| 258 | let pulls = db::list_pulls(&state.db, r.repo.id, showing_open).await?; |
| 259 | let (open_count, closed_count) = db::count_pulls(&state.db, r.repo.id).await?; |
| 260 | render(&PullsPage { |
| 261 | ctx, |
| 262 | r, |
| 263 | tab: "pulls", |
| 264 | pulls, |
| 265 | showing_open, |
| 266 | open_count, |
| 267 | closed_count, |
| 268 | }) |
| 269 | } |
| 270 | |
| 271 | // --------------------------------------------------------------------------- |
| 272 | // Pull request pages |
| 273 | // --------------------------------------------------------------------------- |
| 274 | |
| 275 | /// Everything derived from git about a pull request. |
| 276 | pub struct PullData { |
| 277 | pub head_exists: bool, |
| 278 | pub base_exists: bool, |
| 279 | pub commits: Vec<CommitInfo>, |
| 280 | pub mergeability: Option<Mergeability>, |
| 281 | pub files: usize, |
| 282 | pub additions: usize, |
| 283 | pub deletions: usize, |
| 284 | pub diff: Option<DiffSummary>, |
| 285 | } |
| 286 | |
| 287 | fn parse_oid(s: Option<&str>) -> Option<Oid> { |
| 288 | s.and_then(|s| Oid::from_str(s).ok()) |
| 289 | } |
| 290 | |
| 291 | /// The commit range a pull request covers: (merge base, head). |
| 292 | fn pull_range(repo: &Repository, pr: &PullRequest) -> (Option<Oid>, Option<Oid>) { |
| 293 | let stored_head = parse_oid(pr.head_sha.as_deref()).or_else(|| git::pull_ref(repo, pr.number)); |
| 294 | if !pr.is_open() { |
| 295 | return (parse_oid(pr.base_sha.as_deref()), stored_head); |
| 296 | } |
| 297 | let head = git::branch_tip(repo, &pr.head_branch).or(stored_head); |
| 298 | let base = git::branch_tip(repo, &pr.base_branch); |
| 299 | match (base, head) { |
| 300 | (Some(b), Some(h)) => (git::merge_base(repo, b, h), Some(h)), |
| 301 | _ => (parse_oid(pr.base_sha.as_deref()), head), |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | fn pull_data(repo: &Repository, pr: &PullRequest, with_diff: bool) -> Result<PullData, AppError> { |
| 306 | let head_tip = git::branch_tip(repo, &pr.head_branch); |
| 307 | let base_tip = git::branch_tip(repo, &pr.base_branch); |
| 308 | let (from, to) = pull_range(repo, pr); |
| 309 | let mut data = PullData { |
| 310 | head_exists: head_tip.is_some(), |
| 311 | base_exists: base_tip.is_some(), |
| 312 | commits: Vec::new(), |
| 313 | mergeability: None, |
| 314 | files: 0, |
| 315 | additions: 0, |
| 316 | deletions: 0, |
| 317 | diff: None, |
| 318 | }; |
| 319 | if let (Some(from), Some(to)) = (from, to) { |
| 320 | data.commits = git::commits_between(repo, from, to)?; |
| 321 | (data.files, data.additions, data.deletions) = git::diff_stats(repo, from, to)?; |
| 322 | if with_diff { |
| 323 | data.diff = Some(git::range_diff(repo, from, to)?); |
| 324 | } |
| 325 | } |
| 326 | if let (true, Some(base), Some(head)) = (pr.is_open(), base_tip, head_tip) { |
| 327 | data.mergeability = Some(git::mergeability(repo, base, head)?); |
| 328 | } |
| 329 | Ok(data) |
| 330 | } |
| 331 | |
| 332 | async fn load_pull( |
| 333 | state: &AppState, |
| 334 | r: &RepoCtx, |
| 335 | number: i64, |
| 336 | with_diff: bool, |
| 337 | ) -> Result<(PullRequest, PullData), AppError> { |
| 338 | let mut pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 339 | .await? |
| 340 | .ok_or(AppError::NotFound)?; |
| 341 | if pr.is_open() { |
| 342 | // Pick up pushes and merges that happened since the last sync. |
| 343 | sync::refresh_pulls(state, r.repo.id, None).await?; |
| 344 | pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 345 | .await? |
| 346 | .ok_or(AppError::NotFound)?; |
| 347 | } |
| 348 | let path = r.path.clone(); |
| 349 | let pr_clone = pr.clone(); |
| 350 | let data = blocking(move || pull_data(&git::open(&path)?, &pr_clone, with_diff)).await?; |
| 351 | Ok((pr, data)) |
| 352 | } |
| 353 | |
| 354 | pub struct TimelineItem { |
| 355 | pub event: PrEvent, |
| 356 | pub html: String, |
| 357 | } |
| 358 | |
| 359 | #[derive(Template)] |
| 360 | #[template(path = "repo/pull.html")] |
| 361 | struct PullPage { |
| 362 | ctx: Ctx, |
| 363 | r: RepoCtx, |
| 364 | tab: &'static str, |
| 365 | pr: PullRequest, |
| 366 | data: PullData, |
| 367 | pull_tab: &'static str, |
| 368 | body_html: String, |
| 369 | timeline: Vec<TimelineItem>, |
| 370 | can_close: bool, |
| 371 | can_delete_branch: bool, |
| 372 | } |
| 373 | |
| 374 | async fn pull_page( |
| 375 | state: &AppState, |
| 376 | ctx: Ctx, |
| 377 | owner: &str, |
| 378 | name: &str, |
| 379 | number: i64, |
| 380 | pull_tab: &'static str, |
| 381 | ) -> WebResult { |
| 382 | let r = load_repo(state, &ctx, owner, name).await?; |
| 383 | let (pr, data) = load_pull(state, &r, number, pull_tab == "files").await?; |
| 384 | let user = ctx.user.as_deref(); |
| 385 | let can_close = user.is_some_and(|u| u.id == pr.author_id) || r.access.write; |
| 386 | let can_delete_branch = r.access.write |
| 387 | && !pr.is_open() |
| 388 | && data.head_exists |
| 389 | && pr.head_branch != r.repo.default_branch |
| 390 | && db::find_open_pull_by_head(&state.db, r.repo.id, &pr.head_branch) |
| 391 | .await? |
| 392 | .is_none(); |
| 393 | let timeline = if pull_tab == "conversation" { |
| 394 | db::list_events(&state.db, pr.id) |
| 395 | .await? |
| 396 | .into_iter() |
| 397 | .map(|event| TimelineItem { |
| 398 | html: if event.kind == "comment" { |
| 399 | markdown::render(&event.body, None) |
| 400 | } else { |
| 401 | String::new() |
| 402 | }, |
| 403 | event, |
| 404 | }) |
| 405 | .collect() |
| 406 | } else { |
| 407 | Vec::new() |
| 408 | }; |
| 409 | let body_html = markdown::render(&pr.body, None); |
| 410 | render(&PullPage { |
| 411 | ctx, |
| 412 | r, |
| 413 | tab: "pulls", |
| 414 | pr, |
| 415 | data, |
| 416 | pull_tab, |
| 417 | body_html, |
| 418 | timeline, |
| 419 | can_close, |
| 420 | can_delete_branch, |
| 421 | }) |
| 422 | } |
| 423 | |
| 424 | pub async fn conversation( |
| 425 | State(state): State<AppState>, |
| 426 | ctx: Ctx, |
| 427 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 428 | ) -> WebResult { |
| 429 | pull_page(&state, ctx, &owner, &name, number, "conversation").await |
| 430 | } |
| 431 | |
| 432 | pub async fn commits( |
| 433 | State(state): State<AppState>, |
| 434 | ctx: Ctx, |
| 435 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 436 | ) -> WebResult { |
| 437 | pull_page(&state, ctx, &owner, &name, number, "commits").await |
| 438 | } |
| 439 | |
| 440 | pub async fn files( |
| 441 | State(state): State<AppState>, |
| 442 | ctx: Ctx, |
| 443 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 444 | ) -> WebResult { |
| 445 | pull_page(&state, ctx, &owner, &name, number, "files").await |
| 446 | } |
| 447 | |
| 448 | // --------------------------------------------------------------------------- |
| 449 | // Actions |
| 450 | // --------------------------------------------------------------------------- |
| 451 | |
| 452 | #[derive(Deserialize)] |
| 453 | pub struct CommentForm { |
| 454 | #[serde(default)] |
| 455 | body: String, |
| 456 | close: Option<String>, |
| 457 | } |
| 458 | |
| 459 | pub async fn comment( |
| 460 | State(state): State<AppState>, |
| 461 | ctx: Ctx, |
| 462 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 463 | Form(f): Form<CommentForm>, |
| 464 | ) -> WebResult { |
| 465 | let user = ctx.require_user()?.clone(); |
| 466 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 467 | let pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 468 | .await? |
| 469 | .ok_or(AppError::NotFound)?; |
| 470 | let body = f.body.trim(); |
| 471 | if !body.is_empty() { |
| 472 | db::add_event(&state.db, pr.id, Some(user.id), "comment", body).await?; |
| 473 | } |
| 474 | let url = format!("{}/pull/{number}", r.base()); |
| 475 | if f.close.is_some() && pr.is_open() { |
| 476 | return close_pull(&state, &r, &pr, user.id, &url).await; |
| 477 | } |
| 478 | if body.is_empty() { |
| 479 | return Ok(redirect_flash(&url, "error", "Comment cannot be empty.")); |
| 480 | } |
| 481 | Ok(redirect(&url)) |
| 482 | } |
| 483 | |
| 484 | async fn close_pull( |
| 485 | state: &AppState, |
| 486 | r: &RepoCtx, |
| 487 | pr: &PullRequest, |
| 488 | user_id: i64, |
| 489 | url: &str, |
| 490 | ) -> WebResult { |
| 491 | if !(r.access.write || pr.author_id == user_id) { |
| 492 | return Err(AppError::Forbidden( |
| 493 | "Only the author or a collaborator can close this.".into(), |
| 494 | )); |
| 495 | } |
| 496 | let path = r.path.clone(); |
| 497 | let pr_clone = pr.clone(); |
| 498 | let (base, head) = blocking(move || Ok(pull_range(&git::open(&path)?, &pr_clone))).await?; |
| 499 | let head = head.map(|h| h.to_string()).unwrap_or_default(); |
| 500 | let base = base.map(|b| b.to_string()).unwrap_or_default(); |
| 501 | db::mark_closed(&state.db, pr.id, &head, &base).await?; |
| 502 | db::add_event(&state.db, pr.id, Some(user_id), "closed", "").await?; |
| 503 | Ok(redirect(url)) |
| 504 | } |
| 505 | |
| 506 | #[derive(Deserialize)] |
| 507 | pub struct StateForm { |
| 508 | action: String, |
| 509 | } |
| 510 | |
| 511 | pub async fn set_state( |
| 512 | State(state): State<AppState>, |
| 513 | ctx: Ctx, |
| 514 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 515 | Form(f): Form<StateForm>, |
| 516 | ) -> WebResult { |
| 517 | let user = ctx.require_user()?.clone(); |
| 518 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 519 | let pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 520 | .await? |
| 521 | .ok_or(AppError::NotFound)?; |
| 522 | let url = format!("{}/pull/{number}", r.base()); |
| 523 | match f.action.as_str() { |
| 524 | "close" if pr.is_open() => close_pull(&state, &r, &pr, user.id, &url).await, |
| 525 | "reopen" if pr.state == "closed" => { |
| 526 | if !(r.access.write || pr.author_id == user.id) { |
| 527 | return Err(AppError::Forbidden( |
| 528 | "Only the author or a collaborator can reopen this.".into(), |
| 529 | )); |
| 530 | } |
| 531 | if db::find_open_pull(&state.db, r.repo.id, &pr.base_branch, &pr.head_branch) |
| 532 | .await? |
| 533 | .is_some() |
| 534 | { |
| 535 | return Ok(redirect_flash( |
| 536 | &url, |
| 537 | "error", |
| 538 | "Another open pull request already exists for these branches.", |
| 539 | )); |
| 540 | } |
| 541 | let path = r.path.clone(); |
| 542 | let (base_branch, head_branch, n) = |
| 543 | (pr.base_branch.clone(), pr.head_branch.clone(), pr.number); |
| 544 | let tips = blocking(move || { |
| 545 | let repo = git::open(&path)?; |
| 546 | let (Some(base), Some(head)) = ( |
| 547 | git::branch_tip(&repo, &base_branch), |
| 548 | git::branch_tip(&repo, &head_branch), |
| 549 | ) else { |
| 550 | return Ok(None); |
| 551 | }; |
| 552 | git::set_pull_ref(&repo, n, head)?; |
| 553 | Ok(git::merge_base(&repo, base, head).map(|mb| (head, mb))) |
| 554 | }) |
| 555 | .await?; |
| 556 | let Some((head, merge_base)) = tips else { |
| 557 | return Ok(redirect_flash( |
| 558 | &url, |
| 559 | "error", |
| 560 | "This pull request cannot be reopened because a branch no longer exists.", |
| 561 | )); |
| 562 | }; |
| 563 | db::mark_reopened(&state.db, pr.id, &head.to_string(), &merge_base.to_string()).await?; |
| 564 | db::add_event(&state.db, pr.id, Some(user.id), "reopened", "").await?; |
| 565 | Ok(redirect(&url)) |
| 566 | } |
| 567 | _ => Ok(redirect(&url)), |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | #[derive(Deserialize)] |
| 572 | pub struct MergeForm { |
| 573 | method: String, |
| 574 | } |
| 575 | |
| 576 | pub async fn merge( |
| 577 | State(state): State<AppState>, |
| 578 | ctx: Ctx, |
| 579 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 580 | Form(f): Form<MergeForm>, |
| 581 | ) -> WebResult { |
| 582 | let user = ctx.require_user()?.clone(); |
| 583 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 584 | r.require_write()?; |
| 585 | let pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 586 | .await? |
| 587 | .ok_or(AppError::NotFound)?; |
| 588 | let url = format!("{}/pull/{number}", r.base()); |
| 589 | if !pr.is_open() { |
| 590 | return Ok(redirect(&url)); |
| 591 | } |
| 592 | let method = MergeMethod::parse(&f.method) |
| 593 | .ok_or_else(|| AppError::BadRequest("Unknown merge method.".into()))?; |
| 594 | |
| 595 | let path = r.path.clone(); |
| 596 | let pr_clone = pr.clone(); |
| 597 | let (committer_name, committer_email) = (user.name().to_string(), user.email.clone()); |
| 598 | let result = blocking(move || { |
| 599 | let repo = git::open(&path)?; |
| 600 | let (Some(base), Some(head)) = ( |
| 601 | git::branch_tip(&repo, &pr_clone.base_branch), |
| 602 | git::branch_tip(&repo, &pr_clone.head_branch), |
| 603 | ) else { |
| 604 | return Ok(Err("Both branches must exist to merge.".to_string())); |
| 605 | }; |
| 606 | match git::mergeability(&repo, base, head)? { |
| 607 | Mergeability::Clean => {} |
| 608 | Mergeability::Conflicts => { |
| 609 | return Ok(Err( |
| 610 | "This branch has conflicts that must be resolved first.".into(), |
| 611 | )); |
| 612 | } |
| 613 | Mergeability::UpToDate => return Ok(Err("There is nothing to merge.".into())), |
| 614 | } |
| 615 | let merge_base = git::merge_base(&repo, base, head) |
| 616 | .ok_or_else(|| AppError::BadRequest("Branches have unrelated histories.".into()))?; |
| 617 | let message = match method { |
| 618 | MergeMethod::Merge => format!( |
| 619 | "Merge pull request #{} from {}\n\n{}", |
| 620 | pr_clone.number, pr_clone.head_branch, pr_clone.title |
| 621 | ), |
| 622 | MergeMethod::Squash => { |
| 623 | let mut m = format!("{} (#{})\n", pr_clone.title, pr_clone.number); |
| 624 | let commits = git::commits_between(&repo, base, head)?; |
| 625 | if commits.len() > 1 { |
| 626 | m.push('\n'); |
| 627 | for c in commits { |
| 628 | m.push_str(&format!("* {}\n", c.summary)); |
| 629 | } |
| 630 | } |
| 631 | m |
| 632 | } |
| 633 | MergeMethod::Rebase => String::new(), |
| 634 | }; |
| 635 | let sig = git2::Signature::now(&committer_name, &committer_email)?; |
| 636 | let new_tip = match git::merge( |
| 637 | &repo, |
| 638 | &MergeRequest { |
| 639 | base_branch: &pr_clone.base_branch, |
| 640 | base, |
| 641 | head, |
| 642 | method, |
| 643 | message: &message, |
| 644 | committer: &sig, |
| 645 | }, |
| 646 | ) { |
| 647 | Ok(tip) => tip, |
| 648 | Err(e) => return Ok(Err(e.to_string())), |
| 649 | }; |
| 650 | git::set_pull_ref(&repo, pr_clone.number, head)?; |
| 651 | Ok(Ok((new_tip, head, merge_base))) |
| 652 | }) |
| 653 | .await?; |
| 654 | |
| 655 | let (new_tip, head, merge_base) = match result { |
| 656 | Ok(v) => v, |
| 657 | Err(msg) => return Ok(redirect_flash(&url, "error", &msg)), |
| 658 | }; |
| 659 | db::mark_merged( |
| 660 | &state.db, |
| 661 | pr.id, |
| 662 | Some(user.id), |
| 663 | Some(&new_tip.to_string()), |
| 664 | &head.to_string(), |
| 665 | &merge_base.to_string(), |
| 666 | ) |
| 667 | .await?; |
| 668 | db::add_event(&state.db, pr.id, Some(user.id), "merged", "").await?; |
| 669 | db::touch_pushed(&state.db, r.repo.id).await?; |
| 670 | sync::refresh_pulls(&state, r.repo.id, Some(user.id)).await?; |
| 671 | Ok(redirect_flash(&url, "success", "Pull request merged.")) |
| 672 | } |
| 673 | |
| 674 | #[derive(Deserialize)] |
| 675 | pub struct EditForm { |
| 676 | title: String, |
| 677 | #[serde(default)] |
| 678 | body: String, |
| 679 | } |
| 680 | |
| 681 | pub async fn edit( |
| 682 | State(state): State<AppState>, |
| 683 | ctx: Ctx, |
| 684 | Path((owner, name, number)): Path<(String, String, i64)>, |
| 685 | Form(f): Form<EditForm>, |
| 686 | ) -> WebResult { |
| 687 | let user = ctx.require_user()?.clone(); |
| 688 | let r = load_repo(&state, &ctx, &owner, &name).await?; |
| 689 | let pr = db::pull_by_number(&state.db, r.repo.id, number) |
| 690 | .await? |
| 691 | .ok_or(AppError::NotFound)?; |
| 692 | if !(r.access.write || pr.author_id == user.id) { |
| 693 | return Err(AppError::Forbidden( |
| 694 | "Only the author or a collaborator can edit this.".into(), |
| 695 | )); |
| 696 | } |
| 697 | let title = f.title.trim().chars().take(256).collect::<String>(); |
| 698 | if title.is_empty() { |
| 699 | return Err(AppError::BadRequest("A pull request needs a title.".into())); |
| 700 | } |
| 701 | db::update_pull_text(&state.db, pr.id, &title, f.body.trim()).await?; |
| 702 | Ok(redirect(&format!("{}/pull/{number}", r.base()))) |
| 703 | } |