pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/report.rs
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.
| Issues and pull requests replace intents and attempts | 1 | //! Reports a pull request's progress to g1t through its public API. |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 2 | |
| 3 | use std::time::{Duration, Instant}; | |
| 4 | ||
| 5 | use anyhow::{Context, Result}; | |
| 6 | use serde::Serialize; | |
| 7 | ||
| 8 | /// Entries are sent in batches at most this often. | |
| 9 | const FLUSH_INTERVAL: Duration = Duration::from_millis(1500); | |
| 10 | const FLUSH_SIZE: usize = 50; | |
| 11 | /// Tool output can be enormous; the session keeps the start of it. | |
| 12 | const MAX_ENTRY_CHARS: usize = 8000; | |
| 13 | ||
| 14 | /// One step of the session, as the API accepts it. | |
| 15 | #[derive(Debug, Serialize)] | |
| 16 | pub struct Entry { | |
| 17 | kind: &'static str, | |
| 18 | text: String, | |
| 19 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 20 | tool: Option<String>, | |
| 21 | } | |
| 22 | ||
| 23 | impl Entry { | |
| 24 | pub fn new(kind: &'static str, text: &str) -> Self { | |
| 25 | let mut chars = text.chars(); | |
| 26 | let mut text: String = chars.by_ref().take(MAX_ENTRY_CHARS).collect(); | |
| 27 | if chars.next().is_some() { | |
| 28 | text.push_str("\n… (truncated)"); | |
| 29 | } | |
| 30 | Entry { | |
| 31 | kind, | |
| 32 | text, | |
| 33 | tool: None, | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | pub fn tool(kind: &'static str, tool: &str, text: &str) -> Self { | |
| 38 | Entry { | |
| 39 | tool: Some(tool.to_owned()), | |
| 40 | ..Entry::new(kind, text) | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// Replaces every occurrence of a secret with a marker. An agent can print | |
| 46 | /// its environment or a git remote; whatever it prints is recorded. | |
| 47 | fn redact(text: &str, secrets: &[String]) -> String { | |
| 48 | secrets.iter().fold(text.to_owned(), |text, secret| { | |
| 49 | text.replace(secret, "[redacted]") | |
| 50 | }) | |
| 51 | } | |
| 52 | ||
| 53 | pub struct Reporter { | |
| 54 | api: String, | |
| 55 | token: String, | |
| Issues and pull requests replace intents and attempts | 56 | /// The pull request's path in the API: `repos/<owner>/<name>/pulls/<number>`. |
| 57 | pull: String, | |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 58 | /// Values that must never reach a session, which is as public as the |
| 59 | /// repository: the credentials this process was started with. | |
| 60 | secrets: Vec<String>, | |
| 61 | pending: Vec<Entry>, | |
| 62 | last_flush: Instant, | |
| 63 | } | |
| 64 | ||
| 65 | impl Reporter { | |
| 66 | pub fn from_env() -> Result<Self> { | |
| 67 | let var = |name: &str| std::env::var(name).with_context(|| format!("{name} is not set")); | |
| 68 | let token = var("G1T_TOKEN")?; | |
| 69 | let secrets = std::iter::once(token.clone()) | |
| 70 | .chain(std::env::var("ANTHROPIC_API_KEY")) | |
| g1t agents: model menu and optional AI Gateway routing | 71 | .chain(std::env::var("AI_GATEWAY_TOKEN")) |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 72 | .filter(|secret| !secret.is_empty()) |
| 73 | .collect(); | |
| 74 | Ok(Reporter { | |
| 75 | api: var("G1T_API")?, | |
| 76 | token, | |
| Issues and pull requests replace intents and attempts | 77 | pull: format!("repos/{}/pulls/{}", var("G1T_REPO")?, var("PULL_NUMBER")?), |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 78 | secrets, |
| 79 | pending: Vec::new(), | |
| 80 | last_flush: Instant::now(), | |
| 81 | }) | |
| 82 | } | |
| 83 | ||
| 84 | fn post(&self, action: &str, body: serde_json::Value) -> Result<()> { | |
| Issues and pull requests replace intents and attempts | 85 | ureq::post(&format!("{}/v1/{}/{action}", self.api, self.pull)) |
| 86 | .set("authorization", &format!("Bearer {}", self.token)) | |
| 87 | .send_json(body) | |
| 88 | .with_context(|| format!("{action} request failed"))?; | |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 89 | Ok(()) |
| 90 | } | |
| 91 | ||
| 92 | /// Queues an entry, sending the batch if it is due. | |
| 93 | pub fn record(&mut self, mut entry: Entry) { | |
| 94 | entry.text = redact(&entry.text, &self.secrets); | |
| 95 | self.pending.push(entry); | |
| 96 | if self.pending.len() >= FLUSH_SIZE || self.last_flush.elapsed() >= FLUSH_INTERVAL { | |
| 97 | self.flush(); | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /// Sends everything queued. A failed send is logged and the entries | |
| 102 | /// kept, so one bad request does not lose the session or stop the run. | |
| 103 | pub fn flush(&mut self) { | |
| 104 | self.last_flush = Instant::now(); | |
| 105 | if self.pending.is_empty() { | |
| 106 | return; | |
| 107 | } | |
| 108 | let body = serde_json::json!({ "entries": self.pending }); | |
| 109 | match self.post("session", body) { | |
| 110 | Ok(()) => self.pending.clear(), | |
| 111 | Err(error) => eprintln!("g1t-runner: {error:#}"), | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| Issues and pull requests replace intents and attempts | 115 | /// Marks the pull request ready for review, with `summary` as its |
| 116 | /// description. | |
| 117 | pub fn ready(&self, summary: &str) -> Result<()> { | |
| 118 | self.post("ready", serde_json::json!({ "summary": summary })) | |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 119 | } |
| 120 | ||
| Issues and pull requests replace intents and attempts | 121 | /// Closes the pull request without merging. |
| 122 | pub fn close(&self) -> Result<()> { | |
| 123 | self.post("close", serde_json::json!({})) | |
| Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed) | 124 | } |
| 125 | } | |
| 126 | ||
| 127 | #[cfg(test)] | |
| 128 | mod tests { | |
| 129 | use super::*; | |
| 130 | ||
| 131 | #[test] | |
| 132 | fn secrets_are_removed_from_entries() { | |
| 133 | let secrets = vec!["g1t_abc123".to_owned(), "sk-ant-xyz".to_owned()]; | |
| 134 | let text = "origin https://me:g1t_abc123@g1t.sh/a.git | |
| 135 | KEY=sk-ant-xyz g1t_abc123"; | |
| 136 | let clean = redact(text, &secrets); | |
| 137 | assert!(!clean.contains("g1t_abc123")); | |
| 138 | assert!(!clean.contains("sk-ant-xyz")); | |
| 139 | assert_eq!(clean.matches("[redacted]").count(), 3); | |
| 140 | } | |
| 141 | ||
| 142 | #[test] | |
| 143 | fn long_entries_are_truncated() { | |
| 144 | let entry = Entry::new("note", &"x".repeat(MAX_ENTRY_CHARS + 10)); | |
| 145 | assert!(entry.text.ends_with("(truncated)")); | |
| 146 | assert!(Entry::new("note", "short").text == "short"); | |
| 147 | } | |
| 148 | } |