//! The Files changed tab of a pull request, `/{owner}/{repo}/pulls/{n}/files`. Each diff line has //! an anchor and a "+" link (`?comment=::`), which opens an inline comment //! form under that line. Existing review threads render under their lines. Signed-in viewers see //! the review bar at the top. The header and tabs come from `pull_view`. The review POST routes //! are in `pull_reviews`. use std::collections::HashMap; use forge_core::{Action, IssueState, Side, Thread, authorize}; use forge_git::history::CommitDiff; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::request::uri; use topcoat::router::{page, path_param}; use topcoat::view::{View, component, view}; use crate::i18n::pulls::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::edit_history::thread_edit_counts; use crate::pages::issue_parts::{csrf_of, may_converse, parse_number, triage, viewer}; use crate::pages::pull_reviews::{error_text_in, pull_diff}; use crate::pages::pull_view::{commits_of, frame_in, is_merged, pull_header, pull_subnav_in}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::pages::tree::link_context; use crate::state::AppState; use crate::views::diff::{DiffLang, LineComments, diff_section}; use crate::views::review_thread::ThreadCtx; use crate::views::{alert, shell}; path_param!(owner); path_param!(repo); path_param!(number); /// Parses a non-empty run of ASCII digits. `str::parse` alone would also accept a leading `+`. fn digits(raw: &str) -> Option { if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { return None; } raw.parse().ok() } /// The `comment=::` target of an inline form. Anything else is ignored. fn comment_target(cx: &Cx) -> Option<(usize, Side, u32)> { let request = uri(cx); let query = request.query()?; let (_, raw) = form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "comment")?; let mut parts = raw.split(':'); let index = digits::(parts.next()?)?; let side = parts.next()?.parse::().ok()?; let line = digits::(parts.next()?)?; parts.next().is_none().then_some((index, side, line)) } /// The message for the `review_error` query value of the request, when it is a known code. The /// codes and their messages are shared with the review routes (`pull_reviews::error_text`). fn review_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 == "review_error")?; error_text_in(lang, &code).map(str::to_owned) } /// Index of the diff file a thread's path names, matched on either side of a rename. fn file_index(diff: &CommitDiff, path: &str) -> Option { diff.files .iter() .position(|f| f.new_path.as_deref() == Some(path) || f.old_path.as_deref() == Some(path)) } /// The review bar: the viewer's pending comment count, a summary, the review type and Submit, /// plus Discard. The PR author can only comment, so approve and request changes are left out. #[component] async fn review_bar( lang: Lang, pr_href: String, csrf: String, pending: usize, is_author: bool, ) -> Result { let reviews_action = format!("{pr_href}/reviews"); let discard_action = format!("{pr_href}/reviews/discard"); let pending_text = g::pending_comments(lang, pending); Ok(view! {

(g::t(lang, K::FinishReview))

(pending_text.as_str())

