//! Pages of one pull request at `/{owner}/{repo}/pulls/{n}`: the Conversation (header, timeline, //! merge box, comment and state form, sidebar), Commits and Files changed. The timeline, sidebar //! and comment POST logic are shared with issues (`issue_parts`); label, assignee and milestone //! POSTs are in `issue_meta`. Comments post to `/pulls/{n}/comments`, close and reopen to //! `/pulls/{n}/state`. The merge POST (`/pulls/{n}/merge`) lives in `pulls_merge`. use std::collections::HashMap; use forge_core::{Action, IssueState, Mergeable, PullRequest, Review, ReviewState, authorize}; use forge_git::browse::CommitInfo; use forge_git::history::CommitDiff; use forge_git::pulls::{is_ancestor, range_diff_files, range_log}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param}; use topcoat::view::{View, component, view}; use crate::auth::require_user; use crate::i18n::pulls_review::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::commits::{CommitRow, now_secs}; use crate::pages::issue_meta::item_href; use crate::pages::issue_parts::{ CommentForm, ItemKind, Posted, When, check_write, comment_form, csrf_of, discussion_timeline, item_sidebar, load_discussion, load_sidebar, lock_message, may_change, may_converse, parse_number, post_comment, triage, viewer, }; use crate::pages::notifications::notify_box_for; use crate::pages::pull_branch::{self, branch_box, branch_error_from}; use crate::pages::pull_reviews::error_text_in; use crate::pages::pulls_merge::{ ERR_ALREADY_MERGED, ERR_BASE_MISSING, ERR_BASE_MOVED, ERR_CHANGES_REQUESTED, ERR_CONFLICT, ERR_FF_IMPOSSIBLE, ERR_HEAD_MISSING, ERR_MESSAGE_INVALID, ERR_NEEDS_APPROVALS, ERR_NOT_OPEN, ERR_NOT_UP_TO_DATE, ERR_UP_TO_DATE, merge_gate, }; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary, commit_status}; use crate::views::diff::LIMITS; use crate::views::{alert, now_unix, repo_href, shell, short_oid}; path_param!(owner); path_param!(repo); path_param!(number); /// Commits listed (and counted) for one pull request. const MAX_COMMITS: usize = 250; /// Whether the pull request has been merged. A merged pull request is closed and cannot change. pub(crate) fn is_merged(pr: &PullRequest) -> bool { pr.merged_at.is_some() || pr.mergeable == Mergeable::Merged } /// The pull request named by `{number}` in this repo, or 404. async fn pull_for(state: &AppState, ctx: &RepoCtx, number: i64) -> Result { match state.store.pull_by_number(&ctx.repo.id, number).await? { Some(pr) => Ok(pr), None => Err(not_found().into()), } } /// The repo and pull request for a request whose path names `{owner}/{repo}/pulls/{number}`. async fn load_pull(cx: &Cx) -> Result<(RepoCtx, PullRequest)> { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let pr = pull_for(app_context::(cx), &ctx, number).await?; Ok((ctx, pr)) } // Git data /// The commits in `base..head`, oldest first, up to `MAX_COMMITS`. pub(crate) async fn commits_of( state: &AppState, ctx: &RepoCtx, pr: &PullRequest, ) -> Result> { Ok(range_log( &state.git, &ctx.path, &pr.base_oid, &pr.head_oid, MAX_COMMITS, ) .await?) } /// The diff of the head side of the pull request (merge base to head). pub(crate) async fn diff_of( state: &AppState, ctx: &RepoCtx, pr: &PullRequest, ) -> Result { Ok(range_diff_files(&state.git, &ctx.path, &pr.base_oid, &pr.head_oid, LIMITS).await?) } // Header /// What the header and sub-tabs show: the title, state, byline and tab counts. #[derive(Clone)] pub(crate) struct Frame { title: String, number: i64, pub(crate) page_title: String, pub(crate) href: String, author: String, opened: When, pub(crate) commit_count: usize, pub(crate) file_count: usize, base_branch: String, head_label: String, state_class: &'static str, state_text: &'static str, lang: Lang, } /// The header for a pull request, with its strings in `lang`. pub(crate) async fn frame_in( lang: Lang, state: &AppState, ctx: &RepoCtx, pr: &PullRequest, commit_count: usize, file_count: usize, ) -> Result { let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let number = pr.issue.number; let users = state .store .usernames_by_ids(std::slice::from_ref(&pr.issue.author_id)) .await?; let author = users .get(&pr.issue.author_id) .map(|(display, _)| display.clone()) .unwrap_or_else(|| g::t(lang, K::Unknown).to_owned()); // The head branch, qualified by its repo when that is a fork. A deleted head repo has none. let head_label = match pr.head_repo_id.as_deref() { None => g::branch_deleted(lang, &pr.head_branch), Some(id) if id == ctx.repo.id => pr.head_branch.clone(), Some(id) => match state.store.repo_by_id(id).await? { Some(head) => format!("{}:{}", head.owner_name, pr.head_branch), None => g::branch_deleted(lang, &pr.head_branch), }, }; let (state_class, state_text) = if is_merged(pr) { ("issue-badge issue-badge-merged", g::t(lang, K::StateMerged)) } else if matches!(pr.issue.state, IssueState::Open) { ("issue-badge issue-badge-open", g::t(lang, K::StateOpen)) } else { ("issue-badge issue-badge-closed", g::t(lang, K::StateClosed)) }; Ok(Frame { lang, title: pr.issue.title.clone(), number, page_title: format!("{} #{number} \u{b7} {owner}/{name}", pr.issue.title), href: repo_href(&owner, &name, "pulls", &number.to_string()), author, opened: When::new(lang, &pr.issue.created_at, ctx.viewer_tz(), now_unix()), commit_count, file_count, base_branch: pr.base_branch.clone(), head_label, state_class, state_text, }) } #[component] pub(crate) async fn pull_header(frame: Frame) -> Result { let Frame { title, number, state_class, state_text, author, opened, commit_count, base_branch, head_label, lang, .. } = frame; let number_label = format!("#{number}"); let commits_text = g::commit_count_line(lang, commit_count); let wants = g::t(lang, K::ByWantsToMerge); let into = g::t(lang, K::ByInto); let from = g::t(lang, K::ByFrom); let byline_end = g::t(lang, K::BylineEnd); // Turkish puts the source branch first and the commit count before the verb. let turkish = matches!(lang, Lang::Tr); Ok(view! {

