1.2: Sandboxing #7
@@ -51,11 +51,20 @@ Herald reviews pull requests inside an ephemeral
|
|||||||
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
||||||
lines changed — additions and deletions, with the line numbers of the new and
|
lines changed — additions and deletions, with the line numbers of the new and
|
||||||
old versions of the file respectively — then lets it explore the repository
|
old versions of the file respectively — then lets it explore the repository
|
||||||
with read-only tools (`ls`, `read_file`, `grep`, `find`) run inside the
|
with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside
|
||||||
container: the code itself is not sent, so the model reads it at those lines,
|
the container: the code itself is not sent, so the model reads it at those lines,
|
||||||
4. posts the review, anchoring each comment on the added or removed line it
|
4. posts the review, anchoring each comment on the added or removed line it
|
||||||
refers to, and removes the container and the temporary clone.
|
refers to, and removes the container and the temporary clone.
|
||||||
|
|
||||||
|
Generated files are left out of the changes handed to the model: lockfiles
|
||||||
|
(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written
|
||||||
|
dependency churn whose thousands of lines would drown the code under review, and
|
||||||
|
they are never a place where a comment belongs.
|
||||||
|
qpismont marked this conversation as resolved
Outdated
|
|||||||
|
|
||||||
|
Each comment is tagged with a severity — `bug`, `security`, `performance` or
|
||||||
|
`maintainability` — shown at the start of the comment, and the summary also lists
|
||||||
|
what the pull request does well.
|
||||||
|
|
||||||
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
||||||
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
||||||
Docker-compatible socket. The repository must contain a
|
Docker-compatible socket. The repository must contain a
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub struct ReviewItem {
|
|||||||
pub line: Option<u64>,
|
pub line: Option<u64>,
|
||||||
#[serde(default, deserialize_with = "deserialize_side")]
|
#[serde(default, deserialize_with = "deserialize_side")]
|
||||||
pub side: Option<ReviewSide>,
|
pub side: Option<ReviewSide>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_severity")]
|
||||||
|
pub severity: Option<ReviewSeverity>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,46 @@ impl ReviewSide {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What kind of problem a review reports.
|
||||||
|
///
|
||||||
|
/// The categories are the ones the prompt asks for; a review whose severity is
|
||||||
|
/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the
|
||||||
|
/// least alarming bucket, instead of failing the whole review.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReviewSeverity {
|
||||||
|
/// Wrong behaviour.
|
||||||
|
Bug,
|
||||||
|
/// A vulnerability.
|
||||||
|
Security,
|
||||||
|
/// A resource problem.
|
||||||
|
Performance,
|
||||||
|
/// Everything else: readability, structure, naming, tests.
|
||||||
|
Maintainability,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewSeverity {
|
||||||
|
/// Reads the severity the model asked for, tolerating casing and synonyms.
|
||||||
|
fn parse(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"bug" | "bugs" | "correctness" | "error" => Some(Self::Bug),
|
||||||
|
"security" | "vulnerability" => Some(Self::Security),
|
||||||
|
"performance" | "perf" => Some(Self::Performance),
|
||||||
|
"maintainability" | "quality" | "style" => Some(Self::Maintainability),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-case label used in the review markdown.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Bug => "bug",
|
||||||
|
Self::Security => "security",
|
||||||
|
Self::Performance => "performance",
|
||||||
|
Self::Maintainability => "maintainability",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads the side the model asked for. An unreadable value is ignored instead of
|
/// Reads the side the model asked for. An unreadable value is ignored instead of
|
||||||
/// failing the whole review: the side is then resolved from the changed lines.
|
/// failing the whole review: the side is then resolved from the changed lines.
|
||||||
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
|
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
|
||||||
@@ -62,6 +104,20 @@ where
|
|||||||
.and_then(ReviewSide::parse))
|
.and_then(ReviewSide::parse))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the severity the model asked for, ignoring an unreadable value: the
|
||||||
|
/// review is then reported as [`ReviewSeverity::Maintainability`].
|
||||||
|
fn deserialize_severity<'de, D>(deserializer: D) -> Result<Option<ReviewSeverity>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
Ok(raw
|
||||||
|
.as_ref()
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.and_then(ReviewSeverity::parse))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Bot {
|
pub struct Bot {
|
||||||
bot_name: String,
|
bot_name: String,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::str::FromStr;
|
|||||||
use tracing::{info, instrument, warn};
|
use tracing::{info, instrument, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::{ReviewResult, ReviewSide},
|
bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide},
|
||||||
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
||||||
gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
|
gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
|
||||||
metrics,
|
metrics,
|
||||||
@@ -53,6 +53,7 @@ pub async fn exec_review(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut changed_lines = parse_changed_lines(&git_diff);
|
let mut changed_lines = parse_changed_lines(&git_diff);
|
||||||
|
drop_generated_files(&mut changed_lines);
|
||||||
resolve_filenames(&mut changed_lines, &files);
|
resolve_filenames(&mut changed_lines, &files);
|
||||||
|
|
||||||
let changes = format_changes(&files, &changed_lines);
|
let changes = format_changes(&files, &changed_lines);
|
||||||
@@ -126,11 +127,9 @@ async fn run_sandboxed_review(
|
|||||||
review_payload: &ReviewPayload,
|
review_payload: &ReviewPayload,
|
||||||
bot_request: &str,
|
bot_request: &str,
|
||||||
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
||||||
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
|
||||||
|
|
||||||
let sandbox = Sandbox::create(
|
let sandbox = Sandbox::create(
|
||||||
&sandbox_config.runtime,
|
&sandbox_config.runtime,
|
||||||
&repo_url,
|
&review_payload.repository.clone_url,
|
||||||
gitea_api.token(),
|
gitea_api.token(),
|
||||||
review_payload.pull_request.number,
|
review_payload.pull_request.number,
|
||||||
)
|
)
|
||||||
@@ -172,6 +171,12 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
|||||||
review_result.reviews.len()
|
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() {
|
if !review_result.comment.is_empty() {
|
||||||
md.push_str("\n---\n\n");
|
md.push_str("\n---\n\n");
|
||||||
md.push_str("### Summary\n\n");
|
md.push_str("### Summary\n\n");
|
||||||
@@ -188,6 +193,30 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
|||||||
md
|
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.
|
/// The lines a pull request changed, per file.
|
||||||
///
|
///
|
||||||
/// Line numbers are the ones Gitea expects to place a review comment:
|
/// Line numbers are the ones Gitea expects to place a review comment:
|
||||||
@@ -386,18 +415,61 @@ fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Renders the changes for the model: the files the pull request touches, then
|
||||||
/// the lines to review per file.
|
/// the lines to review per file.
|
||||||
fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String {
|
fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String {
|
||||||
let mut sections = Vec::new();
|
let mut sections = Vec::new();
|
||||||
|
|
||||||
if !files.is_empty() {
|
let described = files
|
||||||
let described = files
|
.iter()
|
||||||
.iter()
|
.filter(|file| !is_ignored(&file.filename))
|
||||||
.map(describe_file)
|
.map(describe_file)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
if !described.is_empty() {
|
||||||
sections.push(format!("Files changed by the pull request: {described}"));
|
sections.push(format!("Files changed by the pull request: {described}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +738,7 @@ mod tests {
|
|||||||
filename: String::from(filename),
|
filename: String::from(filename),
|
||||||
line,
|
line,
|
||||||
side,
|
side,
|
||||||
|
severity: None,
|
||||||
message: String::from("message"),
|
message: String::from("message"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,6 +872,56 @@ mod tests {
|
|||||||
assert_eq!(format_changes(&[], &changed_lines), expected);
|
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]
|
#[test]
|
||||||
fn a_renamed_file_is_described_with_its_previous_path() {
|
fn a_renamed_file_is_described_with_its_previous_path() {
|
||||||
let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed");
|
let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed");
|
||||||
@@ -960,4 +1083,79 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,50 +9,71 @@ pub const BOT_PROCESS_MSG: &str = "
|
|||||||
";
|
";
|
||||||
|
|
||||||
pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
||||||
You are a senior software engineer reviewing a pull request.
|
You are a senior software engineer reviewing a pull request inside an
|
||||||
|
isolated sandbox.
|
||||||
|
|
||||||
The repository is checked out in your working directory. Use the provided
|
The repository is checked out in your working directory. Explore it with the
|
||||||
tools (ls, read_file, grep, find, file_size) to explore the code and gather the context
|
provided read-only tools (ls, file_size, read_file, grep, find); paths are
|
||||||
you need before answering. Paths are relative to the repository root.
|
relative to the repository root. Every line you read is prefixed with its
|
||||||
|
absolute line number: that is the number you must cite to anchor a comment.
|
||||||
|
|
||||||
When you have enough information, answer with the requested JSON only, as a raw
|
Read before you assert: a review anchored on a line you did not read is
|
||||||
JSON object: no markdown code fence, nothing before or after it.
|
worthless, so never comment on code you have not seen. Gather enough context
|
||||||
|
to be confident, but do not re-read what you already have.
|
||||||
|
|
||||||
|
When you answer, send the requested JSON only: a raw JSON object, no markdown
|
||||||
|
code fence, nothing before or after it.
|
||||||
";
|
";
|
||||||
|
|
||||||
pub const REVIEW_PROMPT: &str = "
|
pub const REVIEW_PROMPT: &str = "
|
||||||
You are a senior software engineer reviewing code changes.
|
You are a senior software engineer reviewing a pull request.
|
||||||
|
|
||||||
Check good practices and code quality.
|
Judge the changes for correctness, security, resource use and
|
||||||
|
maintainability. Report real problems; do not invent issues, and do not
|
||||||
|
report pure formatting a formatter would fix.
|
||||||
|
|
||||||
|
Be exhaustive: one review per distinct issue, and cover every changed file
|
||||||
|
that has something to report. Do not stop at the first few findings, and do
|
||||||
|
not merge several issues into a single review.
|
||||||
|
|
||||||
This is the pull request subject: \"{subject}\"
|
This is the pull request subject: \"{subject}\"
|
||||||
|
|
||||||
This is the user comment: \"{comment}\"
|
This is the user comment: \"{comment}\"
|
||||||
|
If the user asks about something precise, address that first.
|
||||||
|
|
||||||
The pull request changes these files and lines:
|
The pull request changes these files and lines:
|
||||||
|
|
||||||
{changes}
|
{changes}
|
||||||
|
|
||||||
`added` line numbers refer to the new version of the file, `removed` line
|
The code is not provided. Read the files you need with the available tools
|
||||||
numbers to the old version, as they appear in the diff.
|
before answering.
|
||||||
|
|
||||||
The code is not provided: read the files you need with the available tools
|
`added` line numbers refer to the new version of the file, which is what your
|
||||||
before answering. Review only the listed lines.
|
working directory contains now. `removed` line numbers refer to the old
|
||||||
|
version, which is not in your working directory: you cannot read a removed
|
||||||
|
line, only the code around where it used to be.
|
||||||
|
|
||||||
Return your feedback, in french, with only this json format, reviews must contain each review
|
Every review must anchor on one of the listed line numbers:
|
||||||
All fields are mandatory.
|
- filename: the full path exactly as listed above,
|
||||||
Answer with the raw json object only: no markdown code fence, no text before or
|
- line: one of the numbers listed for that file,
|
||||||
after it.
|
- side: \"added\" for a number from the `added` list, \"removed\" for one from
|
||||||
(filename field must contain the full path with extension; line must be one of the
|
the `removed` list,
|
||||||
listed line numbers for that file, and side must be \"added\" when the line comes
|
- severity: exactly one of \"bug\", \"security\", \"performance\" or
|
||||||
from the `added` list or \"removed\" when it comes from the `removed` list)
|
\"maintainability\" — a bug is wrong behaviour, security a vulnerability,
|
||||||
and comment must contain a final summary:
|
performance a resource problem, maintainability everything else.
|
||||||
|
|
||||||
|
Answer in french, with the raw json object only: no markdown code fence, no
|
||||||
|
text before or after it. All fields are mandatory. The `comment` field must
|
||||||
|
hold a short summary that lists the issues, and also what the pull request
|
||||||
|
does well: the author should get the compliments too.
|
||||||
|
|
||||||
{
|
{
|
||||||
\"reviews\": [
|
\"reviews\": [
|
||||||
{
|
{
|
||||||
\"filename\": \"\",
|
\"filename\": \"\",
|
||||||
\"line\": ,
|
\"line\": 0,
|
||||||
\"side\": \"\",
|
\"side\": \"\",
|
||||||
|
\"severity\": \"\",
|
||||||
\"message\": \"\"
|
\"message\": \"\"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tokio_util::io::StreamReader;
|
|||||||
use tracing::{instrument, warn};
|
use tracing::{instrument, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::{ReviewResult, ReviewSide},
|
bot::{ReviewResult, ReviewSeverity, ReviewSide},
|
||||||
consts::MAX_DIFF_SIZE,
|
consts::MAX_DIFF_SIZE,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
};
|
};
|
||||||
@@ -49,15 +49,6 @@ impl GiteaAPI {
|
|||||||
&self.token
|
&self.token
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTPS clone URL for a repository, suitable for `git clone`.
|
|
||||||
pub fn repo_clone_url(&self, full_name: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/{}.git",
|
|
||||||
self.base_url.trim_end_matches('/'),
|
|
||||||
full_name.trim_start_matches('/')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
|
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
|
||||||
let url = format!("{}/api/v1/user", self.base_url);
|
let url = format!("{}/api/v1/user", self.base_url);
|
||||||
@@ -231,7 +222,11 @@ impl GiteaAPI {
|
|||||||
.filter_map(|review| {
|
.filter_map(|review| {
|
||||||
let line = review.line?;
|
let line = review.line?;
|
||||||
let path = review.filename.clone();
|
let path = review.filename.clone();
|
||||||
let body = review.message.clone();
|
let severity = review
|
||||||
|
.severity
|
||||||
|
.unwrap_or(ReviewSeverity::Maintainability)
|
||||||
|
.label();
|
||||||
|
let body = format!("**[{severity}]** {}", review.message);
|
||||||
|
|
||||||
// A line removed by the pull request only exists in the old
|
// A line removed by the pull request only exists in the old
|
||||||
// version of the file, so it is anchored with `old_position`.
|
// version of the file, so it is anchored with `old_position`.
|
||||||
@@ -333,6 +328,7 @@ pub struct User {
|
|||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
|
pub clone_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file changed by a pull request, as reported by the API.
|
/// A file changed by a pull request, as reported by the API.
|
||||||
@@ -390,7 +386,8 @@ mod tests {
|
|||||||
"title": "My PR"
|
"title": "My PR"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"full_name": "owner/repo"
|
"full_name": "owner/repo",
|
||||||
|
"clone_url": "https://github.com/owner/repo.git"
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
@@ -453,7 +450,8 @@ mod tests {
|
|||||||
"title": "My PR"
|
"title": "My PR"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"full_name": "owner/repo"
|
"full_name": "owner/repo",
|
||||||
|
"clone_url": "https://github.com/owner/repo.git"
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -485,7 +483,8 @@ mod tests {
|
|||||||
"title": "My PR"
|
"title": "My PR"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"full_name": "owner/repo"
|
"full_name": "owner/repo",
|
||||||
|
"clone_url": "https://github.com/owner/repo.git"
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 12,
|
"id": 12,
|
||||||
@@ -501,6 +500,10 @@ mod tests {
|
|||||||
assert_eq!(payload.action, "created");
|
assert_eq!(payload.action, "created");
|
||||||
assert_eq!(payload.comment.id, 12);
|
assert_eq!(payload.comment.id, 12);
|
||||||
assert_eq!(payload.comment.body, "Needs work");
|
assert_eq!(payload.comment.body, "Needs work");
|
||||||
|
assert_eq!(
|
||||||
|
payload.repository.clone_url,
|
||||||
|
"https://github.com/owner/repo.git"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -521,7 +524,8 @@ mod tests {
|
|||||||
"title": "My PR"
|
"title": "My PR"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"full_name": "owner/repo"
|
"full_name": "owner/repo",
|
||||||
|
"clone_url": "https://github.com/owner/repo.git"
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -548,7 +552,8 @@ mod tests {
|
|||||||
"title": "My PR"
|
"title": "My PR"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"full_name": "owner/repo"
|
"full_name": "owner/repo",
|
||||||
|
"clone_url": "https://github.com/owner/repo.git"
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer
|
||||||
|
//! éphémère, et sa configuration.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
|
/// Devcontainer locations recognized within a repository, in priority order.
|
||||||
|
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
||||||
|
|
||||||
|
/// Sandbox-related runtime configuration.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SandboxConfig {
|
||||||
|
/// Client du daemon de containers qui exécute la sandbox.
|
||||||
|
pub runtime: ContainerRuntime,
|
||||||
|
/// Maximum number of tool-calling iterations per agent run.
|
||||||
|
pub max_iterations: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cloned repository running inside an ephemeral devcontainer.
|
||||||
|
pub struct Sandbox {
|
||||||
|
// Owns the temporary directory; dropping it cleans up the clone.
|
||||||
|
_workspace: TempDir,
|
||||||
|
/// Path of the clone, as copied into the container.
|
||||||
|
repo_dir: PathBuf,
|
||||||
|
container: Container,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sandbox {
|
||||||
|
/// Clones the pull request head, builds the devcontainer and starts it.
|
||||||
|
///
|
||||||
|
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
|
||||||
|
/// for both same-repository and forked pull requests.
|
||||||
|
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
|
||||||
|
pub async fn create(
|
||||||
|
runtime: &ContainerRuntime,
|
||||||
|
clone_url: &str,
|
||||||
|
token: &str,
|
||||||
|
pull_request_number: u64,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
||||||
|
let repo_dir = workspace.path().join("repo");
|
||||||
|
|
||||||
|
clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?;
|
||||||
|
make_readable(&repo_dir).await?;
|
||||||
|
|
||||||
|
let devcontainer_path = find_devcontainer(&repo_dir)
|
||||||
|
.with_context(|| format!("no devcontainer found in `{clone_url}`"))?;
|
||||||
|
|
||||||
|
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
||||||
|
|
||||||
|
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||||
|
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||||
|
|
||||||
|
let sandbox = Self {
|
||||||
|
_workspace: workspace,
|
||||||
|
repo_dir,
|
||||||
|
container,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = sandbox.check_workspace().await {
|
||||||
|
let _ = sandbox.container.remove().await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sandbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie que le clone est bien visible dans le container.
|
||||||
|
///
|
||||||
|
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
|
||||||
|
/// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les
|
||||||
|
/// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi
|
||||||
|
/// que ce soit.
|
||||||
|
async fn check_workspace(&self) -> anyhow::Result<()> {
|
||||||
|
let workspace_folder = self.workspace_folder();
|
||||||
|
let probe = self
|
||||||
|
.container
|
||||||
|
.exec(&["ls", "-A", "--", workspace_folder])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?;
|
||||||
|
|
||||||
|
if !probe.success() {
|
||||||
|
let details = self.diagnose_workspace().await;
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"the sandbox cannot list `{workspace_folder}`: {} ({details})",
|
||||||
|
probe.stderr.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if probe.stdout.trim().is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
clone = %self.repo_dir.display(),
|
||||||
|
workspace = %workspace_folder,
|
||||||
|
entries = probe.stdout.lines().count(),
|
||||||
|
"Sandbox workspace is readable"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rassemble de quoi expliquer un refus d'accès au workspace.
|
||||||
|
///
|
||||||
|
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de
|
||||||
|
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque
|
||||||
|
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de
|
||||||
|
/// montage permettent de trancher.
|
||||||
|
async fn diagnose_workspace(&self) -> String {
|
||||||
|
let mut details = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(output) = self.container.exec(&["id"]).await {
|
||||||
|
details.push(output.stdout.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(output) = self
|
||||||
|
.container
|
||||||
|
.exec(&["ls", "-ld", "--", self.workspace_folder()])
|
||||||
|
.await
|
||||||
|
&& output.success()
|
||||||
|
{
|
||||||
|
details.push(output.stdout.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
details.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a command in the container as an argv vector (no shell).
|
||||||
|
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
|
||||||
|
Ok(self.container.exec(cmd).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the repository inside the container.
|
||||||
|
pub fn workspace_folder(&self) -> &str {
|
||||||
|
self.container.workspace_folder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops and removes the container. The temporary clone is removed on drop.
|
||||||
|
pub async fn cleanup(self) -> anyhow::Result<()> {
|
||||||
|
self.container.remove().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
|
||||||
|
DEVCONTAINER_PATHS
|
||||||
|
.iter()
|
||||||
|
.map(|relative| repo_dir.join(relative))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clone_pull_request(
|
||||||
|
repo_url: &str,
|
||||||
|
token: &str,
|
||||||
|
pull_request_number: u64,
|
||||||
|
dest: &Path,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let dest = dest.display().to_string();
|
||||||
|
|
||||||
|
run_git(
|
||||||
|
token,
|
||||||
|
&[
|
||||||
|
"clone".to_string(),
|
||||||
|
"--depth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
repo_url.to_string(),
|
||||||
|
dest.clone(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
run_git(
|
||||||
|
token,
|
||||||
|
&[
|
||||||
|
"-C".to_string(),
|
||||||
|
dest.clone(),
|
||||||
|
"fetch".to_string(),
|
||||||
|
"--depth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
"origin".to_string(),
|
||||||
|
format!("refs/pull/{pull_request_number}/head"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
run_git(
|
||||||
|
token,
|
||||||
|
&[
|
||||||
|
"-C".to_string(),
|
||||||
|
dest,
|
||||||
|
"checkout".to_string(),
|
||||||
|
"FETCH_HEAD".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rend le clone lisible par les utilisateurs du container sandbox.
|
||||||
|
///
|
||||||
|
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless
|
||||||
|
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
|
||||||
|
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission
|
||||||
|
/// denied` sur des fichiers que Herald vient de cloner lui-même.
|
||||||
|
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
|
||||||
|
let output = tokio::process::Command::new("chmod")
|
||||||
|
.args(["-R", "a+rX"])
|
||||||
|
.arg(repo_dir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("failed to spawn chmod")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"chmod failed on `{}`: {}",
|
||||||
|
repo_dir.display(),
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs git with the token injected through `http.extraHeader`, keeping the
|
||||||
|
/// secret out of the process arguments.
|
||||||
|
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
|
||||||
|
let output = tokio::process::Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
||||||
|
.env(
|
||||||
|
"GIT_CONFIG_VALUE_0",
|
||||||
|
format!("Authorization: token {token}"),
|
||||||
|
)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("failed to spawn git")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"git {} failed: {}",
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_devcontainer_prefers_dot_devcontainer_dir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let nested = dir.path().join(".devcontainer");
|
||||||
|
std::fs::create_dir(&nested).unwrap();
|
||||||
|
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
|
||||||
|
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
find_devcontainer(dir.path()),
|
||||||
|
Some(nested.join("devcontainer.json"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_devcontainer_returns_none_when_absent() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(find_devcontainer(dir.path()), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,290 +7,7 @@
|
|||||||
//! tool-calling loop against OpenRouter.
|
//! tool-calling loop against OpenRouter.
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
mod instance;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
use std::{
|
pub use instance::{Sandbox, SandboxConfig};
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::Stdio,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
use tracing::{info, instrument};
|
|
||||||
|
|
||||||
/// Devcontainer locations recognized within a repository, in priority order.
|
|
||||||
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
|
||||||
|
|
||||||
/// Sandbox-related runtime configuration.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SandboxConfig {
|
|
||||||
/// Client du daemon de containers qui exécute la sandbox.
|
|
||||||
pub runtime: ContainerRuntime,
|
|
||||||
/// Maximum number of tool-calling iterations per agent run.
|
|
||||||
pub max_iterations: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A cloned repository running inside an ephemeral devcontainer.
|
|
||||||
pub struct Sandbox {
|
|
||||||
// Owns the temporary directory; dropping it cleans up the clone.
|
|
||||||
_workspace: TempDir,
|
|
||||||
/// Path of the clone, as copied into the container.
|
|
||||||
repo_dir: PathBuf,
|
|
||||||
container: Container,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sandbox {
|
|
||||||
/// Clones the pull request head, builds the devcontainer and starts it.
|
|
||||||
///
|
|
||||||
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
|
|
||||||
/// for both same-repository and forked pull requests.
|
|
||||||
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
|
|
||||||
pub async fn create(
|
|
||||||
runtime: &ContainerRuntime,
|
|
||||||
repo_url: &str,
|
|
||||||
token: &str,
|
|
||||||
pull_request_number: u64,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
|
||||||
let repo_dir = workspace.path().join("repo");
|
|
||||||
|
|
||||||
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
|
|
||||||
make_readable(&repo_dir).await?;
|
|
||||||
|
|
||||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
|
||||||
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
|
|
||||||
|
|
||||||
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
|
||||||
|
|
||||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
|
||||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
|
||||||
|
|
||||||
let sandbox = Self {
|
|
||||||
_workspace: workspace,
|
|
||||||
repo_dir,
|
|
||||||
container,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = sandbox.check_workspace().await {
|
|
||||||
let _ = sandbox.container.remove().await;
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(sandbox)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie que le clone est bien visible dans le container.
|
|
||||||
///
|
|
||||||
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
|
|
||||||
/// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les
|
|
||||||
/// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi
|
|
||||||
/// que ce soit.
|
|
||||||
async fn check_workspace(&self) -> anyhow::Result<()> {
|
|
||||||
let workspace_folder = self.workspace_folder();
|
|
||||||
let probe = self
|
|
||||||
.container
|
|
||||||
.exec(&["ls", "-A", "--", workspace_folder])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?;
|
|
||||||
|
|
||||||
if !probe.success() {
|
|
||||||
let details = self.diagnose_workspace().await;
|
|
||||||
|
|
||||||
anyhow::bail!(
|
|
||||||
"the sandbox cannot list `{workspace_folder}`: {} ({details})",
|
|
||||||
probe.stderr.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if probe.stdout.trim().is_empty() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
clone = %self.repo_dir.display(),
|
|
||||||
workspace = %workspace_folder,
|
|
||||||
entries = probe.stdout.lines().count(),
|
|
||||||
"Sandbox workspace is readable"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rassemble de quoi expliquer un refus d'accès au workspace.
|
|
||||||
///
|
|
||||||
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de
|
|
||||||
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque
|
|
||||||
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de
|
|
||||||
/// montage permettent de trancher.
|
|
||||||
async fn diagnose_workspace(&self) -> String {
|
|
||||||
let mut details = Vec::new();
|
|
||||||
|
|
||||||
if let Ok(output) = self.container.exec(&["id"]).await {
|
|
||||||
details.push(output.stdout.trim().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(output) = self
|
|
||||||
.container
|
|
||||||
.exec(&["ls", "-ld", "--", self.workspace_folder()])
|
|
||||||
.await
|
|
||||||
&& output.success()
|
|
||||||
{
|
|
||||||
details.push(output.stdout.trim().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
details.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Executes a command in the container as an argv vector (no shell).
|
|
||||||
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
|
|
||||||
Ok(self.container.exec(cmd).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Path of the repository inside the container.
|
|
||||||
pub fn workspace_folder(&self) -> &str {
|
|
||||||
self.container.workspace_folder()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops and removes the container. The temporary clone is removed on drop.
|
|
||||||
pub async fn cleanup(self) -> anyhow::Result<()> {
|
|
||||||
self.container.remove().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
|
|
||||||
DEVCONTAINER_PATHS
|
|
||||||
.iter()
|
|
||||||
.map(|relative| repo_dir.join(relative))
|
|
||||||
.find(|candidate| candidate.is_file())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clone_pull_request(
|
|
||||||
repo_url: &str,
|
|
||||||
token: &str,
|
|
||||||
pull_request_number: u64,
|
|
||||||
dest: &Path,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let dest = dest.display().to_string();
|
|
||||||
|
|
||||||
run_git(
|
|
||||||
token,
|
|
||||||
&[
|
|
||||||
"clone".to_string(),
|
|
||||||
"--depth".to_string(),
|
|
||||||
"1".to_string(),
|
|
||||||
repo_url.to_string(),
|
|
||||||
dest.clone(),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
run_git(
|
|
||||||
token,
|
|
||||||
&[
|
|
||||||
"-C".to_string(),
|
|
||||||
dest.clone(),
|
|
||||||
"fetch".to_string(),
|
|
||||||
"--depth".to_string(),
|
|
||||||
"1".to_string(),
|
|
||||||
"origin".to_string(),
|
|
||||||
format!("refs/pull/{pull_request_number}/head"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
run_git(
|
|
||||||
token,
|
|
||||||
&[
|
|
||||||
"-C".to_string(),
|
|
||||||
dest,
|
|
||||||
"checkout".to_string(),
|
|
||||||
"FETCH_HEAD".to_string(),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rend le clone lisible par les utilisateurs du container sandbox.
|
|
||||||
///
|
|
||||||
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless
|
|
||||||
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
|
|
||||||
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission
|
|
||||||
/// denied` sur des fichiers que Herald vient de cloner lui-même.
|
|
||||||
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
|
|
||||||
let output = tokio::process::Command::new("chmod")
|
|
||||||
.args(["-R", "a+rX"])
|
|
||||||
.arg(repo_dir)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("failed to spawn chmod")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"chmod failed on `{}`: {}",
|
|
||||||
repo_dir.display(),
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs git with the token injected through `http.extraHeader`, keeping the
|
|
||||||
/// secret out of the process arguments.
|
|
||||||
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
|
|
||||||
let output = tokio::process::Command::new("git")
|
|
||||||
.args(args)
|
|
||||||
.env("GIT_CONFIG_COUNT", "1")
|
|
||||||
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
|
||||||
.env(
|
|
||||||
"GIT_CONFIG_VALUE_0",
|
|
||||||
format!("Authorization: token {token}"),
|
|
||||||
)
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("failed to spawn git")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"git {} failed: {}",
|
|
||||||
args.join(" "),
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_devcontainer_prefers_dot_devcontainer_dir() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let nested = dir.path().join(".devcontainer");
|
|
||||||
std::fs::create_dir(&nested).unwrap();
|
|
||||||
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
|
|
||||||
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
find_devcontainer(dir.path()),
|
|
||||||
Some(nested.join("devcontainer.json"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_devcontainer_returns_none_when_absent() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
assert_eq!(find_devcontainer(dir.path()), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
repository: Repository {
|
repository: Repository {
|
||||||
full_name: "owner/repo".to_string(),
|
full_name: "owner/repo".to_string(),
|
||||||
|
clone_url: "https://github.com/owner/repo.git".to_string(),
|
||||||
},
|
},
|
||||||
comment: Comment {
|
comment: Comment {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -274,7 +275,7 @@ mod tests {
|
|||||||
.map(|tool| tool.function.name)
|
.map(|tool| tool.function.name)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(names, vec!["ls", "read_file", "grep", "find", "file_size"]);
|
assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user
L'affirmation « Each sandbox is isolated » est plus forte que la réalité tant que les
runArgs/containerEnvdu dépôt analysé sont transmis tels quels àdocker run(cf.container.rs). Soit durcir le code, soit nuancer la section en listant les limites de l'isolation.