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/reply.rs

100 lines4,372 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.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! Answers a question someone asked `@g1t-agent` in a comment, in the
2//! thread it was asked in, changing nothing.
3//!
4//! The agent reads the repository as it is (the default branch for an
5//! issue, the pull request's head for a pull request) and writes its answer
6//! to a file. This program posts the answer as `g1t-agent`, with the
7//! agent's own token, which the agent itself never holds.
8//!
9//! Configuration comes from the environment:
10//!
11//! - `G1T_API`, `G1T_REPO`, `REPLY_NUMBER`: where the question was asked.
12//! - `G1T_AGENT_TOKEN`: g1t-agent's token for this run, to post the answer.
13//! - `GIT_REMOTE`, `GIT_REF`: what to read, and at which branch or commit.
14//! - `G1T_USER`, `G1T_TOKEN`: to read it, if it is private.
15//! - `PROMPT`: the question and what the agent is told around it.
16
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20
21use crate::report::Reporter;
22use crate::{WORKDIR, auth_option, env, git, harness};
23
24const ANSWER_FILE: &str = "/work/answer.md";
25const MAX_ANSWER_CHARS: usize = 20_000;
26
27const INSTRUCTIONS: &str = "Write your answer to /work/answer.md as Markdown, addressed to whoever asked: \
28plain sentences, specific, naming files and functions where that helps, no headings and no emoji. \
29Read the code before you answer; say so when you are not sure. Do not change any file in the repository, \
30and do not post the answer with add_comment: it is posted in the thread for you. Then finish.";
31
32fn answer() -> Result<String> {
33 let remote = env("GIT_REMOTE")?;
34 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
35 let workdir = Path::new(WORKDIR);
36 std::fs::create_dir_all("/work")?;
37 git(
38 Path::new("/work"),
39 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
40 )
41 .context("could not clone the repository")?;
42 if let Ok(reference) = env("GIT_REF")
43 && !reference.is_empty()
44 {
45 // A commit the clone may not have fetched by name: fetch it.
46 let _ = git(workdir, &["-c", &auth, "fetch", "--quiet", "origin", &reference]);
47 git(workdir, &["checkout", "--quiet", "--detach", &reference])
48 .or_else(|_| git(workdir, &["checkout", "--quiet", "--detach", "FETCH_HEAD"]))
49 .context("could not check out what the question is about")?;
50 }
51 let prompt = format!("{}\n\n{INSTRUCTIONS}", env("PROMPT")?);
52 // Steps show on the agent run; the answer is what matters here.
53 let mut reporter = Reporter::silent();
54 let summary = harness::run_claude(workdir, &prompt, &mut reporter)?;
55 let written = std::fs::read_to_string(ANSWER_FILE).unwrap_or_default();
56 let answer = if written.trim().is_empty() { summary } else { written };
57 let answer: String = answer.trim().chars().take(MAX_ANSWER_CHARS).collect();
58 if answer.is_empty() {
59 bail!("the agent wrote no answer");
60 }
61 Ok(answer)
62}
63
64pub fn main() -> i32 {
65 let (Ok(api), Ok(repo), Ok(number), Ok(token)) = (
66 env("G1T_API"),
67 env("G1T_REPO"),
68 env("REPLY_NUMBER"),
69 env("G1T_AGENT_TOKEN"),
70 ) else {
71 eprintln!("g1t-runner: G1T_API, G1T_REPO, REPLY_NUMBER and G1T_AGENT_TOKEN must be set");
72 return 2;
73 };
74 let secrets: Vec<String> = ["G1T_TOKEN", "G1T_AGENT_TOKEN", "ANTHROPIC_API_KEY", "BILLING_TOKEN", "AGENT_RUN_TOKEN"]
75 .iter()
76 .filter_map(|name| std::env::var(name).ok())
77 .filter(|secret| !secret.is_empty())
78 .collect();
79 let outcome = answer();
80 let body = match &outcome {
81 Ok(answer) => answer.clone(),
82 Err(error) => {
83 eprintln!("g1t-runner: {error:#}");
84 "I could not answer this: the run failed before I had an answer. Its steps are on the Agents page.".to_owned()
85 }
86 };
87 // Whatever the agent wrote passes through here, so nothing it could
88 // have read from its environment leaves in the answer.
89 let body = secrets
90 .iter()
91 .fold(body, |text, secret| text.replace(secret, "[redacted]"));
92 let posted = ureq::post(&format!("{api}/repos/{repo}/issues/{number}/comments"))
93 .set("Authorization", &format!("Bearer {token}"))
94 .send_json(serde_json::json!({ "body": body }));
95 if let Err(error) = posted {
96 eprintln!("g1t-runner: could not post the answer: {error:#}");
97 return 1;
98 }
99 i32::from(outcome.is_err())
100}