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 shell | 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 | ||
| 17 | use std::path::Path; | |
| 18 | ||
| 19 | use anyhow::{Context, Result, bail}; | |
| 20 | use serde_json::{Value, json}; | |
| 21 | ||
| 22 | use crate::report::Reporter; | |
| 23 | use crate::{WORKDIR, auth_option, env, git, harness}; | |
| 24 | ||
| 25 | const DIFF_FILE: &str = "/work/change.diff"; | |
| 26 | const REVIEW_FILE: &str = "/work/review.json"; | |
| 27 | ||
| 28 | const INSTRUCTIONS: &str = "You are reviewing a pull request. The repository is checked out in the current directory at the pull request's head. \ | |
| 29 | The 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 | ||
| 31 | Judge whether the change does what it is for, whether it is correct, and whether it would break anything. \ | |
| 32 | Be 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 | ||
| 34 | Write 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. \ | |
| 45 | Use \"request_changes\" only if something must be fixed before merging. Then finish."; | |
| 46 | ||
| 47 | fn 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 | ||
| 104 | pub 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 | } |