//! Pull request review routes: line comments on the Files tab, replies, edits, deletes, thread //! resolution, review submission, discarding a pending review and dismissing a review. Writes //! are POST with CSRF. The server recomputes the diff and decides what a line is, so a form can //! only comment on a line the current diff shows. //! //! A refused write redirects back to its tab with `?review_error=`; the codes are the //! `ERR_*` constants below. Permissions: any signed-in reader may comment and review; only the //! author edits a comment; the author or Triage deletes; the PR author or Write resolves; Write //! dismisses. Archived repos refuse every write. Closed or merged pull requests accept comments //! and replies but no new reviews. use forge_core::reviews::{ReviewComment, ReviewState, Side}; use forge_core::{Action, IssueState, PullRequest, User, authorize}; use forge_git::history::{CommitDiff, DiffStatus, FileDiff, LineKind}; use forge_git::pulls::range_diff_files; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, view}; use crate::auth::require_user; use crate::i18n::pulls_review::{self as g, K}; use crate::i18n::{self, Lang}; use crate::notify::{self, Activity}; use crate::pages::issue_parts::{check_write, csrf_of, may_change, may_converse, parse_number}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::views::diff::LIMITS; use crate::views::{alert, repo_href, shell}; use crate::webhooks::emit::{Sender, emit_review}; path_param!(owner); path_param!(repo); path_param!(number); path_param!(comment_id); path_param!(review_id); /// The `review_error` codes a refused write redirects with. pub(crate) const ERR_STALE: &str = "stale"; pub(crate) const ERR_OWN_PR: &str = "own_pr"; pub(crate) const ERR_EMPTY: &str = "empty"; pub(crate) const ERR_INVALID: &str = "invalid"; pub(crate) const ERR_BAD_LINE: &str = "bad_line"; pub(crate) const ERR_CLOSED: &str = "closed"; /// The message for a `review_error` code in `lang`, or `None` for an unknown code. pub(crate) fn error_text_in(lang: Lang, code: &str) -> Option<&'static str> { match code { ERR_STALE => Some(g::t(lang, K::ReviewErrStale)), ERR_OWN_PR => Some(g::t(lang, K::ReviewErrOwnPr)), ERR_EMPTY => Some(g::t(lang, K::ReviewErrEmpty)), ERR_INVALID => Some(g::t(lang, K::ReviewErrInvalid)), ERR_BAD_LINE => Some(g::t(lang, K::ReviewErrBadLine)), ERR_CLOSED => Some(g::t(lang, K::ReviewErrClosed)), _ => None, } } /// The message for the `review_error` query value of the request, when it is a known code. fn 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) } /// The form fields of every review POST. Unused fields are empty. #[derive(Deserialize, Default)] #[serde(default)] struct ReviewForm { csrf: String, body: String, /// `single` or `review` for a line comment. action: String, head_oid: String, file_index: String, side: String, line: String, /// `comment`, `approve` or `request_changes` for a review submit. #[serde(rename = "state")] verdict: String, reason: String, /// `1` to resolve a thread, `0` to unresolve it. resolved: String, /// The tab the form was posted from: `files` or anything else for the conversation. #[serde(rename = "return")] ret: String, } // Diff and lookups /// The diff of the pull request: the merge base of `base_oid` and `head_oid` to head, with the /// Files tab's limits. The Files tab and the comment routes both read it, so a line means the /// same thing on both. pub(crate) async fn pull_diff( state: &AppState, ctx: &RepoCtx, pr: &PullRequest, ) -> Result { Ok(range_diff_files(&state.git, &ctx.path, &pr.base_oid, &pr.head_oid, LIMITS).await?) } /// 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()), } } /// Whether the pull request is closed or merged. Its comments stay open; its reviews do not. fn is_closed(pr: &PullRequest) -> bool { pr.issue.state != IssueState::Open || pr.merged_at.is_some() } /// The path a diff file's comments are stored under: the new path, or the old path for a /// deletion. fn file_path(file: &FileDiff) -> Option<&str> { if file.status == DiffStatus::Deleted { file.old_path.as_deref() } else { file.new_path.as_deref().or(file.old_path.as_deref()) } } /// Whether `line` is the line `number` on `side`: an added or context line on the new side, a /// deleted line on the old side. fn on_line(line: &forge_git::history::DiffLine, side: Side, number: u32) -> bool { match side { Side::New => { matches!(line.kind, LineKind::Add | LineKind::Context) && line.new_no == Some(number) } Side::Old => line.kind == LineKind::Del && line.old_no == Some(number), } } /// The `diff_hunk` for a comment on `number` of `side`: up to four lines of the same hunk ending /// at that line, each with a `+`, `-` or space prefix. `None` when the diff has no such line. fn diff_hunk_at(file: &FileDiff, side: Side, number: u32) -> Option { for hunk in &file.hunks { let Some(at) = hunk .lines .iter() .position(|line| on_line(line, side, number)) else { continue; }; let mut lines: Vec = hunk.lines[..=at] .iter() .rev() .filter_map(|line| { let prefix = match line.kind { LineKind::Add => '+', LineKind::Del => '-', LineKind::Context => ' ', LineKind::NoNewline => return None, }; Some(format!("{prefix}{}", line.text)) }) .take(4) .collect(); lines.reverse(); return Some(lines.join("\n")); } None } /// A comment of this pull request that the viewer can see (a submitted one, or their own /// pending one), root or reply. Anything else is 404. async fn comment_on_pull( state: &AppState, pr: &PullRequest, viewer: &User, comment_id: &str, ) -> Result { for thread in state .store .review_threads(&pr.issue.id, Some(&viewer.id)) .await? { if thread.root.id == comment_id { return Ok(thread.root); } if let Some(reply) = thread.replies.into_iter().find(|r| r.id == comment_id) { return Ok(reply); } } Err(not_found().into()) } /// The id of the root comment a comment belongs to (its own id for a root). fn root_of(comment: &ReviewComment) -> String { comment .reply_to .clone() .unwrap_or_else(|| comment.id.clone()) } /// The conversation, or the Files tab when `ret` is `files`, for this pull request. fn tab_url(ctx: &RepoCtx, number: i64, ret: &str) -> String { let base = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &number.to_string(), ); if ret == "files" { format!("{base}/files") } else { base } } /// Redirects back to `tab` with a refusal code; `anchor` is `#rc-` or empty. fn refusal(tab: &str, code: &str, anchor: &str) -> SeeOther { see_other(format!("{tab}?review_error={code}{anchor}")) } // Line comments /// Adds a line comment (single, or into the author's pending review) on the Files tab. The /// request must name the head the form was rendered for, and the line must be on that diff. #[route(POST "/{owner}/{repo}/pulls/{number}/review_comments")] async fn review_comment_post(cx: &Cx, Form(form): Form) -> 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?; if !may_converse(&ctx, &pr.issue) { return Err(forbidden().into()); } let files = tab_url(&ctx, number, "files"); if form.head_oid != pr.head_oid { return Ok(refusal(&files, ERR_STALE, "")); } let pending = match form.action.as_str() { "single" => false, "review" => true, _ => return Ok(refusal(&files, ERR_INVALID, "")), }; if form.body.trim().is_empty() { return Ok(refusal(&files, ERR_EMPTY, "")); } if pending && is_closed(&pr) { return Ok(refusal(&files, ERR_CLOSED, "")); } let (Ok(file_index), Ok(line), Ok(side)) = ( form.file_index.parse::(), form.line.parse::(), form.side.parse::(), ) else { return Ok(refusal(&files, ERR_BAD_LINE, "")); }; let back = format!("{files}?comment={file_index}:{}:{line}", side.as_str()); let diff = pull_diff(state, &ctx, &pr).await?; let Some(file) = diff.files.get(file_index) else { return Ok(refusal(&files, ERR_BAD_LINE, "")); }; let (Some(path), Some(hunk)) = (file_path(file), diff_hunk_at(file, side, line)) else { return Ok(refusal(&files, ERR_BAD_LINE, "")); }; match state .store .add_review_comment( &pr.issue.id, &user.id, pending, path, side, line, &pr.head_oid, &hunk, &form.body, ) .await { Ok(_) => Ok(see_other(format!( "{files}#L-{file_index}-{}-{line}", side.as_str() ))), Err(forge_core::Error::Invalid(_)) => { Ok(see_other(format!("{back}&review_error={ERR_INVALID}"))) } Err(e) => Err(e.into()), } } /// Replies in the thread of a root comment. Replies post at once, even on a closed pull request. #[route(POST "/{owner}/{repo}/pulls/{number}/review_comments/{comment_id}/reply")] async fn review_comment_reply(cx: &Cx, Form(form): Form) -> 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?; if !may_converse(&ctx, &pr.issue) { return Err(forbidden().into()); } let parent = comment_on_pull(state, &pr, &user, path_param::(cx)).await?; let root = root_of(&parent); let tab = tab_url(&ctx, number, &form.ret); let anchor = format!("#rc-{root}"); if form.body.trim().is_empty() { return Ok(refusal(&tab, ERR_EMPTY, &anchor)); } match state .store .reply_review_comment(&pr.issue.id, &user.id, &root, &form.body) .await { Ok(_) => Ok(see_other(format!("{tab}{anchor}"))), Err(forge_core::Error::Invalid(_)) => Ok(refusal(&tab, ERR_INVALID, &anchor)), Err(e) => Err(e.into()), } } /// The edit form for a comment. Only its author may open it. #[page("/{owner}/{repo}/pulls/{number}/review_comments/{comment_id}/edit")] async fn review_comment_edit_form(cx: &Cx) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, _) = require_user(cx).await?; let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let pr = pull_for(state, &ctx, number).await?; let comment = comment_on_pull(state, &pr, &user, path_param::(cx)).await?; if comment.author_id != user.id { return Err(forbidden().into()); } let error = error_from(lang, cx); edit_page(cx, ctx, number, comment, error, lang).await } /// The edit page: a textarea with the comment's body, and the refusal message when there is one. async fn edit_page( cx: &Cx, ctx: RepoCtx, number: i64, comment: ReviewComment, error: Option<&'static str>, lang: Lang, ) -> Result { let state = app_context::(cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let csrf = csrf_of(&ctx); let action = repo_href( &owner, &name, "pulls", &format!("{number}/review_comments/{}/edit", comment.id), ); let back = format!("{}#rc-{}", tab_url(&ctx, number, ""), root_of(&comment)); let page_title = g::edit_title(lang, number, &format!("{owner}/{name}")); let heading = g::t(lang, K::EditReviewComment); let comment_label = g::t(lang, K::CommentLabel); let save_label = g::t(lang, K::SaveComment); let cancel_label = g::t(lang, K::CancelButton); 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)

