g1t/crates/runner/src/main.rs

145 lines5,069 bytesCodeBlame
1//! Runs a coding agent on one pull request and reports back to g1t.
2//!
3//! This is the program a hosted sandbox starts. It clones the pull
4//! request's fork, runs the agent harness headless, streams what the agent
5//! does into the pull request's session as it happens, pushes the result
6//! and marks the pull request ready for review. It talks to g1t only through the public API and git, exactly
7//! as an agent on someone's own machine would.
8//!
9//! With `MODE=checks` it runs acceptance checks instead; see `checks`.
10//!
11//! Configuration comes from the environment:
12//!
13//! - `G1T_API`, `G1T_TOKEN`, `G1T_USER`: where and who to report as.
14//! - `G1T_REPO`, `PULL_NUMBER`, `GIT_REMOTE`: the pull request and its fork.
15//! - `PROMPT`: what the agent is asked to do.
16//! - `COMMIT_MESSAGE`: used if the agent leaves changes uncommitted.
17//! - `ANTHROPIC_API_KEY`: read by the harness itself.
18
19mod checks;
20mod harness;
21mod report;
22
23use std::path::Path;
24use std::process::Command;
25
26use anyhow::{Context, Result, bail};
27use base64::Engine;
28use base64::engine::general_purpose::STANDARD;
29
30use report::{Entry, Reporter};
31
32pub(crate) const WORKDIR: &str = "/work/repo";
33
34pub(crate) fn env(name: &str) -> Result<String> {
35 std::env::var(name).with_context(|| format!("{name} is not set"))
36}
37
38/// Runs git and returns its trimmed output, failing on a non-zero exit.
39pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String> {
40 let output = Command::new("git")
41 .current_dir(dir)
42 .args(args)
43 .output()
44 .context("could not run git")?;
45 if !output.status.success() {
46 bail!(
47 "git {} failed: {}",
48 args.first().unwrap_or(&""),
49 String::from_utf8_lossy(&output.stderr).trim()
50 );
51 }
52 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
53}
54
55/// A git option that authenticates one command. The credential is passed
56/// per command and never written to the clone's config or its remote URL,
57/// where the agent would find it.
58pub(crate) fn auth_option(user: &str, token: &str) -> String {
59 let credentials = STANDARD.encode(format!("{user}:{token}"));
60 format!("http.extraHeader=Authorization: Basic {credentials}")
61}
62
63fn run(reporter: &mut Reporter) -> Result<String> {
64 let prompt = env("PROMPT")?;
65 let remote = env("GIT_REMOTE")?;
66 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
67 let workdir = Path::new(WORKDIR);
68
69 if let Ok(model) = std::env::var("AGENT_MODEL_NAME") {
70 reporter.record(Entry::new("note", &format!("Running on {model}.")));
71 }
72 reporter.record(Entry::new("prompt", &prompt));
73 reporter.flush();
74
75 std::fs::create_dir_all("/work")?;
76 git(
77 Path::new("/work"),
78 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
79 )
80 .context("could not clone the pull request's fork")?;
81 git(workdir, &["config", "user.name", "g1t agent"])?;
82 git(workdir, &["config", "user.email", "agent@g1t.sh"])?;
83 let branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
84 let start = git(workdir, &["rev-parse", "HEAD"]).unwrap_or_default();
85
86 let summary = harness::run_claude(workdir, &prompt, reporter)?;
87
88 // Commit whatever the agent left in the working tree.
89 if !git(workdir, &["status", "--porcelain"])?.is_empty() {
90 let message = std::env::var("COMMIT_MESSAGE").unwrap_or_else(|_| "Agent changes".into());
91 git(workdir, &["add", "--all"])?;
92 git(workdir, &["commit", "--quiet", "--message", &message])?;
93 }
94 let head = git(workdir, &["rev-parse", "HEAD"])?;
95 if head == start {
96 bail!("the agent finished without changing anything");
97 }
98 git(
99 workdir,
100 &[
101 "-c",
102 &auth,
103 "push",
104 "--quiet",
105 "origin",
106 &format!("HEAD:{branch}"),
107 ],
108 )
109 .context("could not push the pull request's commits")?;
110 reporter.record(Entry::new(
111 "note",
112 &format!("Pushed {}.", &head[..head.len().min(12)]),
113 ));
114 Ok(summary)
115}
116
117fn main() {
118 // The same image also runs acceptance checks, with no agent involved.
119 if std::env::var("MODE").as_deref() == Ok("checks") {
120 std::process::exit(checks::main());
121 }
122 let mut reporter = match Reporter::from_env() {
123 Ok(reporter) => reporter,
124 Err(error) => {
125 eprintln!("g1t-runner: {error:#}");
126 std::process::exit(2);
127 }
128 };
129 match run(&mut reporter) {
130 Ok(summary) => {
131 reporter.flush();
132 if let Err(error) = reporter.ready(&summary) {
133 eprintln!("g1t-runner: could not mark the pull request ready: {error:#}");
134 std::process::exit(1);
135 }
136 }
137 Err(error) => {
138 eprintln!("g1t-runner: {error:#}");
139 reporter.record(Entry::new("note", &format!("The run failed: {error:#}")));
140 reporter.flush();
141 let _ = reporter.close();
142 std::process::exit(1);
143 }
144 }
145}