(title.as_str()) " " (number_label.as_str())

}) } /// Class for one sub-navigation link: the one named by `active` is marked current. fn tab_class(active: &str, tab: &str) -> &'static str { if active == tab { "subnav-link current" } else { "subnav-link" } } /// The pull request's sub-tabs, with their labels in `lang`. #[component] pub(crate) async fn pull_subnav_in( lang: Lang, href: String, active: String, commits: usize, files: usize, ) -> Result { let commits_href = format!("{href}/commits"); let files_href = format!("{href}/files"); let commits_label = g::commits_tab(lang, commits); let files_label = g::files_tab(lang, files); let conversation = g::t(lang, K::TabConversation); let aria = g::t(lang, K::PullPages); Ok(view! { }) } // Merge box /// Where the merge form posts, and whether fast-forward is offered. struct MergeForm { action: String, csrf: String, ff: bool, } /// The mergeability status and, for writers on a clean branch, the merge form. `tone` is one of /// `ok`, `bad` or `muted` and picks the box colors. #[component] async fn merge_box( headline: String, tone: String, conflicts: Vec, reload: Option, form: Option, merge_error: Option, approvals: Option, blocked: Option, checks: Option, lang: Lang, ) -> Result { let class = format!("merge-box merge-{tone}"); let reload_text = g::t(lang, K::ReloadToCheck); let style_legend = g::t(lang, K::MergeStyleLegend); let style_merge = g::t(lang, K::StyleMergeCommit); let style_squash = g::t(lang, K::StyleSquash); let style_ff = g::t(lang, K::StyleFastForward); let message_label = g::t(lang, K::MergeMessageLabel); let merge_button = g::t(lang, K::MergeButton); Ok(view! {
if let Some(text) = merge_error { }

(headline.as_str())

if let Some(summary) = checks { checks_summary(summary: summary, lang: StatusLang(lang)) } if let Some(text) = approvals {

(text.as_str())

} if let Some(text) = blocked {

(text.as_str())

} if !conflicts.is_empty() {
    for file in conflicts.iter() {
  • (file.as_str())
  • }
} if let Some(href) = reload {

(reload_text)

} if let Some(f) = form {
(style_legend) if f.ff { }
}
}) } /// The note that replaces the merge box once the pull request is merged. #[component] async fn merged_notice( who: String, when: When, commit: String, commit_href: String, lang: Lang, ) -> Result { let short = short_oid(&commit).to_owned(); let prefix = g::t(lang, K::MergedByPrefix); let middle = g::t(lang, K::MergedByMiddle); let as_sep = g::t(lang, K::MergedAsSep); Ok(view! {

