steid

@jamesgill /

1//! Pull requests — `/{handle}/repos/{name}/pulls`.
2//!
3//! The list, the new-request form, and one request under three sub-tabs: **Conversation**
4//! (the body, the comments and the merge box), **Commits** and **Files changed**.
5//!
6//! The two tabs after the first are a comparison of `target...source`, rendered by
7//! [`commit`](super::commit)'s own components — the same three-dot comparison the compare
8//! page shows, which is exactly what a pull request means by "what this branch adds".
9//! Nothing about the diff is reimplemented here; `compare_revisions` is the use case and
10//! `commit_log`, `diff_view` and `diff_stats` are the renderers, so a pull request's files
11//! and a comparison's files cannot drift apart.
12//!
13//! **Bodies are markdown, rendered through [`markdown`](super::markdown)**, which never
14//! passes source HTML through — the property that makes rendering user content on the
15//! session cookie's origin safe at all. See that module before changing anything here.
16//!
17//! `/issues/{n}` and `/pulls/{n}` **redirect to each other** for a number that is the
18//! other kind. The two share one sequence precisely so `#7` names one thing, and a
19//! cross-reference written as `#7` cannot know which kind it found.
20
21use std::time::SystemTime;
22
23use serde::Deserialize;
24use topcoat::{
25 Result,
26 context::Cx,
27 icon::{icon, iconify::iconify_icon},
28 router::{
29 StatusCode,
30 content::Form,
31 error::{bad_request, forbidden, not_found},
32 page, path_param, query_params,
33 },
34 view::{attributes, component, view},
35};
36
37use crate::{
38 application::{
39 CommentView, Compared, Error, MergeAuthor, Mergeability, NewPull, Numbered, PullDetail,
40 PullEdit, PullList, PullSummary, RefList, RepoView, Stores, comment_on_pull,
41 compare_revisions, create_pull, edit_pull, list_pulls, list_refs, merge_pull,
42 numbered_thing, set_pull_state, suggested_title, view_pull,
43 },
44 components::{
45 badge::{BadgeVariant, badge},
46 button::{ButtonSize, ButtonVariant, button, button_variants},
47 flash::{FlashKind, flash},
48 input::input,
49 label::label,
50 textarea::textarea,
51 },
52 domain::{DomainError, IssueNumber, MergeStrategy, OrgName, PullRequestState, RefName},
53};
54
55use super::{
56 browse::commit_log,
57 commit::{commit_url, diff_view, took_too_long, unwrap_page},
58 context::{
59 current_actor, git_features, identity, issues, labels, location, memberships, merges, orgs,
60 owned_repo, public_origin, pulls, push_events, queries, repos, server_error, users,
61 webhook_sender, webhooks,
62 },
63 csrf::{csrf_field, csrf_verified},
64 format::{ago, timestamp},
65 issues::issue_url,
66 labels::{Filtering, label_pill, labels_url},
67 layout::wide,
68 markdown,
69 paging::{Pages, list_pages, requested_page, showing},
70 repo::{Tab, repo_for, repo_header},
71 urls::encode,
72};
73
74/// `{number}` from the path, raw — validation is [`IssueNumber`]'s job.
75#[path_param]
76struct Number(str);
77
78/// `?state=merged|closed`, `?label=bug` and `?page=N`. Anything else in `state` means
79/// open.
80#[query_params(error = bad_request)]
81struct StateQuery {
82 state: Option<String>,
83 label: Option<String>,
84 page: Option<String>,
85}
86
87/// `?source=&target=`, prefilling the new-request form from the compare page.
88#[query_params(error = bad_request)]
89struct PrefillQuery {
90 source: Option<String>,
91 target: Option<String>,
92}
93
94/// `?page=N` on the Commits sub-tab. The diff on Files changed does not page — it is
95/// what the whole range changes either way, exactly as a comparison's does.
96#[query_params(error = bad_request)]
97struct PageQuery {
98 page: Option<String>,
99}
100
101#[derive(Debug, Deserialize)]
102struct PullForm {
103 /// The hidden field every form carries; see [`csrf`](super::csrf).
104 ///
105 /// `default` so that a submission with no token at all is refused by
106 /// `csrf_verified` — a 403 saying what happened — rather than failing
107 /// deserialization into a bare 400 that says nothing.
108 #[serde(default)]
109 csrf_token: String,
110 title: String,
111 body: String,
112 source: String,
113 target: String,
114}
115
116#[derive(Debug, Deserialize)]
117struct EditForm {
118 /// The hidden field every form carries; see [`csrf`](super::csrf).
119 ///
120 /// `default` so that a submission with no token at all is refused by
121 /// `csrf_verified` — a 403 saying what happened — rather than failing
122 /// deserialization into a bare 400 that says nothing.
123 #[serde(default)]
124 csrf_token: String,
125 title: String,
126 body: String,
127}
128
129#[derive(Debug, Deserialize)]
130struct CommentForm {
131 /// The hidden field every form carries; see [`csrf`](super::csrf).
132 ///
133 /// `default` so that a submission with no token at all is refused by
134 /// `csrf_verified` — a 403 saying what happened — rather than failing
135 /// deserialization into a bare 400 that says nothing.
136 #[serde(default)]
137 csrf_token: String,
138 body: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct StateForm {
143 /// The hidden field every form carries; see [`csrf`](super::csrf).
144 ///
145 /// `default` so that a submission with no token at all is refused by
146 /// `csrf_verified` — a 403 saying what happened — rather than failing
147 /// deserialization into a bare 400 that says nothing.
148 #[serde(default)]
149 csrf_token: String,
150 state: String,
151}
152
153#[derive(Debug, Deserialize)]
154struct MergeForm {
155 /// The hidden field every form carries; see [`csrf`](super::csrf).
156 ///
157 /// `default` so that a submission with no token at all is refused by
158 /// `csrf_verified` — a 403 saying what happened — rather than failing
159 /// deserialization into a bare 400 that says nothing.
160 #[serde(default)]
161 csrf_token: String,
162 strategy: String,
163 /// An unticked checkbox is not submitted at all, which is why this is an `Option`
164 /// and not a `bool`: `serde` would refuse the missing field.
165 delete_source: Option<String>,
166}
167
168/// Which sub-tab of one pull request is being looked at.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum Sub {
171 Conversation,
172 Commits,
173 Files,
174}
175
176// --- URLs ---------------------------------------------------------------------------
177
178pub(super) fn pulls_url(handle: &str, name: &str) -> String {
179 format!("/{handle}/repos/{name}/pulls")
180}
181
182/// The list's URL with whichever of state, label and page it needs.
183///
184/// One builder for all three, the reason the issue list has one: paging out of a filter
185/// would silently widen the list, switching state must not lose the label, and neither
186/// must strand the reader on page 4 of a list that now has one page.
187fn listed_url(
188 handle: &str,
189 name: &str,
190 state: PullRequestState,
191 filter: &str,
192 page: usize,
193) -> String {
194 let mut url = pulls_url(handle, name);
195 let mut parts = Vec::new();
196
197 if !state.is_open() {
198 parts.push(format!("state={state}"));
199 }
200
201 if !filter.is_empty() {
202 parts.push(format!("label={}", encode(filter, false)));
203 }
204
205 // Page 1 is the bare URL: a `?page=1` in a shared link says nothing and makes two
206 // URLs for one page.
207 if page > 1 {
208 parts.push(format!("page={page}"));
209 }
210
211 if !parts.is_empty() {
212 url.push('?');
213 url.push_str(&parts.join("&"));
214 }
215
216 url
217}
218
219pub(super) fn pull_url(handle: &str, name: &str, number: IssueNumber) -> String {
220 format!("/{handle}/repos/{name}/pulls/{number}")
221}
222
223/// The new-request form, prefilled with two branches.
224///
225/// Each half is percent-encoded on its own, so a slash inside a branch name survives as
226/// part of the value rather than becoming anything else.
227pub(super) fn new_pull_url(handle: &str, name: &str, source: &str, target: &str) -> String {
228 format!(
229 "/{handle}/repos/{name}/pulls/new?source={}&target={}",
230 encode(source, false),
231 encode(target, false)
232 )
233}
234
235// --- Resolution ---------------------------------------------------------------------
236
237/// The number in the path, or 404.
238///
239/// A number that does not parse names nothing, exactly like one that is out of range,
240/// and answering differently would tell a prober which of the two they had found.
241fn number_for(cx: &Cx) -> Result<IssueNumber> {
242 path_param::<Number>(cx)
243 .parse::<IssueNumber>()
244 .map_err(|_| not_found().into())
245}
246
247/// What looking a `#n` up found.
248///
249/// Three answers rather than two, because a number that is the *other* kind of thing is
250/// neither what was asked for nor a 404: it is a number this repository has handed out,
251/// and the visitor belongs at the other page. That is what makes a `#7` written in a
252/// body a link that lands whatever kind of thing it names.
253///
254/// Generic over what was being looked for, so the issues page and this one share it —
255/// and, with it, share [`elsewhere_or_missing`] and cannot disagree about when a number
256/// is worth redirecting.
257pub(super) enum Found<T> {
258 /// Boxed because the detail is much the larger variant and clippy is right about
259 /// what carrying it by value would cost on every return.
260 Here(Box<T>),
261 /// The URL to send them to, with a 303.
262 Elsewhere(String),
263}
264
265/// One pull request, or where the visitor should be instead.
266async fn detail(cx: &Cx, repo: &RepoView, number: IssueNumber) -> Result<Found<PullDetail>> {
267 let found = view_pull(
268 &current_actor(cx).await?,
269 &repo.handle,
270 &repo.name,
271 number,
272 git_features(cx).merge_tree,
273 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
274 &pulls(cx),
275 &users(cx),
276 &queries(cx),
277 &labels(cx),
278 )
279 .await
280 .map_err(server_error)?;
281
282 match found {
283 Some(detail) => Ok(Found::Here(Box::new(detail))),
284 None => elsewhere_or_missing(cx, repo, number, Numbered::Issue).await,
285 }
286}
287
288/// Where a missing number should send the visitor: the *other* kind of thing if that is
289/// what it turns out to be, and a 404 if it is neither.
290///
291/// Shared by both directions — this module's pages and the issues page's — so the two
292/// cannot end up disagreeing about when a number is worth redirecting.
293///
294/// The redirect is spelled as a URL rather than as a response because `#[page]` must
295/// return a view, so the caller emits `SEE_OTHER` plus a `Location` inside its own
296/// `view!` — see [`location`].
297pub(super) async fn elsewhere_or_missing<T>(
298 cx: &Cx,
299 repo: &RepoView,
300 number: IssueNumber,
301 wanted: Numbered,
302) -> Result<Found<T>> {
303 let found = numbered_thing(
304 &current_actor(cx).await?,
305 &repo.handle,
306 &repo.name,
307 number,
308 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
309 &issues(cx),
310 &pulls(cx),
311 )
312 .await
313 .map_err(server_error)?;
314
315 let handle = repo.handle.as_str();
316 let name = repo.name.as_str();
317
318 // Only the kind the caller is *not* showing is worth a redirect. Anything else — the
319 // number belongs to nothing, or to a repository this actor cannot read — is a 404,
320 // one answer for both so a prober cannot count what is on a private repository.
321 match (found == wanted, wanted) {
322 (true, Numbered::Issue) => Ok(Found::Elsewhere(issue_url(handle, name, number))),
323 (true, Numbered::PullRequest) => Ok(Found::Elsewhere(pull_url(handle, name, number))),
324 _ => Err(not_found().into()),
325 }
326}
327
328/// The request a failed write re-renders from.
329///
330/// A write only reaches here having already resolved the pull request, so a number that
331/// has become an *issue* in the meantime is not a redirect worth following — it is a
332/// state nothing can produce, and a 404 is the honest answer.
333async fn reread(cx: &Cx, repo: &RepoView, number: IssueNumber) -> Result<Box<PullDetail>> {
334 match detail(cx, repo, number).await? {
335 Found::Here(pull) => Ok(pull),
336 Found::Elsewhere(_) => Err(not_found().into()),
337 }
338}
339
340/// Turns a use case failure into either a message to re-render with, or a response.
341///
342/// Three arms of its own, then [`context::write_failure`](super::context::write_failure)
343/// for the ones every form shares.
344fn write_failure(error: Error) -> std::result::Result<String, topcoat::Error> {
345 match error {
346 Error::Domain(DomainError::Validation { field, reason }) => {
347 Ok(format!("That {field} is no good: {reason}."))
348 }
349 // The repository is archived or still importing. The reason is the useful part
350 // and it is the same sentence a `git push` gets, so it is shown rather than
351 // turned into a status code.
352 Error::Domain(DomainError::Archived) => {
353 Ok("This repository is archived, so nothing can be merged into it.".to_owned())
354 }
355 Error::Domain(DomainError::Unavailable { reason, .. }) => {
356 Ok(format!("This repository is unavailable: {reason}"))
357 }
358 // Somebody pushed between the page rendering and the button being pressed. A
359 // message, not a failure: reloading shows the new state and the merge can be
360 // tried again.
361 Error::GitMerge(merge) if merge.is_stale() => Ok(merge.to_string()),
362 other => super::context::write_failure(other),
363 }
364}
365
366// --- The list -------------------------------------------------------------------------
367
368#[page("/{handle}/repos/{name}/pulls")]
369async fn pulls_page(cx: &Cx) -> Result {
370 let repo = repo_for(cx).await?;
371 let query = query_params::<StateQuery>(cx)?;
372
373 // An unrecognised `?state=` shows the open list rather than erroring: this is a link
374 // someone may have mangled, not a form, and the only thing it can reveal is which
375 // requests are open anyway.
376 let state = query
377 .state
378 .as_deref()
379 .and_then(|value| value.parse::<PullRequestState>().ok())
380 .unwrap_or(PullRequestState::Open);
381
382 // Kept as the raw string as well as resolved, so a filter naming a label that has
383 // been deleted still says what it was filtering on rather than silently widening.
384 let filter = query.label.as_deref().filter(|name| !name.is_empty());
385
386 let listed = list_pulls(
387 &current_actor(cx).await?,
388 &repo.handle,
389 &repo.name,
390 state,
391 filter,
392 requested_page(query.page.as_deref()),
393 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
394 &pulls(cx),
395 &users(cx),
396 &labels(cx),
397 )
398 .await
399 .map_err(server_error)?
400 // `repo_for` already resolved the repository as visible, so `None` here means it
401 // vanished between the two reads. A 404 is the honest answer to that.
402 .ok_or_else(not_found)?;
403
404 view! {
405 wide(
406 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
407 pull_list(
408 handle: repo.handle.as_str(),
409 name: repo.name.as_str(),
410 listed: &listed,
411 filter: filter.unwrap_or(""),
412 )
413 )
414 }
415}
416
417/// The three state filters, the label filter, and the way in, on one row.
418#[component]
419async fn pulls_toolbar(handle: &str, name: &str, listed: &PullList, filter: &str) -> Result {
420 // The primary colour is not used here: which list you are looking at is marked the
421 // same way the issues toolbar marks it, by contrast rather than by hue, so the merge
422 // box below stays the one coloured thing on the page.
423 let current = |active: bool| {
424 if active {
425 "text-foreground"
426 } else {
427 "text-muted-foreground hover:text-foreground"
428 }
429 };
430
431 view! {
432 <div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
433 for (state, count) in [
434 (PullRequestState::Open, listed.open),
435 (PullRequestState::Merged, listed.merged),
436 (PullRequestState::Closed, listed.closed),
437 ] {
438 // The state links keep the label filter and drop the page, exactly as
439 // the issues toolbar's do.
440 <a
441 href=(listed_url(handle, name, state, filter, 1))
442 class=(format!(
443 "flex items-center gap-1.5 {}",
444 current(listed.state == state)
445 ))
446 >
447 state_icon(state: state, muted: listed.state != state)
448 (label_for(state))
449 <span class="font-mono text-xs text-muted-foreground">(count)</span>
450 </a>
451 }
452
453 // The active filter, with the way out of it. Drawn from the raw query value
454 // rather than only when it resolved, so a filter on a label that has since
455 // been deleted still says what it is filtering on.
456 if !filter.is_empty() {
457 <span class="flex items-center gap-1.5">
458 if let Some(pill) = listed.filter.as_ref() {
459 label_pill(handle: handle, name: name, pill: pill, to: Filtering::Pulls)
460 } else {
461 <span class="label-pill label-gray">(filter)</span>
462 }
463 <a
464 href=(listed_url(handle, name, listed.state, "", 1))
465 class="text-muted-foreground hover:text-foreground"
466 title=("Clear this filter")
467 >"×"</a>
468 </span>
469 }
470
471 <span class="ml-auto flex items-center gap-4">
472 <a
473 href=(labels_url(handle, name))
474 class="text-muted-foreground hover:text-foreground"
475 >"Labels"</a>
476 if listed.viewer_is_owner {
477 <a
478 href=(format!("{}/new", pulls_url(handle, name)))
479 class=(button_variants(ButtonVariant::Primary, ButtonSize::Sm))
480 >"New pull request"</a>
481 }
482 </span>
483 </div>
484 }
485}
486
487/// What a state is called on a tab and in a sentence.
488fn label_for(state: PullRequestState) -> &'static str {
489 match state {
490 PullRequestState::Open => "Open",
491 PullRequestState::Merged => "Merged",
492 PullRequestState::Closed => "Closed",
493 }
494}
495
496/// The state marker.
497///
498/// The one live fact about a pull request is whether it is still open, so that is what
499/// carries colour: open in `success`, **merged in the purple** that says it landed, and
500/// closed muted. Merged is the one place a third colour earns its place — "finished" and
501/// "abandoned" are opposite outcomes and a single muted grey for both would say a branch
502/// was rejected when it shipped.
503#[component]
504async fn state_icon(state: PullRequestState, #[default] muted: bool) -> Result {
505 let tone = match (muted, state) {
506 (true, _) | (_, PullRequestState::Closed) => "text-muted-foreground",
507 (_, PullRequestState::Open) => "text-success",
508 (_, PullRequestState::Merged) => "text-merged",
509 };
510
511 view! {
512 icon(
513 data: match state {
514 PullRequestState::Open => iconify_icon!("feather:git-pull-request"),
515 PullRequestState::Merged => iconify_icon!("feather:git-merge"),
516 PullRequestState::Closed => iconify_icon!("feather:x-circle"),
517 },
518 attrs: attributes! { class=(format!("size-3.5 shrink-0 {tone}")) }
519 )
520 }
521}
522
523#[component]
524async fn pull_list(handle: &str, name: &str, listed: &PullList, filter: &str) -> Result {
525 let items = &listed.pulls;
526 let noun = match filter.is_empty() {
527 true => format!("{} pull requests", label_for(listed.state).to_lowercase()),
528 false => format!(
529 "{} pull requests labelled {filter}",
530 label_for(listed.state).to_lowercase()
531 ),
532 };
533 let sentence = showing(items.offset(), items.items.len(), items.total, &noun);
534 let pages = Pages::numbered(items.page, items.pages, |page| {
535 listed_url(handle, name, listed.state, filter, page)
536 });
537
538 view! {
539 pulls_toolbar(handle: handle, name: name, listed: listed, filter: filter)
540
541 if items.items.is_empty() {
542 <div class="rounded-lg border border-border px-4 py-10 text-center">
543 <p class="text-sm text-muted-foreground">
544 "No " (label_for(listed.state).to_lowercase()) " pull requests"
545 if filter.is_empty() { "." } else { " labelled " (filter) "." }
546 </p>
547 // No button here: the toolbar carries one for the owner forty pixels
548 // above, and offering it twice reads as two different things.
549 if !listed.viewer_is_owner && listed.state.is_open() {
550 <p class="mt-1 text-xs text-muted-foreground">
551 "Only the owner can open one here."
552 </p>
553 }
554 </div>
555 } else {
556 <ul class="divide-y divide-border rounded-lg border border-border">
557 for pull in &items.items {
558 pull_row(handle: handle, name: name, pull: pull)
559 }
560 </ul>
561 list_pages(summary: sentence.as_str(), pages: &pages)
562 }
563 }
564}
565
566/// One request on one line: what it is, then who and when and between which branches.
567#[component]
568async fn pull_row(handle: &str, name: &str, pull: &PullSummary) -> Result {
569 let author = pull
570 .author
571 .as_ref()
572 .map(OrgName::as_str)
573 .unwrap_or("someone");
574
575 view! {
576 <li class="flex items-center gap-3 px-4 py-2.5 text-sm">
577 state_icon(state: pull.state)
578
579 <div class="min-w-0 flex-1">
580 <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
581 <a
582 href=(pull_url(handle, name, pull.number))
583 class="truncate font-medium hover:text-primary"
584 >(pull.title.as_str())</a>
585 // After the title, before the meta line: a pill is part of what the
586 // request is, not part of when it happened.
587 for pill in &pull.labels {
588 label_pill(handle: handle, name: name, pill: pill, to: Filtering::Pulls)
589 }
590 </div>
591 <p class="mt-0.5 truncate text-xs text-muted-foreground">
592 <span class="font-mono">"#" (pull.number.get())</span>
593 " opened "
594 <span title=(timestamp(pull.created_at))>(ago(pull.created_at))</span>
595 " by "
596 <span class="font-mono">"@" (author)</span>
597 " · "
598 branch_pair(
599 source: pull.source_branch.as_str(),
600 target: pull.target_branch.as_str(),
601 )
602 </p>
603 </div>
604
605 if pull.comments > 0 {
606 <span
607 class="flex shrink-0 items-center gap-1 text-xs text-muted-foreground"
608 title=(format!("{} comments", pull.comments))
609 >
610 icon(
611 data: iconify_icon!("feather:message-square"),
612 attrs: attributes! { class="size-3.5" }
613 )
614 <span class="font-mono">(pull.comments)</span>
615 </span>
616 }
617 </li>
618 }
619}
620
621/// `source → target`, in mono, which is what distinguishes two requests with similar
622/// titles.
623#[component]
624async fn branch_pair(source: &str, target: &str) -> Result {
625 view! {
626 <span class="font-mono">(source)</span>
627 " → "
628 <span class="font-mono">(target)</span>
629 }
630}
631
632// --- Opening one ----------------------------------------------------------------------
633
634#[page("/{handle}/repos/{name}/pulls/new")]
635async fn new_pull_page(cx: &Cx) -> Result {
636 let repo = owned_repo(cx).await?;
637 let prefill = query_params::<PrefillQuery>(cx)?;
638 let refs = branch_names(cx, &repo).await?;
639
640 // Prefilled from the compare page, which is the way in this form is designed for.
641 // Typed directly it opens empty except for the target, which defaults to whatever
642 // the repository's default branch is — the answer nine times in ten.
643 let source = prefill.source.as_deref().unwrap_or_default().to_owned();
644 let target = match prefill.target.as_deref() {
645 Some(target) => target.to_owned(),
646 None => default_branch(cx, &repo).await?,
647 };
648
649 // Only worth asking when both fields actually name something. A form opened with no
650 // source has nothing to suggest a title from, and would pay three git questions to
651 // find that out.
652 let title = match (RefName::new(&source), RefName::new(&target)) {
653 (Ok(source), Ok(target)) => suggested_title(
654 &current_actor(cx).await?,
655 &repo.handle,
656 &repo.name,
657 &source,
658 &target,
659 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
660 &queries(cx),
661 )
662 .await
663 .map_err(server_error)?
664 .unwrap_or_default(),
665 _ => String::new(),
666 };
667
668 let draft = Draft {
669 source,
670 target,
671 title,
672 ..Draft::default()
673 };
674
675 view! {
676 wide(
677 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
678 new_pull_form(
679 handle: repo.handle.as_str(),
680 name: repo.name.as_str(),
681 draft: &draft,
682 refs: &refs,
683 error: "",
684 )
685 )
686 }
687}
688
689/// Opens the request, then redirects to it.
690#[page(POST "/{handle}/repos/{name}/pulls")]
691async fn create(cx: &Cx, Form(submitted): Form<PullForm>) -> Result {
692 csrf_verified(cx, &submitted.csrf_token).await?;
693
694 let repo = owned_repo(cx).await?;
695
696 // A branch name that is not a well-formed revision is a message on the form rather
697 // than a 400: it is something somebody typed, and the field it belongs to is known.
698 let parsed = (
699 RefName::new(&submitted.source),
700 RefName::new(&submitted.target),
701 );
702
703 let message = match parsed {
704 (Ok(source), Ok(target)) => {
705 let outcome = create_pull(
706 &current_actor(cx).await?,
707 &repo.handle,
708 &repo.name,
709 &NewPull {
710 title: submitted.title.clone(),
711 body: submitted.body.clone(),
712 source,
713 target,
714 },
715 SystemTime::now(),
716 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
717 &pulls(cx),
718 &queries(cx),
719 )
720 .await;
721
722 match outcome {
723 Ok(pull) => {
724 return view! {
725 (StatusCode::SEE_OTHER)
726 (location(&pull_url(
727 repo.handle.as_str(),
728 repo.name.as_str(),
729 pull.number,
730 ))?)
731 };
732 }
733 Err(error) => write_failure(error)?,
734 }
735 }
736 _ => "That is not a branch name.".to_owned(),
737 };
738
739 let refs = branch_names(cx, &repo).await?;
740 // Re-rendered with what was typed. Bouncing back to an empty form would throw away a
741 // description somebody spent ten minutes on to tell them a branch name was wrong.
742 let draft = Draft {
743 title: submitted.title,
744 body: submitted.body,
745 source: submitted.source,
746 target: submitted.target,
747 };
748
749 view! {
750 wide(
751 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
752 new_pull_form(
753 handle: repo.handle.as_str(),
754 name: repo.name.as_str(),
755 draft: &draft,
756 refs: &refs,
757 error: message.as_str(),
758 )
759 )
760 }
761}
762
763/// What the new-request form currently holds.
764///
765/// One struct rather than four more parameters, and for a better reason than the
766/// argument count: these four are what a *rejected* submission has to put back, so they
767/// travel together everywhere and a caller cannot restore three of them.
768#[derive(Debug, Default)]
769pub(super) struct Draft {
770 pub title: String,
771 pub body: String,
772 pub source: String,
773 pub target: String,
774}
775
776#[component]
777async fn new_pull_form(
778 handle: &str,
779 name: &str,
780 draft: &Draft,
781 /// Branch names for the `<datalist>`, so the common case autocompletes.
782 refs: &[String],
783 error: &str,
784) -> Result {
785 view! {
786 <div class="max-w-3xl">
787 <h1 class="text-lg font-semibold tracking-tight">"New pull request"</h1>
788
789 if !error.is_empty() {
790 <div class="mt-4">
791 flash(kind: FlashKind::Error, (error))
792 </div>
793 }
794
795 <form method="post" action=(pulls_url(handle, name)) class="mt-5 space-y-4">
796 csrf_field()
797 // The two branches first: which change this is about comes before what
798 // it is called, and a title typed against the wrong pair is wasted.
799 // Text inputs with a datalist rather than two selects, exactly as the
800 // compare form settled — the branch may have been pushed a second ago.
801 <div class="flex flex-wrap items-end gap-3">
802 <div class="flex min-w-44 flex-1 flex-col gap-1.5">
803 label(attrs: attributes! { for="source" class="text-xs" }, "Merge")
804 input(attrs: attributes! {
805 id="source" name="source" list="pull-branches" required=(true)
806 autocomplete="off" spellcheck="false" class="font-mono"
807 value=(draft.source.as_str()) placeholder="a branch"
808 })
809 </div>
810
811 <span class="pb-2.5 text-sm text-muted-foreground">"into"</span>
812
813 <div class="flex min-w-44 flex-1 flex-col gap-1.5">
814 label(attrs: attributes! { for="target" class="text-xs" }, "Base")
815 input(attrs: attributes! {
816 id="target" name="target" list="pull-branches" required=(true)
817 autocomplete="off" spellcheck="false" class="font-mono"
818 value=(draft.target.as_str()) placeholder="main"
819 })
820 </div>
821 </div>
822
823 <datalist id="pull-branches">
824 for branch in refs {
825 <option value=(branch.as_str())></option>
826 }
827 </datalist>
828
829 <div class="space-y-2">
830 label(attrs: attributes! { for="title" }, "Title")
831 input(attrs: attributes! {
832 id="title"
833 name="title"
834 type="text"
835 value=(draft.title.as_str())
836 placeholder="Speed blame up on large files"
837 required=(true)
838 })
839 </div>
840
841 <div class="space-y-2">
842 label(attrs: attributes! { for="body" }, "Description")
843 textarea(
844 attrs: attributes! {
845 id="body"
846 name="body"
847 rows="10"
848 placeholder="Markdown is supported."
849 },
850 (draft.body.as_str())
851 )
852 <p class="text-xs text-muted-foreground">
853 "Markdown. HTML in the source is shown as text, never rendered."
854 </p>
855 </div>
856
857 <div class="flex items-center gap-3">
858 button(attrs: attributes! { type="submit" }, "Open pull request")
859 <a
860 href=(pulls_url(handle, name))
861 class="text-sm text-muted-foreground hover:text-foreground"
862 >"Cancel"</a>
863 </div>
864 </form>
865 </div>
866 }
867}
868
869// --- One pull request -------------------------------------------------------------------
870
871#[page("/{handle}/repos/{name}/pulls/{number}")]
872async fn pull_page(cx: &Cx) -> Result {
873 let repo = repo_for(cx).await?;
874
875 let pull = match detail(cx, &repo, number_for(cx)?).await? {
876 Found::Here(pull) => pull,
877 Found::Elsewhere(url) => {
878 return view! { (StatusCode::SEE_OTHER) (location(&url)?) };
879 }
880 };
881
882 view! {
883 wide(
884 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
885 pull_frame(repo: &repo, pull: &pull, active: Sub::Conversation,
886 conversation(
887 handle: repo.handle.as_str(),
888 name: repo.name.as_str(),
889 pull: &pull,
890 error: "",
891 comment: "",
892 editing: false,
893 )
894 )
895 )
896 }
897}
898
899/// The Commits sub-tab: the comparison's commit list, paged.
900#[page("/{handle}/repos/{name}/pulls/{number}/commits")]
901async fn pull_commits_page(cx: &Cx) -> Result {
902 let repo = repo_for(cx).await?;
903
904 let pull = match detail(cx, &repo, number_for(cx)?).await? {
905 Found::Here(pull) => pull,
906 Found::Elsewhere(url) => {
907 return view! { (StatusCode::SEE_OTHER) (location(&url)?) };
908 }
909 };
910
911 let page = requested_page(query_params::<PageQuery>(cx)?.page.as_deref());
912 let compared = compared_for(cx, &repo, &pull, page).await?;
913
914 let handle = repo.handle.as_str();
915 let name = repo.name.as_str();
916
917 // The whole range's commits are paged; the diff below is not shown on this tab at
918 // all, which is what makes the page cheap enough to number.
919 let listed = match &compared {
920 Some(Compared::Ready(comparison)) => Some(comparison.as_ref()),
921 _ => None,
922 };
923
924 // Built before the `view!`, not inside it: a `format!` or a `showing(..)` in an
925 // argument position is a temporary the borrow it produces outlives — the same trap
926 // the compare page's own `unknown` sentence sidesteps.
927 let sentence = listed
928 .map(|comparison| {
929 showing(
930 comparison.commits.offset(),
931 comparison.commits.items.len(),
932 comparison.commits.total,
933 "commits",
934 )
935 })
936 .unwrap_or_default();
937 let base = pull_url(handle, name, pull.number);
938 // `Pages::numbered` with a total of zero renders nothing at all, which is exactly
939 // what the states with no comparison want.
940 let pages = match listed {
941 Some(comparison) => {
942 Pages::numbered(comparison.commits.page, comparison.commits.pages, |page| {
943 format!("{base}/commits?page={page}")
944 })
945 }
946 None => Pages::numbered(1, 0, |_| String::new()),
947 };
948
949 view! {
950 wide(
951 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
952 pull_frame(repo: &repo, pull: &pull, active: Sub::Commits,
953 match (compared.is_some(), listed) {
954 (false, _) => took_too_long(),
955 (true, Some(comparison)) => {
956 <div class="mb-2 text-xs text-muted-foreground">
957 "From merge base "
958 <a
959 href=(commit_url(handle, name, comparison.merge_base.as_str()))
960 class="font-mono hover:text-foreground"
961 >(comparison.merge_base.short())</a>
962 </div>
963 commit_log(
964 handle: handle,
965 name: name,
966 commits: &comparison.commits.items,
967 )
968 list_pages(summary: sentence.as_str(), pages: &pages)
969 }
970 (true, None) => nothing_to_compare(pull: &pull),
971 }
972 )
973 )
974 }
975}
976
977/// The Files changed sub-tab: the comparison's diff, rendered by the commit page's own
978/// renderer so a pull request's files and a comparison's files cannot drift apart.
979#[page("/{handle}/repos/{name}/pulls/{number}/files")]
980async fn pull_files_page(cx: &Cx) -> Result {
981 let repo = repo_for(cx).await?;
982
983 let pull = match detail(cx, &repo, number_for(cx)?).await? {
984 Found::Here(pull) => pull,
985 Found::Elsewhere(url) => {
986 return view! { (StatusCode::SEE_OTHER) (location(&url)?) };
987 }
988 };
989
990 let compared = compared_for(cx, &repo, &pull, 1).await?;
991
992 let handle = repo.handle.as_str();
993 let name = repo.name.as_str();
994
995 let listed = match &compared {
996 Some(Compared::Ready(comparison)) => Some(comparison.as_ref()),
997 _ => None,
998 };
999
1000 view! {
1001 wide(
1002 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
1003 pull_frame(repo: &repo, pull: &pull, active: Sub::Files,
1004 match (compared.is_some(), listed) {
1005 (false, _) => took_too_long(),
1006 (true, Some(comparison)) => diff_view(
1007 handle: handle,
1008 name: name,
1009 rev: comparison.head_id.as_str(),
1010 diff: &comparison.diff,
1011 ),
1012 (true, None) => nothing_to_compare(pull: &pull),
1013 }
1014 )
1015 )
1016 }
1017}
1018
1019/// What the Commits and Files tabs compare: the target against the source, three-dot,
1020/// which is exactly what a pull request proposes.
1021///
1022/// `Ok(None)` means git ran out of time, which the caller renders as a state rather than
1023/// a 500 — the rule the commit and compare pages already set.
1024async fn compared_for(
1025 cx: &Cx,
1026 repo: &RepoView,
1027 pull: &PullDetail,
1028 page: usize,
1029) -> Result<Option<Compared>> {
1030 unwrap_page(
1031 compare_revisions(
1032 &repo.handle,
1033 &repo.name,
1034 &pull.target_branch,
1035 &pull.source_branch,
1036 page,
1037 &current_actor(cx).await?,
1038 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1039 &queries(cx),
1040 )
1041 .await,
1042 )
1043}
1044
1045/// What the two comparison tabs say when there is nothing to show.
1046///
1047/// One panel for four causes — a branch that is gone, two revisions that are the same
1048/// commit, histories that share nothing, a source already contained in the target —
1049/// because the reader's next move is the same in all four and the Conversation tab's
1050/// merge box already says which it is in words.
1051#[component]
1052async fn nothing_to_compare(pull: &PullDetail) -> Result {
1053 view! {
1054 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
1055 "There is nothing to show here. "
1056 <span class="font-mono">(pull.source_branch.as_str())</span>
1057 " adds no commits to "
1058 <span class="font-mono">(pull.target_branch.as_str())</span>
1059 ", or one of the two branches is no longer in this repository."
1060 </p>
1061 }
1062}
1063
1064/// The header and sub-tab strip every one of the three tabs carries.
1065///
1066/// A hairline strip like the settings section list rather than a second bordered tab
1067/// row: the repository's own tab strip is directly above it, and two identical strips
1068/// stacked would make the page look like it had lost its place.
1069#[component]
1070async fn pull_frame(
1071 repo: &RepoView,
1072 pull: &PullDetail,
1073 active: Sub,
1074 child: topcoat::view::View,
1075) -> Result {
1076 let handle = repo.handle.as_str();
1077 let name = repo.name.as_str();
1078 let url = pull_url(handle, name, pull.number);
1079 let author = pull
1080 .author
1081 .as_ref()
1082 .map(OrgName::as_str)
1083 .unwrap_or("someone");
1084
1085 let tab = |current| {
1086 if current {
1087 "border-primary text-foreground"
1088 } else {
1089 "border-transparent text-muted-foreground hover:text-foreground"
1090 }
1091 };
1092
1093 view! {
1094 <h1 class="text-xl font-semibold tracking-tight">
1095 (pull.title.as_str())
1096 <span class="ml-2 font-mono text-lg font-normal text-muted-foreground">
1097 "#" (pull.number.get())
1098 </span>
1099 </h1>
1100
1101 <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
1102 state_badge(state: pull.state)
1103 <span>
1104 <span class="font-mono">"@" (author)</span>
1105 " wants to merge "
1106 branch_pair(
1107 source: pull.source_branch.as_str(),
1108 target: pull.target_branch.as_str(),
1109 )
1110 </span>
1111 <span title=(timestamp(pull.created_at))>"opened " (ago(pull.created_at))</span>
1112 </div>
1113
1114 // On the frame rather than on the Conversation tab: what a request is labelled
1115 // is as true on Files changed as it is here.
1116 if !pull.labels.is_empty() {
1117 <div class="mt-2 flex flex-wrap items-center gap-1.5">
1118 for pill in &pull.labels {
1119 label_pill(handle: handle, name: name, pill: pill, to: Filtering::Pulls)
1120 }
1121 </div>
1122 }
1123
1124 <nav class="mt-3 mb-4 flex items-center gap-5 border-b border-border text-sm">
1125 for (sub, name, href) in [
1126 (Sub::Conversation, "Conversation", url.clone()),
1127 (Sub::Commits, "Commits", format!("{url}/commits")),
1128 (Sub::Files, "Files changed", format!("{url}/files")),
1129 ] {
1130 <a
1131 href=(href.as_str())
1132 class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == sub)))
1133 >(name)</a>
1134 }
1135 </nav>
1136
1137 (child)
1138 }
1139}
1140
1141/// The state badge: the same three-way distinction the row icon makes, in words.
1142#[component]
1143async fn state_badge(state: PullRequestState) -> Result {
1144 view! {
1145 match state {
1146 PullRequestState::Merged => badge(variant: BadgeVariant::Merged, "Merged"),
1147 PullRequestState::Open => badge(variant: BadgeVariant::Primary, "Open"),
1148 PullRequestState::Closed => badge(variant: BadgeVariant::Secondary, "Closed"),
1149 }
1150 }
1151}
1152
1153// --- The conversation tab -----------------------------------------------------------------
1154
1155#[component]
1156async fn conversation(
1157 handle: &str,
1158 name: &str,
1159 pull: &PullDetail,
1160 error: &str,
1161 /// A comment that was rejected, put back in the box.
1162 comment: &str,
1163 /// Whether the edit panel starts open — true only when an edit was refused.
1164 editing: bool,
1165) -> Result {
1166 let author = pull
1167 .author
1168 .as_ref()
1169 .map(OrgName::as_str)
1170 .unwrap_or("someone");
1171 let url = pull_url(handle, name, pull.number);
1172
1173 view! {
1174 <div class="max-w-3xl">
1175 if !error.is_empty() {
1176 <div class="mb-4">
1177 flash(kind: FlashKind::Error, (error))
1178 </div>
1179 }
1180
1181 <div class="space-y-3">
1182 <article class="rounded-lg border border-border">
1183 <header class="flex items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground">
1184 <span class="font-mono">"@" (author)</span>
1185 <span title=(timestamp(pull.created_at))>(ago(pull.created_at))</span>
1186 </header>
1187 body_panel(
1188 handle: handle,
1189 name: name,
1190 source: pull.body.as_str(),
1191 empty: "No description given.",
1192 )
1193 </article>
1194
1195 for comment in &pull.comments {
1196 pull_comment(handle: handle, name: name, comment: comment)
1197 }
1198 </div>
1199
1200 // What happened, once it has. Replaces the merge box entirely rather than
1201 // sitting beside it: a merged request has one fact left to state.
1202 if let Some(landed) = pull.merge_commit.as_ref() {
1203 <div class="mt-5 rounded-lg border border-border px-4 py-3 text-sm">
1204 <span class="flex flex-wrap items-center gap-x-2 gap-y-1">
1205 icon(
1206 data: iconify_icon!("feather:git-merge"),
1207 attrs: attributes! { class="size-4 shrink-0 text-merged" }
1208 )
1209 "Merged "
1210 <a
1211 href=(commit_url(handle, name, landed.as_str()))
1212 class="font-mono hover:text-primary"
1213 >(landed.short())</a>
1214 " into "
1215 <span class="font-mono">(pull.target_branch.as_str())</span>
1216 if let Some(at) = pull.merged_at {
1217 <span class="text-muted-foreground" title=(timestamp(at))>(ago(at))</span>
1218 }
1219 </span>
1220 </div>
1221 } else if pull.viewer_is_owner {
1222 merge_box(handle: handle, name: name, pull: pull)
1223 }
1224
1225 if pull.viewer_is_owner {
1226 // Two forms, one row, as the issue page settled: a form cannot nest, and
1227 // the Comment button reaches its own by `form=` id rather than by
1228 // containment — which is what keeps the textarea out of the row.
1229 <form
1230 id="pull-comment-form"
1231 method="post"
1232 action=(format!("{url}/comments"))
1233 class="mt-5 space-y-2"
1234 >
1235 csrf_field()
1236 label(attrs: attributes! { for="pull-comment-body" }, "Comment")
1237 textarea(
1238 attrs: attributes! {
1239 id="pull-comment-body"
1240 name="body"
1241 rows="5"
1242 placeholder="Markdown is supported."
1243 },
1244 (comment)
1245 )
1246 </form>
1247
1248 <div class="mt-3 flex flex-wrap items-center gap-2">
1249 button(
1250 size: ButtonSize::Sm,
1251 attrs: attributes! { type="submit" form="pull-comment-form" },
1252 "Comment"
1253 )
1254
1255 // Absent once merged: there is no state left to move it to, and a
1256 // "Close" button on a landed request would offer to undo history.
1257 if !pull.state.is_merged() {
1258 <form method="post" action=(format!("{url}/state"))>
1259 csrf_field()
1260 <input
1261 type="hidden"
1262 name="state"
1263 value=(if pull.state.is_open() { "closed" } else { "open" })
1264 />
1265 button(
1266 variant: ButtonVariant::Outline,
1267 size: ButtonSize::Sm,
1268 attrs: attributes! { type="submit" },
1269 if pull.state.is_open() {
1270 "Close pull request"
1271 } else {
1272 "Reopen pull request"
1273 }
1274 )
1275 </form>
1276 }
1277 </div>
1278
1279 // The issue page's control, in the same words and posting the same
1280 // shape — the route is `labels.rs`'s, beside the issue one.
1281 <details class="mt-3">
1282 <summary class="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
1283 "Labels"
1284 </summary>
1285
1286 if pull.repo_labels.is_empty() {
1287 <p class="mt-2 text-xs text-muted-foreground">
1288 "This repository has no labels yet. "
1289 <a href=(labels_url(handle, name)) class="underline hover:text-foreground">
1290 "Make one"
1291 </a>
1292 "."
1293 </p>
1294 } else {
1295 // Every box, ticked or not, submitted as the whole set — which is
1296 // what makes pressing Save twice mean the same as once.
1297 <form method="post" action=(format!("{url}/labels")) class="mt-2 space-y-2">
1298 csrf_field()
1299 <div class="flex flex-wrap gap-x-4 gap-y-2">
1300 for choice in &pull.repo_labels {
1301 <label class="flex items-center gap-2 text-xs">
1302 <input
1303 type="checkbox"
1304 name="label"
1305 value=(choice.id.as_str())
1306 checked=(pull.labels.iter().any(|on| on.id == choice.id))
1307 />
1308 <span class=(format!("label-pill label-{}", choice.colour))>
1309 (choice.name.as_str())
1310 </span>
1311 </label>
1312 }
1313 </div>
1314 <div class="flex items-center gap-3 pt-1">
1315 button(
1316 size: ButtonSize::Sm,
1317 variant: ButtonVariant::Outline,
1318 attrs: attributes! { type="submit" },
1319 "Save labels"
1320 )
1321 <a
1322 href=(labels_url(handle, name))
1323 class="text-xs text-muted-foreground hover:text-foreground"
1324 >"Manage labels"</a>
1325 </div>
1326 </form>
1327 }
1328 </details>
1329
1330 <details class="mt-3" open=(editing)>
1331 <summary class="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
1332 "Edit title and description"
1333 </summary>
1334
1335 <form method="post" action=(format!("{url}/edit")) class="mt-3 space-y-4">
1336 csrf_field()
1337 <div class="space-y-2">
1338 label(attrs: attributes! { for="edit-title" }, "Title")
1339 input(attrs: attributes! {
1340 id="edit-title"
1341 name="title"
1342 type="text"
1343 value=(pull.title.as_str())
1344 required=(true)
1345 })
1346 </div>
1347 <div class="space-y-2">
1348 label(attrs: attributes! { for="edit-body" }, "Description")
1349 textarea(
1350 attrs: attributes! { id="edit-body" name="body" rows="10" },
1351 (pull.body.as_str())
1352 )
1353 </div>
1354 // The branches are not here: changing either would make every
1355 // comment above an opinion about a different change.
1356 button(
1357 size: ButtonSize::Sm,
1358 attrs: attributes! { type="submit" },
1359 "Save changes"
1360 )
1361 </form>
1362 </details>
1363 } else {
1364 <p class="mt-5 rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground">
1365 "Only the owner can comment on, change or merge this pull request."
1366 </p>
1367 }
1368
1369 <p class="mt-8">
1370 <a
1371 href=(pulls_url(handle, name))
1372 class="text-sm text-muted-foreground hover:text-foreground"
1373 >"Back to pull requests"</a>
1374 </p>
1375 </div>
1376 }
1377}
1378
1379/// The merge box: one line of status, then — only when there is something to press — a
1380/// strategy, a checkbox and one primary button.
1381///
1382/// The button is the one primary-coloured control on the page, which is the ui.md rule
1383/// doing its job: it is the single live thing here, and everything else is either
1384/// position or history.
1385#[component]
1386async fn merge_box(handle: &str, name: &str, pull: &PullDetail) -> Result {
1387 let url = pull_url(handle, name, pull.number);
1388 let strategies = pull.merge.strategies();
1389 // Deleting the source is the tidy default, and it is safe: the branch is in the
1390 // merge commit's history. Merging a branch into itself is not a proposal, so the
1391 // control is not drawn at all there.
1392 let deletable = pull.source_branch != pull.target_branch;
1393 // …but the default branch is the one people clone and push to, so proposing to
1394 // delete it is a different kind of thing. The box is still there — somebody may
1395 // genuinely mean it — and it opens unticked.
1396 let delete_by_default = !pull.source_is_default;
1397
1398 view! {
1399 <div class="mt-5 rounded-lg border border-border px-4 py-3">
1400 <p class="flex flex-wrap items-center gap-2 text-sm">
1401 merge_status_icon(merge: &pull.merge, state: pull.state)
1402 (merge_sentence(&pull.merge, pull.state))
1403 </p>
1404
1405 if let Mergeability::Conflicted { paths } = &pull.merge {
1406 // Named, because which files disagree is the whole of what the reader
1407 // can act on — and a conflict list is a `git` command they run locally.
1408 <ul class="mt-2 flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs text-muted-foreground">
1409 for path in paths.iter().take(10) {
1410 <li class="truncate">(path.as_str())</li>
1411 }
1412 if paths.len() > 10 {
1413 <li>"…and " (paths.len() - 10) " more"</li>
1414 }
1415 </ul>
1416 <p class="mt-2 text-xs text-muted-foreground">
1417 "Merge "
1418 <span class="font-mono text-foreground">(pull.target_branch.as_str())</span>
1419 " into "
1420 <span class="font-mono text-foreground">(pull.source_branch.as_str())</span>
1421 " locally, resolve the conflicts and push."
1422 </p>
1423 }
1424
1425 if !strategies.is_empty() {
1426 <form method="post" action=(format!("{url}/merge")) class="mt-3 flex flex-wrap items-end gap-3">
1427 csrf_field()
1428 <div class="flex min-w-56 flex-col gap-1.5">
1429 label(attrs: attributes! { for="strategy" class="text-xs" }, "How")
1430 // A native select rather than the styled component: this sits
1431 // inside a bordered box beside a button, and the component's own
1432 // chevron wrapper wants a column of its own.
1433 <select
1434 id="strategy"
1435 name="strategy"
1436 class="h-9 rounded-lg border border-border bg-background px-3 text-sm"
1437 >
1438 for strategy in &strategies {
1439 <option value=(strategy.as_str())>(strategy.label())</option>
1440 }
1441 </select>
1442 </div>
1443
1444 if deletable {
1445 <label class="flex items-center gap-2 pb-2.5 text-xs text-muted-foreground">
1446 // On by default: the branch's commits are in the target's
1447 // history once this lands, so the branch itself is the copy
1448 // nobody needs. Off when that branch is the default one.
1449 <input
1450 type="checkbox"
1451 name="delete_source"
1452 value="yes"
1453 checked=(delete_by_default)
1454 />
1455 "Delete "
1456 <span class="font-mono">(pull.source_branch.as_str())</span>
1457 " afterwards"
1458 </label>
1459 }
1460
1461 <button
1462 type="submit"
1463 class=(format!(
1464 "ml-auto {}",
1465 button_variants(ButtonVariant::Primary, ButtonSize::Md)
1466 ))
1467 >"Merge pull request"</button>
1468 </form>
1469 }
1470 </div>
1471 }
1472}
1473
1474/// The dot beside the merge status: the only colour in the box that is not the button.
1475#[component]
1476async fn merge_status_icon(merge: &Mergeability, state: PullRequestState) -> Result {
1477 let (data, tone) = match (state.is_open(), merge) {
1478 (false, _) => (iconify_icon!("feather:x-circle"), "text-muted-foreground"),
1479 (_, Mergeability::Ready { .. }) => (iconify_icon!("feather:check-circle"), "text-success"),
1480 (_, Mergeability::Conflicted { .. }) => {
1481 (iconify_icon!("feather:alert-triangle"), "text-destructive")
1482 }
1483 _ => (iconify_icon!("feather:info"), "text-muted-foreground"),
1484 };
1485
1486 view! {
1487 icon(data: data, attrs: attributes! { class=(format!("size-4 shrink-0 {tone}")) })
1488 }
1489}
1490
1491/// The one line the merge box leads with.
1492///
1493/// Plain sentences rather than a status word: "Conflicts in 3 files" is a thing to act
1494/// on, "Conflicted" is a label to decode.
1495fn merge_sentence(merge: &Mergeability, state: PullRequestState) -> String {
1496 if !state.is_open() {
1497 return "This pull request is closed. Reopen it to merge.".to_owned();
1498 }
1499
1500 match merge {
1501 Mergeability::Ready { fast_forward: true } => {
1502 "Can be merged, and the base can simply catch up.".to_owned()
1503 }
1504 Mergeability::Ready { .. } => "Can be merged.".to_owned(),
1505 Mergeability::Conflicted { paths } => match paths.len() {
1506 1 => "Conflicts in 1 file:".to_owned(),
1507 other => format!("Conflicts in {other} files:"),
1508 },
1509 Mergeability::NothingToMerge => {
1510 "Nothing to merge — every commit is already on the base branch.".to_owned()
1511 }
1512 Mergeability::Unrelated => {
1513 "These two branches share no history, so there is nothing to merge.".to_owned()
1514 }
1515 Mergeability::MissingBranch { branch } => {
1516 format!("There is no branch called {branch} in this repository any more.")
1517 }
1518 Mergeability::Unsupported => "Merging needs git 2.38 or newer on the server.".to_owned(),
1519 Mergeability::NotOpen => "This pull request is not open.".to_owned(),
1520 }
1521}
1522
1523#[component]
1524async fn pull_comment(handle: &str, name: &str, comment: &CommentView) -> Result {
1525 let author = comment
1526 .author
1527 .as_ref()
1528 .map(OrgName::as_str)
1529 .unwrap_or("someone");
1530
1531 view! {
1532 <article class="rounded-lg border border-border">
1533 <header class="flex items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground">
1534 <span class="font-mono">"@" (author)</span>
1535 <span title=(timestamp(comment.created_at))>(ago(comment.created_at))</span>
1536 </header>
1537 body_panel(handle: handle, name: name, source: comment.body.as_str(), empty: "")
1538 </article>
1539 }
1540}
1541
1542/// A markdown body inside a panel.
1543///
1544/// Split out so the one `view!` holding rendered markdown is a single line, and safe
1545/// because [`markdown`](super::markdown) writes every tag itself.
1546#[component]
1547async fn body_panel(handle: &str, name: &str, source: &str, empty: &str) -> Result {
1548 view! {
1549 if source.is_empty() {
1550 <p class="px-4 py-3 text-sm text-muted-foreground italic">(empty)</p>
1551 } else {
1552 <div class="overflow-x-auto px-4 py-2 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
1553 (markdown::render(
1554 source,
1555 Some(markdown::Crossref {
1556 handle,
1557 name,
1558 origin: None,
1559 }),
1560 ))
1561 </div>
1562 }
1563 }
1564}
1565
1566// --- Writes -----------------------------------------------------------------------------
1567
1568/// Adds a comment, then redirects so a reload cannot repeat it.
1569#[page(POST "/{handle}/repos/{name}/pulls/{number}/comments")]
1570async fn add_comment(cx: &Cx, Form(submitted): Form<CommentForm>) -> Result {
1571 csrf_verified(cx, &submitted.csrf_token).await?;
1572
1573 let repo = owned_repo(cx).await?;
1574 let number = number_for(cx)?;
1575
1576 let outcome = comment_on_pull(
1577 &current_actor(cx).await?,
1578 &repo.handle,
1579 &repo.name,
1580 number,
1581 &submitted.body,
1582 SystemTime::now(),
1583 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1584 &pulls(cx),
1585 )
1586 .await;
1587
1588 let message = match outcome {
1589 Ok(()) => return view! { see_pull(repo: &repo, number: number) },
1590 Err(error) => write_failure(error)?,
1591 };
1592
1593 let pull = reread(cx, &repo, number).await?;
1594
1595 view! {
1596 wide(
1597 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
1598 pull_frame(repo: &repo, pull: &pull, active: Sub::Conversation,
1599 conversation(
1600 handle: repo.handle.as_str(),
1601 name: repo.name.as_str(),
1602 pull: &pull,
1603 error: message.as_str(),
1604 // Kept, so a rejected comment is not silently thrown away.
1605 comment: submitted.body.as_str(),
1606 editing: false,
1607 )
1608 )
1609 )
1610 }
1611}
1612
1613/// Closes or reopens, then redirects.
1614#[page(POST "/{handle}/repos/{name}/pulls/{number}/state")]
1615async fn set_state(cx: &Cx, Form(submitted): Form<StateForm>) -> Result {
1616 csrf_verified(cx, &submitted.csrf_token).await?;
1617
1618 let repo = owned_repo(cx).await?;
1619 let number = number_for(cx)?;
1620
1621 // A value neither state can name is a tampered form, not something to default:
1622 // defaulting would close a request somebody asked to reopen. `merged` parses but the
1623 // use case refuses it — landing a branch is the merge form's job.
1624 let state = submitted
1625 .state
1626 .parse::<PullRequestState>()
1627 .map_err(|_| bad_request("unknown pull request state"))?;
1628
1629 match set_pull_state(
1630 &current_actor(cx).await?,
1631 &repo.handle,
1632 &repo.name,
1633 number,
1634 state,
1635 SystemTime::now(),
1636 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1637 &pulls(cx),
1638 )
1639 .await
1640 {
1641 Ok(()) => {}
1642 // Nothing typed to preserve, so a failure here is a response rather than a
1643 // re-render.
1644 Err(error) => {
1645 write_failure(error)?;
1646 }
1647 }
1648
1649 view! { see_pull(repo: &repo, number: number) }
1650}
1651
1652/// Changes the title and body, then redirects.
1653#[page(POST "/{handle}/repos/{name}/pulls/{number}/edit")]
1654async fn edit(cx: &Cx, Form(submitted): Form<EditForm>) -> Result {
1655 csrf_verified(cx, &submitted.csrf_token).await?;
1656
1657 let repo = owned_repo(cx).await?;
1658 let number = number_for(cx)?;
1659
1660 let outcome = edit_pull(
1661 &current_actor(cx).await?,
1662 &repo.handle,
1663 &repo.name,
1664 number,
1665 &PullEdit {
1666 title: submitted.title.clone(),
1667 body: submitted.body.clone(),
1668 },
1669 SystemTime::now(),
1670 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1671 &pulls(cx),
1672 )
1673 .await;
1674
1675 let message = match outcome {
1676 Ok(()) => return view! { see_pull(repo: &repo, number: number) },
1677 Err(error) => write_failure(error)?,
1678 };
1679
1680 // The stored request, with the rejected edit put back into the form and the panel
1681 // opened on it — otherwise the reason is visible and what caused it is hidden behind
1682 // a closed disclosure.
1683 let stored = reread(cx, &repo, number).await?;
1684 let attempted = PullDetail {
1685 title: submitted.title.clone(),
1686 body: submitted.body.clone(),
1687 ..*stored
1688 };
1689
1690 view! {
1691 wide(
1692 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
1693 pull_frame(repo: &repo, pull: &attempted, active: Sub::Conversation,
1694 conversation(
1695 handle: repo.handle.as_str(),
1696 name: repo.name.as_str(),
1697 pull: &attempted,
1698 error: message.as_str(),
1699 comment: "",
1700 editing: true,
1701 )
1702 )
1703 )
1704 }
1705}
1706
1707/// Merges, then redirects to the request.
1708///
1709/// The webhooks are delivered in a spawned task, exactly as a push's are: a delivery
1710/// runs for up to two and a half minutes with its retries, and nothing about whether the
1711/// merge succeeded depends on whether a receiver answered.
1712#[page(POST "/{handle}/repos/{name}/pulls/{number}/merge")]
1713async fn submit_merge(cx: &Cx, Form(submitted): Form<MergeForm>) -> Result {
1714 csrf_verified(cx, &submitted.csrf_token).await?;
1715
1716 let repo = owned_repo(cx).await?;
1717 let number = number_for(cx)?;
1718
1719 // A strategy the domain cannot name is a tampered form. Defaulting would land a
1720 // squash where somebody asked for a merge commit, and history cannot be un-landed.
1721 let strategy = submitted
1722 .strategy
1723 .parse::<MergeStrategy>()
1724 .map_err(|_| bad_request("unknown merge strategy"))?;
1725
1726 // The identity the top bar already read this request. `None` would mean the session
1727 // outlived its user, which the use case refuses a moment later anyway — the fallback
1728 // exists so a merge commit always has a name on it rather than one git invented from
1729 // the host.
1730 let author = match identity(cx).await? {
1731 Some(who) => MergeAuthor {
1732 name: who.handle.to_string(),
1733 email: who.email.to_string(),
1734 },
1735 None => return Err(forbidden().into()),
1736 };
1737
1738 let outcome = merge_pull(
1739 &current_actor(cx).await?,
1740 &repo.handle,
1741 &repo.name,
1742 number,
1743 strategy,
1744 submitted.delete_source.is_some(),
1745 git_features(cx).merge_tree,
1746 SystemTime::now(),
1747 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1748 &pulls(cx),
1749 &push_events(cx),
1750 &issues(cx),
1751 &queries(cx),
1752 &merges(cx),
1753 &author,
1754 )
1755 .await;
1756
1757 let message = match outcome {
1758 Ok(merged) => {
1759 deliver(cx, &repo, merged.events);
1760 return view! { see_pull(repo: &repo, number: number) };
1761 }
1762 Err(error) => write_failure(error)?,
1763 };
1764
1765 // Re-rendered rather than redirected: the reason a merge was refused — a conflict
1766 // that arrived a minute ago, a branch that moved — is the whole of what the page has
1767 // to say, and a redirect would drop it.
1768 let pull = reread(cx, &repo, number).await?;
1769
1770 view! {
1771 wide(
1772 repo_header(repo: &repo, rev: "", active: Tab::Pulls)
1773 pull_frame(repo: &repo, pull: &pull, active: Sub::Conversation,
1774 conversation(
1775 handle: repo.handle.as_str(),
1776 name: repo.name.as_str(),
1777 pull: &pull,
1778 error: message.as_str(),
1779 comment: "",
1780 editing: false,
1781 )
1782 )
1783 )
1784 }
1785}
1786
1787/// Sends the merge's push events to the repository's webhooks, in a task of its own.
1788///
1789/// Detached for the reason the git transport detaches its own: the retries alone run for
1790/// two and a half minutes, and a person waiting on a redirect must not wait on somebody
1791/// else's server. A failure is logged and nothing else — the merge has landed either way.
1792fn deliver(cx: &Cx, repo: &RepoView, events: Vec<crate::domain::PushEvent>) {
1793 use crate::{
1794 application::{DeliveryContext, RETRY_BACKOFF, deliver_push},
1795 infrastructure::clock::SystemClock,
1796 };
1797
1798 if events.is_empty() {
1799 return;
1800 }
1801
1802 let context = DeliveryContext {
1803 handle: repo.handle.clone(),
1804 name: repo.name.clone(),
1805 repo_url: format!("{}/{}/repos/{}", public_origin(cx), repo.handle, repo.name),
1806 pusher: Some(repo.handle.to_string()),
1807 };
1808
1809 // Cloned out of `cx` before the task starts: the request context does not outlive the
1810 // response, and every one of these is a cheap handle onto the pool or the config.
1811 let (queries, webhooks, deliveries, sender) = (
1812 queries(cx),
1813 webhooks(cx),
1814 super::context::deliveries(cx),
1815 webhook_sender(cx),
1816 );
1817
1818 tokio::spawn(async move {
1819 if let Err(error) = deliver_push(
1820 &events,
1821 &context,
1822 &queries,
1823 &webhooks,
1824 &deliveries,
1825 &sender,
1826 &SystemClock,
1827 &RETRY_BACKOFF,
1828 )
1829 .await
1830 {
1831 eprintln!(
1832 "steid: the webhooks for {}/{} could not be reached after a merge: {error}",
1833 context.handle, context.name
1834 );
1835 }
1836 });
1837}
1838
1839/// The post-redirect-get reply every write here ends with.
1840///
1841/// A `#[component]` rather than a plain function because `view!` needs `__cx` in scope,
1842/// which only a page or a component has — the same reason the browse helpers are
1843/// components. The status is a **303**: `redirect()` is a 307 and would make the browser
1844/// re-POST the form to the pull request's own URL.
1845#[component]
1846async fn see_pull(repo: &RepoView, number: IssueNumber) -> Result {
1847 view! {
1848 (StatusCode::SEE_OTHER)
1849 (location(&pull_url(repo.handle.as_str(), repo.name.as_str(), number))?)
1850 }
1851}
1852
1853// --- Shared reads -------------------------------------------------------------------------
1854
1855/// The repository's branch names, for the form's `<datalist>`. One `git` process.
1856async fn branch_names(cx: &Cx, repo: &RepoView) -> Result<Vec<String>> {
1857 let refs: RefList = list_refs(
1858 &repo.handle,
1859 &repo.name,
1860 &current_actor(cx).await?,
1861 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
1862 &queries(cx),
1863 )
1864 .await
1865 .map_err(server_error)?
1866 .unwrap_or_default();
1867
1868 Ok(refs
1869 .branches
1870 .iter()
1871 .map(|branch| branch.as_str().to_owned())
1872 .collect())
1873}
1874
1875/// What the form's base field opens with. One `git` process; empty for a repository with
1876/// no commits, where there is nothing to prefill with.
1877async fn default_branch(cx: &Cx, repo: &RepoView) -> Result<String> {
1878 use crate::application::port::GitQuery;
1879
1880 Ok(queries(cx)
1881 .default_branch(&repo.handle, &repo.name)
1882 .await
1883 .map_err(server_error)?
1884 .map(|branch| branch.as_str().to_owned())
1885 .unwrap_or_default())
1886}
1887
1888#[cfg(test)]
1889mod tests {
1890 use super::*;
1891
1892 #[test]
1893 fn the_first_page_of_the_open_list_is_the_bare_url() {
1894 assert_eq!(
1895 listed_url("ada", "steid", PullRequestState::Open, "", 1),
1896 "/ada/repos/steid/pulls"
1897 );
1898 }
1899
1900 #[test]
1901 fn a_page_link_carries_the_state_with_it() {
1902 // Paging out of a state filter would silently show a different list.
1903 assert_eq!(
1904 listed_url("ada", "steid", PullRequestState::Merged, "", 3),
1905 "/ada/repos/steid/pulls?state=merged&page=3"
1906 );
1907 }
1908
1909 #[test]
1910 fn a_state_link_drops_the_page() {
1911 // Page 4 of the open requests is not page 4 of the merged ones.
1912 assert_eq!(
1913 listed_url("ada", "steid", PullRequestState::Closed, "", 1),
1914 "/ada/repos/steid/pulls?state=closed"
1915 );
1916 }
1917
1918 #[test]
1919 fn a_page_link_carries_the_state_and_the_label_with_it() {
1920 // Paging out of a label filter would silently widen the list.
1921 assert_eq!(
1922 listed_url("ada", "steid", PullRequestState::Merged, "needs review", 3),
1923 "/ada/repos/steid/pulls?state=merged&label=needs%20review&page=3"
1924 );
1925 }
1926
1927 #[test]
1928 fn a_state_link_keeps_the_label() {
1929 assert_eq!(
1930 listed_url("ada", "steid", PullRequestState::Closed, "bug", 1),
1931 "/ada/repos/steid/pulls?state=closed&label=bug"
1932 );
1933 }
1934
1935 #[test]
1936 fn a_prefilled_form_encodes_each_branch_on_its_own() {
1937 // A slash inside a branch name must survive as part of the value.
1938 assert_eq!(
1939 new_pull_url("ada", "steid", "feature/login", "main"),
1940 "/ada/repos/steid/pulls/new?source=feature%2Flogin&target=main"
1941 );
1942 }
1943
1944 #[test]
1945 fn the_merge_sentence_names_what_can_be_acted_on() {
1946 let sentence = |merge| merge_sentence(&merge, PullRequestState::Open);
1947
1948 assert_eq!(
1949 sentence(Mergeability::Conflicted {
1950 paths: vec!["a".to_owned(), "b".to_owned(), "c".to_owned()],
1951 }),
1952 "Conflicts in 3 files:"
1953 );
1954 assert_eq!(
1955 sentence(Mergeability::Conflicted {
1956 paths: vec!["a".to_owned()],
1957 }),
1958 "Conflicts in 1 file:"
1959 );
1960 assert_eq!(
1961 sentence(Mergeability::Unsupported),
1962 "Merging needs git 2.38 or newer on the server."
1963 );
1964 // A closed request says so whatever git thinks of its branches.
1965 assert_eq!(
1966 merge_sentence(
1967 &Mergeability::Ready {
1968 fast_forward: false
1969 },
1970 PullRequestState::Closed
1971 ),
1972 "This pull request is closed. Reopen it to merge."
1973 );
1974 }
1975}