use std::str::FromStr; use tracing::{info, instrument, warn}; use crate::{ bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide}, consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, metrics, open_router::{OpenRouterClient, Tool}, sandbox::{Sandbox, SandboxConfig, agent}, text::excerpt, }; #[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))] pub async fn exec_review( gitea_api: &GiteaAPI, open_router_client: &OpenRouterClient, model: &str, sandbox_config: &SandboxConfig, tools: Vec, review_payload: ReviewPayload, ) -> anyhow::Result<()> { tracing::info!( repo = %review_payload.repository.full_name, pr = review_payload.pull_request.number, action = %review_payload.action, "Starting review" ); let new_comment = gitea_api .comment( &BOT_PROCESS_MSG.replace("{model}", model), &review_payload.repository.full_name, review_payload.pull_request.number, ) .await?; let bot_result: Result = async { let full_name = &review_payload.repository.full_name; let index = review_payload.pull_request.number; let git_diff = gitea_api.pull_request_diff(full_name, index).await?; // The file list only refines the paths and describes the changes: a // failure is not fatal, the diff is the source of truth for the lines. let files = match gitea_api.pull_request_files(full_name, index).await { Ok(files) => files, Err(err) => { warn!(%err, "Failed to list the pull request files"); Vec::new() } }; let mut changed_lines = parse_changed_lines(&git_diff); drop_generated_files(&mut changed_lines); resolve_filenames(&mut changed_lines, &files); let changes = format_changes(&files, &changed_lines); let bot_request = REVIEW_PROMPT .replace("{subject}", &review_payload.pull_request.title) .replace("{comment}", &review_payload.comment.body) .replace("{changes}", &changes); let (mut review_result, cost) = run_sandboxed_review( gitea_api, open_router_client, sandbox_config, tools, &review_payload, &bot_request, ) .await?; resolve_review_sides(&mut review_result, &changed_lines); review_result.cost = cost; if let Some(cost) = review_result.cost { metrics::openrouter_cost_usd(cost); } let final_review_markdown = review_result_to_markdown(&review_result); gitea_api .post_pull_request_review( &review_result, &final_review_markdown, &review_payload.repository.full_name, review_payload.pull_request.number, ) .await?; Ok(review_result) } .await; match bot_result { Ok(_) => { gitea_api .delete_comment(&review_payload.repository.full_name, new_comment.id) .await } Err(e) => { gitea_api .edit_comment( &format!("Error while reviewing: {}", e), &review_payload.repository.full_name, new_comment.id, ) .await } } } /// Runs the review inside a sandbox container, letting the model explore the /// repository with tools before answering. /// /// The answer of the model is parsed by [`ReviewResult::from_str`], which the /// agent loop enforces: an answer that is not a review is sent back to the model /// for correction. async fn run_sandboxed_review( gitea_api: &GiteaAPI, open_router_client: &OpenRouterClient, sandbox_config: &SandboxConfig, tools: Vec, review_payload: &ReviewPayload, bot_request: &str, ) -> anyhow::Result<(ReviewResult, Option)> { let sandbox = Sandbox::create( &sandbox_config.runtime, &review_payload.repository.clone_url, gitea_api.token(), review_payload.pull_request.number, ) .await?; let result = agent::run( open_router_client, &sandbox, tools, SANDBOX_SYSTEM_PROMPT, bot_request, sandbox_config.max_iterations, ) .await; if let Err(err) = sandbox.cleanup().await { warn!(%err, "Failed to clean up sandbox container"); } let result = result?; info!( iterations = result.iterations, cost = ?result.cost, "Sandboxed review finished" ); Ok((result.answer, result.cost)) } fn review_result_to_markdown(review_result: &ReviewResult) -> String { if review_result.reviews.is_empty() { return String::from("No issues found. ✅"); } let mut md = String::from("## Review Feedback\n\n"); md.push_str(&format!( "### {} issues found.\n\n", review_result.reviews.len() )); let breakdown = severity_breakdown(&review_result.reviews); if !breakdown.is_empty() { md.push_str(&breakdown); md.push('\n'); } if !review_result.comment.is_empty() { md.push_str("\n---\n\n"); md.push_str("### Summary\n\n"); md.push_str(&review_result.comment); md.push('\n'); } if let Some(cost) = review_result.cost { md.push_str("\n---\n\n"); md.push_str(&format!("### Cost: ${}", cost)); md.push('\n'); } md } /// Counts the reviews per severity, most severe first. /// /// A review whose severity the model omitted or wrote unreadably is counted as /// [`ReviewSeverity::Maintainability`], the same fallback used when posting it. fn severity_breakdown(reviews: &[ReviewItem]) -> String { [ ReviewSeverity::Bug, ReviewSeverity::Security, ReviewSeverity::Performance, ReviewSeverity::Maintainability, ] .into_iter() .filter_map(|severity| { let count = reviews .iter() .filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity) .count(); (count > 0).then(|| format!("- {} {}", count, severity.label())) }) .collect::>() .join("\n") } /// The lines a pull request changed, per file. /// /// Line numbers are the ones Gitea expects to place a review comment: /// [`ReviewSide::Added`] numbers refer to the new version of the file (sent as /// `new_position`), [`ReviewSide::Removed`] numbers to the old version (sent as /// `old_position`). type ChangedLines = Vec; struct ChangedFile { filename: String, added: Vec, removed: Vec, } impl ChangedFile { fn new(filename: &str) -> Self { Self { filename: String::from(filename), added: Vec::new(), removed: Vec::new(), } } /// Lines that can be commented on for the given side. fn lines(&self, side: ReviewSide) -> &[u64] { match side { ReviewSide::Added => &self.added, ReviewSide::Removed => &self.removed, } } /// Side a line belongs to, used when the model did not state one. fn side_of(&self, line: u64) -> Option { if self.added.contains(&line) { Some(ReviewSide::Added) } else if self.removed.contains(&line) { Some(ReviewSide::Removed) } else { None } } } /// Lists the lines changed by the diff, per file, on both sides. /// /// Only the line numbers are kept: the model reads the code itself through the /// sandbox tools. fn parse_changed_lines(git_diff: &str) -> ChangedLines { let mut files = Vec::new(); let mut current_file: Option = None; let mut in_hunk = false; let mut old_line: u64 = 0; let mut new_line: u64 = 0; for line in git_diff.lines() { if line.starts_with("diff --git ") { current_file = None; in_hunk = false; continue; } // `--- a/x` and `+++ b/x` only appear before the first hunk of a file. // Inside a hunk, a line may legitimately start with them: removing // `--x` gives `---x`, adding `++i;` gives `+++i;`. if !in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ")) { // A header is never a content line: `+++ /dev/null` on a deleted // file keeps the path of the other side in place. if let Some(path) = header_file_path(line) { current_file = Some(path); } continue; } if line.starts_with("@@") { if let Some((old_start, new_start)) = parse_hunk_starts(line) { old_line = old_start; new_line = new_start; in_hunk = true; } continue; } let Some(filename) = current_file.as_deref() else { continue; }; match line.as_bytes().first() { Some(b' ') => { old_line += 1; new_line += 1; } Some(b'-') => { changed_file(&mut files, filename).removed.push(old_line); old_line += 1; } Some(b'+') => { changed_file(&mut files, filename).added.push(new_line); new_line += 1; } // `\ No newline at end of file`, and anything unexpected: a line // that advances neither side. _ => {} } } files } /// Path of the file on one side of the diff, from a `--- a/` or /// `+++ b/` header line. /// /// These lines are the only unambiguous source for the path: the `diff --git` /// line is cut at the first space, and it names the old path of a renamed file. /// `None` for `/dev/null`, which leaves the path of the other side in place. fn header_file_path(line: &str) -> Option { let (prefix, raw) = match line.strip_prefix("--- ") { Some(raw) => ("a/", raw), None => ("b/", line.strip_prefix("+++ ")?), }; let path = decode_git_path(raw); Some(String::from(path.strip_prefix(prefix)?)) } /// Decodes a path as git writes it in a diff header: git wraps it in quotes and /// escapes the bytes that need it (`\303\251` for `é`) when the path contains /// non-printable or non-ASCII characters. fn decode_git_path(raw: &str) -> String { let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else { return String::from(raw); }; let bytes = quoted.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while let Some(byte) = bytes.get(index) { index += 1; if *byte != b'\\' { decoded.push(*byte); continue; } match bytes.get(index) { // Octal escapes are the ones that matter for a path: git uses them // for every non-ASCII byte. Some(digit @ b'0'..=b'7') => { let mut value = u32::from(digit - b'0'); let mut digits = 1; while digits < 3 { match bytes.get(index + digits) { Some(next @ b'0'..=b'7') => { value = value * 8 + u32::from(next - b'0'); digits += 1; } _ => break, } } decoded.push(u8::try_from(value).unwrap_or(b'?')); index += digits; } Some(escaped) => { decoded.push(match escaped { b't' => b'\t', b'n' => b'\n', b'r' => b'\r', other => *other, }); index += 1; } None => decoded.push(b'\\'), } } String::from_utf8_lossy(&decoded).into_owned() } /// Replaces the paths parsed from the diff with the exact paths reported by the /// API, which are the ones the model sees in the sandbox. fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) { for changed in changed_lines.iter_mut() { let parsed = changed.filename.as_str(); let Some(file) = files.iter().find(|file| file.filename == parsed) else { if !files.is_empty() { warn!(path = %parsed, "Changed file is not in the pull request file list"); } continue; }; changed.filename = file.filename.clone(); } } /// Basenames of generated files that are never reviewed. /// /// Their diffs are machine-written dependency churn: they run to thousands of /// lines, flood the prompt with line numbers and drown the code the model should /// look at. A lockfile is also never a place where a review comment belongs. const IGNORED_FILE_NAMES: [&str; 16] = [ "Cargo.lock", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "composer.lock", "Gemfile.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "go.sum", "packages.lock.json", "flake.lock", "pubspec.lock", ]; /// Whether a changed path is a generated file that is never reviewed. /// /// The comparison is on the basename, so a lockfile nested in a workspace (for /// example `crates/foo/Cargo.lock`) is matched too. fn is_ignored(filename: &str) -> bool { let basename = filename.rsplit('/').next().unwrap_or(filename); IGNORED_FILE_NAMES.contains(&basename) } /// Drops the generated files from the changed lines. /// /// Removing them here keeps the prompt free of their line numbers, and also makes /// [`resolve_review_sides`] reject any review the model anchors on one of them. fn drop_generated_files(changed_lines: &mut ChangedLines) { changed_lines.retain(|file| !is_ignored(&file.filename)); } /// Renders the changes for the model: the files the pull request touches, then /// the lines to review per file. fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { let mut sections = Vec::new(); let described = files .iter() .filter(|file| !is_ignored(&file.filename)) .map(describe_file) .collect::>() .join(", "); if !described.is_empty() { sections.push(format!("Files changed by the pull request: {described}")); } sections.push(format!( "Lines to review, per file:\n{}", format_changed_lines(changed_lines) )); sections.join("\n\n") } /// Describes a changed file for the model, including how it changed. fn describe_file(file: &PullRequestFile) -> String { match &file.previous_filename { Some(previous) => format!("{} ({} from {})", file.filename, file.status, previous), None => format!("{} ({})", file.filename, file.status), } } /// Borrows the entry of `files` for a file, creating it on its first change. fn changed_file<'a>(files: &'a mut ChangedLines, filename: &str) -> &'a mut ChangedFile { let index = match files.iter().position(|file| file.filename == filename) { Some(index) => index, None => { files.push(ChangedFile::new(filename)); files.len() - 1 } }; &mut files[index] } /// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per /// line, keeping only the sides the file actually has. fn format_changed_lines(changed_lines: &ChangedLines) -> String { changed_lines .iter() .map(|file| { let mut sides = Vec::new(); for (label, lines) in [("added", &file.added), ("removed", &file.removed)] { if !lines.is_empty() { sides.push(format!("{label} {}", format_line_numbers(lines))); } } format!("{}: {}", file.filename, sides.join(" / ")) }) .collect::>() .join("\n") } fn format_line_numbers(lines: &[u64]) -> String { lines .iter() .map(u64::to_string) .collect::>() .join(", ") } /// Number of characters of a model answer kept in the logs when it cannot be /// parsed. const MAX_LOGGED_ANSWER: usize = 500; impl FromStr for ReviewResult { type Err = anyhow::Error; /// Parses the review the model answered with. /// /// This is the contract the agent loop enforces: a rejected answer is sent /// back to the model, with the reason, so that it can correct itself. /// /// The contract is a raw JSON object, but models sometimes wrap it in a /// markdown code fence or surround it with a sentence: the object is then /// extracted from the answer before failing, and the answer is logged so a /// breach of the contract can be diagnosed. fn from_str(message: &str) -> Result { let error = match serde_json::from_str::(message) { Ok(review_result) => return Ok(review_result), Err(error) => error, }; // A markdown code fence or a sentence around the object is tolerated, with // a warning: the contract asks for a raw JSON object. if let Some(json) = json_object(message) && let Ok(review_result) = serde_json::from_str::(json) { warn!( "Model answer is not a raw JSON object, it was extracted from the surrounding text" ); return Ok(review_result); } // The reason of the rejection is logged along with the answer: without it, // a broken answer is impossible to diagnose. warn!( answer = %excerpt(message, MAX_LOGGED_ANSWER), reason = %error, "Model answer is not the expected JSON" ); anyhow::bail!("the answer is not valid JSON: {error}") } } /// Returns the outermost `{...}` of an answer, which ignores a markdown code /// fence or any text around it. fn json_object(message: &str) -> Option<&str> { let start = message.find('{')?; let end = message.rfind('}')?; (start < end).then(|| &message[start..=end]) } /// Resolves the side each review is anchored on and drops the reviews that do /// not match a line the pull request changes. /// /// The model is asked to pick a line and a side from the provided lists, but /// nothing forces it to, and Gitea accepts any position: a wrong one places the /// comment on an unrelated line of the file instead of failing. A review that /// omits its side is resolved from the lists, and one that matches no changed /// line is dropped. fn resolve_review_sides(review_result: &mut ReviewResult, changed_lines: &ChangedLines) { let mut dropped = Vec::new(); review_result.reviews.retain_mut(|review| { let side = review.line.and_then(|line| { let file = changed_lines .iter() .find(|file| file.filename == review.filename)?; let side = review.side.or_else(|| file.side_of(line))?; file.lines(side).contains(&line).then_some(side) }); match side { Some(side) => { review.side = Some(side); true } None => { dropped.push(match review.line { Some(line) => format!("{}:{line}", review.filename), None => format!("{}:no line", review.filename), }); false } } }); if !dropped.is_empty() { warn!( dropped = dropped.len(), reviews = %dropped.join(", "), "Dropped reviews that are not anchored on a changed line" ); } } /// Extracts the old and new starting line numbers of a hunk header such as /// `@@ -12,3 +12,5 @@`. The counts are optional and git may append a section /// heading after the closing `@@`. fn parse_hunk_starts(hunk_header: &str) -> Option<(u64, u64)> { let body = hunk_header.strip_prefix("@@ ")?; let body = body.split(" @@").next()?; let (old, new) = body.split_once(" +")?; let old = old.strip_prefix('-')?.split(',').next()?; let new = new.split(',').next()?; Some((old.parse().ok()?, new.parse().ok()?)) } #[cfg(test)] mod tests { use super::*; use crate::bot::ReviewItem; /// Additions, a removal and a line changed on both sides. const DIFF: &str = concat!( "diff --git a/src/foo.rs b/src/foo.rs\n", "--- a/src/foo.rs\n", "+++ b/src/foo.rs\n", "@@ -1,4 +1,6 @@\n", " fn main() {\n", "+ let x = 1;\n", "- let removed = 0;\n", " println!(\"hello\");\n", "+ let y = 2;\n", "+ let z = 3;\n", " }\n", "diff --git a/src/bar.rs b/src/bar.rs\n", "--- a/src/bar.rs\n", "+++ b/src/bar.rs\n", "@@ -10,4 +10,6 @@\n", " old context\n", "+ let a = 10;\n", " more context\n", "+ let b = 20;\n", ); /// A pull request that only deletes a file. const DELETION_ONLY: &str = concat!( "diff --git a/src/old.rs b/src/old.rs\n", "deleted file mode 100644\n", "--- a/src/old.rs\n", "+++ /dev/null\n", "@@ -1,3 +0,0 @@\n", "-fn a() {}\n", "-fn b() {}\n", "-fn c() {}\n", ); /// A hunk whose content starts with `+++` / `---`, which must not be taken /// for the file headers. const TRICKY_CONTENT: &str = concat!( "diff --git a/src/tricky.js b/src/tricky.js\n", "--- a/src/tricky.js\n", "+++ b/src/tricky.js\n", "@@ -1,4 +1,4 @@\n", " let i = 0;\n", "+++i;\n", "---x;\n", " console.log(i);\n", ); /// A pull request that renames a file. const RENAMED: &str = concat!( "diff --git a/src/old.rs b/src/new.rs\n", "similarity index 50%\n", "rename from src/old.rs\n", "rename to src/new.rs\n", "--- a/src/old.rs\n", "+++ b/src/new.rs\n", "@@ -1,1 +1,1 @@\n", "-fn old() {}\n", "+fn new() {}\n", ); /// A file whose path contains a space, which the `diff --git` line cannot /// express without ambiguity. const PATH_WITH_SPACE: &str = concat!( "diff --git a/src/my file.rs b/src/my file.rs\n", "--- a/src/my file.rs\n", "+++ b/src/my file.rs\n", "@@ -1,1 +1,2 @@\n", " fn a() {}\n", "+fn b() {}\n", ); /// A file whose path git quotes and escapes (`caf\\303\\251.md` is /// `caf\u{e9}.md`). const QUOTED_PATH: &str = concat!( "diff --git \"a/docs/caf\\303\\251.md\" \"b/docs/caf\\303\\251.md\"\n", "--- \"a/docs/caf\\303\\251.md\"\n", "+++ \"b/docs/caf\\303\\251.md\"\n", "@@ -1,1 +1,2 @@\n", " intro\n", "+ajout\n", ); fn review(filename: &str, line: Option, side: Option) -> ReviewItem { ReviewItem { filename: String::from(filename), line, side, severity: None, message: String::from("message"), } } fn review_result(reviews: Vec) -> ReviewResult { ReviewResult { reviews, comment: String::new(), cost: None, } } fn pull_request_file( filename: &str, previous_filename: Option<&str>, status: &str, ) -> PullRequestFile { PullRequestFile { filename: String::from(filename), previous_filename: previous_filename.map(String::from), status: String::from(status), } } #[test] fn changed_lines_are_listed_per_file_and_side() { let expected = concat!( "src/foo.rs: added 2, 4, 5 / removed 2\n", "src/bar.rs: added 11, 13" ); assert_eq!(format_changed_lines(&parse_changed_lines(DIFF)), expected); } #[test] fn a_deletion_only_pull_request_lists_removed_lines() { let expected = "src/old.rs: removed 1, 2, 3"; assert_eq!( format_changed_lines(&parse_changed_lines(DELETION_ONLY)), expected ); } #[test] fn hunk_content_starting_with_plus_or_minus_is_counted() { let expected = "src/tricky.js: added 2 / removed 2"; assert_eq!( format_changed_lines(&parse_changed_lines(TRICKY_CONTENT)), expected ); } #[test] fn a_renamed_file_uses_its_new_path() { let expected = "src/new.rs: added 1 / removed 1"; assert_eq!( format_changed_lines(&parse_changed_lines(RENAMED)), expected ); } #[test] fn a_path_with_a_space_is_read_from_the_headers() { let expected = "src/my file.rs: added 2"; assert_eq!( format_changed_lines(&parse_changed_lines(PATH_WITH_SPACE)), expected ); } #[test] fn a_quoted_path_is_decoded() { let expected = "docs/café.md: added 2"; assert_eq!( format_changed_lines(&parse_changed_lines(QUOTED_PATH)), expected ); } #[test] fn filenames_are_resolved_against_the_api_list() { let mut changed_lines = parse_changed_lines(DIFF); let files = vec![ pull_request_file("src/bar.rs", None, "modified"), pull_request_file("src/foo.rs", Some("src/renamed.rs"), "renamed"), ]; resolve_filenames(&mut changed_lines, &files); assert_eq!(changed_lines[0].filename, "src/foo.rs"); assert_eq!(changed_lines[1].filename, "src/bar.rs"); } #[test] fn a_file_absent_from_the_api_list_keeps_the_diff_path() { let mut changed_lines = parse_changed_lines(DIFF); let files = vec![pull_request_file("src/bar.rs", None, "modified")]; resolve_filenames(&mut changed_lines, &files); assert_eq!(changed_lines[0].filename, "src/foo.rs"); assert_eq!(changed_lines[1].filename, "src/bar.rs"); } #[test] fn changes_describe_the_files_then_the_lines() { let changed_lines = parse_changed_lines(DELETION_ONLY); let files = vec![pull_request_file("src/old.rs", None, "deleted")]; let expected = concat!( "Files changed by the pull request: src/old.rs (deleted)\n", "\n", "Lines to review, per file:\n", "src/old.rs: removed 1, 2, 3" ); assert_eq!(format_changes(&files, &changed_lines), expected); } #[test] fn changes_without_the_api_list_only_hold_the_lines() { let changed_lines = parse_changed_lines(DELETION_ONLY); let expected = "Lines to review, per file:\nsrc/old.rs: removed 1, 2, 3"; assert_eq!(format_changes(&[], &changed_lines), expected); } #[test] fn generated_lockfiles_are_ignored() { assert!(is_ignored("Cargo.lock")); assert!(is_ignored("crates/herald-server/Cargo.lock")); assert!(is_ignored("package-lock.json")); assert!(is_ignored("web/yarn.lock")); assert!(is_ignored("go.sum")); assert!(!is_ignored("src/main.rs")); assert!(!is_ignored("Cargo.toml")); assert!(!is_ignored("docs/lockfiles.md")); } #[test] fn generated_files_are_dropped_from_the_changed_lines() { const DIFF_WITH_LOCKFILE: &str = concat!( "diff --git a/Cargo.lock b/Cargo.lock\n", "--- a/Cargo.lock\n", "+++ b/Cargo.lock\n", "@@ -1,1 +1,2 @@\n", " [[package]]\n", "+name = \"x\"\n", "diff --git a/src/foo.rs b/src/foo.rs\n", "--- a/src/foo.rs\n", "+++ b/src/foo.rs\n", "@@ -1,1 +1,2 @@\n", " fn a() {}\n", "+fn b() {}\n", ); let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE); drop_generated_files(&mut changed_lines); assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2"); } #[test] fn a_generated_file_is_not_described_to_the_model() { let files = vec![ pull_request_file("Cargo.lock", None, "modified"), pull_request_file("src/foo.rs", None, "modified"), ]; let changed_lines = parse_changed_lines(DIFF); let changes = format_changes(&files, &changed_lines); assert!(!changes.contains("Cargo.lock")); assert!(changes.contains("src/foo.rs (modified)")); } #[test] fn a_renamed_file_is_described_with_its_previous_path() { let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); assert_eq!(describe_file(&file), "src/new.rs (renamed from src/old.rs)"); } #[test] fn reviews_keep_their_changed_line_and_resolve_their_side() { let changed_lines = parse_changed_lines(DIFF); let mut review_result = review_result(vec![ review("src/foo.rs", Some(4), Some(ReviewSide::Added)), review("src/foo.rs", Some(2), Some(ReviewSide::Removed)), review("src/bar.rs", Some(13), None), ]); resolve_review_sides(&mut review_result, &changed_lines); let sides = review_result .reviews .iter() .map(|review| review.side) .collect::>(); assert_eq!( sides, vec![ Some(ReviewSide::Added), Some(ReviewSide::Removed), Some(ReviewSide::Added) ] ); } #[test] fn a_line_changed_on_both_sides_defaults_to_added() { let changed_lines = parse_changed_lines(DIFF); let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]); resolve_review_sides(&mut review_result, &changed_lines); assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); } #[test] fn reviews_outside_changed_lines_are_dropped() { let changed_lines = parse_changed_lines(DIFF); let mut review_result = review_result(vec![ // valid review("src/foo.rs", Some(2), Some(ReviewSide::Added)), // a line that exists but is not part of the change review("src/foo.rs", Some(3), None), // a line beyond the change review("src/foo.rs", Some(999), None), // a changed line, but on the wrong side review("src/foo.rs", Some(4), Some(ReviewSide::Removed)), // a line changed in another file review("src/foo.rs", Some(11), None), // an unknown file review("src/baz.rs", Some(1), None), // no line at all review("src/bar.rs", None, None), ]); resolve_review_sides(&mut review_result, &changed_lines); assert_eq!(review_result.reviews.len(), 1); assert_eq!(review_result.reviews[0].filename, "src/foo.rs"); assert_eq!(review_result.reviews[0].line, Some(2)); assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); } #[test] fn a_deleted_line_is_kept_as_a_removed_anchor() { let changed_lines = parse_changed_lines(DELETION_ONLY); let mut review_result = review_result(vec![ review("src/old.rs", Some(2), Some(ReviewSide::Removed)), review("src/old.rs", Some(2), Some(ReviewSide::Added)), ]); resolve_review_sides(&mut review_result, &changed_lines); assert_eq!(review_result.reviews.len(), 1); assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed)); } #[test] fn a_raw_answer_is_parsed() { let answer = r#"{"reviews": [], "comment": "ok"}"#; assert_eq!(answer.parse::().unwrap().comment, "ok"); } #[test] fn a_fenced_answer_is_extracted() { let answer = concat!( "Voici ma review :\n", "```json\n", "{\"reviews\": [], \"comment\": \"rien à signaler\"}\n", "```\n" ); assert_eq!( answer.parse::().unwrap().comment, "rien à signaler" ); } #[test] fn a_sentence_around_the_object_is_ignored() { let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#; assert_eq!(answer.parse::().unwrap().comment, "ok"); } #[test] fn an_answer_without_json_is_rejected() { assert!("Je n'ai rien relevé.".parse::().is_err()); } #[test] fn an_answer_that_is_an_object_but_not_a_review_is_rejected() { assert!(r#"{"message": "LGTM"}"#.parse::().is_err()); } #[test] fn odd_sides_from_the_model_are_tolerated() { let changed_lines = parse_changed_lines(DIFF); let mut review_result: ReviewResult = serde_json::from_str( r#"{ "reviews": [ { "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" }, { "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" }, { "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" }, { "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" }, { "filename": "src/foo.rs", "line": 5, "message": "e" } ], "comment": "" }"#, ) .unwrap(); resolve_review_sides(&mut review_result, &changed_lines); let sides = review_result .reviews .iter() .map(|review| review.side) .collect::>(); assert_eq!( sides, vec![ Some(ReviewSide::Added), Some(ReviewSide::Added), Some(ReviewSide::Removed), Some(ReviewSide::Added), Some(ReviewSide::Added) ] ); } #[test] fn severities_are_read_from_the_model() { let result: ReviewResult = serde_json::from_str( r#"{ "reviews": [ { "filename": "a", "line": 1, "severity": "Bug", "message": "x" }, { "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" }, { "filename": "c", "line": 3, "severity": "perf", "message": "x" }, { "filename": "d", "line": 4, "severity": "banana", "message": "x" } ], "comment": "" }"#, ) .unwrap(); let severities = result .reviews .iter() .map(|review| review.severity) .collect::>(); assert_eq!( severities, vec![ Some(ReviewSeverity::Bug), Some(ReviewSeverity::Security), Some(ReviewSeverity::Performance), None, ] ); } #[test] fn the_breakdown_counts_severities_most_severe_first() { let reviews = vec![ ReviewItem { severity: Some(ReviewSeverity::Maintainability), ..review("a", Some(1), None) }, ReviewItem { severity: Some(ReviewSeverity::Bug), ..review("b", Some(2), None) }, ReviewItem { severity: Some(ReviewSeverity::Bug), ..review("c", Some(3), None) }, // A severity the model left out counts as maintainability. review("d", Some(4), None), ]; assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability"); } #[test] fn the_markdown_breaks_the_issues_down_by_severity() { let mut result = review_result(vec![ ReviewItem { severity: Some(ReviewSeverity::Bug), ..review("a", Some(1), None) }, ReviewItem { severity: Some(ReviewSeverity::Performance), ..review("b", Some(2), None) }, ]); result.comment = String::from("Le découpage en crates est propre."); let markdown = review_result_to_markdown(&result); assert!(markdown.contains("- 1 bug")); assert!(markdown.contains("- 1 performance")); assert!(markdown.contains("Le découpage en crates est propre.")); } }