forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
57.9 KiB
1//! Pages of one pull request at `/{owner}/{repo}/pulls/{n}`: the Conversation (header, timeline,
2//! merge box, comment and state form, sidebar), Commits and Files changed. The timeline, sidebar
3//! and comment POST logic are shared with issues (`issue_parts`); label, assignee and milestone
4//! POSTs are in `issue_meta`. Comments post to `/pulls/{n}/comments`, close and reopen to
5//! `/pulls/{n}/state`. The merge POST (`/pulls/{n}/merge`) lives in `pulls_merge`.
6
7use std::collections::HashMap;
8
9use forge_core::{Action, IssueState, Mergeable, PullRequest, Review, ReviewState, authorize};
10use forge_git::browse::CommitInfo;
11use forge_git::history::CommitDiff;
12use forge_git::pulls::{is_ancestor, range_diff_files, range_log};
13use topcoat::Result;
14use topcoat::context::{Cx, app_context};
15use topcoat::router::content::Form;
16use topcoat::router::error::{not_found, see_other};
17use topcoat::router::request::uri;
18use topcoat::router::{page, path_param};
19use topcoat::view::{View, component, view};
20
21use crate::auth::require_user;
22use crate::i18n::pulls_review::{self as g, K};
23use crate::i18n::{self, Lang};
24use crate::pages::commits::{CommitRow, now_secs};
25use crate::pages::issue_meta::item_href;
26use crate::pages::issue_parts::{
27 CommentForm, ItemKind, Posted, When, check_write, comment_form, csrf_of, discussion_timeline,
28 item_sidebar, load_discussion, load_sidebar, lock_message, may_change, may_converse,
29 parse_number, post_comment, triage, viewer,
30};
31use crate::pages::notifications::notify_box_for;
32use crate::pages::pull_branch::{self, branch_box, branch_error_from};
33use crate::pages::pull_reviews::error_text_in;
34use crate::pages::pulls_merge::{
35 ERR_ALREADY_MERGED, ERR_BASE_MISSING, ERR_BASE_MOVED, ERR_CHANGES_REQUESTED, ERR_CONFLICT,
36 ERR_FF_IMPOSSIBLE, ERR_HEAD_MISSING, ERR_MESSAGE_INVALID, ERR_NEEDS_APPROVALS, ERR_NOT_OPEN,
37 ERR_NOT_UP_TO_DATE, ERR_UP_TO_DATE, merge_gate,
38};
39use crate::pages::repo::repo_head;
40use crate::pages::repo_ctx::{RepoCtx, load_repo};
41use crate::state::AppState;
42use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary, commit_status};
43use crate::views::diff::LIMITS;
44use crate::views::{alert, now_unix, repo_href, shell, short_oid};
45
46path_param!(owner);
47path_param!(repo);
48path_param!(number);
49
50/// Commits listed (and counted) for one pull request.
51const MAX_COMMITS: usize = 250;
52
53/// Whether the pull request has been merged. A merged pull request is closed and cannot change.
54pub(crate) fn is_merged(pr: &PullRequest) -> bool {
55 pr.merged_at.is_some() || pr.mergeable == Mergeable::Merged
56}
57
58/// The pull request named by `{number}` in this repo, or 404.
59async fn pull_for(state: &AppState, ctx: &RepoCtx, number: i64) -> Result<PullRequest> {
60 match state.store.pull_by_number(&ctx.repo.id, number).await? {
61 Some(pr) => Ok(pr),
62 None => Err(not_found().into()),
63 }
64}
65
66/// The repo and pull request for a request whose path names `{owner}/{repo}/pulls/{number}`.
67async fn load_pull(cx: &Cx) -> Result<(RepoCtx, PullRequest)> {
68 let number = parse_number(path_param::<Number>(cx))?;
69 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
70 let pr = pull_for(app_context::<AppState>(cx), &ctx, number).await?;
71 Ok((ctx, pr))
72}
73
74// Git data
75
76/// The commits in `base..head`, oldest first, up to `MAX_COMMITS`.
77pub(crate) async fn commits_of(
78 state: &AppState,
79 ctx: &RepoCtx,
80 pr: &PullRequest,
81) -> Result<Vec<CommitInfo>> {
82 Ok(range_log(
83 &state.git,
84 &ctx.path,
85 &pr.base_oid,
86 &pr.head_oid,
87 MAX_COMMITS,
88 )
89 .await?)
90}
91
92/// The diff of the head side of the pull request (merge base to head).
93pub(crate) async fn diff_of(
94 state: &AppState,
95 ctx: &RepoCtx,
96 pr: &PullRequest,
97) -> Result<CommitDiff> {
98 Ok(range_diff_files(&state.git, &ctx.path, &pr.base_oid, &pr.head_oid, LIMITS).await?)
99}
100
101// Header
102
103/// What the header and sub-tabs show: the title, state, byline and tab counts.
104#[derive(Clone)]
105pub(crate) struct Frame {
106 title: String,
107 number: i64,
108 pub(crate) page_title: String,
109 pub(crate) href: String,
110 author: String,
111 opened: When,
112 pub(crate) commit_count: usize,
113 pub(crate) file_count: usize,
114 base_branch: String,
115 head_label: String,
116 state_class: &'static str,
117 state_text: &'static str,
118 lang: Lang,
119}
120
121/// The header for a pull request, with its strings in `lang`.
122pub(crate) async fn frame_in(
123 lang: Lang,
124 state: &AppState,
125 ctx: &RepoCtx,
126 pr: &PullRequest,
127 commit_count: usize,
128 file_count: usize,
129) -> Result<Frame> {
130 let owner = ctx.repo.owner_name.clone();
131 let name = ctx.repo.name.clone();
132 let number = pr.issue.number;
133
134 let users = state
135 .store
136 .usernames_by_ids(std::slice::from_ref(&pr.issue.author_id))
137 .await?;
138 let author = users
139 .get(&pr.issue.author_id)
140 .map(|(display, _)| display.clone())
141 .unwrap_or_else(|| g::t(lang, K::Unknown).to_owned());
142
143 // The head branch, qualified by its repo when that is a fork. A deleted head repo has none.
144 let head_label = match pr.head_repo_id.as_deref() {
145 None => g::branch_deleted(lang, &pr.head_branch),
146 Some(id) if id == ctx.repo.id => pr.head_branch.clone(),
147 Some(id) => match state.store.repo_by_id(id).await? {
148 Some(head) => format!("{}:{}", head.owner_name, pr.head_branch),
149 None => g::branch_deleted(lang, &pr.head_branch),
150 },
151 };
152
153 let (state_class, state_text) = if is_merged(pr) {
154 ("issue-badge issue-badge-merged", g::t(lang, K::StateMerged))
155 } else if matches!(pr.issue.state, IssueState::Open) {
156 ("issue-badge issue-badge-open", g::t(lang, K::StateOpen))
157 } else {
158 ("issue-badge issue-badge-closed", g::t(lang, K::StateClosed))
159 };
160
161 Ok(Frame {
162 lang,
163 title: pr.issue.title.clone(),
164 number,
165 page_title: format!("{} #{number} \u{b7} {owner}/{name}", pr.issue.title),
166 href: repo_href(&owner, &name, "pulls", &number.to_string()),
167 author,
168 opened: When::new(lang, &pr.issue.created_at, ctx.viewer_tz(), now_unix()),
169 commit_count,
170 file_count,
171 base_branch: pr.base_branch.clone(),
172 head_label,
173 state_class,
174 state_text,
175 })
176}
177
178#[component]
179pub(crate) async fn pull_header(frame: Frame) -> Result<impl View> {
180 let Frame {
181 title,
182 number,
183 state_class,
184 state_text,
185 author,
186 opened,
187 commit_count,
188 base_branch,
189 head_label,
190 lang,
191 ..
192 } = frame;
193 let number_label = format!("#{number}");
194 let commits_text = g::commit_count_line(lang, commit_count);
195 let wants = g::t(lang, K::ByWantsToMerge);
196 let into = g::t(lang, K::ByInto);
197 let from = g::t(lang, K::ByFrom);
198 let byline_end = g::t(lang, K::BylineEnd);
199 // Turkish puts the source branch first and the commit count before the verb.
200 let turkish = matches!(lang, Lang::Tr);
201 Ok(view! {
202 <section class="issue-head">
203 <h1 class="issue-heading">(title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1>
204 <p class="issue-byline">
205 <span class=(state_class)>(state_text)</span>
206 <strong>(author.as_str())</strong>
207 if turkish {
208 (wants) <code>(head_label.as_str())</code>
209 (from) <code>(base_branch.as_str())</code>
210 (into) (commits_text.as_str())
211 } else {
212 (wants) (commits_text.as_str()) (into)
213 <code>(base_branch.as_str())</code>
214 (from)
215 <code>(head_label.as_str())</code>
216 }
217 (byline_end)
218 <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time>
219 </p>
220 </section>
221 })
222}
223
224/// Class for one sub-navigation link: the one named by `active` is marked current.
225fn tab_class(active: &str, tab: &str) -> &'static str {
226 if active == tab {
227 "subnav-link current"
228 } else {
229 "subnav-link"
230 }
231}
232
233/// The pull request's sub-tabs, with their labels in `lang`.
234#[component]
235pub(crate) async fn pull_subnav_in(
236 lang: Lang,
237 href: String,
238 active: String,
239 commits: usize,
240 files: usize,
241) -> Result<impl View> {
242 let commits_href = format!("{href}/commits");
243 let files_href = format!("{href}/files");
244 let commits_label = g::commits_tab(lang, commits);
245 let files_label = g::files_tab(lang, files);
246 let conversation = g::t(lang, K::TabConversation);
247 let aria = g::t(lang, K::PullPages);
248 Ok(view! {
249 <nav class="subnav" aria-label=(aria)>
250 <a class=(tab_class(&active, "conversation")) href=(href.as_str())>(conversation)</a>
251 <a class=(tab_class(&active, "commits")) href=(commits_href.as_str())>(commits_label.as_str())</a>
252 <a class=(tab_class(&active, "files")) href=(files_href.as_str())>(files_label.as_str())</a>
253 </nav>
254 })
255}
256
257// Merge box
258
259/// Where the merge form posts, and whether fast-forward is offered.
260struct MergeForm {
261 action: String,
262 csrf: String,
263 ff: bool,
264}
265
266/// The mergeability status and, for writers on a clean branch, the merge form. `tone` is one of
267/// `ok`, `bad` or `muted` and picks the box colors.
268#[component]
269async fn merge_box(
270 headline: String,
271 tone: String,
272 conflicts: Vec<String>,
273 reload: Option<String>,
274 form: Option<MergeForm>,
275 merge_error: Option<String>,
276 approvals: Option<String>,
277 blocked: Option<String>,
278 checks: Option<StatusSummary>,
279 lang: Lang,
280) -> Result<impl View> {
281 let class = format!("merge-box merge-{tone}");
282 let reload_text = g::t(lang, K::ReloadToCheck);
283 let style_legend = g::t(lang, K::MergeStyleLegend);
284 let style_merge = g::t(lang, K::StyleMergeCommit);
285 let style_squash = g::t(lang, K::StyleSquash);
286 let style_ff = g::t(lang, K::StyleFastForward);
287 let message_label = g::t(lang, K::MergeMessageLabel);
288 let merge_button = g::t(lang, K::MergeButton);
289 Ok(view! {
290 <section class=(class.as_str())>
291 if let Some(text) = merge_error {
292 <p class="alert merge-error" role="alert">(text.as_str())</p>
293 }
294 <p class="merge-headline">(headline.as_str())</p>
295 if let Some(summary) = checks {
296 checks_summary(summary: summary, lang: StatusLang(lang))
297 }
298 if let Some(text) = approvals {
299 <p class="muted small">(text.as_str())</p>
300 }
301 if let Some(text) = blocked {
302 <p class="merge-blocked">(text.as_str())</p>
303 }
304 if !conflicts.is_empty() {
305 <ul class="merge-files">
306 for file in conflicts.iter() {
307 <li><code>(file.as_str())</code></li>
308 }
309 </ul>
310 }
311 if let Some(href) = reload {
312 <p class="muted small"><a href=(href.as_str())>(reload_text)</a></p>
313 }
314 if let Some(f) = form {
315 <form method="post" action=(f.action.as_str()) class="stack merge-form">
316 <input type="hidden" name="csrf" value=(f.csrf.as_str())>
317 <fieldset class="merge-styles">
318 <legend>(style_legend)</legend>
319 <label><input type="radio" name="style" value="merge" checked=(true)>(style_merge)</label>
320 <label><input type="radio" name="style" value="squash">(style_squash)</label>
321 if f.ff {
322 <label><input type="radio" name="style" value="ff">(style_ff)</label>
323 }
324 </fieldset>
325 <label>
326 <span>(message_label)</span>
327 <textarea name="message" rows="3"></textarea>
328 </label>
329 <div><button type="submit" class="primary">(merge_button)</button></div>
330 </form>
331 }
332 </section>
333 })
334}
335
336/// The note that replaces the merge box once the pull request is merged.
337#[component]
338async fn merged_notice(
339 who: String,
340 when: When,
341 commit: String,
342 commit_href: String,
343 lang: Lang,
344) -> Result<impl View> {
345 let short = short_oid(&commit).to_owned();
346 let prefix = g::t(lang, K::MergedByPrefix);
347 let middle = g::t(lang, K::MergedByMiddle);
348 let as_sep = g::t(lang, K::MergedAsSep);
349 Ok(view! {
350 <section class="merge-box merge-merged">
351 <p class="merge-headline">
352 (prefix) <strong>(who.as_str())</strong> (middle)
353 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
354 if !commit.is_empty() {
355 (as_sep)
356 <a class="mono" href=(commit_href.as_str())>(short.as_str())</a>
357 }
358 </p>
359 </section>
360 })
361}
362
363// Review summary
364
365/// A Dismiss form for one counted review: its action URL and the label of its reason field.
366struct DismissRow {
367 action: String,
368 label: String,
369}
370
371/// Who approves and who requests changes, then a Dismiss form per counted review for Write
372/// holders. Either summary line may be absent.
373#[component]
374async fn review_box(
375 approved: Option<String>,
376 changes: Option<String>,
377 dismiss: Vec<DismissRow>,
378 csrf: String,
379 lang: Lang,
380) -> Result<impl View> {
381 let dismiss_text = g::t(lang, K::DismissButton);
382 Ok(view! {
383 <section class="review-box">
384 if let Some(text) = approved {
385 <p class="review-summary">(text.as_str())</p>
386 }
387 if let Some(text) = changes {
388 <p class="review-summary">(text.as_str())</p>
389 }
390 for row in dismiss.iter() {
391 <form method="post" action=(row.action.as_str()) class="review-dismiss">
392 <input type="hidden" name="csrf" value=(csrf.as_str())>
393 <label>
394 <span>(row.label.as_str())</span>
395 <input type="text" name="reason" required=(true)>
396 </label>
397 <button type="submit" class="link-button">(dismiss_text)</button>
398 </form>
399 }
400 </section>
401 })
402}
403
404/// The approvals and change requests that count toward the summary: each reviewer's latest one,
405/// with dismissed reviews left out, so an earlier undismissed state counts again. In submission
406/// order, as `review_summary` counts them.
407async fn counted_reviews(state: &AppState, pr: &PullRequest) -> Result<Vec<Review>> {
408 let mut latest: HashMap<String, Review> = HashMap::new();
409 for review in state.store.list_reviews(&pr.issue.id).await? {
410 if matches!(
411 review.state,
412 ReviewState::Approved | ReviewState::ChangesRequested
413 ) {
414 latest.insert(review.author_id.clone(), review);
415 }
416 }
417 let mut counted: Vec<Review> = latest.into_values().collect();
418 counted.sort_by(|a, b| {
419 a.submitted_at
420 .cmp(&b.submitted_at)
421 .then_with(|| a.id.cmp(&b.id))
422 });
423 Ok(counted)
424}
425
426// Commits and files
427
428/// The combined status of the pull request's head commit, `None` when nothing has reported on it.
429async fn head_checks(
430 state: &AppState,
431 ctx: &RepoCtx,
432 pr: &PullRequest,
433) -> Result<Option<StatusSummary>> {
434 let rows = state.store.statuses_for(&ctx.repo.id, &pr.head_oid).await?;
435 Ok((!rows.is_empty()).then(|| StatusSummary::from_rows(&rows)))
436}
437
438#[component]
439async fn commit_list(rows: Vec<CommitRow>, lang: Lang) -> Result<impl View> {
440 let empty = rows.is_empty();
441 let no_commits = g::t(lang, K::NoCommits);
442 let committed = g::t(lang, K::Committed);
443 let browse = g::t(lang, K::BrowseFiles);
444 Ok(view! {
445 if empty {
446 <p class="muted">(no_commits)</p>
447 } else {
448 <ul class="commit-list">
449 for row in rows.iter() {
450 <li class="commit-row">
451 <div class="commit-main">
452 <a class="commit-summary" href=(row.commit_href.as_str())>(row.summary.as_str())</a>
453 <div class="commit-meta muted small">
454 <span class="commit-author">(row.author.as_str())</span>
455 <span>(committed)</span>
456 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
457 </div>
458 </div>
459 <div class="commit-actions">
460 commit_status(summary: row.status.clone(), lang: StatusLang(lang))
461 <a class="mono commit-oid" href=(row.commit_href.as_str())>(row.short.as_str())</a>
462 <a class="small muted" href=(row.tree_href.as_str())>(browse)</a>
463 </div>
464 </li>
465 }
466 </ul>
467 }
468 })
469}
470
471// Pages
472
473/// The Conversation tab. `draft` and `error` belong to the comment form, so a refused comment
474/// keeps its text.
475async fn pull_conversation(
476 cx: &Cx,
477 ctx: RepoCtx,
478 pr: PullRequest,
479 draft: String,
480 error: Option<String>,
481 merge_error: Option<String>,
482) -> Result<impl View> {
483 let state = app_context::<AppState>(cx);
484 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
485 let commit_count = commits_of(state, &ctx, &pr).await?.len();
486 let file_count = diff_of(state, &ctx, &pr).await?.files.len();
487 let frame = frame_in(lang, state, &ctx, &pr, commit_count, file_count).await?;
488 let href = frame.href.clone();
489 let owner = ctx.repo.owner_name.clone();
490 let name = ctx.repo.name.clone();
491
492 let discussion = load_discussion(
493 lang,
494 state,
495 &ctx,
496 &pr.issue,
497 ItemKind::Pull,
498 &pr.base_branch,
499 )
500 .await?;
501 let sidebar = load_sidebar(state, &ctx.repo.id, &pr.issue).await?;
502 let notify = notify_box_for(state, &ctx, lang, &pr.issue.id, &href).await?;
503 let csrf = csrf_of(&ctx);
504 let checks = head_checks(state, &ctx, &pr).await?;
505 let archived = ctx.repo.archived;
506 let signed_in = ctx.viewer.is_some();
507 let merged = is_merged(&pr);
508 let open = matches!(pr.issue.state, IssueState::Open) && !merged;
509 let can_comment = signed_in && !archived && may_converse(&ctx, &pr.issue);
510 let locked_for_viewer = !may_converse(&ctx, &pr.issue);
511 let lock_notice = lock_message(lang, &pr.issue);
512 let can_close = may_change(&ctx, &pr.issue.author_id) && !merged;
513 let write_meta = !archived && triage(&ctx);
514 let comment_action = format!("{href}/comments");
515 let state_action = format!("{href}/state");
516 let login_href = "/login".to_owned();
517 let archived_text = g::t(lang, K::ArchivedReadOnly);
518 let login_before = g::t(lang, K::LoginBefore);
519 let login_link = g::t(lang, K::LoginLink);
520 let login_after = g::t(lang, K::LoginAfter);
521
522 let (headline, tone, conflicts, reload) = match pr.mergeable {
523 Mergeable::Clean => (g::t(lang, K::MergeClean).to_owned(), "ok", Vec::new(), None),
524 Mergeable::Conflict => (
525 g::t(lang, K::MergeConflict).to_owned(),
526 "bad",
527 pr.conflict_files.clone(),
528 None,
529 ),
530 Mergeable::UpToDate => (
531 g::t(lang, K::MergeUpToDate).to_owned(),
532 "muted",
533 Vec::new(),
534 None,
535 ),
536 Mergeable::Unknown | Mergeable::Merged => (
537 g::t(lang, K::MergeChecking).to_owned(),
538 "muted",
539 Vec::new(),
540 Some(href.clone()),
541 ),
542 };
543 let can_write = !archived
544 && authorize(
545 viewer(&ctx),
546 &ctx.repo,
547 ctx.role,
548 ctx.org_role,
549 Action::Write,
550 )
551 .is_ok();
552 // The merge rules of the base branch: approvals, change requests and up-to-date status.
553 let gate = if open {
554 Some(merge_gate(state, &ctx.repo, &pr).await?)
555 } else {
556 None
557 };
558 let blocked = gate
559 .as_ref()
560 .and_then(|gate| gate.blocked())
561 .and_then(|code| merge_error_text(lang, code))
562 .map(str::to_owned);
563 let approvals = gate
564 .as_ref()
565 .filter(|gate| gate.rule.required_approvals > 0)
566 .map(|gate| {
567 g::approvals_of(
568 lang,
569 gate.approvals.min(gate.rule.required_approvals),
570 gate.rule.required_approvals,
571 )
572 });
573 let merge_form = if open
574 && can_write
575 && pr.mergeable == Mergeable::Clean
576 && pr.head_repo_id.is_some()
577 && blocked.is_none()
578 {
579 let ff = is_ancestor(&state.git, &ctx.path, &pr.base_oid, &pr.head_oid).await?;
580 Some(MergeForm {
581 action: format!("{href}/merge"),
582 csrf: csrf.clone(),
583 ff,
584 })
585 } else {
586 None
587 };
588
589 let merged_who = match pr.merged_by.clone() {
590 Some(id) => state
591 .store
592 .usernames_by_ids(std::slice::from_ref(&id))
593 .await?
594 .get(&id)
595 .map(|(display, _)| display.clone())
596 .unwrap_or_else(|| g::t(lang, K::Unknown).to_owned()),
597 None => g::t(lang, K::Unknown).to_owned(),
598 };
599 let merged_when = pr
600 .merged_at
601 .as_deref()
602 .map(|stamp| When::new(lang, stamp, ctx.viewer_tz(), now_unix()));
603 let merge_commit = pr.merge_commit.clone().unwrap_or_default();
604 let merge_commit_href = format!("/{owner}/{name}/commit/{merge_commit}");
605
606 let review_error = review_error_from(lang, cx);
607 let summary = state.store.review_summary(&pr.issue.id).await?;
608 let approved = (!summary.approved_by.is_empty())
609 .then(|| g::approved_by(lang, &summary.approved_by.join(", ")));
610 let changes = (!summary.changes_requested_by.is_empty())
611 .then(|| g::changes_requested_by(lang, &summary.changes_requested_by.join(", ")));
612 let review_box_shown = approved.is_some() || changes.is_some();
613 // Dismiss forms are for Write holders on an open pull request.
614 let dismiss: Vec<DismissRow> = if open && can_write {
615 counted_reviews(state, &pr)
616 .await?
617 .into_iter()
618 .map(|review| DismissRow {
619 action: format!("{href}/reviews/{}/dismiss", review.id),
620 label: g::dismiss_reason(lang, &review.author_name),
621 })
622 .collect()
623 } else {
624 Vec::new()
625 };
626
627 let branch = pull_branch::branch_action(state, &ctx, &pr, &csrf).await?;
628 let branch_error = branch_error_from(cx, lang);
629 let branch_shown = !open && (branch.is_some() || branch_error.is_some());
630
631 let page_title = frame.page_title.clone();
632 let header = ctx.head(state, "pulls").await?;
633 Ok(view! { cx =>
634 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
635 repo_head(head: header)
636 pull_header(frame: frame.clone())
637 pull_subnav_in(lang: lang, href: href.clone(), active: "conversation".to_owned(),
638 commits: commit_count, files: file_count)
639 if let Some(message) = review_error {
640 alert(message: message)
641 }
642 <div class="issue-layout">
643 <div class="issue-column">
644 discussion_timeline(lang: lang, discussion: discussion)
645 if review_box_shown {
646 review_box(approved: approved, changes: changes, dismiss: dismiss,
647 csrf: csrf.clone(), lang: lang)
648 }
649 if open {
650 merge_box(headline: headline, tone: tone.to_owned(), conflicts: conflicts,
651 reload: reload, form: merge_form, merge_error: merge_error,
652 approvals: approvals, blocked: blocked, checks: checks, lang: lang)
653 }
654 if let Some(when) = merged_when {
655 merged_notice(who: merged_who, when: when, commit: merge_commit,
656 commit_href: merge_commit_href, lang: lang)
657 }
658 if branch_shown {
659 branch_box(lang: lang, action: branch, error: branch_error)
660 }
661 if can_comment {
662 comment_form(lang: lang, action: comment_action, state_action: state_action,
663 csrf: csrf.clone(), draft: draft, error: error, can_close: can_close, open: open)
664 }
665 if archived {
666 <p class="muted">(archived_text)</p>
667 }
668 if locked_for_viewer {
669 <p class="muted">(lock_notice.as_str())</p>
670 } else if !signed_in {
671 <p class="muted">(login_before)<a href=(login_href.as_str())>(login_link)</a>(login_after)</p>
672 }
673 </div>
674 <aside class="issue-sidebar">
675 item_sidebar(lang: lang, write: write_meta, base: href.clone(), csrf: csrf.clone(), sidebar: sidebar, notify: notify)
676 </aside>
677 </div>
678 )
679 })
680}
681
682/// The message for a `merge_error` code set by `pulls_merge`. Unknown codes have no message, so a
683/// crafted query value shows nothing.
684fn merge_error_text(lang: Lang, code: &str) -> Option<&'static str> {
685 Some(match code {
686 ERR_CONFLICT => g::t(lang, K::ErrConflict),
687 ERR_UP_TO_DATE => g::t(lang, K::ErrUpToDate),
688 ERR_FF_IMPOSSIBLE => g::t(lang, K::ErrFfImpossible),
689 ERR_BASE_MOVED => g::t(lang, K::ErrBaseMoved),
690 ERR_ALREADY_MERGED => g::t(lang, K::ErrAlreadyMerged),
691 ERR_NOT_OPEN => g::t(lang, K::ErrNotOpen),
692 ERR_HEAD_MISSING => g::t(lang, K::ErrHeadMissing),
693 ERR_BASE_MISSING => g::t(lang, K::ErrBaseMissing),
694 ERR_NEEDS_APPROVALS => g::t(lang, K::ErrNeedsApprovals),
695 ERR_CHANGES_REQUESTED => g::t(lang, K::ErrChangesRequested),
696 ERR_NOT_UP_TO_DATE => g::t(lang, K::ErrNotUpToDate),
697 ERR_MESSAGE_INVALID => g::t(lang, K::ErrMessageInvalid),
698 _ => return None,
699 })
700}
701
702/// The message for the `merge_error` query value of the request, when it is a known code.
703fn merge_error_from(lang: Lang, cx: &Cx) -> Option<String> {
704 let request = uri(cx);
705 let query = request.query()?;
706 let (_, code) =
707 form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "merge_error")?;
708 merge_error_text(lang, &code).map(str::to_owned)
709}
710
711/// The message for the `review_error` query value of the request, when it is a known code.
712fn review_error_from(lang: Lang, cx: &Cx) -> Option<&'static str> {
713 let request = uri(cx);
714 let query = request.query()?;
715 let (_, code) =
716 form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "review_error")?;
717 error_text_in(lang, &code)
718}
719
720#[page("/{owner}/{repo}/pulls/{number}")]
721async fn pull_show(cx: &Cx) -> Result<impl View> {
722 let (ctx, pr) = load_pull(cx).await?;
723 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
724 let merge_error = merge_error_from(lang, cx);
725 pull_conversation(cx, ctx, pr, String::new(), None, merge_error).await
726}
727
728#[page("/{owner}/{repo}/pulls/{number}/commits")]
729async fn pull_commits(cx: &Cx) -> Result<impl View> {
730 let (ctx, pr) = load_pull(cx).await?;
731 let state = app_context::<AppState>(cx);
732 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
733 let commits = commits_of(state, &ctx, &pr).await?;
734 let file_count = diff_of(state, &ctx, &pr).await?.files.len();
735 let frame = frame_in(lang, state, &ctx, &pr, commits.len(), file_count).await?;
736
737 let owner = ctx.repo.owner_name.clone();
738 let name = ctx.repo.name.clone();
739 let tz = ctx.viewer_tz().to_owned();
740 let now = now_secs();
741 let oids: Vec<&str> = commits.iter().map(|info| info.oid.as_str()).collect();
742 let statuses = state
743 .store
744 .statuses_for_many_rows(&ctx.repo.id, &oids)
745 .await?;
746 let rows: Vec<CommitRow> = commits
747 .iter()
748 .map(|info| {
749 CommitRow::new(&owner, &name, info, now, &tz, lang).with_statuses(&statuses, &info.oid)
750 })
751 .collect();
752
753 let page_title = frame.page_title.clone();
754 let href = frame.href.clone();
755 let header = ctx.head(state, "pulls").await?;
756 Ok(view! {
757 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
758 repo_head(head: header)
759 pull_header(frame: frame.clone())
760 pull_subnav_in(lang: lang, href: href, active: "commits".to_owned(),
761 commits: frame.commit_count, files: frame.file_count)
762 commit_list(rows: rows, lang: lang)
763 )
764 })
765}
766
767/// Adds a comment and/or changes the state of a pull request. Shared by the comment and state
768/// routes. A merged pull request's state cannot change, and a refused comment re-renders the
769/// Conversation with its text.
770async fn pull_post(cx: &Cx, form: CommentForm) -> Result<impl View> {
771 let number = parse_number(path_param::<Number>(cx))?;
772 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
773 let (user, session) = require_user(cx).await?;
774 check_write(&ctx, &session, &form.csrf)?;
775 let state = app_context::<AppState>(cx);
776 let pr = pull_for(state, &ctx, number).await?;
777 let lang = i18n::lang(cx, Some(&user));
778
779 let changes_state = !matches!(form.action.as_str(), "" | "comment");
780 let posted = if changes_state && is_merged(&pr) {
781 Posted::Refused {
782 message: g::t(lang, K::MergedCannotChange).to_owned(),
783 }
784 } else {
785 post_comment(lang, state, &ctx, &user, &pr.issue, true, &form).await?
786 };
787 match posted {
788 Posted::Saved { comment_id } => {
789 let anchor = match comment_id {
790 Some(id) => format!("comment-{id}"),
791 None => format!("pull-{number}"),
792 };
793 Err(see_other(format!(
794 "{}#{anchor}",
795 item_href(&ctx, ItemKind::Pull, number)
796 ))
797 .into())
798 }
799 Posted::Refused { message } => {
800 pull_conversation(cx, ctx, pr, form.body, Some(message), None).await
801 }
802 }
803}
804
805/// Comments on a pull request (`/{owner}/{repo}/pulls/{n}/comments`).
806#[page(POST "/{owner}/{repo}/pulls/{number}/comments")]
807async fn pull_comment(cx: &Cx, Form(form): Form<CommentForm>) -> Result<impl View> {
808 pull_post(cx, form).await
809}
810
811/// Closes or reopens a pull request, with an optional comment (`/{owner}/{repo}/pulls/{n}/state`).
812#[page(POST "/{owner}/{repo}/pulls/{number}/state")]
813async fn pull_state(cx: &Cx, Form(form): Form<CommentForm>) -> Result<impl View> {
814 pull_post(cx, form).await
815}
816
817#[cfg(test)]
818mod tests {
819 use forge_core::{IssueState, Mergeable, PullRequest, ReviewState, Side, User, Visibility};
820 use forge_git::GitEnv;
821 use topcoat::router::request::Request;
822 use topcoat::router::{Body, Router, to_bytes};
823
824 use crate::auth::{SESSION_COOKIE, percent_encode};
825 use crate::pages::commits::test_support::{Fixture, OWNER, REPO, git};
826
827 fn cookie(token: &str) -> String {
828 format!("__Host-{SESSION_COOKIE}={token}")
829 }
830
831 /// The page's own `<main>` element. The shell's header also echoes the request URL
832 /// (theme form `back` field), so checks about the page body are scoped to `<main>`.
833 fn page_main(body: &str) -> &str {
834 body.split_once("<main tabindex=\"-1\">")
835 .and_then(|(_, rest)| rest.split_once("</main>"))
836 .map(|(main, _)| main)
837 .expect("page has a <main> element")
838 }
839
840 /// Sends one request through the router and returns `(status, location, body)`.
841 async fn send(
842 router: &Router,
843 method: &str,
844 uri: &str,
845 token: Option<&str>,
846 form: Option<String>,
847 ) -> (u16, Option<String>, String) {
848 let mut req = Request::builder().method(method).uri(uri);
849 if let Some(token) = token {
850 req = req.header("cookie", cookie(token));
851 }
852 let body = match form {
853 Some(form) => {
854 req = req.header("content-type", "application/x-www-form-urlencoded");
855 Body::from(form)
856 }
857 None => Body::empty(),
858 };
859 let res = router.handle(req.body(body).unwrap()).await;
860 let status = res.status().as_u16();
861 let location = res
862 .headers()
863 .get("location")
864 .map(|v| v.to_str().unwrap().to_owned());
865 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
866 (
867 status,
868 location,
869 String::from_utf8_lossy(&bytes).into_owned(),
870 )
871 }
872
873 /// A user with a session whose CSRF token is `{token}-csrf`.
874 async fn sign_in(fx: &Fixture, name: &str, token: &str) -> User {
875 let user = fx
876 .store
877 .create_user(name, None, "not-a-real-hash", false)
878 .await
879 .unwrap();
880 crate::test_support::create_session(&fx.store, &user.id, token, &format!("{token}-csrf"))
881 .await;
882 user
883 }
884
885 /// The repo owner, signed in with token `alice` (CSRF `alice-csrf`).
886 async fn owner_session(fx: &Fixture) {
887 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
888 crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await;
889 }
890
891 fn csrf_form(token: &str, pairs: &[(&str, &str)]) -> String {
892 let mut form = format!("csrf={}", percent_encode(&format!("{token}-csrf")));
893 for (key, value) in pairs {
894 form.push_str(&format!("&{key}={}", percent_encode(value)));
895 }
896 form
897 }
898
899 async fn pull(fx: &Fixture, number: i64) -> PullRequest {
900 let repo = fx
901 .store
902 .repo_by_owner_and_name(OWNER, REPO)
903 .await
904 .unwrap()
905 .unwrap();
906 fx.store
907 .pull_by_number(&repo.id, number)
908 .await
909 .unwrap()
910 .unwrap()
911 }
912
913 /// Pushes a `feature` branch (one commit on top of the root, adding `feat.txt`) and opens a
914 /// pull request for it against `main`. Returns the pull request number.
915 async fn open_pull(fx: &Fixture) -> i64 {
916 let base_oid = git(&fx.work, &["rev-parse", "HEAD"]);
917 git(&fx.work, &["checkout", "-q", "-b", "feature", &fx.root]);
918 std::fs::write(fx.work.join("feat.txt"), "feature\n").unwrap();
919 git(&fx.work, &["add", "-A"]);
920 git(&fx.work, &["commit", "-q", "-m", "Add feature file"]);
921 let head_oid = git(&fx.work, &["rev-parse", "HEAD"]);
922 git(
923 &fx.work,
924 &[
925 "push",
926 "-q",
927 &fx.bare.to_string_lossy(),
928 "HEAD:refs/heads/feature",
929 ],
930 );
931
932 let repo = fx
933 .store
934 .repo_by_owner_and_name(OWNER, REPO)
935 .await
936 .unwrap()
937 .unwrap();
938 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
939 let pr = fx
940 .store
941 .create_pull(
942 &repo.id,
943 &owner.id,
944 "Add feature",
945 "Body text",
946 "main",
947 &repo.id,
948 "feature",
949 &head_oid,
950 &base_oid,
951 )
952 .await
953 .unwrap();
954 let env = GitEnv {
955 git_bin: "git".into(),
956 data_dir: fx.work.clone(),
957 };
958 forge_git::pulls::fetch_into_pull_ref(&env, &fx.bare, &fx.bare, &head_oid, pr.issue.number)
959 .await
960 .unwrap();
961 pr.issue.number
962 }
963
964 #[tokio::test]
965 async fn turkish_viewer_gets_turkish_pull_tabs() {
966 let fx = Fixture::new(Visibility::Public).await;
967 owner_session(&fx).await;
968 let number = open_pull(&fx).await;
969 let uri = format!("/alice/demo/pulls/{number}");
970 let req = Request::builder()
971 .method("GET")
972 .uri(uri.as_str())
973 .header("accept-language", "tr")
974 .body(Body::empty())
975 .unwrap();
976 let res = fx.router.handle(req).await;
977 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
978 let body = String::from_utf8_lossy(&bytes).into_owned();
979 assert!(body.contains("Tartışma</a>"), "Turkish conversation tab");
980 assert!(body.contains("Commitler "), "Turkish commits tab");
981 assert!(body.contains("Değişen dosyalar "), "Turkish files tab");
982 assert!(!body.contains(">Conversation<"), "no English tab label");
983 }
984
985 #[tokio::test]
986 async fn conversation_renders_title_badge_and_merge_box() {
987 let fx = Fixture::new(Visibility::Public).await;
988 owner_session(&fx).await;
989 let number = open_pull(&fx).await;
990 let pr = pull(&fx, number).await;
991 fx.store
992 .set_mergeability(
993 &pr.issue.id,
994 &pr.base_oid,
995 Some(&fx.root),
996 Mergeable::Clean,
997 &[],
998 )
999 .await
1000 .unwrap();
1001
1002 let uri = format!("/alice/demo/pulls/{number}");
1003 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1004 assert_eq!(status, 200);
1005 assert!(body.contains("Add feature"));
1006 assert!(body.contains("issue-badge issue-badge-open"));
1007 assert!(body.contains("This branch has no conflicts with the base branch"));
1008 assert!(body.contains(&format!("action=\"{uri}/merge\"")));
1009 assert!(body.contains("name=\"style\""));
1010 assert!(body.contains(&format!("action=\"{uri}/comments\"")));
1011 assert!(body.contains("Commits 1"));
1012 assert!(body.contains("Files changed 1"));
1013 }
1014
1015 #[tokio::test]
1016 async fn merge_error_code_shows_its_message_and_unknown_code_shows_nothing() {
1017 let fx = Fixture::new(Visibility::Public).await;
1018 owner_session(&fx).await;
1019 let number = open_pull(&fx).await;
1020 let uri = format!("/alice/demo/pulls/{number}");
1021
1022 let (status, _, body) = send(
1023 &fx.router,
1024 "GET",
1025 &format!("{uri}?merge_error=conflict"),
1026 Some("alice"),
1027 None,
1028 )
1029 .await;
1030 assert_eq!(status, 200);
1031 assert!(body.contains("class=\"alert merge-error\""), "{body}");
1032 assert!(body.contains("This branch has conflicts that must be resolved"));
1033
1034 let (status, _, body) = send(
1035 &fx.router,
1036 "GET",
1037 &format!("{uri}?merge_error=%3Cscript%3Ealert(1)%3C%2Fscript%3E"),
1038 Some("alice"),
1039 None,
1040 )
1041 .await;
1042 assert_eq!(status, 200);
1043 assert!(!body.contains("merge-error"), "{body}");
1044 assert!(!page_main(&body).contains("alert(1)"), "{body}");
1045 }
1046
1047 #[tokio::test]
1048 async fn commits_tab_lists_the_commit() {
1049 let fx = Fixture::new(Visibility::Public).await;
1050 let number = open_pull(&fx).await;
1051 let uri = format!("/alice/demo/pulls/{number}/commits");
1052 let (status, _, body) = send(&fx.router, "GET", &uri, None, None).await;
1053 assert_eq!(status, 200);
1054 assert!(body.contains("Add feature file"));
1055 }
1056
1057 #[tokio::test]
1058 async fn files_tab_shows_the_changed_file() {
1059 let fx = Fixture::new(Visibility::Public).await;
1060 let number = open_pull(&fx).await;
1061 let uri = format!("/alice/demo/pulls/{number}/files");
1062 let (status, _, body) = send(&fx.router, "GET", &uri, None, None).await;
1063 assert_eq!(status, 200);
1064 assert!(body.contains("feat.txt"));
1065 }
1066
1067 #[tokio::test]
1068 async fn conflict_lists_files_and_offers_no_merge() {
1069 let fx = Fixture::new(Visibility::Public).await;
1070 owner_session(&fx).await;
1071 let number = open_pull(&fx).await;
1072 let pr = pull(&fx, number).await;
1073 fx.store
1074 .set_mergeability(
1075 &pr.issue.id,
1076 &pr.base_oid,
1077 Some(&fx.root),
1078 Mergeable::Conflict,
1079 &["feat.txt".to_owned()],
1080 )
1081 .await
1082 .unwrap();
1083
1084 let uri = format!("/alice/demo/pulls/{number}");
1085 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1086 assert_eq!(status, 200);
1087 assert!(body.contains("This branch has conflicts that must be resolved"));
1088 assert!(body.contains("<code>feat.txt</code>"));
1089 assert!(!body.contains(&format!("action=\"{uri}/merge\"")));
1090 assert!(!body.contains("name=\"style\""));
1091 }
1092
1093 #[tokio::test]
1094 async fn read_only_viewer_sees_no_merge_form() {
1095 let fx = Fixture::new(Visibility::Public).await;
1096 sign_in(&fx, "bob", "bob").await;
1097 let number = open_pull(&fx).await;
1098 let pr = pull(&fx, number).await;
1099 fx.store
1100 .set_mergeability(
1101 &pr.issue.id,
1102 &pr.base_oid,
1103 Some(&fx.root),
1104 Mergeable::Clean,
1105 &[],
1106 )
1107 .await
1108 .unwrap();
1109
1110 let uri = format!("/alice/demo/pulls/{number}");
1111 let (status, _, body) = send(&fx.router, "GET", &uri, Some("bob"), None).await;
1112 assert_eq!(status, 200);
1113 assert!(body.contains("This branch has no conflicts with the base branch"));
1114 assert!(!body.contains("name=\"style\""));
1115 assert!(!body.contains("Merge pull request"));
1116 }
1117
1118 #[tokio::test]
1119 async fn issue_url_for_a_pull_redirects_to_the_pull() {
1120 let fx = Fixture::new(Visibility::Public).await;
1121 let number = open_pull(&fx).await;
1122 let (status, location, _) = send(
1123 &fx.router,
1124 "GET",
1125 &format!("/alice/demo/issues/{number}"),
1126 None,
1127 None,
1128 )
1129 .await;
1130 assert_eq!(status, 303);
1131 assert_eq!(
1132 location.as_deref(),
1133 Some(format!("/alice/demo/pulls/{number}").as_str())
1134 );
1135 }
1136
1137 #[tokio::test]
1138 async fn comment_on_a_pull_is_saved() {
1139 let fx = Fixture::new(Visibility::Public).await;
1140 owner_session(&fx).await;
1141 let number = open_pull(&fx).await;
1142 let uri = format!("/alice/demo/pulls/{number}/comments");
1143 let form = csrf_form("alice", &[("body", "Looks good to me")]);
1144 let (status, location, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await;
1145 assert_eq!(status, 303);
1146 assert!(
1147 location
1148 .unwrap()
1149 .starts_with(&format!("/alice/demo/pulls/{number}#comment-"))
1150 );
1151
1152 let pr = pull(&fx, number).await;
1153 let comments = fx.store.list_comments(&pr.issue.id).await.unwrap();
1154 assert_eq!(comments.len(), 1);
1155 assert_eq!(comments[0].body, "Looks good to me");
1156 }
1157
1158 #[tokio::test]
1159 async fn state_route_closes_and_reopens_a_pull() {
1160 let fx = Fixture::new(Visibility::Public).await;
1161 owner_session(&fx).await;
1162 let number = open_pull(&fx).await;
1163 let uri = format!("/alice/demo/pulls/{number}/state");
1164
1165 let form = csrf_form("alice", &[("action", "close_not_planned")]);
1166 let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await;
1167 assert_eq!(status, 303);
1168 let pr = pull(&fx, number).await;
1169 assert!(matches!(pr.issue.state, IssueState::Closed));
1170
1171 let form = csrf_form("alice", &[("action", "reopen")]);
1172 let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await;
1173 assert_eq!(status, 303);
1174 let pr = pull(&fx, number).await;
1175 assert!(matches!(pr.issue.state, IssueState::Open));
1176 }
1177
1178 #[tokio::test]
1179 async fn author_edits_pull_title_and_body() {
1180 let fx = Fixture::new(Visibility::Public).await;
1181 owner_session(&fx).await;
1182 let number = open_pull(&fx).await;
1183 let uri = format!("/alice/demo/pulls/{number}");
1184
1185 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1186 assert_eq!(status, 200);
1187 assert!(body.contains(&format!("href=\"{uri}/edit\"")));
1188
1189 let (status, _, body) = send(
1190 &fx.router,
1191 "GET",
1192 &format!("{uri}/edit"),
1193 Some("alice"),
1194 None,
1195 )
1196 .await;
1197 assert_eq!(status, 200);
1198 assert!(body.contains("Edit pull request"));
1199
1200 let form = csrf_form("alice", &[("title", "Renamed pull"), ("body", "New text")]);
1201 let (status, location, _) = send(
1202 &fx.router,
1203 "POST",
1204 &format!("{uri}/edit"),
1205 Some("alice"),
1206 Some(form),
1207 )
1208 .await;
1209 assert_eq!(status, 303);
1210 assert_eq!(location.as_deref(), Some(uri.as_str()));
1211
1212 let pr = pull(&fx, number).await;
1213 assert_eq!(pr.issue.title, "Renamed pull");
1214 assert_eq!(pr.issue.body, "New text");
1215 let events = fx.store.list_events(&pr.issue.id).await.unwrap();
1216 assert!(events.iter().any(|e| e.kind == "renamed"));
1217 }
1218
1219 #[tokio::test]
1220 async fn non_author_reader_cannot_edit_a_pull() {
1221 let fx = Fixture::new(Visibility::Public).await;
1222 owner_session(&fx).await;
1223 sign_in(&fx, "bob", "bob-token").await;
1224 let number = open_pull(&fx).await;
1225 let uri = format!("/alice/demo/pulls/{number}");
1226
1227 let (_, _, body) = send(&fx.router, "GET", &uri, Some("bob-token"), None).await;
1228 assert!(!body.contains(&format!("href=\"{uri}/edit\"")));
1229
1230 let (status, _, _) = send(
1231 &fx.router,
1232 "GET",
1233 &format!("{uri}/edit"),
1234 Some("bob-token"),
1235 None,
1236 )
1237 .await;
1238 assert_eq!(status, 403);
1239 let form = csrf_form("bob-token", &[("title", "Hijacked"), ("body", "x")]);
1240 let (status, _, _) = send(
1241 &fx.router,
1242 "POST",
1243 &format!("{uri}/edit"),
1244 Some("bob-token"),
1245 Some(form),
1246 )
1247 .await;
1248 assert_eq!(status, 403);
1249 assert_eq!(pull(&fx, number).await.issue.title, "Add feature");
1250 }
1251
1252 #[tokio::test]
1253 async fn author_edits_and_deletes_a_pull_comment() {
1254 let fx = Fixture::new(Visibility::Public).await;
1255 owner_session(&fx).await;
1256 let number = open_pull(&fx).await;
1257 let pr = pull(&fx, number).await;
1258 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
1259 let comment = fx
1260 .store
1261 .add_comment(&pr.issue.id, &owner.id, "first draft")
1262 .await
1263 .unwrap();
1264 let base = format!("/alice/demo/pulls/{number}/comments/{}", comment.id);
1265
1266 let (status, _, _) = send(
1267 &fx.router,
1268 "GET",
1269 &format!("{base}/edit"),
1270 Some("alice"),
1271 None,
1272 )
1273 .await;
1274 assert_eq!(status, 200);
1275 let form = csrf_form("alice", &[("body", "edited text")]);
1276 let (status, location, _) = send(
1277 &fx.router,
1278 "POST",
1279 &format!("{base}/edit"),
1280 Some("alice"),
1281 Some(form),
1282 )
1283 .await;
1284 assert_eq!(status, 303);
1285 assert_eq!(
1286 location,
1287 Some(format!("/alice/demo/pulls/{number}#comment-{}", comment.id))
1288 );
1289 let saved = fx
1290 .store
1291 .comment_by_id(&pr.issue.id, &comment.id)
1292 .await
1293 .unwrap()
1294 .unwrap();
1295 assert_eq!(saved.body, "edited text");
1296 assert!(saved.edited);
1297
1298 let (status, location, _) = send(
1299 &fx.router,
1300 "POST",
1301 &format!("{base}/delete"),
1302 Some("alice"),
1303 Some(csrf_form("alice", &[])),
1304 )
1305 .await;
1306 assert_eq!(status, 303);
1307 assert_eq!(location, Some(format!("/alice/demo/pulls/{number}")));
1308 assert!(
1309 fx.store
1310 .comment_by_id(&pr.issue.id, &comment.id)
1311 .await
1312 .unwrap()
1313 .is_none()
1314 );
1315 assert_eq!(pull(&fx, number).await.issue.comment_count, 0);
1316 }
1317
1318 #[tokio::test]
1319 async fn issue_routes_do_not_accept_a_pull_number() {
1320 let fx = Fixture::new(Visibility::Public).await;
1321 owner_session(&fx).await;
1322 let number = open_pull(&fx).await;
1323 let pr = pull(&fx, number).await;
1324 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
1325 let comment = fx
1326 .store
1327 .add_comment(&pr.issue.id, &owner.id, "keep me")
1328 .await
1329 .unwrap();
1330
1331 let (status, _, _) = send(
1332 &fx.router,
1333 "GET",
1334 &format!("/alice/demo/issues/{number}/edit"),
1335 Some("alice"),
1336 None,
1337 )
1338 .await;
1339 assert_eq!(status, 404);
1340 let form = csrf_form("alice", &[("title", "Nope"), ("body", "x")]);
1341 let (status, _, _) = send(
1342 &fx.router,
1343 "POST",
1344 &format!("/alice/demo/issues/{number}/edit"),
1345 Some("alice"),
1346 Some(form),
1347 )
1348 .await;
1349 assert_eq!(status, 404);
1350 let base = format!("/alice/demo/issues/{number}/comments/{}", comment.id);
1351 let (status, _, _) = send(
1352 &fx.router,
1353 "GET",
1354 &format!("{base}/edit"),
1355 Some("alice"),
1356 None,
1357 )
1358 .await;
1359 assert_eq!(status, 404);
1360 let (status, _, _) = send(
1361 &fx.router,
1362 "POST",
1363 &format!("{base}/delete"),
1364 Some("alice"),
1365 Some(csrf_form("alice", &[])),
1366 )
1367 .await;
1368 assert_eq!(status, 404);
1369
1370 let pr = pull(&fx, number).await;
1371 assert_eq!(pr.issue.title, "Add feature");
1372 assert!(
1373 fx.store
1374 .comment_by_id(&pr.issue.id, &comment.id)
1375 .await
1376 .unwrap()
1377 .is_some()
1378 );
1379 }
1380
1381 /// A pending line comment on `feat.txt` by `user`, submitted as a review with `state` and
1382 /// `body`. Returns the root comment's id.
1383 async fn submit_thread(
1384 fx: &Fixture,
1385 number: i64,
1386 user: &User,
1387 state: ReviewState,
1388 body: &str,
1389 ) -> String {
1390 let pr = pull(fx, number).await;
1391 let root = fx
1392 .store
1393 .add_review_comment(
1394 &pr.issue.id,
1395 &user.id,
1396 true,
1397 "feat.txt",
1398 Side::New,
1399 1,
1400 &pr.head_oid,
1401 "@@ -0,0 +1 @@\n+feature",
1402 "Please rename this",
1403 )
1404 .await
1405 .unwrap();
1406 fx.store
1407 .submit_review(&pr.issue.id, &user.id, state, body, &pr.head_oid)
1408 .await
1409 .unwrap();
1410 root.id
1411 }
1412
1413 async fn all_reviews(fx: &Fixture, number: i64) -> Vec<forge_core::Review> {
1414 let pr = pull(fx, number).await;
1415 fx.store.list_reviews(&pr.issue.id).await.unwrap()
1416 }
1417
1418 #[tokio::test]
1419 async fn approved_review_shows_its_verb_body_and_outdated_thread() {
1420 let fx = Fixture::new(Visibility::Public).await;
1421 owner_session(&fx).await;
1422 let bob = sign_in(&fx, "bob", "bob-token").await;
1423 let number = open_pull(&fx).await;
1424 let root = submit_thread(&fx, number, &bob, ReviewState::Approved, "Ship it").await;
1425 let pr = pull(&fx, number).await;
1426 fx.store
1427 .mark_outdated(&pr.issue.id, std::slice::from_ref(&root))
1428 .await
1429 .unwrap();
1430
1431 let uri = format!("/alice/demo/pulls/{number}");
1432 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1433 assert_eq!(status, 200);
1434 assert!(body.contains("approved these changes"), "{body}");
1435 assert!(body.contains("Ship it"), "{body}");
1436 assert!(body.contains("review-thread"), "{body}");
1437 assert!(body.contains("Please rename this"), "{body}");
1438 assert!(body.contains("feat.txt:1"), "{body}");
1439 assert!(body.contains("@@ -0,0 +1 @@"), "{body}");
1440 assert!(body.contains("review-badge-outdated"), "{body}");
1441 assert!(body.contains("Approved by bob"), "{body}");
1442 }
1443
1444 #[tokio::test]
1445 async fn reply_only_review_gets_no_entry_of_its_own() {
1446 let fx = Fixture::new(Visibility::Public).await;
1447 owner_session(&fx).await;
1448 let bob = sign_in(&fx, "bob", "bob-token").await;
1449 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
1450 let number = open_pull(&fx).await;
1451 let root = submit_thread(&fx, number, &bob, ReviewState::Commented, "").await;
1452 let pr = pull(&fx, number).await;
1453 fx.store
1454 .reply_review_comment(&pr.issue.id, &owner.id, &root, "Done, thanks")
1455 .await
1456 .unwrap();
1457 assert_eq!(all_reviews(&fx, number).await.len(), 2);
1458
1459 let uri = format!("/alice/demo/pulls/{number}");
1460 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1461 assert_eq!(status, 200);
1462 assert_eq!(body.matches("id=\"review-").count(), 1, "{body}");
1463 assert!(body.contains("Done, thanks"), "{body}");
1464 assert!(body.contains("Please rename this"), "{body}");
1465 }
1466
1467 #[tokio::test]
1468 async fn dismissed_review_is_struck_with_its_reason() {
1469 let fx = Fixture::new(Visibility::Public).await;
1470 owner_session(&fx).await;
1471 let bob = sign_in(&fx, "bob", "bob-token").await;
1472 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
1473 let number = open_pull(&fx).await;
1474 let pr = pull(&fx, number).await;
1475 fx.store
1476 .submit_review(
1477 &pr.issue.id,
1478 &bob.id,
1479 ReviewState::Approved,
1480 "LGTM",
1481 &pr.head_oid,
1482 )
1483 .await
1484 .unwrap();
1485 let review = all_reviews(&fx, number).await.remove(0);
1486 fx.store
1487 .dismiss_review(&review.id, &owner.id, "superseded")
1488 .await
1489 .unwrap();
1490
1491 let uri = format!("/alice/demo/pulls/{number}");
1492 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1493 assert_eq!(status, 200);
1494 assert!(body.contains("review-verb review-verb-dismissed"), "{body}");
1495 assert!(body.contains("dismissed: superseded"), "{body}");
1496 assert!(body.contains("LGTM"), "{body}");
1497 assert!(!body.contains("Approved by bob"), "{body}");
1498 }
1499
1500 #[tokio::test]
1501 async fn merge_box_summarises_approvals_and_changes_requests() {
1502 let fx = Fixture::new(Visibility::Public).await;
1503 owner_session(&fx).await;
1504 let bob = sign_in(&fx, "bob", "bob-token").await;
1505 let carol = sign_in(&fx, "carol", "carol-token").await;
1506 let number = open_pull(&fx).await;
1507 let pr = pull(&fx, number).await;
1508 fx.store
1509 .submit_review(
1510 &pr.issue.id,
1511 &bob.id,
1512 ReviewState::Approved,
1513 "",
1514 &pr.head_oid,
1515 )
1516 .await
1517 .unwrap();
1518 fx.store
1519 .submit_review(
1520 &pr.issue.id,
1521 &carol.id,
1522 ReviewState::ChangesRequested,
1523 "Needs tests",
1524 &pr.head_oid,
1525 )
1526 .await
1527 .unwrap();
1528 let approval = all_reviews(&fx, number).await.remove(0);
1529
1530 let uri = format!("/alice/demo/pulls/{number}");
1531 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1532 assert_eq!(status, 200);
1533 assert!(body.contains("Approved by bob"), "{body}");
1534 assert!(body.contains("Changes requested by carol"), "{body}");
1535 assert!(
1536 body.contains(&format!("action=\"{uri}/reviews/{}/dismiss\"", approval.id)),
1537 "{body}"
1538 );
1539
1540 // A reader sees the summary but no dismiss form.
1541 let (_, _, body) = send(&fx.router, "GET", &uri, Some("carol-token"), None).await;
1542 assert!(body.contains("Approved by bob"), "{body}");
1543 assert!(!body.contains("/dismiss\""), "{body}");
1544 }
1545
1546 #[tokio::test]
1547 async fn review_error_code_shows_on_the_conversation() {
1548 let fx = Fixture::new(Visibility::Public).await;
1549 owner_session(&fx).await;
1550 let number = open_pull(&fx).await;
1551 let uri = format!("/alice/demo/pulls/{number}");
1552
1553 let (status, _, body) = send(
1554 &fx.router,
1555 "GET",
1556 &format!("{uri}?review_error=own_pr"),
1557 Some("alice"),
1558 None,
1559 )
1560 .await;
1561 assert_eq!(status, 200);
1562 assert!(
1563 body.contains("You cannot approve or request changes on your own pull request."),
1564 "{body}"
1565 );
1566
1567 let (_, _, body) = send(
1568 &fx.router,
1569 "GET",
1570 &format!("{uri}?review_error=%3Cscript%3E"),
1571 Some("alice"),
1572 None,
1573 )
1574 .await;
1575 assert!(!body.contains("<script>"), "{body}");
1576 assert!(!body.contains("You cannot approve"), "{body}");
1577 }
1578
1579 #[tokio::test]
1580 async fn other_users_pending_review_comments_stay_hidden() {
1581 let fx = Fixture::new(Visibility::Public).await;
1582 owner_session(&fx).await;
1583 let bob = sign_in(&fx, "bob", "bob-token").await;
1584 let number = open_pull(&fx).await;
1585 let pr = pull(&fx, number).await;
1586 fx.store
1587 .add_review_comment(
1588 &pr.issue.id,
1589 &bob.id,
1590 true,
1591 "feat.txt",
1592 Side::New,
1593 1,
1594 &pr.head_oid,
1595 "@@ -0,0 +1 @@\n+feature",
1596 "secret draft",
1597 )
1598 .await
1599 .unwrap();
1600
1601 let uri = format!("/alice/demo/pulls/{number}");
1602 let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await;
1603 assert_eq!(status, 200);
1604 assert!(!body.contains("secret draft"), "{body}");
1605 assert!(!body.contains("review-item"), "{body}");
1606 }
1607}