(g::t(lang, K::ReviewType)) if !is_author { }
}) } #[page("/{owner}/{repo}/pulls/{number}/files")] async fn pull_files(cx: &Cx) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let pr = match state.store.pull_by_number(&ctx.repo.id, number).await? { Some(pr) => pr, None => return Err(not_found().into()), }; let lang = i18n::lang(cx, viewer(&ctx)); let commit_count = commits_of(state, &ctx, &pr).await?.len(); let diff = pull_diff(state, &ctx, &pr).await?; let frame = frame_in(lang, state, &ctx, &pr, commit_count, diff.files.len()).await?; let viewer_id = viewer(&ctx).map(|user| user.id.clone()); let signed_in = viewer_id.is_some(); let archived = ctx.repo.archived; let open = matches!(pr.issue.state, IssueState::Open) && !is_merged(&pr); let is_author = viewer_id.as_deref() == Some(pr.issue.author_id.as_str()); let can_comment = signed_in && !archived && may_converse(&ctx, &pr.issue); let can_write = !archived && authorize( viewer(&ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_ok(); // Comments and replies stay open on a closed pull request; new reviews do not. let can_review = can_comment && open; let href = frame.href.clone(); let csrf = csrf_of(&ctx); // The viewer's own pending comments are included by `review_threads`. Outdated threads are // left out of the Files tab; they still show in the Conversation. let threads = state .store .review_threads(&pr.issue.id, viewer_id.as_deref()) .await?; let pending = threads.iter().filter(|t| t.root.pending).count(); let edits = thread_edit_counts(state, &threads).await?; let mut by_line: HashMap<(usize, Side, u32), Vec> = HashMap::new(); for thread in threads.into_iter().filter(|t| !t.root.outdated) { if let Some(index) = file_index(&diff, &thread.root.path) { by_line .entry((index, thread.root.side, thread.root.line)) .or_default() .push(thread); } } let link = link_context(state, &ctx.repo, &ctx.repo.default_branch, ""); let thread_ctx = ThreadCtx { pr_href: href.clone(), csrf: csrf.clone(), viewer_id: viewer_id.clone(), can_comment, can_resolve: can_comment && (is_author || can_write), can_moderate: can_comment && triage(&ctx), can_review, tz: ctx.viewer_tz().to_owned(), link: Some(link), edits, }; let comments = LineComments { anchors: can_comment, threads: by_line, open_form: comment_target(cx).filter(|_| can_comment), files_href: format!("{href}/files"), has_pending: pending > 0, ctx: thread_ctx, }; let error = review_error_from(lang, cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let parent = pr.merge_base.clone(); let page_title = frame.page_title.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.clone(), active: "files".to_owned(), commits: frame.commit_count, files: frame.file_count) if let Some(message) = error { alert(message: message.as_str()) } if can_review { review_bar(lang: lang, pr_href: href.clone(), csrf: csrf.clone(), pending: pending, is_author: is_author) } diff_section(diff: &diff, owner: owner.as_str(), repo: name.as_str(), oid: pr.head_oid.as_str(), parent: parent.as_deref(), subject: g::t(lang, K::ThisPullRequest), lang: DiffLang(lang), comments: comments) ) }) } #[cfg(test)] mod tests { use forge_core::{Side, User, Visibility}; use forge_git::GitEnv; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; 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 echoes the request URL (theme /// form `back` field), so the body checks 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 GET through the router and returns `(status, body)`. async fn get(router: &Router, uri: &str, token: Option<&str>) -> (u16, String) { let mut req = Request::builder().method("GET").uri(uri); if let Some(token) = token { req = req.header("cookie", cookie(token)); } let res = router.handle(req.body(Body::empty()).unwrap()).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, 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`. 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; } /// Pushes a `feature` branch (one commit adding `feat.txt` with the line `feature`) 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 } /// The pull request's issue id and head commit. async fn pull_ids(fx: &Fixture, number: i64) -> (String, String) { let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); let pr = fx .store .pull_by_number(&repo.id, number) .await .unwrap() .unwrap(); (pr.issue.id.clone(), pr.head_oid.clone()) } fn files_uri(number: i64, query: &str) -> String { format!("/{OWNER}/{REPO}/pulls/{number}/files{query}") } #[tokio::test] async fn turkish_viewer_gets_turkish_review_bar() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let req = Request::builder() .method("GET") .uri(files_uri(number, "")) .header("cookie", cookie("alice")) .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("İncelemeyi tamamla"), "{body}"); assert!(body.contains("Bekleyen yorumları sil"), "{body}"); } #[tokio::test] async fn files_tab_has_line_anchors_and_plus_links_for_signed_in_viewers() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let (status, body) = get(&fx.router, &files_uri(number, ""), Some("alice")).await; assert_eq!(status, 200); assert!(body.contains("id=\"L-0-new-1\""), "{body}"); assert!(body.contains("?comment=0:new:1#L-0-new-1"), "{body}"); assert!(body.contains("review-bar"), "{body}"); } #[tokio::test] async fn comment_param_opens_the_form_under_its_line() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let (_, head) = pull_ids(&fx, number).await; let (status, body) = get( &fx.router, &files_uri(number, "?comment=0:new:1"), Some("alice"), ) .await; assert_eq!(status, 200); assert!(body.contains("name=\"file_index\" value=\"0\""), "{body}"); assert!(body.contains("name=\"side\" value=\"new\""), "{body}"); assert!(body.contains("name=\"line\" value=\"1\""), "{body}"); assert!( body.contains(&format!("name=\"head_oid\" value=\"{head}\"")), "{body}" ); assert!(body.contains("value=\"single\""), "{body}"); assert!(body.contains("value=\"review\""), "{body}"); let line = body.find("id=\"L-0-new-1\"").unwrap(); let form = body.find("name=\"file_index\"").unwrap(); assert!(form > line, "the form sits after its line"); } #[tokio::test] async fn garbage_comment_param_is_ignored() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; for query in [ "?comment=abc", "?comment=0:bogus:1", "?comment=%2B0:new:1", "?comment=0:new:1:2", "?comment=99:new:1", "?comment=0:new:", "?comment=0:new:-1", ] { let (status, body) = get(&fx.router, &files_uri(number, query), Some("alice")).await; assert_eq!(status, 200, "{query}"); assert!(!body.contains("name=\"file_index\""), "{query}"); } } #[tokio::test] async fn existing_thread_renders_under_its_line() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let (issue_id, head) = pull_ids(&fx, number).await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); fx.store .add_review_comment( &issue_id, &owner.id, false, "feat.txt", Side::New, 1, &head, "+feature", "Looks good", ) .await .unwrap(); let (status, body) = get(&fx.router, &files_uri(number, ""), Some("alice")).await; assert_eq!(status, 200); assert!(body.contains("Looks good"), "{body}"); let line = body.find("id=\"L-0-new-1\"").unwrap(); let thread = body.find("class=\"review-thread\"").unwrap(); assert!(thread > line, "the thread sits after its line"); } #[tokio::test] async fn outdated_thread_is_not_shown_on_the_files_tab() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; let number = open_pull(&fx).await; let (issue_id, head) = pull_ids(&fx, number).await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); let comment = fx .store .add_review_comment( &issue_id, &owner.id, false, "feat.txt", Side::New, 1, &head, "+feature", "Stale remark", ) .await .unwrap(); fx.store .mark_outdated(&issue_id, std::slice::from_ref(&comment.id)) .await .unwrap(); let (status, body) = get(&fx.router, &files_uri(number, ""), Some("alice")).await; assert_eq!(status, 200); assert!(!body.contains("Stale remark"), "{body}"); assert!(!body.contains("class=\"review-thread\""), "{body}"); } #[tokio::test] async fn pending_comment_is_visible_only_to_its_author() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; let (issue_id, head) = pull_ids(&fx, number).await; let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); fx.store .add_review_comment( &issue_id, &owner.id, true, "feat.txt", Side::New, 1, &head, "+feature", "Draft secret", ) .await .unwrap(); let (_, anonymous) = get(&fx.router, &files_uri(number, ""), None).await; assert!(!anonymous.contains("Draft secret")); let (_, bob) = get(&fx.router, &files_uri(number, ""), Some("bob")).await; assert!(!bob.contains("Draft secret")); let (_, author) = get(&fx.router, &files_uri(number, ""), Some("alice")).await; assert!(author.contains("Draft secret"), "{author}"); assert!(author.contains("Pending"), "{author}"); assert!(author.contains("1 pending comment."), "{author}"); } #[tokio::test] async fn review_bar_hides_approve_for_the_pr_author() { let fx = Fixture::new(Visibility::Public).await; owner_session(&fx).await; sign_in(&fx, "bob", "bob").await; let number = open_pull(&fx).await; let (_, author) = get(&fx.router, &files_uri(number, ""), Some("alice")).await; assert!(author.contains("value=\"comment\""), "{author}"); assert!(!author.contains("value=\"approve\""), "{author}"); assert!(!author.contains("value=\"request_changes\""), "{author}"); let (_, other) = get(&fx.router, &files_uri(number, ""), Some("bob")).await; assert!(other.contains("value=\"approve\""), "{other}"); assert!(other.contains("value=\"request_changes\""), "{other}"); } #[tokio::test] async fn anonymous_viewer_sees_no_comment_controls() { let fx = Fixture::new(Visibility::Public).await; let number = open_pull(&fx).await; let (status, body) = get(&fx.router, &files_uri(number, "?comment=0:new:1"), None).await; assert_eq!(status, 200); assert!(!page_main(&body).contains("?comment="), "{body}"); assert!(!body.contains("id=\"L-"), "{body}"); assert!(!body.contains("name=\"file_index\""), "{body}"); assert!(!body.contains("review-bar"), "{body}"); assert!(!body.contains("class=\"diff-plus\""), "{body}"); } #[tokio::test] async fn known_review_error_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 (_, known) = get( &fx.router, &files_uri(number, "?review_error=own_pr"), Some("alice"), ) .await; assert!(known.contains("cannot approve"), "{known}"); let (status, unknown) = get( &fx.router, &files_uri(number, "?review_error=%3Cb%3Ehi%3C%2Fb%3E"), Some("alice"), ) .await; assert_eq!(status, 200); assert!(!unknown.contains("role=\"alert\""), "{unknown}"); assert!(!unknown.contains("hi"), "{unknown}"); } #[tokio::test] async fn commit_page_still_renders_the_plain_diff() { let fx = Fixture::new(Visibility::Public).await; let (status, body) = get( &fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.modified), None, ) .await; assert_eq!(status, 200); assert!(body.contains("class=\"diff-table\""), "{body}"); assert!(!body.contains("id=\"L-"), "{body}"); assert!(!body.contains("diff-plus"), "{body}"); } }