(prefix) (who.as_str()) (middle) if !commit.is_empty() { (as_sep) (short.as_str()) }

}) } // Review summary /// A Dismiss form for one counted review: its action URL and the label of its reason field. struct DismissRow { action: String, label: String, } /// Who approves and who requests changes, then a Dismiss form per counted review for Write /// holders. Either summary line may be absent. #[component] async fn review_box( approved: Option, changes: Option, dismiss: Vec, csrf: String, lang: Lang, ) -> Result { let dismiss_text = g::t(lang, K::DismissButton); Ok(view! {
if let Some(text) = approved {

(text.as_str())

} if let Some(text) = changes {

(text.as_str())

} for row in dismiss.iter() {
}
}) } /// The approvals and change requests that count toward the summary: each reviewer's latest one, /// with dismissed reviews left out, so an earlier undismissed state counts again. In submission /// order, as `review_summary` counts them. async fn counted_reviews(state: &AppState, pr: &PullRequest) -> Result> { let mut latest: HashMap = HashMap::new(); for review in state.store.list_reviews(&pr.issue.id).await? { if matches!( review.state, ReviewState::Approved | ReviewState::ChangesRequested ) { latest.insert(review.author_id.clone(), review); } } let mut counted: Vec = latest.into_values().collect(); counted.sort_by(|a, b| { a.submitted_at .cmp(&b.submitted_at) .then_with(|| a.id.cmp(&b.id)) }); Ok(counted) } // Commits and files /// The combined status of the pull request's head commit, `None` when nothing has reported on it. async fn head_checks( state: &AppState, ctx: &RepoCtx, pr: &PullRequest, ) -> Result> { let rows = state.store.statuses_for(&ctx.repo.id, &pr.head_oid).await?; Ok((!rows.is_empty()).then(|| StatusSummary::from_rows(&rows))) } #[component] async fn commit_list(rows: Vec, lang: Lang) -> Result { let empty = rows.is_empty(); let no_commits = g::t(lang, K::NoCommits); let committed = g::t(lang, K::Committed); let browse = g::t(lang, K::BrowseFiles); Ok(view! { if empty {

