pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/review.rs

140 lines5,595 bytesCodeBlame
1//! Has an agent review a pull request and reports what it found.
2//!
3//! The agent reads the change and the code around it, and writes its review
4//! to a file as JSON: a verdict, a summary, and comments on lines. This
5//! program posts that to g1t, which records it as a review by a g1t agent.
6//! The agent never holds the credential that reports the review.
7//!
8//! Configuration comes from the environment:
9//!
10//! - `G1T_API`, `REVIEW_RUN`, `REVIEW_TOKEN`: where and how to report.
11//! - `GIT_REMOTE`, `GIT_COMMIT`: the pull request's head.
12//! - `UPSTREAM_REMOTE`, `UPSTREAM_BRANCH`: what it would merge into.
13//! - `G1T_USER`, `G1T_TOKEN`: to read the repository, if it is private.
14//! - `PROMPT`: what the pull request is and what it is for.
15//! - `AGENT_MODEL_NAME`: recorded with the review.
16
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20use serde_json::{Value, json};
21
22use crate::report::Reporter;
23use crate::{WORKDIR, auth_option, env, git, harness};
24
25const DIFF_FILE: &str = "/work/change.diff";
26const REVIEW_FILE: &str = "/work/review.json";
27
28const INSTRUCTIONS: &str = "You are reviewing a pull request. The repository is checked out in the current directory at the pull request's head. \
29The change under review is in /work/change.diff; read it first, then read the surrounding code as needed. You may run the project's tests. Do not modify the repository.
30
31Judge whether the change does what it is for, whether it is correct, and whether it would break anything. \
32Be specific and brief. Comment only on real problems or things a maintainer would want to know; do not praise, and do not restate the diff.
33
34Write your review to /work/review.json as JSON with exactly this shape:
35
36{
37 \"verdict\": \"approve\" or \"request_changes\",
38 \"body\": \"A summary in Markdown: what you checked and your conclusion.\",
39 \"comments\": [
40 { \"path\": \"path/in/the/repository\", \"line\": 12, \"body\": \"What is wrong on this line and what to do about it.\" }
41 ]
42}
43
44`line` is the line number in the file as it is after the change. `comments` may be empty. \
45Use \"request_changes\" only if something must be fixed before merging. Then finish.";
46
47fn review() -> Result<Value> {
48 let remote = env("GIT_REMOTE")?;
49 let commit = env("GIT_COMMIT")?;
50 let upstream = env("UPSTREAM_REMOTE")?;
51 let upstream_branch = env("UPSTREAM_BRANCH")?;
52 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
53 let workdir = Path::new(WORKDIR);
54
55 std::fs::create_dir_all("/work")?;
56 git(
57 Path::new("/work"),
58 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
59 )
60 .context("could not clone the pull request")?;
61 git(
62 workdir,
63 &[
64 "-c",
65 "advice.detachedHead=false",
66 "checkout",
67 "--quiet",
68 &commit,
69 ],
70 )?;
71 git(
72 workdir,
73 &["-c", &auth, "fetch", "--quiet", &upstream, &upstream_branch],
74 )
75 .with_context(|| format!("could not fetch {upstream_branch}"))?;
76 // The change is everything since the pull request left the branch.
77 let base = git(workdir, &["merge-base", "FETCH_HEAD", "HEAD"])?;
78 let diff = git(workdir, &["diff", &base, "HEAD"])?;
79 if diff.trim().is_empty() {
80 bail!("the pull request changes nothing");
81 }
82 std::fs::write(DIFF_FILE, diff)?;
83
84 let prompt = format!("{}\n\n{INSTRUCTIONS}", env("PROMPT")?);
85 // A review has no session of its own; what matters is what it concludes.
86 let mut reporter = Reporter::silent();
87 let summary = harness::run_claude(workdir, &prompt, &mut reporter)?;
88
89 let written = std::fs::read_to_string(REVIEW_FILE).unwrap_or_default();
90 let mut review: Value = match serde_json::from_str(&written) {
91 Ok(review @ Value::Object(_)) => review,
92 // The agent answered without writing the file: its answer is the review.
93 _ => json!({ "body": summary, "comments": [] }),
94 };
95 if !matches!(
96 review["verdict"].as_str(),
97 Some("approve" | "request_changes")
98 ) {
99 review["verdict"] = Value::Null;
100 }
101 Ok(review)
102}
103
104pub fn main() -> i32 {
105 let (Ok(api), Ok(run), Ok(token)) = (env("G1T_API"), env("REVIEW_RUN"), env("REVIEW_TOKEN"))
106 else {
107 eprintln!("g1t-runner: G1T_API, REVIEW_RUN and REVIEW_TOKEN must be set");
108 return 2;
109 };
110 let secrets: Vec<String> = ["G1T_TOKEN", "REVIEW_TOKEN", "ANTHROPIC_API_KEY"]
111 .iter()
112 .filter_map(|name| std::env::var(name).ok())
113 .filter(|secret| !secret.is_empty())
114 .collect();
115 let redact = |text: String| {
116 secrets
117 .iter()
118 .fold(text, |text, secret| text.replace(secret, "[redacted]"))
119 };
120
121 let outcome = review();
122 let report = match &outcome {
123 Ok(review) => review.clone(),
124 Err(error) => json!({ "error": format!("{error:#}") }),
125 };
126 // Whatever the agent wrote passes through here, so nothing it could
127 // have read from its environment leaves in a review. The run's own
128 // token is added afterwards: it is what authorises the report.
129 let mut report: Value =
130 serde_json::from_str(&redact(report.to_string())).unwrap_or_else(|_| json!({}));
131 report["token"] = token.into();
132 if let Ok(model) = std::env::var("AGENT_MODEL_NAME") {
133 report["model"] = model.into();
134 }
135 if let Err(error) = ureq::post(&format!("{api}/reviews/{run}")).send_json(report) {
136 eprintln!("g1t-runner: could not report the review: {error:#}");
137 return 1;
138 }
139 i32::from(outcome.is_err())
140}