flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/review.rs

136 lines5,882 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 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)?;
59 git(
60 workdir,
61 &[
62 "-c",
63 "advice.detachedHead=false",
64 "checkout",
65 "--quiet",
66 &commit,
67 ],
68 )?;
69 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")?;
72 // 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}