1162 lines
36 KiB
Rust
1162 lines
36 KiB
Rust
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<Tool>,
|
|
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<ReviewResult, anyhow::Error> = 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<Tool>,
|
|
review_payload: &ReviewPayload,
|
|
bot_request: &str,
|
|
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
|
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::<Vec<_>>()
|
|
.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<ChangedFile>;
|
|
|
|
struct ChangedFile {
|
|
filename: String,
|
|
added: Vec<u64>,
|
|
removed: Vec<u64>,
|
|
}
|
|
|
|
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<ReviewSide> {
|
|
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<String> = 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/<path>` or
|
|
/// `+++ b/<path>` 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<String> {
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn format_line_numbers(lines: &[u64]) -> String {
|
|
lines
|
|
.iter()
|
|
.map(u64::to_string)
|
|
.collect::<Vec<_>>()
|
|
.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<Self, Self::Err> {
|
|
let error = match serde_json::from_str::<Self>(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::<Self>(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<u64>, side: Option<ReviewSide>) -> ReviewItem {
|
|
ReviewItem {
|
|
filename: String::from(filename),
|
|
line,
|
|
side,
|
|
severity: None,
|
|
message: String::from("message"),
|
|
}
|
|
}
|
|
|
|
fn review_result(reviews: Vec<ReviewItem>) -> 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::<Vec<_>>();
|
|
|
|
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::<ReviewResult>().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::<ReviewResult>().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::<ReviewResult>().unwrap().comment, "ok");
|
|
}
|
|
|
|
#[test]
|
|
fn an_answer_without_json_is_rejected() {
|
|
assert!("Je n'ai rien relevé.".parse::<ReviewResult>().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn an_answer_that_is_an_object_but_not_a_review_is_rejected() {
|
|
assert!(r#"{"message": "LGTM"}"#.parse::<ReviewResult>().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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>();
|
|
|
|
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."));
|
|
}
|
|
}
|