samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
703 lines· 22.1 KB ·Rust
Raw
1//! Pull requests: compare, open, discuss, review changes, merge and close.
2
3use askama::Template;
4use axum::Form;
5use axum::extract::{Path, Query, State};
6use git2::{Oid, Repository};
7use serde::Deserialize;
8
9use super::fmt::urlpath;
10use super::repo::{RepoCtx, load_repo};
11use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render};
12use crate::db::{self, PrEvent, PullRequest};
13use crate::git::{self, CommitInfo, DiffSummary, MergeMethod, MergeRequest, Mergeability};
14use crate::markdown;
15use crate::state::AppState;
16use crate::sync;
17
18// ---------------------------------------------------------------------------
19// Compare
20// ---------------------------------------------------------------------------
21
22pub 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
34impl 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")]
42struct ComparePage {
43 ctx: Ctx,
44 r: RepoCtx,
45 tab: &'static str,
46 view: CompareView,
47}
48
49#[derive(Deserialize)]
50pub struct CompareQuery {
51 base: Option<String>,
52 head: Option<String>,
53}
54
55pub 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
74pub 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
87async 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`
159fn 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)]
169pub struct CreateForm {
170 base: String,
171 head: String,
172 title: String,
173 #[serde(default)]
174 body: String,
175}
176
177pub 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")]
235struct 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)]
246pub struct ListQuery {
247 state: Option<String>,
248}
249
250pub 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.
276pub 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
287fn 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).
292fn 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
305fn 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
332async 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
354pub struct TimelineItem {
355 pub event: PrEvent,
356 pub html: String,
357}
358
359#[derive(Template)]
360#[template(path = "repo/pull.html")]
361struct 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
374async 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
424pub 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
432pub 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
440pub 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)]
453pub struct CommentForm {
454 #[serde(default)]
455 body: String,
456 close: Option<String>,
457}
458
459pub 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
484async 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)]
507pub struct StateForm {
508 action: String,
509}
510
511pub 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)]
572pub struct MergeForm {
573 method: String,
574}
575
576pub 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)]
675pub struct EditForm {
676 title: String,
677 #[serde(default)]
678 body: String,
679}
680
681pub 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}