(no_commits)

} else { } }) } // Pages /// The Conversation tab. `draft` and `error` belong to the comment form, so a refused comment /// keeps its text. async fn pull_conversation( cx: &Cx, ctx: RepoCtx, pr: PullRequest, draft: String, error: Option, merge_error: Option, ) -> Result { let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let commit_count = commits_of(state, &ctx, &pr).await?.len(); let file_count = diff_of(state, &ctx, &pr).await?.files.len(); let frame = frame_in(lang, state, &ctx, &pr, commit_count, file_count).await?; let href = frame.href.clone(); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let discussion = load_discussion( lang, state, &ctx, &pr.issue, ItemKind::Pull, &pr.base_branch, ) .await?; let sidebar = load_sidebar(state, &ctx.repo.id, &pr.issue).await?; let notify = notify_box_for(state, &ctx, lang, &pr.issue.id, &href).await?; let csrf = csrf_of(&ctx); let checks = head_checks(state, &ctx, &pr).await?; let archived = ctx.repo.archived; let signed_in = ctx.viewer.is_some(); let merged = is_merged(&pr); let open = matches!(pr.issue.state, IssueState::Open) && !merged; let can_comment = signed_in && !archived && may_converse(&ctx, &pr.issue); let locked_for_viewer = !may_converse(&ctx, &pr.issue); let lock_notice = lock_message(lang, &pr.issue); let can_close = may_change(&ctx, &pr.issue.author_id) && !merged; let write_meta = !archived && triage(&ctx); let comment_action = format!("{href}/comments"); let state_action = format!("{href}/state"); let login_href = "/login".to_owned(); let archived_text = g::t(lang, K::ArchivedReadOnly); let login_before = g::t(lang, K::LoginBefore); let login_link = g::t(lang, K::LoginLink); let login_after = g::t(lang, K::LoginAfter); let (headline, tone, conflicts, reload) = match pr.mergeable { Mergeable::Clean => (g::t(lang, K::MergeClean).to_owned(), "ok", Vec::new(), None), Mergeable::Conflict => ( g::t(lang, K::MergeConflict).to_owned(), "bad", pr.conflict_files.clone(), None, ), Mergeable::UpToDate => ( g::t(lang, K::MergeUpToDate).to_owned(), "muted", Vec::new(), None, ), Mergeable::Unknown | Mergeable::Merged => ( g::t(lang, K::MergeChecking).to_owned(), "muted", Vec::new(), Some(href.clone()), ), }; let can_write = !archived && authorize( viewer(&ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_ok(); // The merge rules of the base branch: approvals, change requests and up-to-date status. let gate = if open { Some(merge_gate(state, &ctx.repo, &pr).await?) } else { None }; let blocked = gate .as_ref() .and_then(|gate| gate.blocked()) .and_then(|code| merge_error_text(lang, code)) .map(str::to_owned); let approvals = gate .as_ref() .filter(|gate| gate.rule.required_approvals > 0) .map(|gate| { g::approvals_of( lang, gate.approvals.min(gate.rule.required_approvals), gate.rule.required_approvals, ) }); let merge_form = if open && can_write && pr.mergeable == Mergeable::Clean && pr.head_repo_id.is_some() && blocked.is_none() { let ff = is_ancestor(&state.git, &ctx.path, &pr.base_oid, &pr.head_oid).await?; Some(MergeForm { action: format!("{href}/merge"), csrf: csrf.clone(), ff, }) } else { None }; let merged_who = match pr.merged_by.clone() { Some(id) => state .store .usernames_by_ids(std::slice::from_ref(&id)) .await? .get(&id) .map(|(display, _)| display.clone()) .unwrap_or_else(|| g::t(lang, K::Unknown).to_owned()), None => g::t(lang, K::Unknown).to_owned(), }; let merged_when = pr .merged_at .as_deref() .map(|stamp| When::new(lang, stamp, ctx.viewer_tz(), now_unix())); let merge_commit = pr.merge_commit.clone().unwrap_or_default(); let merge_commit_href = format!("/{owner}/{name}/commit/{merge_commit}"); let review_error = review_error_from(lang, cx); let summary = state.store.review_summary(&pr.issue.id).await?; let approved = (!summary.approved_by.is_empty()) .then(|| g::approved_by(lang, &summary.approved_by.join(", "))); let changes = (!summary.changes_requested_by.is_empty()) .then(|| g::changes_requested_by(lang, &summary.changes_requested_by.join(", "))); let review_box_shown = approved.is_some() || changes.is_some(); // Dismiss forms are for Write holders on an open pull request. let dismiss: Vec = if open && can_write { counted_reviews(state, &pr) .await? .into_iter() .map(|review| DismissRow { action: format!("{href}/reviews/{}/dismiss", review.id), label: g::dismiss_reason(lang, &review.author_name), }) .collect() } else { Vec::new() }; let branch = pull_branch::branch_action(state, &ctx, &pr, &csrf).await?; let branch_error = branch_error_from(cx, lang); let branch_shown = !open && (branch.is_some() || branch_error.is_some()); let page_title = frame.page_title.clone(); let header = ctx.head(state, "pulls").await?; Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) pull_header(frame: frame.clone()) pull_subnav_in(lang: lang, href: href.clone(), active: "conversation".to_owned(), commits: commit_count, files: file_count) if let Some(message) = review_error { alert(message: message) }
discussion_timeline(lang: lang, discussion: discussion) if review_box_shown { review_box(approved: approved, changes: changes, dismiss: dismiss, csrf: csrf.clone(), lang: lang) } if open { merge_box(headline: headline, tone: tone.to_owned(), conflicts: conflicts, reload: reload, form: merge_form, merge_error: merge_error, approvals: approvals, blocked: blocked, checks: checks, lang: lang) } if let Some(when) = merged_when { merged_notice(who: merged_who, when: when, commit: merge_commit, commit_href: merge_commit_href, lang: lang) } if branch_shown { branch_box(lang: lang, action: branch, error: branch_error) } if can_comment { comment_form(lang: lang, action: comment_action, state_action: state_action, csrf: csrf.clone(), draft: draft, error: error, can_close: can_close, open: open) } if archived {

(archived_text)

} if locked_for_viewer {

(lock_notice.as_str())

} else if !signed_in {

(login_before)(login_link)(login_after)

}
) }) } /// The message for a `merge_error` code set by `pulls_merge`. Unknown codes have no message, so a /// crafted query value shows nothing. fn merge_error_text(lang: Lang, code: &str) -> Option<&'static str> { Some(match code { ERR_CONFLICT => g::t(lang, K::ErrConflict), ERR_UP_TO_DATE => g::t(lang, K::ErrUpToDate), ERR_FF_IMPOSSIBLE => g::t(lang, K::ErrFfImpossible), ERR_BASE_MOVED => g::t(lang, K::ErrBaseMoved), ERR_ALREADY_MERGED => g::t(lang, K::ErrAlreadyMerged), ERR_NOT_OPEN => g::t(lang, K::ErrNotOpen), ERR_HEAD_MISSING => g::t(lang, K::ErrHeadMissing), ERR_BASE_MISSING => g::t(lang, K::ErrBaseMissing), ERR_NEEDS_APPROVALS => g::t(lang, K::ErrNeedsApprovals), ERR_CHANGES_REQUESTED => g::t(lang, K::ErrChangesRequested), ERR_NOT_UP_TO_DATE => g::t(lang, K::ErrNotUpToDate), ERR_MESSAGE_INVALID => g::t(lang, K::ErrMessageInvalid), _ => return None, }) } /// The message for the `merge_error` query value of the request, when it is a known code. fn merge_error_from(lang: Lang, cx: &Cx) -> Option { let request = uri(cx); let query = request.query()?; let (_, code) = form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "merge_error")?; merge_error_text(lang, &code).map(str::to_owned) } /// The message for the `review_error` query value of the request, when it is a known code. fn review_error_from(lang: Lang, cx: &Cx) -> Option<&'static str> { let request = uri(cx); let query = request.query()?; let (_, code) = form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "review_error")?; error_text_in(lang, &code) } #[page("/{owner}/{repo}/pulls/{number}")] async fn pull_show(cx: &Cx) -> Result { let (ctx, pr) = load_pull(cx).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let merge_error = merge_error_from(lang, cx); pull_conversation(cx, ctx, pr, String::new(), None, merge_error).await } #[page("/{owner}/{repo}/pulls/{number}/commits")] async fn pull_commits(cx: &Cx) -> Result { let (ctx, pr) = load_pull(cx).await?; let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let commits = commits_of(state, &ctx, &pr).await?; let file_count = diff_of(state, &ctx, &pr).await?.files.len(); let frame = frame_in(lang, state, &ctx, &pr, commits.len(), file_count).await?; let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let tz = ctx.viewer_tz().to_owned(); let now = now_secs(); let oids: Vec<&str> = commits.iter().map(|info| info.oid.as_str()).collect(); let statuses = state .store .statuses_for_many_rows(&ctx.repo.id, &oids) .await?; let rows: Vec = commits .iter() .map(|info| { CommitRow::new(&owner, &name, info, now, &tz, lang).with_statuses(&statuses, &info.oid) }) .collect(); let page_title = frame.page_title.clone(); let href = frame.href.clone(); let header = ctx.head(state, "pulls").await?; Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) pull_header(frame: frame.clone()) pull_subnav_in(lang: lang, href: href, active: "commits".to_owned(), commits: frame.commit_count, files: frame.file_count) commit_list(rows: rows, lang: lang) ) }) } /// Adds a comment and/or changes the state of a pull request. Shared by the comment and state /// routes. A merged pull request's state cannot change, and a refused comment re-renders the /// Conversation with its text. async fn pull_post(cx: &Cx, form: CommentForm) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let state = app_context::(cx); let pr = pull_for(state, &ctx, number).await?; let lang = i18n::lang(cx, Some(&user)); let changes_state = !matches!(form.action.as_str(), "" | "comment"); let posted = if changes_state && is_merged(&pr) { Posted::Refused { message: g::t(lang, K::MergedCannotChange).to_owned(), } } else { post_comment(lang, state, &ctx, &user, &pr.issue, true, &form).await? }; match posted { Posted::Saved { comment_id } => { let anchor = match comment_id { Some(id) => format!("comment-{id}"), None => format!("pull-{number}"), }; Err(see_other(format!( "{}#{anchor}", item_href(&ctx, ItemKind::Pull, number) )) .into()) } Posted::Refused { message } => { pull_conversation(cx, ctx, pr, form.body, Some(message), None).await } } } /// Comments on a pull request (`/{owner}/{repo}/pulls/{n}/comments`). #[page(POST "/{owner}/{repo}/pulls/{number}/comments")] async fn pull_comment(cx: &Cx, Form(form): Form) -> Result { pull_post(cx, form).await } /// Closes or reopens a pull request, with an optional comment (`/{owner}/{repo}/pulls/{n}/state`). #[page(POST "/{owner}/{repo}/pulls/{number}/state")] async fn pull_state(cx: &Cx, Form(form): Form) -> Result { pull_post(cx, form).await } #[cfg(test)] mod tests { use forge_core::{IssueState, Mergeable, PullRequest, ReviewState, Side, User, Visibility}; use forge_git::GitEnv; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::{SESSION_COOKIE, percent_encode}; use crate::pages::commits::test_support::{Fixture, OWNER, REPO, git}; fn cookie(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// The page's own `
` element. The shell's header also echoes the request URL /// (theme form `back` field), so checks about the page body are scoped to `
`. fn page_main(body: &str) -> &str { body.split_once("
") .and_then(|(_, rest)| rest.split_once("
")) .map(|(main, _)| main) .expect("page has a
element") } /// Sends one request through the router and returns `(status, location, body)`. async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, Option, String) { let mut req = Request::builder().method(method).uri(uri); if let Some(token) = token { req = req.header("cookie", cookie(token)); } let body = match form { Some(form) => { req = req.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(req.body(body).unwrap()).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .map(|v| v.to_str().unwrap().to_owned()); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); ( status, location, String::from_utf8_lossy(&bytes).into_owned(), ) } /// A user with a session whose CSRF token is `{token}-csrf`. async fn sign_in(fx: &Fixture, name: &str, token: &str) -> User { let user = fx .store .create_user(name, None, "not-a-real-hash", false) .await .unwrap(); crate::test_support::create_session(&fx.store, &user.id, token, &format!("{token}-csrf")) .await; user } /// The repo owner, signed in with token `alice` (CSRF `alice-csrf`). async fn owner_session(fx: &Fixture) { let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await; } fn csrf_form(token: &str, pairs: &[(&str, &str)]) -> String { let mut form = format!("csrf={}", percent_encode(&format!("{token}-csrf"))); for (key, value) in pairs { form.push_str(&format!("&{key}={}", percent_encode(value))); } form } async fn pull(fx: &Fixture, number: i64) -> PullRequest { let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); fx.store .pull_by_number(&repo.id, number) .await .unwrap() .unwrap() } /// Pushes a `feature` branch (one commit on top of the root, adding `feat.txt`) and opens a /// pull request for it against `main`. Returns the pull request number. async fn open_pull(fx: &Fixture) -> i64 { let base_oid = git(&fx.work, &["rev-parse", "HEAD"]); git(&fx.work, &["checkout", "-q", "-b", "feature", &fx.root]); std::fs::write(fx.work.join("feat.txt"), "feature\n").unwrap(); git(&fx.work, &["add", "-A"]); git(&fx.work, &["commit", "-q", "-m", "Add feature file"]); let head_oid = git(&fx.work, &["rev-parse", "HEAD"]); git( &fx.work, &[ "push", "-q", &fx.bare.to_string_lossy(), "HEAD:refs/heads/feature", ], ); let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let pr = fx .store .create_pull( &repo.id, &owner.id, "Add feature", "Body text", "main", &repo.id, "feature", &head_oid, &base_oid, ) .await .unwrap(); let env = GitEnv { git_bin: "git".into(), data_dir: fx.work.clone(), }; forge_git::pulls::fetch_into_pull_ref(&env, &fx.bare, &fx.bare, &head_oid, pr.issue.number) .await .unwrap(); pr.issue.number } #[tokio::test] async fn turkish_viewer_gets_turkish_pull_tabs() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}"); let req = Request::builder() .method("GET") .uri(uri.as_str()) .header("accept-language", "tr") .body(Body::empty()) .unwrap(); let res = fx.router.handle(req).await; let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); let body = String::from_utf8_lossy(&bytes).into_owned(); assert!(body.contains("Tartışma"), "Turkish conversation tab"); assert!(body.contains("Commitler "), "Turkish commits tab"); assert!(body.contains("Değişen dosyalar "), "Turkish files tab"); assert!(!body.contains(">Conversation<"), "no English tab label"); } #[tokio::test] async fn conversation_renders_title_badge_and_merge_box() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; fx.store .set_mergeability( &pr.issue.id, &pr.base_oid, Some(&fx.root), Mergeable::Clean, &[], ) .await .unwrap(); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("Add feature")); assert!(body.contains("issue-badge issue-badge-open")); assert!(body.contains("This branch has no conflicts with the base branch")); assert!(body.contains(&format!("action=\"{uri}/merge\""))); assert!(body.contains("name=\"style\"")); assert!(body.contains(&format!("action=\"{uri}/comments\""))); assert!(body.contains("Commits 1")); assert!(body.contains("Files changed 1")); } #[tokio::test] async fn merge_error_code_shows_its_message_and_unknown_code_shows_nothing() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send( &fx.router, "GET", &format!("{uri}?merge_error=conflict"), Some("alice"), None, ) .await; assert_eq!(status, 200); assert!(body.contains("class=\"alert merge-error\""), "{body}"); assert!(body.contains("This branch has conflicts that must be resolved")); let (status, _, body) = send( &fx.router, "GET", &format!("{uri}?merge_error=%3Cscript%3Ealert(1)%3C%2Fscript%3E"), Some("alice"), None, ) .await; assert_eq!(status, 200); assert!(!body.contains("merge-error"), "{body}"); assert!(!page_main(&body).contains("alert(1)"), "{body}"); } #[tokio::test] async fn commits_tab_lists_the_commit() { let fx = Fixture::new(Visibility::Public).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}/commits"); let (status, _, body) = send(&fx.router, "GET", &uri, None, None).await; assert_eq!(status, 200); assert!(body.contains("Add feature file")); } #[tokio::test] async fn files_tab_shows_the_changed_file() { let fx = Fixture::new(Visibility::Public).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}/files"); let (status, _, body) = send(&fx.router, "GET", &uri, None, None).await; assert_eq!(status, 200); assert!(body.contains("feat.txt")); } #[tokio::test] async fn conflict_lists_files_and_offers_no_merge() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; fx.store .set_mergeability( &pr.issue.id, &pr.base_oid, Some(&fx.root), Mergeable::Conflict, &["feat.txt".to_owned()], ) .await .unwrap(); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("This branch has conflicts that must be resolved")); assert!(body.contains("feat.txt")); assert!(!body.contains(&format!("action=\"{uri}/merge\""))); assert!(!body.contains("name=\"style\"")); } #[tokio::test] async fn read_only_viewer_sees_no_merge_form() { let fx = Fixture::new(Visibility::Public).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; fx.store .set_mergeability( &pr.issue.id, &pr.base_oid, Some(&fx.root), Mergeable::Clean, &[], ) .await .unwrap(); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("bob"), None).await; assert_eq!(status, 200); assert!(body.contains("This branch has no conflicts with the base branch")); assert!(!body.contains("name=\"style\"")); assert!(!body.contains("Merge pull request")); } #[tokio::test] async fn issue_url_for_a_pull_redirects_to_the_pull() { let fx = Fixture::new(Visibility::Public).await; let number = open_pull(&fx).await; let (status, location, _) = send( &fx.router, "GET", &format!("/alice/demo/issues/{number}"), None, None, ) .await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}").as_str()) ); } #[tokio::test] async fn comment_on_a_pull_is_saved() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}/comments"); let form = csrf_form("alice", &[("body", "Looks good to me")]); let (status, location, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert!( location .unwrap() .starts_with(&format!("/alice/demo/pulls/{number}#comment-")) ); let pr = pull(&fx, number).await; let comments = fx.store.list_comments(&pr.issue.id).await.unwrap(); assert_eq!(comments.len(), 1); assert_eq!(comments[0].body, "Looks good to me"); } #[tokio::test] async fn state_route_closes_and_reopens_a_pull() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}/state"); let form = csrf_form("alice", &[("action", "close_not_planned")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); let pr = pull(&fx, number).await; assert!(matches!(pr.issue.state, IssueState::Closed)); let form = csrf_form("alice", &[("action", "reopen")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); let pr = pull(&fx, number).await; assert!(matches!(pr.issue.state, IssueState::Open)); } #[tokio::test] async fn author_edits_pull_title_and_body() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains(&format!("href=\"{uri}/edit\""))); let (status, _, body) = send( &fx.router, "GET", &format!("{uri}/edit"), Some("alice"), None, ) .await; assert_eq!(status, 200); assert!(body.contains("Edit pull request")); let form = csrf_form("alice", &[("title", "Renamed pull"), ("body", "New text")]); let (status, location, _) = send( &fx.router, "POST", &format!("{uri}/edit"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!(location.as_deref(), Some(uri.as_str())); let pr = pull(&fx, number).await; assert_eq!(pr.issue.title, "Renamed pull"); assert_eq!(pr.issue.body, "New text"); let events = fx.store.list_events(&pr.issue.id).await.unwrap(); assert!(events.iter().any(|e| e.kind == "renamed")); } #[tokio::test] async fn non_author_reader_cannot_edit_a_pull() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob-token").await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}"); let (_, _, body) = send(&fx.router, "GET", &uri, Some("bob-token"), None).await; assert!(!body.contains(&format!("href=\"{uri}/edit\""))); let (status, _, _) = send( &fx.router, "GET", &format!("{uri}/edit"), Some("bob-token"), None, ) .await; assert_eq!(status, 403); let form = csrf_form("bob-token", &[("title", "Hijacked"), ("body", "x")]); let (status, _, _) = send( &fx.router, "POST", &format!("{uri}/edit"), Some("bob-token"), Some(form), ) .await; assert_eq!(status, 403); assert_eq!(pull(&fx, number).await.issue.title, "Add feature"); } #[tokio::test] async fn author_edits_and_deletes_a_pull_comment() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let comment = fx .store .add_comment(&pr.issue.id, &owner.id, "first draft") .await .unwrap(); let base = format!("/alice/demo/pulls/{number}/comments/{}", comment.id); let (status, _, _) = send( &fx.router, "GET", &format!("{base}/edit"), Some("alice"), None, ) .await; assert_eq!(status, 200); let form = csrf_form("alice", &[("body", "edited text")]); let (status, location, _) = send( &fx.router, "POST", &format!("{base}/edit"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!( location, Some(format!("/alice/demo/pulls/{number}#comment-{}", comment.id)) ); let saved = fx .store .comment_by_id(&pr.issue.id, &comment.id) .await .unwrap() .unwrap(); assert_eq!(saved.body, "edited text"); assert!(saved.edited); let (status, location, _) = send( &fx.router, "POST", &format!("{base}/delete"), Some("alice"), Some(csrf_form("alice", &[])), ) .await; assert_eq!(status, 303); assert_eq!(location, Some(format!("/alice/demo/pulls/{number}"))); assert!( fx.store .comment_by_id(&pr.issue.id, &comment.id) .await .unwrap() .is_none() ); assert_eq!(pull(&fx, number).await.issue.comment_count, 0); } #[tokio::test] async fn issue_routes_do_not_accept_a_pull_number() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let comment = fx .store .add_comment(&pr.issue.id, &owner.id, "keep me") .await .unwrap(); let (status, _, _) = send( &fx.router, "GET", &format!("/alice/demo/issues/{number}/edit"), Some("alice"), None, ) .await; assert_eq!(status, 404); let form = csrf_form("alice", &[("title", "Nope"), ("body", "x")]); let (status, _, _) = send( &fx.router, "POST", &format!("/alice/demo/issues/{number}/edit"), Some("alice"), Some(form), ) .await; assert_eq!(status, 404); let base = format!("/alice/demo/issues/{number}/comments/{}", comment.id); let (status, _, _) = send( &fx.router, "GET", &format!("{base}/edit"), Some("alice"), None, ) .await; assert_eq!(status, 404); let (status, _, _) = send( &fx.router, "POST", &format!("{base}/delete"), Some("alice"), Some(csrf_form("alice", &[])), ) .await; assert_eq!(status, 404); let pr = pull(&fx, number).await; assert_eq!(pr.issue.title, "Add feature"); assert!( fx.store .comment_by_id(&pr.issue.id, &comment.id) .await .unwrap() .is_some() ); } /// A pending line comment on `feat.txt` by `user`, submitted as a review with `state` and /// `body`. Returns the root comment's id. async fn submit_thread( fx: &Fixture, number: i64, user: &User, state: ReviewState, body: &str, ) -> String { let pr = pull(fx, number).await; let root = fx .store .add_review_comment( &pr.issue.id, &user.id, true, "feat.txt", Side::New, 1, &pr.head_oid, "@@ -0,0 +1 @@\n+feature", "Please rename this", ) .await .unwrap(); fx.store .submit_review(&pr.issue.id, &user.id, state, body, &pr.head_oid) .await .unwrap(); root.id } async fn all_reviews(fx: &Fixture, number: i64) -> Vec { let pr = pull(fx, number).await; fx.store.list_reviews(&pr.issue.id).await.unwrap() } #[tokio::test] async fn approved_review_shows_its_verb_body_and_outdated_thread() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let bob = sign_in(&fx, "bob", "bob-token").await; let number = open_pull(&fx).await; let root = submit_thread(&fx, number, &bob, ReviewState::Approved, "Ship it").await; let pr = pull(&fx, number).await; fx.store .mark_outdated(&pr.issue.id, std::slice::from_ref(&root)) .await .unwrap(); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("approved these changes"), "{body}"); assert!(body.contains("Ship it"), "{body}"); assert!(body.contains("review-thread"), "{body}"); assert!(body.contains("Please rename this"), "{body}"); assert!(body.contains("feat.txt:1"), "{body}"); assert!(body.contains("@@ -0,0 +1 @@"), "{body}"); assert!(body.contains("review-badge-outdated"), "{body}"); assert!(body.contains("Approved by bob"), "{body}"); } #[tokio::test] async fn reply_only_review_gets_no_entry_of_its_own() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let bob = sign_in(&fx, "bob", "bob-token").await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let number = open_pull(&fx).await; let root = submit_thread(&fx, number, &bob, ReviewState::Commented, "").await; let pr = pull(&fx, number).await; fx.store .reply_review_comment(&pr.issue.id, &owner.id, &root, "Done, thanks") .await .unwrap(); assert_eq!(all_reviews(&fx, number).await.len(), 2); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert_eq!(body.matches("id=\"review-").count(), 1, "{body}"); assert!(body.contains("Done, thanks"), "{body}"); assert!(body.contains("Please rename this"), "{body}"); } #[tokio::test] async fn dismissed_review_is_struck_with_its_reason() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let bob = sign_in(&fx, "bob", "bob-token").await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let number = open_pull(&fx).await; let pr = pull(&fx, number).await; fx.store .submit_review( &pr.issue.id, &bob.id, ReviewState::Approved, "LGTM", &pr.head_oid, ) .await .unwrap(); let review = all_reviews(&fx, number).await.remove(0); fx.store .dismiss_review(&review.id, &owner.id, "superseded") .await .unwrap(); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("review-verb review-verb-dismissed"), "{body}"); assert!(body.contains("dismissed: superseded"), "{body}"); assert!(body.contains("LGTM"), "{body}"); assert!(!body.contains("Approved by bob"), "{body}"); } #[tokio::test] async fn merge_box_summarises_approvals_and_changes_requests() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let bob = sign_in(&fx, "bob", "bob-token").await; let carol = sign_in(&fx, "carol", "carol-token").await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; fx.store .submit_review( &pr.issue.id, &bob.id, ReviewState::Approved, "", &pr.head_oid, ) .await .unwrap(); fx.store .submit_review( &pr.issue.id, &carol.id, ReviewState::ChangesRequested, "Needs tests", &pr.head_oid, ) .await .unwrap(); let approval = all_reviews(&fx, number).await.remove(0); let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send(&fx.router, "GET", &uri, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("Approved by bob"), "{body}"); assert!(body.contains("Changes requested by carol"), "{body}"); assert!( body.contains(&format!("action=\"{uri}/reviews/{}/dismiss\"", approval.id)), "{body}" ); // A reader sees the summary but no dismiss form. let (_, _, body) = send(&fx.router, "GET", &uri, Some("carol-token"), None).await; assert!(body.contains("Approved by bob"), "{body}"); assert!(!body.contains("/dismiss\""), "{body}"); } #[tokio::test] async fn review_error_code_shows_on_the_conversation() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let uri = format!("/alice/demo/pulls/{number}"); let (status, _, body) = send( &fx.router, "GET", &format!("{uri}?review_error=own_pr"), Some("alice"), None, ) .await; assert_eq!(status, 200); assert!( body.contains("You cannot approve or request changes on your own pull request."), "{body}" ); let (_, _, body) = send( &fx.router, "GET", &format!("{uri}?review_error=%3Cscript%3E"), Some("alice"), None, ) .await; assert!(!body.contains("