pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/review.rs
| 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 | } |