steid

@jamesgill /

steid/src/application/pull_request.rs
Rust42.6 KBCode·Blame·History·Raw
1//! Pull requests: opening one, reading it, commenting, closing, and merging it.
2//!
3//! Authorization is [`access`](super::access)'s, asked rather than restated:
4//! `readable_repo` and `writable_repo` already answer "may this actor see this
5//! repository" and "may this actor write to its tracker", and a second copy of those
6//! rules would be a second chance to get them wrong. **Reading follows the repository's
7//! visibility; every write is owner-only.**
8//!
9//! Merging adds one rule the tracker does not have. Opening and commenting on a pull
10//! request are conversation, and stay available on an archived repository for the reason
11//! issues do — a release is exactly when the conversation happens. **Merging writes git
12//! objects and moves a branch**, so it goes through
13//! [`refuse_writes`](crate::domain::Repository::refuse_writes) with
14//! [`RepoWrite::Contents`], the same guard a `git push` meets, and an archived or
15//! still-importing repository refuses it in the same words.
16//!
17//! # What is stored, and what is read live
18//!
19//! Nothing about where the branches point, while the request is open. Every page
20//! resolves both refs when it renders, so a push to the source branch changes what the
21//! request proposes and a force-push is simply the new proposal. The cost is a handful
22//! of git questions per view; the alternative is a stored head that silently disagrees
23//! with the repository.
24
25use std::{collections::HashMap, time::SystemTime};
26
27use crate::domain::{
28 Actor, DomainError, IssueComment, IssueCommentId, MergeStrategy, ObjectId, OrgName,
29 PullRequest, PullRequestComment, PullRequestCommentId, PullRequestId, PullRequestState,
30 PushEvent, RefName, RepoName, RepoWrite, Repository, closes,
31 repository::{
32 IssueRepository, LabelRepository, MembershipRepository, OrgRepository, PullRequestCounts,
33 PullRequestRepository, PushEventRepository, RepoRepository, UserRepository,
34 },
35};
36
37use super::{
38 access::{Stores, readable_repo, writable_repo},
39 error::{Error, Result},
40 issue::{CommentView, Handles},
41 label::{LabelView, labels_by_pull},
42 paging::{Paged, clamp, offset, page_count},
43 port::{CommitTreeRequest, GitMerge, GitQuery, MergePreview},
44 push::record_push,
45};
46
47/// How many pull requests one page of a list shows.
48///
49/// The issue list's page, because a row reads the same way: two lines, read rather than
50/// scanned past.
51pub const PULL_LIMIT: usize = 50;
52
53/// How many commit subjects a squash message lists before it stops.
54///
55/// A squash of four hundred commits must not produce a four-hundred-line commit
56/// message; past this the message says how many were left out, which is the fact
57/// somebody reading `git log` a year later actually needs.
58pub const MAX_SQUASHED_COMMITS: usize = 50;
59
60/// What someone typed into the new-pull-request form. Raw — validation belongs to
61/// [`PullRequest::new`] and to the branch check in [`create_pull`].
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NewPull {
64 pub title: String,
65 pub body: String,
66 pub source: RefName,
67 pub target: RefName,
68}
69
70/// An edit to a pull request's title and body. The branches are not editable — see
71/// [`PullRequest::edit`].
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PullEdit {
74 pub title: String,
75 pub body: String,
76}
77
78/// A pull request as it appears in a listing.
79///
80/// The body is absent, like [`IssueSummary`](super::IssueSummary)'s; the two branches
81/// are not, because `source → target` is what distinguishes two requests with similar
82/// titles and it is the line the row is scanned by.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PullSummary {
85 pub number: crate::domain::IssueNumber,
86 pub title: String,
87 pub state: PullRequestState,
88 /// The author's handle, or `None` if their account is gone. Rendered as "someone" —
89 /// a request outlives the account that opened it.
90 pub author: Option<OrgName>,
91 pub created_at: SystemTime,
92 pub comments: u64,
93 pub source_branch: RefName,
94 pub target_branch: RefName,
95 /// The labels this request carries — the same labels the issues wear, from the same
96 /// repository-wide set.
97 pub labels: Vec<LabelView>,
98}
99
100/// A repository's pull requests in one state, plus the counts the toolbar needs for all
101/// three.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PullList {
104 /// Which state was asked for, carried back so a page cannot render the wrong tab as
105 /// current after a mangled query string.
106 pub state: PullRequestState,
107 pub open: u64,
108 pub merged: u64,
109 pub closed: u64,
110 /// One page of them, newest first, [`PULL_LIMIT`] to a page.
111 pub pulls: Paged<PullSummary>,
112 pub viewer_is_owner: bool,
113 /// The label `?label=` resolved to, or `None` when nothing was asked for. A filter
114 /// naming a label that has since been deleted resolves to `None` while the raw name
115 /// stays in the URL, which is what lets the page say what it is filtering on rather
116 /// than silently widening.
117 pub filter: Option<LabelView>,
118}
119
120/// Whether a pull request can be merged, and what stands in the way.
121///
122/// A parsed answer rather than an error for each way it can go nowhere, exactly as
123/// [`Compared`](super::Compared) is: a conflict, a deleted branch and a git that is too
124/// old are all real states of a real repository, and the merge box has a sentence for
125/// each. Turning any of them into a failure would put a 500 in front of an ordinary
126/// pull request.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Mergeability {
129 /// The request is merged or closed, so the question does not arise.
130 NotOpen,
131 /// This server's `git` is older than [`merge_preview`](GitQuery::merge_preview)
132 /// needs. Read once at boot — see `GitFeatures` — so the box says so rather than
133 /// offering a button that cannot work.
134 Unsupported,
135 /// One of the two branches is not in the repository any more. Named, because which
136 /// one is the whole of what the reader can act on.
137 MissingBranch { branch: RefName },
138 /// Every commit on the source is already on the target: merging would do nothing.
139 NothingToMerge,
140 /// The two branches share no history, so there is no merge to compute.
141 Unrelated,
142 /// It merges. `fast_forward` is whether the target is an ancestor of the source, in
143 /// which case moving the ref is exact and no commit need be written at all.
144 Ready { fast_forward: bool },
145 /// It does not merge. The paths are what disagreed, in git's order.
146 Conflicted { paths: Vec<String> },
147}
148
149impl Mergeability {
150 pub fn is_ready(&self) -> bool {
151 matches!(self, Self::Ready { .. })
152 }
153
154 /// Which strategies the merge box may offer, in the order it should offer them.
155 ///
156 /// Empty unless the merge is actually ready. **Fast-forward leads when it is
157 /// available**: it is the exact answer there — the target simply catches up, with no
158 /// commit invented to say so — and offering a merge commit first would make the
159 /// tidier outcome the one you have to go looking for.
160 pub fn strategies(&self) -> Vec<MergeStrategy> {
161 match self {
162 Self::Ready { fast_forward: true } => vec![
163 MergeStrategy::FastForward,
164 MergeStrategy::Merge,
165 MergeStrategy::Squash,
166 ],
167 Self::Ready {
168 fast_forward: false,
169 } => vec![MergeStrategy::Merge, MergeStrategy::Squash],
170 _ => Vec::new(),
171 }
172 }
173}
174
175/// A pull request and its conversation.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct PullDetail {
178 pub number: crate::domain::IssueNumber,
179 pub title: String,
180 pub body: String,
181 pub state: PullRequestState,
182 pub author: Option<OrgName>,
183 pub created_at: SystemTime,
184 pub closed_at: Option<SystemTime>,
185 pub merged_at: Option<SystemTime>,
186 /// What landed, once it has. The page links it to the commit page.
187 pub merge_commit: Option<ObjectId>,
188 pub source_branch: RefName,
189 pub target_branch: RefName,
190 /// Oldest first — a conversation reads forwards.
191 pub comments: Vec<CommentView>,
192 pub viewer_is_owner: bool,
193 pub merge: Mergeability,
194 /// The labels this request carries.
195 pub labels: Vec<LabelView>,
196 /// Every label the repository has, for the owner's checkbox list. Empty for anyone
197 /// else, exactly as on an issue: no control to fill, no query to spend.
198 pub repo_labels: Vec<LabelView>,
199 /// Whether the source is the repository's default branch — the branch people push
200 /// to and clone into. Deleting that one after a merge is almost never what somebody
201 /// means, so the page draws the box unchecked; every other branch keeps the tidy
202 /// default. Read only when there is a merge button to draw, so an old or closed
203 /// request costs no extra `git` process.
204 pub source_is_default: bool,
205}
206
207/// What a merge did.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct Merged {
210 /// The commit the target branch now points at.
211 pub commit: ObjectId,
212 /// Whether the source branch was deleted afterwards.
213 pub deleted_source: bool,
214 /// One per ref the merge moved, already recorded — the caller delivers the webhooks
215 /// for them, exactly as the push transport does.
216 pub events: Vec<PushEvent>,
217 /// The issues the request's `Closes #n` closed, in the order they were written.
218 /// Empty for a request that named none, and for one that named only issues already
219 /// closed.
220 pub closed: Vec<crate::domain::IssueNumber>,
221}
222
223/// Which kind of thing a repository's `#n` is.
224///
225/// Exists so `/issues/7` and `/pulls/7` can each send a visitor to the other rather than
226/// 404ing: the two share one number sequence precisely so that `#7` names one thing, and
227/// a cross-link written as `#7` cannot know which kind it found.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum Numbered {
230 Issue,
231 PullRequest,
232 /// Neither, or a repository the actor may not see — one answer, because telling
233 /// them apart would count the issues on a private repository.
234 Neither,
235}
236
237// --- Reads --------------------------------------------------------------------------
238
239/// A repository's pull requests in one state, optionally narrowed to one label.
240///
241/// `Ok(None)` means the repository is not there *or* the viewer may not see it, and the
242/// caller must render both identically — the rule [`view_repo`](super::view_repo) sets.
243///
244/// `filter` is a label **name**, straight off `?label=`, and behaves exactly as the
245/// issue list's does: a name the repository has no label for matches nothing, and
246/// **all three counts obey the filter**, so the toolbar cannot say "Open 12" above two
247/// rows. Paying for that costs the other two states' listings, and only when a filter is
248/// applied.
249#[allow(clippy::too_many_arguments)]
250pub async fn list_pulls(
251 actor: &Actor,
252 handle: &OrgName,
253 name: &RepoName,
254 state: PullRequestState,
255 filter: Option<&str>,
256 page: usize,
257 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
258 pulls: &impl PullRequestRepository,
259 users: &impl UserRepository,
260 labels: &impl LabelRepository,
261) -> Result<Option<PullList>> {
262 let Some((repo, viewer_is_owner)) = readable_repo(actor, handle, name, stores).await? else {
263 return Ok(None);
264 };
265
266 // One query for every row's pills rather than one per row.
267 let on_pull = labels_by_pull(&repo, labels).await?;
268
269 let filter = match filter {
270 Some(name) => Some(labels.find_by_name(&repo.id, name).await?),
271 None => None,
272 };
273
274 let mut counts: PullRequestCounts = pulls.counts(&repo.id).await?;
275 let mut page = page;
276
277 // Two paths, the issue list's: which labels a request wears lives in a second table
278 // that is already read whole to render the pills, so a filtered list is narrowed in
279 // memory and paged from there. Unfiltered, the port pages it.
280 let mut listed = match filter.is_some() {
281 true => pulls.list_by_repo(&repo.id, state, 0, usize::MAX).await?,
282 false => {
283 let total = counts.of(state) as usize;
284 page = clamp(page, page_count(total, PULL_LIMIT));
285
286 pulls
287 .list_by_repo(&repo.id, state, offset(page, PULL_LIMIT), PULL_LIMIT)
288 .await?
289 }
290 };
291
292 if let Some(wanted) = &filter {
293 let carries = |pull: &PullRequest| {
294 wanted.as_ref().is_some_and(|wanted| {
295 on_pull
296 .get(&pull.id)
297 .is_some_and(|labels| labels.iter().any(|label| label.id == wanted.id))
298 })
299 };
300
301 let mut counted = PullRequestCounts::default();
302
303 for other in [
304 PullRequestState::Open,
305 PullRequestState::Merged,
306 PullRequestState::Closed,
307 ] {
308 let total = match other == state {
309 true => {
310 listed.retain(&carries);
311 listed.len() as u64
312 }
313 false => pulls
314 .list_by_repo(&repo.id, other, 0, usize::MAX)
315 .await?
316 .iter()
317 .filter(|pull| carries(pull))
318 .count() as u64,
319 };
320
321 match other {
322 PullRequestState::Open => counted.open = total,
323 PullRequestState::Merged => counted.merged = total,
324 PullRequestState::Closed => counted.closed = total,
325 }
326 }
327
328 counts = counted;
329 }
330
331 // The filtered list arrived whole and is cut here; the unfiltered one was cut by the
332 // port and arrives as its own page already.
333 let total = match filter.is_some() {
334 true => listed.len(),
335 false => counts.of(state) as usize,
336 };
337 let paged = match filter.is_some() {
338 true => Paged::slice(listed, page, PULL_LIMIT),
339 false => Paged::of(listed, total, page, PULL_LIMIT),
340 };
341
342 // One query for every row's comment count rather than one per row.
343 let comments: HashMap<PullRequestId, u64> =
344 pulls.comment_counts(&repo.id).await?.into_iter().collect();
345
346 let mut handles = Handles::default();
347 let mut summaries = Vec::with_capacity(paged.items.len());
348
349 for pull in paged.items {
350 summaries.push(PullSummary {
351 number: pull.number,
352 title: pull.title,
353 state: pull.state,
354 author: handles.of(&pull.author_id, stores.orgs, users).await?,
355 created_at: pull.created_at,
356 comments: comments.get(&pull.id).copied().unwrap_or(0),
357 source_branch: pull.source_branch,
358 target_branch: pull.target_branch,
359 labels: on_pull.get(&pull.id).cloned().unwrap_or_default(),
360 });
361 }
362
363 Ok(Some(PullList {
364 state,
365 open: counts.open,
366 merged: counts.merged,
367 closed: counts.closed,
368 pulls: Paged::of(summaries, paged.total, paged.page, PULL_LIMIT),
369 viewer_is_owner,
370 filter: filter.flatten().map(LabelView::from),
371 }))
372}
373
374/// One pull request, its comments, and whether it can be merged right now.
375///
376/// `Ok(None)` covers an invisible repository, a missing one and a number that is not a
377/// pull request there. The caller 404s on all three and then asks
378/// [`numbered_thing`] whether the number is an *issue*, which is the one case where
379/// answering differently is right.
380///
381/// **Between two and four `git` questions**, and only for an open request: two
382/// resolutions (lines to the kept-alive `cat-file`, not processes), then a `merge-base`,
383/// then a `merge-tree` unless the merge base already settles it. A merged or closed
384/// request asks none — there is nothing left to decide.
385#[allow(clippy::too_many_arguments)]
386pub async fn view_pull(
387 actor: &Actor,
388 handle: &OrgName,
389 name: &RepoName,
390 number: crate::domain::IssueNumber,
391 merge_supported: bool,
392 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
393 pulls: &impl PullRequestRepository,
394 users: &impl UserRepository,
395 queries: &impl GitQuery,
396 labels: &impl LabelRepository,
397) -> Result<Option<PullDetail>> {
398 let Some((repo, viewer_is_owner)) = readable_repo(actor, handle, name, stores).await? else {
399 return Ok(None);
400 };
401
402 let Some(pull) = pulls.find_by_number(&repo.id, number).await? else {
403 return Ok(None);
404 };
405
406 let mut handles = Handles::default();
407 let author = handles.of(&pull.author_id, stores.orgs, users).await?;
408
409 let mut comments = Vec::new();
410 for comment in pulls.list_comments(&pull.id).await? {
411 comments.push(CommentView {
412 author: handles.of(&comment.author_id, stores.orgs, users).await?,
413 body: comment.body,
414 created_at: comment.created_at,
415 });
416 }
417
418 let merge = mergeability(handle, name, &pull, merge_supported, queries).await?;
419
420 // One `symbolic-ref`, and only when the merge box will actually offer the checkbox
421 // this answers for. Nothing else on the page depends on it.
422 let source_is_default = if merge.strategies().is_empty() {
423 false
424 } else {
425 queries
426 .default_branch(handle, name)
427 .await?
428 .is_some_and(|branch| branch == pull.source_branch)
429 };
430
431 Ok(Some(PullDetail {
432 number: pull.number,
433 title: pull.title,
434 body: pull.body,
435 state: pull.state,
436 author,
437 created_at: pull.created_at,
438 closed_at: pull.closed_at,
439 merged_at: pull.merged_at,
440 merge_commit: pull.merge_commit,
441 source_branch: pull.source_branch,
442 target_branch: pull.target_branch,
443 comments,
444 viewer_is_owner,
445 merge,
446 labels: labels
447 .labels_for_pull(&pull.id)
448 .await?
449 .into_iter()
450 .map(LabelView::from)
451 .collect(),
452 // Only the owner has a control to fill, so only the owner pays for the query —
453 // the issue page's rule.
454 repo_labels: if viewer_is_owner {
455 labels
456 .list_by_repo(&repo.id)
457 .await?
458 .into_iter()
459 .map(LabelView::from)
460 .collect()
461 } else {
462 Vec::new()
463 },
464 source_is_default,
465 }))
466}
467
468/// What the new-request form should call a pull request between these two branches.
469///
470/// **The source's commit subject, but only when the source adds exactly one commit.**
471/// One commit is the case where the subject is already a title somebody wrote for this
472/// change, and reusing it saves retyping it. Two or more commits have no single subject
473/// that describes the whole, and picking the newest would name the change after whatever
474/// happened to be last — which is worse than an empty field, because it looks deliberate.
475///
476/// `Ok(None)` for every other case, including a repository the actor cannot read: this
477/// only ever fills in a form, so there is nothing to distinguish and nothing to leak.
478///
479/// **Three `git` questions**: two resolutions to the kept-alive `cat-file`, then one
480/// `rev-list` asked for two commits so that "exactly one" needs no separate count.
481pub async fn suggested_title(
482 actor: &Actor,
483 handle: &OrgName,
484 name: &RepoName,
485 source: &RefName,
486 target: &RefName,
487 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
488 queries: &impl GitQuery,
489) -> Result<Option<String>> {
490 if readable_repo(actor, handle, name, stores).await?.is_none() {
491 return Ok(None);
492 }
493
494 let (target_ref, source_ref) = (branch_ref(target), branch_ref(source));
495 let (target_id, source_id) = tokio::try_join!(
496 queries.resolve(handle, name, &target_ref),
497 queries.resolve(handle, name, &source_ref),
498 )?;
499
500 let (Some(target_id), Some(source_id)) = (target_id, source_id) else {
501 return Ok(None);
502 };
503
504 // Two, so one answer covers both "how many" and "which one".
505 let commits = queries
506 .log_between(handle, name, Some(&target_id), &source_id, 0, 2)
507 .await?;
508
509 Ok(match commits.as_slice() {
510 [only] => Some(only.summary.clone()),
511 _ => None,
512 })
513}
514
515/// Whether a repository's `#n` is an issue, a pull request, or neither.
516///
517/// Asks the issues first only because that is the order they were built in; a number
518/// cannot be both, since one sequence hands it out once.
519pub async fn numbered_thing(
520 actor: &Actor,
521 handle: &OrgName,
522 name: &RepoName,
523 number: crate::domain::IssueNumber,
524 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
525 issues: &impl IssueRepository,
526 pulls: &impl PullRequestRepository,
527) -> Result<Numbered> {
528 let Some((repo, _)) = readable_repo(actor, handle, name, stores).await? else {
529 return Ok(Numbered::Neither);
530 };
531
532 if issues.find_by_number(&repo.id, number).await?.is_some() {
533 return Ok(Numbered::Issue);
534 }
535
536 if pulls.find_by_number(&repo.id, number).await?.is_some() {
537 return Ok(Numbered::PullRequest);
538 }
539
540 Ok(Numbered::Neither)
541}
542
543// --- Writes -------------------------------------------------------------------------
544
545/// Opens a pull request. Owner only.
546///
547/// Both branches are resolved before anything is written: a request against a branch
548/// that is not there can never do anything, and refusing it now is a message on a form
549/// rather than a broken page later. **Only the existence is checked, not the contents** —
550/// a request that turns out to conflict is an ordinary pull request and the whole point
551/// of having one.
552///
553/// The number is allocated from the repository's one sequence, shared with issues, and —
554/// like an issue's — **before** validation, so a rejected title burns one. A gap costs
555/// nothing next to a reused number. It comes from `RepoRepository`, which is why opening
556/// a pull request no longer needs the issue port at all.
557#[allow(clippy::too_many_arguments)]
558pub async fn create_pull(
559 actor: &Actor,
560 handle: &OrgName,
561 name: &RepoName,
562 spec: &NewPull,
563 now: SystemTime,
564 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
565 pulls: &impl PullRequestRepository,
566 queries: &impl GitQuery,
567) -> Result<PullRequest> {
568 let repo = writable_repo(actor, handle, name, stores).await?;
569
570 // Present because `writable_repo` already refused an anonymous actor; unwrapping
571 // would still be a panic waiting for the day that changes.
572 let author_id = actor.user_id().ok_or(DomainError::Forbidden)?.clone();
573
574 for (field, branch) in [
575 ("source branch", &spec.source),
576 ("target branch", &spec.target),
577 ] {
578 if queries.resolve(handle, name, branch).await?.is_none() {
579 return Err(DomainError::validation(
580 field,
581 format!("there is no branch called {branch} in this repository"),
582 )
583 .into());
584 }
585 }
586
587 let number = stores.repos.next_number(&repo.id).await?;
588 let pull = PullRequest::new(
589 PullRequestId::generate(),
590 repo.id.clone(),
591 number,
592 spec.title.clone(),
593 spec.body.clone(),
594 author_id,
595 spec.source.clone(),
596 spec.target.clone(),
597 now,
598 )?;
599
600 pulls.save(&pull).await?;
601
602 Ok(pull)
603}
604
605/// Adds a comment. Owner only.
606///
607/// The request's `updated_at` moves with it: the conversation advanced even though what
608/// the request says did not.
609#[allow(clippy::too_many_arguments)]
610pub async fn comment_on_pull(
611 actor: &Actor,
612 handle: &OrgName,
613 name: &RepoName,
614 number: crate::domain::IssueNumber,
615 body: &str,
616 now: SystemTime,
617 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
618 pulls: &impl PullRequestRepository,
619) -> Result<()> {
620 let repo = writable_repo(actor, handle, name, stores).await?;
621 let mut pull = existing_pull(&repo, number, pulls).await?;
622 let author_id = actor.user_id().ok_or(DomainError::Forbidden)?.clone();
623
624 let comment = PullRequestComment::new(
625 PullRequestCommentId::generate(),
626 pull.id.clone(),
627 author_id,
628 body,
629 now,
630 )?;
631
632 pulls.add_comment(&comment).await?;
633
634 pull.touch(now);
635 pulls.save(&pull).await?;
636
637 Ok(())
638}
639
640/// Closes or reopens a pull request. Owner only.
641///
642/// `Merged` is not a state this can be asked for: landing a request is
643/// [`merge_pull`]'s, and a form that could set it would let somebody mark a branch as
644/// shipped without shipping it.
645#[allow(clippy::too_many_arguments)]
646pub async fn set_pull_state(
647 actor: &Actor,
648 handle: &OrgName,
649 name: &RepoName,
650 number: crate::domain::IssueNumber,
651 state: PullRequestState,
652 now: SystemTime,
653 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
654 pulls: &impl PullRequestRepository,
655) -> Result<()> {
656 let repo = writable_repo(actor, handle, name, stores).await?;
657 let mut pull = existing_pull(&repo, number, pulls).await?;
658
659 match state {
660 PullRequestState::Closed => pull.close(now),
661 PullRequestState::Open => pull.reopen(now),
662 PullRequestState::Merged => {
663 return Err(DomainError::validation(
664 "state",
665 "a pull request is merged by merging it, not by being marked merged",
666 )
667 .into());
668 }
669 }
670
671 pulls.save(&pull).await?;
672
673 Ok(())
674}
675
676/// Changes a pull request's title and body. Owner only.
677#[allow(clippy::too_many_arguments)]
678pub async fn edit_pull(
679 actor: &Actor,
680 handle: &OrgName,
681 name: &RepoName,
682 number: crate::domain::IssueNumber,
683 edit: &PullEdit,
684 now: SystemTime,
685 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
686 pulls: &impl PullRequestRepository,
687) -> Result<()> {
688 let repo = writable_repo(actor, handle, name, stores).await?;
689 let mut pull = existing_pull(&repo, number, pulls).await?;
690
691 // Validated before anything is written, so a rejected title leaves the request
692 // exactly as it was.
693 pull.edit(edit.title.clone(), edit.body.clone(), now)?;
694 pulls.save(&pull).await?;
695
696 Ok(())
697}
698
699/// Merges a pull request. Owner only, and the one write here that touches git.
700///
701/// # The order, and why it is that order
702///
703/// 1. **Authorize, and ask the repository whether it accepts contents at all.** An
704/// archived or importing repository refuses here, in the same words a `git push`
705/// gets, before a single object is written.
706/// 2. **Re-resolve both branches and re-check the merge.** Everything the page showed
707/// is a minute old; a branch may have moved or gone since. The mergeability answer
708/// used to *do* the merge is the one computed here, never the one the form was
709/// rendered from.
710/// 3. **Write the commit**, unless the strategy is a fast-forward, which needs none. A
711/// commit written and then not referenced is unreachable and costs only disk, which
712/// is why this is the step that may safely fail.
713/// 4. **Move the target**, guarded on where it was. This is the atomic step: a push
714/// that landed since step 2 makes it fail rather than overwriting what was pushed.
715/// 5. **Delete the source**, if asked, guarded the same way. A failure here leaves a
716/// merged request with a branch still on it, which is untidy and not wrong.
717/// 6. **Record the push events**, through [`record_push`] — the same function the git
718/// transport calls — so a merge reaches webhooks exactly as a push does. The caller
719/// delivers them.
720/// 7. **Mark it merged**, last, because [`PullRequest::merge`] refuses a second call and
721/// that refusal is only meaningful once the git side has actually happened.
722#[allow(clippy::too_many_arguments)]
723pub async fn merge_pull(
724 actor: &Actor,
725 handle: &OrgName,
726 name: &RepoName,
727 number: crate::domain::IssueNumber,
728 strategy: MergeStrategy,
729 delete_source: bool,
730 merge_supported: bool,
731 now: SystemTime,
732 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
733 pulls: &impl PullRequestRepository,
734 events: &impl PushEventRepository,
735 issues: &impl IssueRepository,
736 queries: &impl GitQuery,
737 merges: &impl GitMerge,
738 author: &MergeAuthor,
739) -> Result<Merged> {
740 let repo = writable_repo(actor, handle, name, stores).await?;
741
742 // The shared write guard, the one a push meets. Merging is a write to the contents,
743 // so an archived repository refuses it — while its issues and this very pull
744 // request's conversation stay open, which is the distinction `RepoWrite` exists for.
745 repo.refuse_writes(RepoWrite::Contents)?;
746
747 let mut pull = existing_pull(&repo, number, pulls).await?;
748
749 if !pull.state.is_open() {
750 return Err(DomainError::validation(
751 "state",
752 format!("this pull request is already {}", pull.state),
753 )
754 .into());
755 }
756
757 let (target_id, source_id) = resolved_ends(handle, name, &pull, queries).await?;
758 let (target_id, source_id) = match (target_id, source_id) {
759 (Some(target), Some(source)) => (target, source),
760 _ => {
761 return Err(DomainError::validation(
762 "branch",
763 "one of the branches is no longer in this repository",
764 )
765 .into());
766 }
767 };
768
769 // Recomputed rather than trusted from the form: the page that offered the button is
770 // a minute old, and a branch that moved since would otherwise be merged on stale
771 // information.
772 let ready = mergeability(handle, name, &pull, merge_supported, queries).await?;
773
774 if !ready.is_ready() {
775 return Err(DomainError::validation("merge", refusal(&ready)).into());
776 }
777
778 if !ready.strategies().contains(&strategy) {
779 return Err(DomainError::validation(
780 "strategy",
781 format!(
782 "{} is not available for this pull request",
783 strategy.label()
784 ),
785 )
786 .into());
787 }
788
789 let landed = match strategy {
790 // Nothing to write: the target simply catches up, and the commit it lands on is
791 // the source's own tip.
792 MergeStrategy::FastForward => source_id.clone(),
793 MergeStrategy::Merge | MergeStrategy::Squash => {
794 let MergePreview::Clean { tree } = queries
795 .merge_preview(handle, name, &target_id, &source_id)
796 .await?
797 else {
798 // Unreachable while `mergeability` said Ready, and cheaper to state
799 // than to reason about across two git calls.
800 return Err(DomainError::validation("merge", refusal(&ready)).into());
801 };
802
803 let (parents, message) = match strategy {
804 MergeStrategy::Squash => (
805 vec![target_id.clone()],
806 squash_message(handle, name, &pull, &target_id, &source_id, queries).await?,
807 ),
808 _ => (
809 vec![target_id.clone(), source_id.clone()],
810 merge_message(&pull),
811 ),
812 };
813
814 merges
815 .commit_tree(
816 handle,
817 name,
818 &CommitTreeRequest {
819 tree,
820 parents,
821 message,
822 author_name: author.name.clone(),
823 author_email: author.email.clone(),
824 now,
825 },
826 )
827 .await?
828 }
829 };
830
831 merges
832 .update_ref(handle, name, &pull.target_branch, &landed, &target_id)
833 .await?;
834
835 // The source is deleted only after the target has actually moved, so a guard that
836 // refuses the move cannot cost somebody their branch.
837 let deleted_source = match delete_source {
838 true => {
839 merges
840 .delete_ref(handle, name, &pull.source_branch, &source_id)
841 .await?;
842 true
843 }
844 false => false,
845 };
846
847 // Synthesised snapshots rather than two `for-each-ref` calls: this code moved the
848 // refs and knows exactly which ones and to what, where a push has to diff because
849 // receive-pack does not tell it. Going through `record_push` anyway is the point —
850 // one place decides what a ref movement means, so a merge and a push produce the
851 // same events and reach the same webhooks.
852 let mut before = vec![(branch_ref(&pull.target_branch), target_id.clone())];
853 let mut after = vec![(branch_ref(&pull.target_branch), landed.clone())];
854
855 if deleted_source {
856 before.push((branch_ref(&pull.source_branch), source_id.clone()));
857 // Absent from `after`, which is what `ref_changes` reads as a deletion.
858 }
859
860 after.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
861 before.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
862
863 let recorded = record_push(actor, handle, name, &before, &after, now, stores, events).await?;
864
865 pull.merge(landed.clone(), now)?;
866 pulls.save(&pull).await?;
867
868 // Last, and deliberately: closing an issue is a consequence of the merge, so it
869 // happens only once the merge is a fact. A failure here would leave the branch
870 // moved and an issue open, which is the right way round — the reverse would close
871 // issues for a merge that never landed.
872 let closed = close_referenced(&repo, &pull, actor, now, issues).await?;
873
874 Ok(Merged {
875 commit: landed,
876 deleted_source,
877 events: recorded,
878 closed,
879 })
880}
881
882/// Closes the issues a merged request said it closes, and says so on each.
883///
884/// **Issues only.** A `Closes #7` naming a pull request closes nothing: the numbers are
885/// one sequence, so `#7` may be either, and closing a *request* because another one
886/// merged is not a thing anybody means by the word.
887///
888/// An issue that is already closed is left alone rather than commented on twice, which
889/// makes a re-merge — impossible today, since merged is terminal — a no-op rather than a
890/// second announcement.
891async fn close_referenced(
892 repo: &Repository,
893 pull: &PullRequest,
894 actor: &Actor,
895 now: SystemTime,
896 issues: &impl IssueRepository,
897) -> Result<Vec<crate::domain::IssueNumber>> {
898 // The title as well as the body: a one-line request often says it there and nowhere
899 // else. Both are read at once so a number written in each is closed once.
900 let referenced = closes(&format!("{}\n{}", pull.title, pull.body));
901 let mut closed = Vec::new();
902
903 // The person who pressed Merge. `None` cannot happen — the write guard above refused
904 // an anonymous actor — and this refuses rather than inventing an author.
905 let author_id = actor.user_id().ok_or(DomainError::Forbidden)?.clone();
906
907 for number in referenced {
908 // Not the request itself, however it is written.
909 if number == pull.number {
910 continue;
911 }
912
913 let Some(mut issue) = issues.find_by_number(&repo.id, number).await? else {
914 continue;
915 };
916
917 if !issue.state.is_open() {
918 continue;
919 }
920
921 issues
922 .add_comment(&IssueComment::new(
923 IssueCommentId::generate(),
924 issue.id.clone(),
925 author_id.clone(),
926 format!(
927 "Closed by pull request #{}, merged into `{}`.",
928 pull.number, pull.target_branch
929 ),
930 now,
931 )?)
932 .await?;
933
934 issue.close(now);
935 issues.save(&issue).await?;
936
937 closed.push(number);
938 }
939
940 Ok(closed)
941}
942
943/// Who a merge commit is attributed to.
944///
945/// Passed in rather than resolved here because the caller already has it: the web layer
946/// reads an [`Identity`](super::Identity) for the top bar on every request, and asking
947/// again would be two lookups for one name.
948#[derive(Debug, Clone, PartialEq, Eq)]
949pub struct MergeAuthor {
950 pub name: String,
951 pub email: String,
952}
953
954// --- Working out whether it merges ----------------------------------------------------
955
956/// A branch name as a ref, so a **branch** is what gets resolved.
957///
958/// The bare name would let a tag or a sha answer: `git rev-parse spike` happily resolves
959/// a tag called `spike`, and a pull request whose target were a tag would have nothing to
960/// move at merge time. `refs/heads/` is also exactly what
961/// [`GitMerge::update_ref`](super::port::GitMerge::update_ref) writes, so what is checked
962/// and what is moved are the same ref by construction.
963pub(super) fn branch_ref(branch: &RefName) -> RefName {
964 RefName::from_trusted(format!("refs/heads/{branch}"))
965}
966
967/// What both branches currently point at, or `None` for one that is gone.
968async fn resolved_ends(
969 handle: &OrgName,
970 name: &RepoName,
971 pull: &PullRequest,
972 queries: &impl GitQuery,
973) -> Result<(Option<ObjectId>, Option<ObjectId>)> {
974 // Concurrently: two questions to a `cat-file` that is already running, so the page
975 // waits on one round trip rather than two.
976 // Bound before the join: a `branch_ref(..)` in an argument position is a temporary
977 // the borrow the future holds would outlive.
978 let (target_ref, source_ref) = (
979 branch_ref(&pull.target_branch),
980 branch_ref(&pull.source_branch),
981 );
982
983 let (target, source) = tokio::try_join!(
984 queries.resolve(handle, name, &target_ref),
985 queries.resolve(handle, name, &source_ref),
986 )?;
987
988 Ok((target, source))
989}
990
991/// Decides what the merge box says.
992///
993/// The merge base does most of the work and costs one process: if it *is* the source,
994/// everything is already on the target; if it is the target, the target can simply catch
995/// up. Only the genuine three-way case pays for a `merge-tree`, which is the one where
996/// the answer cannot be inferred.
997async fn mergeability(
998 handle: &OrgName,
999 name: &RepoName,
1000 pull: &PullRequest,
1001 merge_supported: bool,
1002 queries: &impl GitQuery,
1003) -> Result<Mergeability> {
1004 if !pull.state.is_open() {
1005 return Ok(Mergeability::NotOpen);
1006 }
1007
1008 let (target_id, source_id) = resolved_ends(handle, name, pull, queries).await?;
1009
1010 // Reported before the version check: a branch that is gone is a fact about this
1011 // repository and stays true whatever git can do, and it is the more actionable of
1012 // the two.
1013 let Some(target_id) = target_id else {
1014 return Ok(Mergeability::MissingBranch {
1015 branch: pull.target_branch.clone(),
1016 });
1017 };
1018 let Some(source_id) = source_id else {
1019 return Ok(Mergeability::MissingBranch {
1020 branch: pull.source_branch.clone(),
1021 });
1022 };
1023
1024 let Some(base) = queries
1025 .merge_base(handle, name, &target_id, &source_id)
1026 .await?
1027 else {
1028 return Ok(Mergeability::Unrelated);
1029 };
1030
1031 if base == source_id {
1032 return Ok(Mergeability::NothingToMerge);
1033 }
1034
1035 if base == target_id {
1036 return Ok(Mergeability::Ready { fast_forward: true });
1037 }
1038
1039 if !merge_supported {
1040 return Ok(Mergeability::Unsupported);
1041 }
1042
1043 Ok(
1044 match queries
1045 .merge_preview(handle, name, &target_id, &source_id)
1046 .await?
1047 {
1048 MergePreview::Clean { .. } => Mergeability::Ready {
1049 fast_forward: false,
1050 },
1051 MergePreview::Conflicted { paths } => Mergeability::Conflicted { paths },
1052 },
1053 )
1054}
1055
1056/// The sentence a refused merge carries, built from why it was refused.
1057///
1058/// One place, so the form's refusal and the box's own line cannot disagree about what
1059/// is wrong.
1060fn refusal(ready: &Mergeability) -> String {
1061 match ready {
1062 Mergeability::NotOpen => "this pull request is not open".to_owned(),
1063 Mergeability::Unsupported => "merging needs git 2.38 or newer on the server".to_owned(),
1064 Mergeability::MissingBranch { branch } => {
1065 format!("there is no branch called {branch} in this repository any more")
1066 }
1067 Mergeability::NothingToMerge => {
1068 "there is nothing to merge — every commit is already on the target".to_owned()
1069 }
1070 Mergeability::Unrelated => {
1071 "the two branches share no history, so there is nothing to merge".to_owned()
1072 }
1073 Mergeability::Conflicted { paths } => match paths.len() {
1074 1 => format!("there is a conflict in {}", paths[0]),
1075 other => format!("there are conflicts in {other} files"),
1076 },
1077 Mergeability::Ready { .. } => "this pull request merges cleanly".to_owned(),
1078 }
1079}
1080
1081/// The message a merge commit carries.
1082///
1083/// git's own convention, which is what `git log --merges` and every tool that reads one
1084/// expects: a subject naming the request, then the title as the body. The number is in
1085/// the subject because that is the only place a merge commit can say which request it
1086/// came from once the branch is deleted.
1087fn merge_message(pull: &PullRequest) -> String {
1088 let mut message = format!(
1089 "Merge pull request #{} from {}\n\n{}",
1090 pull.number, pull.source_branch, pull.title
1091 );
1092
1093 if !pull.body.is_empty() {
1094 message.push_str("\n\n");
1095 message.push_str(&pull.body);
1096 }
1097
1098 message
1099}
1100
1101/// The message a squash commit carries.
1102///
1103/// The request's title with `(#n)` appended, in the shape a squashed commit is read in
1104/// a one-line log, then the body, then the subjects of the commits that were squashed —
1105/// which is the only surviving record of them once the branch is gone.
1106async fn squash_message(
1107 handle: &OrgName,
1108 name: &RepoName,
1109 pull: &PullRequest,
1110 target_id: &ObjectId,
1111 source_id: &ObjectId,
1112 queries: &impl GitQuery,
1113) -> Result<String> {
1114 let mut message = format!("{} (#{})", pull.title, pull.number);
1115
1116 if !pull.body.is_empty() {
1117 message.push_str("\n\n");
1118 message.push_str(&pull.body);
1119 }
1120
1121 // Asked for one more than is listed, so the message can say there were more without
1122 // a second count.
1123 let commits = queries
1124 .log_between(
1125 handle,
1126 name,
1127 Some(target_id),
1128 source_id,
1129 0,
1130 MAX_SQUASHED_COMMITS + 1,
1131 )
1132 .await?;
1133
1134 if commits.is_empty() {
1135 return Ok(message);
1136 }
1137
1138 message.push_str("\n\nSquashed commits:\n");
1139
1140 for commit in commits.iter().take(MAX_SQUASHED_COMMITS) {
1141 message.push_str(&format!("\n* {}", commit.summary));
1142 }
1143
1144 if commits.len() > MAX_SQUASHED_COMMITS {
1145 message.push_str("\n* …and more");
1146 }
1147
1148 Ok(message)
1149}
1150
1151/// The pull request a write is aimed at, or `NotFound`.
1152pub(super) async fn existing_pull(
1153 repo: &Repository,
1154 number: crate::domain::IssueNumber,
1155 pulls: &impl PullRequestRepository,
1156) -> Result<PullRequest> {
1157 pulls
1158 .find_by_number(&repo.id, number)
1159 .await?
1160 .ok_or(Error::Domain(DomainError::NotFound {
1161 entity: "pull request",
1162 }))
1163}
1164
1165#[cfg(test)]
1166#[path = "pull_request_tests.rs"]
1167mod tests;