(heading)

if let Some(message) = error { alert(message: message) }
(cancel_label)
) }) } /// Saves an edited comment. Only its author; the store records the previous body. #[route(POST "/{owner}/{repo}/pulls/{number}/review_comments/{comment_id}/edit")] async fn review_comment_edit_save(cx: &Cx, Form(form): Form) -> 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 comment = comment_on_pull(state, &pr, &user, path_param::(cx)).await?; if comment.author_id != user.id { return Err(forbidden().into()); } let edit_url = format!( "{}/review_comments/{}/edit", tab_url(&ctx, number, ""), comment.id ); if form.body.trim().is_empty() { return Ok(refusal(&edit_url, ERR_EMPTY, "")); } match state .store .edit_review_comment(&comment.id, &form.body) .await { Ok(()) => Ok(see_other(format!( "{}#rc-{}", tab_url(&ctx, number, &form.ret), root_of(&comment) ))), Err(forge_core::Error::Invalid(_)) => Ok(refusal(&edit_url, ERR_INVALID, "")), Err(e) => Err(e.into()), } } /// Deletes a comment. Its author or Triage. Deleting a root deletes its replies. #[route(POST "/{owner}/{repo}/pulls/{number}/review_comments/{comment_id}/delete")] async fn review_comment_delete(cx: &Cx, Form(form): Form) -> 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 comment = comment_on_pull(state, &pr, &user, path_param::(cx)).await?; if !may_change(&ctx, &comment.author_id) { return Err(forbidden().into()); } state.store.delete_review_comment(&comment.id).await?; let tab = tab_url(&ctx, number, &form.ret); if comment.reply_to.is_some() { Ok(see_other(format!("{tab}#rc-{}", root_of(&comment)))) } else { Ok(see_other(tab)) } } /// Resolves or unresolves a thread (`resolved=1|0`). The PR author or Write. #[route(POST "/{owner}/{repo}/pulls/{number}/review_comments/{comment_id}/resolve")] async fn review_comment_resolve(cx: &Cx, Form(form): Form) -> 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 comment = comment_on_pull(state, &pr, &user, path_param::(cx)).await?; let is_pr_author = pr.issue.author_id == user.id; if !is_pr_author && authorize( Some(&user), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_err() { return Err(forbidden().into()); } let root = root_of(&comment); let tab = tab_url(&ctx, number, &form.ret); let anchor = format!("#rc-{root}"); let resolved = match form.resolved.as_str() { "1" => true, "0" => false, _ => return Ok(refusal(&tab, ERR_INVALID, &anchor)), }; state .store .set_thread_resolved(&root, &user.id, resolved) .await?; Ok(see_other(format!("{tab}{anchor}"))) } // Reviews /// Submits the author's pending review as a comment, approval or change request. The PR author /// cannot approve or request changes on their own pull request. Closed pull requests refuse it. #[route(POST "/{owner}/{repo}/pulls/{number}/reviews")] async fn review_submit(cx: &Cx, Form(form): Form) -> 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?; if !may_converse(&ctx, &pr.issue) { return Err(forbidden().into()); } let tab = tab_url(&ctx, number, &form.ret); let verdict = match form.verdict.as_str() { "comment" => ReviewState::Commented, "approve" => ReviewState::Approved, "request_changes" => ReviewState::ChangesRequested, _ => return Ok(refusal(&tab, ERR_INVALID, "")), }; if is_closed(&pr) { return Ok(refusal(&tab, ERR_CLOSED, "")); } match state .store .submit_review(&pr.issue.id, &user.id, verdict, &form.body, &pr.head_oid) .await { Ok(review) => { emit_review(state, &ctx.repo, &Sender::of(&user), &pr, &review).await; let text = review_text(state, &pr.issue, &user.id, &review).await; notify::dispatch( state, &ctx.repo, &pr.issue, &user.id, Activity::Review, Some(&text), ) .await; Ok(see_other(format!("{tab}#review-{}", review.id))) } Err(forge_core::Error::Forbidden(_)) => Ok(refusal(&tab, ERR_OWN_PR, "")), Err(forge_core::Error::Invalid(_)) => Ok(refusal(&tab, ERR_EMPTY, "")), Err(e) => Err(e.into()), } } /// The review body and the bodies of the review's comments, joined for mention extraction. async fn review_text( state: &AppState, issue: &forge_core::Issue, viewer_id: &str, review: &forge_core::Review, ) -> String { let mut parts = vec![review.body.clone()]; match state.store.review_threads(&issue.id, Some(viewer_id)).await { Ok(threads) => { for thread in threads { for comment in std::iter::once(thread.root).chain(thread.replies) { if comment.review_id == review.id { parts.push(comment.body); } } } } Err(e) => { tracing::error!(error = %e, issue_id = %issue.id, "notify: review comments lookup failed"); } } parts.join("\n\n") } /// Discards the viewer's pending review and its draft comments. #[route(POST "/{owner}/{repo}/pulls/{number}/reviews/discard")] async fn review_discard(cx: &Cx, Form(form): Form) -> 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?; state .store .discard_pending_review(&pr.issue.id, &user.id) .await?; Ok(see_other(tab_url(&ctx, number, &form.ret))) } /// Dismisses an approval or a change request with a reason. Write only. #[route(POST "/{owner}/{repo}/pulls/{number}/reviews/{review_id}/dismiss")] async fn review_dismiss(cx: &Cx, Form(form): Form) -> 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)?; if authorize( Some(&user), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_err() { return Err(forbidden().into()); } let state = app_context::(cx); let pr = pull_for(state, &ctx, number).await?; let wanted = path_param::(cx); let Some(review) = state .store .list_reviews(&pr.issue.id) .await? .into_iter() .find(|r| r.id == wanted) else { return Err(not_found().into()); }; let tab = tab_url(&ctx, number, &form.ret); if form.reason.trim().is_empty() { return Ok(refusal(&tab, ERR_EMPTY, "")); } match state .store .dismiss_review(&review.id, &user.id, &form.reason) .await { Ok(()) => Ok(see_other(format!("{tab}#review-{}", review.id))), Err(forge_core::Error::Invalid(_)) => Ok(refusal(&tab, ERR_INVALID, "")), Err(e) => Err(e.into()), } } #[cfg(test)] mod tests { use forge_core::reviews::{ReviewState, Side}; use forge_core::{IssueState, PullRequest, 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}") } /// 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 from the root that changes `a.txt` (`two` becomes `changed`) 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("a.txt"), "one\nchanged\nthree\n").unwrap(); git(&fx.work, &["add", "-A"]); git(&fx.work, &["commit", "-q", "-m", "Change a.txt"]); 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, "Change a", "", "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 } /// The pull request's current head, for the `head_oid` field of a comment form. async fn head_of(fx: &Fixture, number: i64) -> String { pull(fx, number).await.head_oid } /// Posts a single line comment on line `line` of the new side of `a.txt` (file index 0). async fn comment_on( fx: &Fixture, number: i64, token: &str, line: &str, body: &str, ) -> (u16, Option) { let head = head_of(fx, number).await; let form = csrf_form( token, &[ ("file_index", "0"), ("side", "new"), ("line", line), ("head_oid", head.as_str()), ("action", "single"), ("body", body), ], ); let (status, location, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/review_comments"), Some(token), Some(form), ) .await; (status, location) } /// The id of the first root comment on the pull request. async fn root_id(fx: &Fixture, number: i64) -> String { let pr = pull(fx, number).await; fx.store.review_threads(&pr.issue.id, None).await.unwrap()[0] .root .id .clone() } #[tokio::test] async fn single_comment_on_an_added_line_creates_a_thread_with_its_hunk() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let (status, location) = comment_on(&fx, number, "alice", "2", "Looks good").await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}/files#L-0-new-2").as_str()) ); let threads = fx.store.review_threads(&pr.issue.id, None).await.unwrap(); assert_eq!(threads.len(), 1); let root = &threads[0].root; assert_eq!( (root.path.as_str(), root.side, root.line), ("a.txt", Side::New, 2) ); assert_eq!(root.diff_hunk, " one\n-two\n+changed"); assert!(!root.pending); } #[tokio::test] async fn comment_on_a_line_outside_the_diff_is_rejected() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let (status, location) = comment_on(&fx, number, "alice", "99", "Nope").await; assert_eq!(status, 303); assert!(location.unwrap().contains("review_error=bad_line")); assert!( fx.store .review_threads(&pr.issue.id, None) .await .unwrap() .is_empty() ); } #[tokio::test] async fn stale_head_is_rejected() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let zero = "0".repeat(40); let form = csrf_form( "alice", &[ ("file_index", "0"), ("side", "new"), ("line", "2"), ("head_oid", zero.as_str()), ("action", "single"), ("body", "Stale"), ], ); let (status, location, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/review_comments"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}/files?review_error=stale").as_str()) ); assert!( fx.store .review_threads(&pr.issue.id, None) .await .unwrap() .is_empty() ); } #[tokio::test] async fn start_review_keeps_the_comment_pending_until_submitted() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let alice = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let head = pr.head_oid.clone(); let form = csrf_form( "alice", &[ ("file_index", "0"), ("side", "new"), ("line", "2"), ("head_oid", head.as_str()), ("action", "review"), ("body", "Draft"), ], ); let (status, _, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/review_comments"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); // Hidden from everyone but the author while pending. assert!( fx.store .review_threads(&pr.issue.id, None) .await .unwrap() .is_empty() ); let mine = fx .store .review_threads(&pr.issue.id, Some(&alice.id)) .await .unwrap(); assert!(mine[0].root.pending); let form = csrf_form("alice", &[("state", "comment"), ("body", "Done")]); let (status, _, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/reviews"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); let threads = fx.store.review_threads(&pr.issue.id, None).await.unwrap(); assert_eq!(threads.len(), 1); assert!(!threads[0].root.pending); } #[tokio::test] async fn reader_approval_is_recorded_and_author_approval_is_refused() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let uri = format!("/alice/demo/pulls/{number}/reviews"); let form = csrf_form("bob", &[("state", "approve")]); let (status, location, _) = send(&fx.router, "POST", &uri, Some("bob"), Some(form)).await; assert_eq!(status, 303); assert!(location.unwrap().contains("#review-")); let reviews = fx.store.list_reviews(&pr.issue.id).await.unwrap(); assert_eq!(reviews.len(), 1); assert_eq!(reviews[0].state, ReviewState::Approved); let form = csrf_form("alice", &[("state", "approve")]); let (status, location, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}?review_error=own_pr").as_str()) ); assert_eq!(fx.store.list_reviews(&pr.issue.id).await.unwrap().len(), 1); } #[tokio::test] async fn reply_joins_the_thread_and_redirects_to_the_root() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; comment_on(&fx, number, "alice", "2", "Root").await; let root = root_id(&fx, number).await; let pr = pull(&fx, number).await; let form = csrf_form("bob", &[("body", "Reply")]); let (status, location, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/review_comments/{root}/reply"), Some("bob"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}#rc-{root}").as_str()) ); let threads = fx.store.review_threads(&pr.issue.id, None).await.unwrap(); assert_eq!(threads[0].replies.len(), 1); assert_eq!(threads[0].replies[0].author_name, "bob"); } #[tokio::test] async fn edit_is_for_the_author_only() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; comment_on(&fx, number, "alice", "2", "First").await; let root = root_id(&fx, number).await; let edit = format!("/alice/demo/pulls/{number}/review_comments/{root}/edit"); let (status, _, body) = send(&fx.router, "GET", &edit, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("Save comment")); let (status, _, _) = send(&fx.router, "GET", &edit, Some("bob"), None).await; assert_eq!(status, 403); let form = csrf_form("bob", &[("body", "Hijacked")]); let (status, _, _) = send(&fx.router, "POST", &edit, Some("bob"), Some(form)).await; assert_eq!(status, 403); let form = csrf_form("alice", &[("body", "Edited")]); let (status, location, _) = send(&fx.router, "POST", &edit, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some(format!("/alice/demo/pulls/{number}#rc-{root}").as_str()) ); let pr = pull(&fx, number).await; let threads = fx.store.review_threads(&pr.issue.id, None).await.unwrap(); assert_eq!(threads[0].root.body, "Edited"); } #[tokio::test] async fn delete_removes_the_thread_for_its_author_and_refuses_others() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; comment_on(&fx, number, "alice", "2", "Doomed").await; let root = root_id(&fx, number).await; let pr = pull(&fx, number).await; let uri = format!("/alice/demo/pulls/{number}/review_comments/{root}/delete"); let form = csrf_form("bob", &[]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("bob"), Some(form)).await; assert_eq!(status, 403); let form = csrf_form("alice", &[]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert!( fx.store .review_threads(&pr.issue.id, None) .await .unwrap() .is_empty() ); } #[tokio::test] async fn resolve_is_for_the_pr_author_or_write() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; comment_on(&fx, number, "alice", "2", "Thread").await; let root = root_id(&fx, number).await; let pr = pull(&fx, number).await; let uri = format!("/alice/demo/pulls/{number}/review_comments/{root}/resolve"); let form = csrf_form("bob", &[("resolved", "1")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("bob"), Some(form)).await; assert_eq!(status, 403); let form = csrf_form("alice", &[("resolved", "1")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); let threads = fx.store.review_threads(&pr.issue.id, None).await.unwrap(); assert!(threads[0].root.resolved); } #[tokio::test] async fn dismiss_needs_write_and_a_reason() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let form = csrf_form("bob", &[("state", "request_changes")]); send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/reviews"), Some("bob"), Some(form), ) .await; let review = fx.store.list_reviews(&pr.issue.id).await.unwrap().remove(0); let uri = format!("/alice/demo/pulls/{number}/reviews/{}/dismiss", review.id); let form = csrf_form("bob", &[("reason", "mine")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("bob"), Some(form)).await; assert_eq!(status, 403); let form = csrf_form("alice", &[("reason", " ")]); let (status, location, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert!(location.unwrap().ends_with("?review_error=empty")); let form = csrf_form("alice", &[("reason", "Addressed")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); let review = fx.store.list_reviews(&pr.issue.id).await.unwrap().remove(0); assert_eq!(review.state, ReviewState::Dismissed); } #[tokio::test] async fn closed_pull_takes_comments_but_not_reviews() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let pr = pull(&fx, number).await; let alice = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); fx.store .set_issue_state( &pr.issue.id, Some(&alice.id), IssueState::Closed, None, serde_json::Value::Null, ) .await .unwrap(); let (status, location) = comment_on(&fx, number, "alice", "2", "After close").await; assert_eq!(status, 303); assert!(location.unwrap().contains("#L-0-new-2")); let form = csrf_form("alice", &[("state", "approve")]); let (status, location, _) = send( &fx.router, "POST", &format!("/alice/demo/pulls/{number}/reviews"), Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert!(location.unwrap().ends_with("?review_error=closed")); } #[tokio::test] async fn anonymous_writes_redirect_to_login_and_missing_csrf_is_forbidden() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let head = head_of(&fx, number).await; let uri = format!("/alice/demo/pulls/{number}/review_comments"); let form = csrf_form( "alice", &[ ("file_index", "0"), ("side", "new"), ("line", "2"), ("head_oid", head.as_str()), ("action", "single"), ("body", "Anon"), ], ); let (status, location, _) = send(&fx.router, "POST", &uri, None, Some(form)).await; assert_eq!(status, 303); assert!(location.unwrap().starts_with("/login")); let form = format!("file_index=0&side=new&line=2&head_oid={head}&action=single&body=NoCsrf"); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 403); } }