g1t/crates/runner/src/review.rs

136 lines5,893 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1//! 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";
Reviews, plans and replies are written where the guardrail allows: g1t's answer files are inside it26pub(crate) const REVIEW_FILE: &str = "/work/review.json";
Agents as a team: lifecycle, merge queue, billing and a new shell27
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")?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents56 crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR).context("could not clone the pull request")?;
57 let head = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
58 crate::clone::ensure(workdir, &auth, "origin", &head, &commit)?;
Agents as a team: lifecycle, merge queue, billing and a new shell59 git(
60 workdir,
61 &[
62 "-c",
63 "advice.detachedHead=false",
64 "checkout",
65 "--quiet",
66 &commit,
67 ],
68 )?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 crate::clone::fetch(workdir, &auth, &upstream, &upstream_branch).with_context(|| format!("could not fetch {upstream_branch}"))?;
70 // Shallow: deep enough to find where the pull request left the branch.
71 crate::clone::share_history(workdir, &auth, &[("origin", head.as_str()), (upstream.as_str(), upstream_branch.as_str())], "HEAD", "FETCH_HEAD")?;
Agents as a team: lifecycle, merge queue, billing and a new shell72 // The change is everything since the pull request left the branch.
73 let base = git(workdir, &["merge-base", "FETCH_HEAD", "HEAD"])?;
74 let diff = git(workdir, &["diff", &base, "HEAD"])?;
75 if diff.trim().is_empty() {
76 bail!("the pull request changes nothing");
77 }
78 std::fs::write(DIFF_FILE, diff)?;
79
80 let prompt = format!("{}\n\n{INSTRUCTIONS}", env("PROMPT")?);
81 // A review has no session of its own; what matters is what it concludes.
82 let mut reporter = Reporter::silent();
83 let summary = harness::run_claude(workdir, &prompt, &mut reporter)?;
84
85 let written = std::fs::read_to_string(REVIEW_FILE).unwrap_or_default();
86 let mut review: Value = match serde_json::from_str(&written) {
87 Ok(review @ Value::Object(_)) => review,
88 // The agent answered without writing the file: its answer is the review.
89 _ => json!({ "body": summary, "comments": [] }),
90 };
91 if !matches!(
92 review["verdict"].as_str(),
93 Some("approve" | "request_changes")
94 ) {
95 review["verdict"] = Value::Null;
96 }
97 Ok(review)
98}
99
100pub fn main() -> i32 {
101 let (Ok(api), Ok(run), Ok(token)) = (env("G1T_API"), env("REVIEW_RUN"), env("REVIEW_TOKEN"))
102 else {
103 eprintln!("g1t-runner: G1T_API, REVIEW_RUN and REVIEW_TOKEN must be set");
104 return 2;
105 };
106 let secrets: Vec<String> = ["G1T_TOKEN", "REVIEW_TOKEN", "ANTHROPIC_API_KEY"]
107 .iter()
108 .filter_map(|name| std::env::var(name).ok())
109 .filter(|secret| !secret.is_empty())
110 .collect();
111 let redact = |text: String| {
112 secrets
113 .iter()
114 .fold(text, |text, secret| text.replace(secret, "[redacted]"))
115 };
116
117 let outcome = review();
118 let report = match &outcome {
119 Ok(review) => review.clone(),
120 Err(error) => json!({ "error": format!("{error:#}") }),
121 };
122 // Whatever the agent wrote passes through here, so nothing it could
123 // have read from its environment leaves in a review. The run's own
124 // token is added afterwards: it is what authorises the report.
125 let mut report: Value =
126 serde_json::from_str(&redact(report.to_string())).unwrap_or_else(|_| json!({}));
127 report["token"] = token.into();
128 if let Ok(model) = std::env::var("AGENT_MODEL_NAME") {
129 report["model"] = model.into();
130 }
131 if let Err(error) = ureq::post(&format!("{api}/reviews/{run}")).send_json(report) {
132 eprintln!("g1t-runner: could not report the review: {error:#}");
133 return 1;
134 }
135 i32::from(outcome.is_err())
136}