g1t/crates/runner/src/reply.rs
| 1 | //! 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 | |
| 17 | use std::path::Path; |
| 18 | |
| 19 | use anyhow::{Context, Result, bail}; |
| 20 | |
| 21 | use crate::report::Reporter; |
| 22 | use crate::{WORKDIR, auth_option, env, git, harness}; |
| 23 | |
| 24 | const ANSWER_FILE: &str = "/work/answer.md"; |
| 25 | const MAX_ANSWER_CHARS: usize = 20_000; |
| 26 | |
| 27 | const INSTRUCTIONS: &str = "Write your answer to /work/answer.md as Markdown, addressed to whoever asked: \ |
| 28 | plain sentences, specific, naming files and functions where that helps, no headings and no emoji. \ |
| 29 | Read the code before you answer; say so when you are not sure. Do not change any file in the repository, \ |
| 30 | and do not post the answer with add_comment: it is posted in the thread for you. Then finish."; |
| 31 | |
| 32 | fn 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 | crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR).context("could not clone the repository")?; |
| 38 | if let Ok(reference) = env("GIT_REF") |
| 39 | && !reference.is_empty() |
| 40 | { |
| 41 | // A commit the clone may not have fetched by name: fetch it, and |
| 42 | // everything if that is refused. |
| 43 | if crate::clone::fetch(workdir, &auth, "origin", &reference).is_err() && crate::clone::is_shallow(workdir) { |
| 44 | let _ = git(workdir, &["-c", &auth, "fetch", "--quiet", "--unshallow", "origin"]); |
| 45 | } |
| 46 | git(workdir, &["checkout", "--quiet", "--detach", &reference]) |
| 47 | .or_else(|_| git(workdir, &["checkout", "--quiet", "--detach", "FETCH_HEAD"])) |
| 48 | .context("could not check out what the question is about")?; |
| 49 | } |
| 50 | let prompt = format!("{}\n\n{INSTRUCTIONS}", env("PROMPT")?); |
| 51 | // Steps show on the agent run; the answer is what matters here. |
| 52 | let mut reporter = Reporter::silent(); |
| 53 | let summary = harness::run_claude(workdir, &prompt, &mut reporter)?; |
| 54 | let written = std::fs::read_to_string(ANSWER_FILE).unwrap_or_default(); |
| 55 | let answer = if written.trim().is_empty() { summary } else { written }; |
| 56 | let answer: String = answer.trim().chars().take(MAX_ANSWER_CHARS).collect(); |
| 57 | if answer.is_empty() { |
| 58 | bail!("the agent wrote no answer"); |
| 59 | } |
| 60 | Ok(answer) |
| 61 | } |
| 62 | |
| 63 | pub fn main() -> i32 { |
| 64 | let (Ok(api), Ok(repo), Ok(number), Ok(token)) = ( |
| 65 | env("G1T_API"), |
| 66 | env("G1T_REPO"), |
| 67 | env("REPLY_NUMBER"), |
| 68 | env("G1T_AGENT_TOKEN"), |
| 69 | ) else { |
| 70 | eprintln!("g1t-runner: G1T_API, G1T_REPO, REPLY_NUMBER and G1T_AGENT_TOKEN must be set"); |
| 71 | return 2; |
| 72 | }; |
| 73 | let secrets: Vec<String> = ["G1T_TOKEN", "G1T_AGENT_TOKEN", "ANTHROPIC_API_KEY", "BILLING_TOKEN", "AGENT_RUN_TOKEN"] |
| 74 | .iter() |
| 75 | .filter_map(|name| std::env::var(name).ok()) |
| 76 | .filter(|secret| !secret.is_empty()) |
| 77 | .collect(); |
| 78 | let outcome = answer(); |
| 79 | let body = match &outcome { |
| 80 | Ok(answer) => answer.clone(), |
| 81 | Err(error) => { |
| 82 | eprintln!("g1t-runner: {error:#}"); |
| 83 | "I could not answer this: the run failed before I had an answer. Its steps are on the Agents page.".to_owned() |
| 84 | } |
| 85 | }; |
| 86 | // Whatever the agent wrote passes through here, so nothing it could |
| 87 | // have read from its environment leaves in the answer. |
| 88 | let body = secrets |
| 89 | .iter() |
| 90 | .fold(body, |text, secret| text.replace(secret, "[redacted]")); |
| 91 | let posted = ureq::post(&format!("{api}/repos/{repo}/issues/{number}/comments")) |
| 92 | .set("Authorization", &format!("Bearer {token}")) |
| 93 | .send_json(serde_json::json!({ "body": body })); |
| 94 | if let Err(error) = posted { |
| 95 | eprintln!("g1t-runner: could not post the answer: {error:#}"); |
| 96 | return 1; |
| 97 | } |
| 98 | i32::from(outcome.is_err()) |
| 99 | } |