pr_01m47d24b0e6n91zwymwxg0vpx/crates/runner/src/report.rs

148 lines4,865 bytesCodeBlame
1//! Reports a pull request's progress to g1t through its public API.
2
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7
8/// Entries are sent in batches at most this often.
9const FLUSH_INTERVAL: Duration = Duration::from_millis(1500);
10const FLUSH_SIZE: usize = 50;
11/// Tool output can be enormous; the session keeps the start of it.
12const MAX_ENTRY_CHARS: usize = 8000;
13
14/// One step of the session, as the API accepts it.
15#[derive(Debug, Serialize)]
16pub struct Entry {
17 kind: &'static str,
18 text: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 tool: Option<String>,
21}
22
23impl 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.
47fn redact(text: &str, secrets: &[String]) -> String {
48 secrets.iter().fold(text.to_owned(), |text, secret| {
49 text.replace(secret, "[redacted]")
50 })
51}
52
53pub struct Reporter {
54 api: String,
55 token: String,
56 /// The pull request's path in the API: `repos/<owner>/<name>/pulls/<number>`.
57 pull: String,
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
65impl 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"))
71 .chain(std::env::var("AI_GATEWAY_TOKEN"))
72 .filter(|secret| !secret.is_empty())
73 .collect();
74 Ok(Reporter {
75 api: var("G1T_API")?,
76 token,
77 pull: format!("repos/{}/pulls/{}", var("G1T_REPO")?, var("PULL_NUMBER")?),
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<()> {
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"))?;
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
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 }))
119 }
120
121 /// Closes the pull request without merging.
122 pub fn close(&self) -> Result<()> {
123 self.post("close", serde_json::json!({}))
124 }
125}
126
127#[cfg(test)]
128mod 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
135KEY=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}