forge
ExploreLog in

Tahinli/forge

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