flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/crates/runner/src/checks.rs

244 lines7,699 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.

Acceptance checks in sandboxes, line comments and review verdicts1//! Runs an issue's acceptance checks against one commit and reports how
2//! each went.
3//!
4//! The sandbox holds nothing but that commit: no agent has run here, so a
5//! passing result says something about the code and not about what an
6//! agent left lying around.
7//!
8//! Configuration comes from the environment:
9//!
10//! - `G1T_API`, `CHECK_RUN`, `CHECK_TOKEN`: where and how to report.
11//! - `GIT_REMOTE`, `GIT_COMMIT`: what to check out.
12//! - `G1T_USER`, `G1T_TOKEN`: to read the repository, if it is private.
13//! - `CHECKS`: the commands, as a JSON array.
14
15use std::path::Path;
16use std::process::{Command, Stdio};
17use std::time::Instant;
18
19use anyhow::{Context, Result, bail};
20use serde::Serialize;
21
22use crate::{WORKDIR, auth_option, env, git};
23
24/// The longest one command may run.
25const COMMAND_TIMEOUT_SECONDS: u32 = 10 * 60;
26/// How much of a command's output is kept: the end, where failures are.
27const MAX_OUTPUT_CHARS: usize = 12_000;
28/// What `timeout` exits with when it had to stop the command.
29const TIMED_OUT: i32 = 124;
30const KILLED: i32 = 137;
31
32#[derive(Debug, Serialize)]
33#[serde(rename_all = "camelCase")]
Agents as a team: lifecycle, merge queue, billing and a new shell34pub(crate) struct CheckResult {
35 pub(crate) command: String,
36 pub(crate) passed: bool,
Acceptance checks in sandboxes, line comments and review verdicts37 exit_code: Option<i32>,
38 output: String,
39 duration_ms: u64,
40}
41
Deployments: a preview for every pull request, production on g1t.page42impl CheckResult {
43 pub(crate) fn output_text(&self) -> &str {
44 &self.output
45 }
46}
47
Acceptance checks in sandboxes, line comments and review verdicts48/// The last `limit` characters of `text`, saying so if any were dropped.
49fn tail(text: &str, limit: usize) -> String {
50 let length = text.chars().count();
51 if length <= limit {
52 return text.to_owned();
53 }
54 let kept: String = text.chars().skip(length - limit).collect();
55 format!("… (earlier output not shown)\n{kept}")
56}
57
Agents as a team: lifecycle, merge queue, billing and a new shell58pub(crate) fn redact(text: &str, secrets: &[String]) -> String {
Acceptance checks in sandboxes, line comments and review verdicts59 secrets.iter().fold(text.to_owned(), |text, secret| {
60 text.replace(secret, "[redacted]")
61 })
62}
63
64/// Runs one command in the checkout, without this process's credentials.
Agents as a team: lifecycle, merge queue, billing and a new shell65pub(crate) fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 crate::abuse::touch();
67 // Mining is never a check's or a build's job (abuse.rs).
68 if let Some(miner) = crate::abuse::miner_in(command) {
69 return CheckResult {
70 command: command.to_owned(),
71 passed: false,
72 exit_code: None,
73 output: format!("g1t does not run cryptocurrency miners ({miner}). This command was not run."),
74 duration_ms: 0,
75 };
76 }
Acceptance checks in sandboxes, line comments and review verdicts77 let started = Instant::now();
78 let output = Command::new("timeout")
79 .args([
80 "--signal=KILL",
81 &COMMAND_TIMEOUT_SECONDS.to_string(),
82 "sh",
83 "-c",
84 // One stream, in the order it was written.
85 &format!("( {command}\n) 2>&1"),
86 ])
87 .current_dir(workdir)
88 .env_remove("G1T_TOKEN")
89 .env_remove("CHECK_TOKEN")
Deployments: a preview for every pull request, production on g1t.page90 .env_remove("DEPLOY_TOKEN")
Acceptance checks in sandboxes, line comments and review verdicts91 .stdin(Stdio::null())
92 .output();
93 let duration_ms = started.elapsed().as_millis() as u64;
94 match output {
95 Ok(output) => {
96 let code = output.status.code();
97 let timed_out = matches!(code, Some(TIMED_OUT | KILLED) | None);
98 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
99 if timed_out {
100 text.push_str(&format!(
101 "\nStopped after {} minutes.",
102 COMMAND_TIMEOUT_SECONDS / 60
103 ));
104 }
105 CheckResult {
106 command: command.to_owned(),
107 passed: output.status.success(),
108 exit_code: code.filter(|_| !timed_out),
109 output: redact(&tail(text.trim_end(), MAX_OUTPUT_CHARS), secrets),
110 duration_ms,
111 }
112 }
113 Err(error) => CheckResult {
114 command: command.to_owned(),
115 passed: false,
116 exit_code: None,
117 output: format!("Could not start the command: {error}"),
118 duration_ms,
119 },
120 }
121}
122
123struct Reporter {
124 url: String,
125 token: String,
126}
127
128impl Reporter {
129 fn send(&self, mut body: serde_json::Value) -> Result<()> {
130 body["token"] = self.token.clone().into();
131 ureq::post(&self.url)
132 .send_json(body)
133 .context("could not report the check run")?;
134 Ok(())
135 }
136}
137
138fn check_out(secrets: &[String]) -> Result<()> {
139 let remote = env("GIT_REMOTE")?;
140 let commit = env("GIT_COMMIT")?;
141 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
142 std::fs::create_dir_all("/work")?;
143 let cloned = git(
144 Path::new("/work"),
145 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
146 )
147 .and_then(|_| {
148 git(
149 Path::new(WORKDIR),
150 &[
151 "-c",
152 "advice.detachedHead=false",
153 "checkout",
154 "--quiet",
155 &commit,
156 ],
157 )
158 });
159 if let Err(error) = cloned {
160 bail!("{}", redact(&format!("{error:#}"), secrets));
161 }
162 Ok(())
163}
164
165pub fn main() -> i32 {
166 let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) {
167 (Ok(api), Ok(run), Ok(token)) => Reporter {
Agents as a team: lifecycle, merge queue, billing and a new shell168 url: format!("{api}/checks/{run}"),
Acceptance checks in sandboxes, line comments and review verdicts169 token,
170 },
171 _ => {
172 eprintln!("g1t-runner: G1T_API, CHECK_RUN and CHECK_TOKEN must be set");
173 return 2;
174 }
175 };
176 let secrets: Vec<String> = ["G1T_TOKEN", "CHECK_TOKEN"]
177 .iter()
178 .filter_map(|name| std::env::var(name).ok())
179 .filter(|secret| !secret.is_empty())
180 .collect();
181 let commands: Vec<String> = std::env::var("CHECKS")
182 .ok()
183 .and_then(|json| serde_json::from_str(&json).ok())
184 .unwrap_or_default();
185
186 // Says the run has started.
187 if let Err(error) = reporter.send(serde_json::json!({})) {
188 eprintln!("g1t-runner: {error:#}");
189 return 1;
190 }
191 let report = match check_out(&secrets) {
192 Err(error) => serde_json::json!({
193 "error": format!("The commit could not be checked out: {error:#}"),
194 }),
195 Ok(()) => {
196 let results: Vec<CheckResult> = commands
197 .iter()
198 .map(|command| run_command(command, Path::new(WORKDIR), &secrets))
199 .collect();
200 serde_json::json!({ "results": results })
201 }
202 };
203 match reporter.send(report) {
204 Ok(()) => 0,
205 Err(error) => {
206 eprintln!("g1t-runner: {error:#}");
207 1
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn long_output_keeps_its_end() {
218 let text = format!("{}END", "x".repeat(50));
219 let kept = tail(&text, 10);
220 assert!(kept.ends_with("xxxxxxxEND"));
221 assert!(kept.starts_with("… (earlier output not shown)"));
222 assert_eq!(tail("short", 10), "short");
223 }
224
225 #[test]
226 fn secrets_do_not_reach_a_report() {
227 let secrets = vec!["g1t_secret".to_owned()];
228 assert_eq!(redact("token=g1t_secret", &secrets), "token=[redacted]");
229 }
230
231 #[cfg(unix)]
232 #[test]
233 fn a_command_passes_or_fails_by_its_exit_code() {
234 let here = std::env::temp_dir();
235 let passed = run_command("echo out; echo err >&2", &here, &[]);
236 assert!(passed.passed);
237 assert_eq!(passed.exit_code, Some(0));
238 assert_eq!(passed.output, "out\nerr");
239 let failed = run_command("echo nope; exit 3", &here, &[]);
240 assert!(!failed.passed);
241 assert_eq!(failed.exit_code, Some(3));
242 assert_eq!(failed.output, "nope");
243 }
244}