1use std::collections::HashMap;
13use std::sync::{Mutex, OnceLock};
14use std::time::{Duration, Instant};
15
16use serde_json::{json, Value};
17
18const CACHE_TTL: Duration = Duration::from_secs(120);
19const LIST_LIMIT: &str = "1000";
22const PR_FIELDS: &str = "number,title,headRefName,files,author";
23const GH_TIMEOUT: Duration = Duration::from_secs(20);
24const FILES_NAMED: usize = 3;
26
27#[cfg(windows)]
28const GH_AGENT: &str = "gh-agent.cmd";
29#[cfg(not(windows))]
30const GH_AGENT: &str = "gh-agent";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub(super) struct OpenPr {
34 pub number: u64,
35 pub title: String,
36 pub branch: String,
37 pub files: Vec<String>,
38 pub agent: String,
40 pub host: Option<String>,
42}
43
44pub(super) fn title_author(title: &str) -> Option<(String, String)> {
46 let (prefix, _) = title.split_once(": ")?;
47 let (agent, host) = prefix.split_once('@')?;
48 let ok = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
49 (ok(agent) && ok(host)).then(|| (agent.to_string(), host.to_string()))
50}
51
52pub(super) fn parse_pr_list(text: &str) -> Result<Vec<OpenPr>, String> {
54 let list: Value = serde_json::from_str(text.trim()).map_err(|e| format!("gh-agent printed something other than JSON: {e}"))?;
55 let items = list.as_array().ok_or("gh-agent printed something other than a list")?;
56 Ok(items.iter().filter_map(parse_pr).collect())
57}
58
59fn parse_pr(p: &Value) -> Option<OpenPr> {
61 let title = p.get("title")?.as_str()?.to_string();
62 let (agent, host) = match title_author(&title) {
63 Some((agent, host)) => (agent, Some(host)),
64 None => {
65 let login = p.pointer("/author/login").and_then(Value::as_str).unwrap_or("unknown");
66 (login.to_string(), None)
67 }
68 };
69 let files = p
70 .get("files")
71 .and_then(Value::as_array)
72 .map(|fs| fs.iter().filter_map(|f| f.get("path")?.as_str().map(str::to_string)).collect())
73 .unwrap_or_default();
74 Some(OpenPr {
75 number: p.get("number")?.as_u64()?,
76 branch: p.get("headRefName").and_then(Value::as_str).unwrap_or_default().to_string(),
77 title,
78 files,
79 agent,
80 host,
81 })
82}
83
84pub(super) async fn open_pr(repo: &str, number: u64) -> Result<Option<OpenPr>, String> {
87 let (repo, n) = (repo.to_string(), number.to_string());
88 let text = tokio::task::spawn_blocking(move || {
89 run_gh_agent(&["pr", "view", &n, "--repo", &repo, "--json", &format!("{PR_FIELDS},state")])
90 })
91 .await
92 .map_err(|e| format!("gh-agent: {e}"))??;
93 let pr: Value = serde_json::from_str(text.trim()).map_err(|e| format!("gh-agent printed something other than JSON: {e}"))?;
94 Ok(if pr.get("state").and_then(Value::as_str) == Some("OPEN") { parse_pr(&pr) } else { None })
95}
96
97type Cache = HashMap<String, (Instant, Vec<OpenPr>)>;
99
100fn cache() -> std::sync::MutexGuard<'static, Cache> {
101 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
102 CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner())
103}
104
105pub(super) async fn open_prs(repo: &str) -> Result<Vec<OpenPr>, String> {
107 if let Some((at, prs)) = cache().get(repo) {
108 if at.elapsed() < CACHE_TTL {
109 return Ok(prs.clone());
110 }
111 }
112 let repo_arg = repo.to_string();
113 let text = tokio::task::spawn_blocking(move || {
114 run_gh_agent(&["pr", "list", "--repo", &repo_arg, "--state", "open", "--limit", LIST_LIMIT, "--json", PR_FIELDS])
115 })
116 .await
117 .map_err(|e| format!("gh-agent: {e}"))??;
118 let prs = parse_pr_list(&text)?;
119 cache().insert(repo.to_string(), (Instant::now(), prs.clone()));
120 Ok(prs)
121}
122
123fn run_gh_agent(args: &[&str]) -> Result<String, String> {
125 use std::io::Read;
126 use std::process::{Command, Stdio};
127 let mut cmd = Command::new(GH_AGENT);
128 cmd.args(args)
129 .stdin(Stdio::null())
130 .stdout(Stdio::piped())
131 .stderr(Stdio::piped());
132 #[cfg(windows)]
133 {
134 use agentmux_common::win32::NoWindow;
135 cmd.no_window();
136 }
137 let mut child = cmd.spawn().map_err(|e| format!("can't run gh-agent: {e}"))?;
138 let mut stdout = child.stdout.take().ok_or("no stdout")?;
139 let mut stderr = child.stderr.take().ok_or("no stderr")?;
140 let out = std::thread::spawn(move || {
141 let mut s = String::new();
142 let _ = stdout.read_to_string(&mut s);
143 s
144 });
145 let err = std::thread::spawn(move || {
146 let mut s = String::new();
147 let _ = stderr.read_to_string(&mut s);
148 s
149 });
150 let deadline = Instant::now() + GH_TIMEOUT;
151 let status = loop {
152 match child.try_wait() {
153 Ok(Some(status)) => break status,
154 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
155 Ok(None) => {
156 let _ = child.kill();
157 let _ = child.wait();
158 return Err(format!("gh-agent took over {} s", GH_TIMEOUT.as_secs()));
159 }
160 Err(e) => return Err(format!("gh-agent: {e}")),
161 }
162 };
163 let (out, err) = (out.join().unwrap_or_default(), err.join().unwrap_or_default());
164 if !status.success() {
165 let why = err.lines().filter(|l| !l.starts_with("gh-agent:")).collect::<Vec<_>>().join(" ");
167 return Err(format!("gh-agent {} failed: {}", args.iter().take(2).copied().collect::<Vec<_>>().join(" "), why.trim()));
168 }
169 Ok(out)
170}
171
172#[derive(Debug, Default)]
174pub(super) struct Asked<'a> {
175 pub path: Option<&'a str>,
177 pub branch: Option<&'a str>,
178 pub query: Option<&'a str>,
179 pub pr: Option<u64>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub(super) struct PrMatch {
184 pub agent: String,
185 pub host: Option<String>,
186 pub detail: String,
187}
188
189fn under(file: &str, path: &str) -> bool {
190 let path = path.trim_end_matches('/');
191 file == path || file.strip_prefix(path).is_some_and(|rest| rest.starts_with('/'))
192}
193
194fn files_phrase(files: &[&String]) -> String {
195 let named: Vec<&str> = files.iter().take(FILES_NAMED).map(|f| f.as_str()).collect();
196 match files.len().saturating_sub(FILES_NAMED) {
197 0 => named.join(", "),
198 more => format!("{} and {more} more", named.join(", ")),
199 }
200}
201
202pub(super) fn pr_matches(prs: &[OpenPr], asked: &Asked, me_agent: &str, me_host: &str) -> Result<Vec<PrMatch>, String> {
206 let mine = |p: &OpenPr| {
207 p.agent.eq_ignore_ascii_case(me_agent) && p.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(me_host))
208 };
209 let asked_files: Option<&[String]> = match asked.pr {
210 Some(n) => Some(
211 &prs.iter()
212 .find(|p| p.number == n)
213 .ok_or_else(|| format!("PR #{n} is not an open PR in this repository"))?
214 .files,
215 ),
216 None => None,
217 };
218 let path = asked.path.filter(|p| !p.is_empty());
219 let words: Vec<String> = asked.query.map(|q| q.split_whitespace().map(str::to_lowercase).collect()).unwrap_or_default();
220 let mut out = Vec::new();
221 for p in prs.iter().filter(|p| !mine(p) && Some(p.number) != asked.pr) {
222 let label = format!("PR #{} ({})", p.number, p.title);
223 let detail = if let Some(files) = asked_files {
224 let shared: Vec<&String> = p.files.iter().filter(|f| files.contains(f)).collect();
225 if shared.is_empty() {
226 continue;
227 }
228 format!("{label} also changes {}", files_phrase(&shared))
229 } else if path.is_none() && asked.branch.is_none() && words.is_empty() {
230 format!("{label} changes {} files", p.files.len())
231 } else {
232 let mut why = Vec::new();
234 if let Some(path) = path {
235 let touched: Vec<&String> = p.files.iter().filter(|f| under(f, path)).collect();
236 if !touched.is_empty() {
237 why.push(format!("changes {}", files_phrase(&touched)));
238 }
239 }
240 if let Some(branch) = asked.branch.filter(|b| p.branch == *b) {
241 why.push(format!("is on branch {branch}"));
242 }
243 let title = p.title.to_lowercase();
244 if !words.is_empty() && words.iter().all(|w| title.contains(w.as_str())) {
245 why.push("matches the query".to_string());
246 }
247 if why.is_empty() {
248 continue;
249 }
250 format!("{label} {}", why.join("; "))
251 };
252 out.push(PrMatch { agent: p.agent.clone(), host: p.host.clone(), detail });
253 }
254 Ok(out)
255}
256
257pub(super) fn merge_into(body: &mut Value, matches: Vec<PrMatch>, me_host: &str, repo: &str) {
261 let Some(agents) = body.get_mut("agents").and_then(Value::as_array_mut) else {
262 return;
263 };
264 for m in matches {
265 let entry = json!({ "kind": "pr", "detail": m.detail, "since_ms": null });
266 let local = m.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(me_host));
267 let existing = agents.iter_mut().find(|a| {
268 let name = a.get("agent").and_then(Value::as_str).unwrap_or_default();
269 let host = a.get("host").and_then(Value::as_str);
270 name.eq_ignore_ascii_case(&m.agent)
271 && match (host, m.host.as_deref()) {
272 (Some(h), Some(mh)) => h.eq_ignore_ascii_case(mh),
273 (Some(_), None) => false,
274 (None, _) => local,
275 }
276 });
277 match existing {
278 Some(a) => {
279 if let Some(list) = a.get_mut("matches").and_then(Value::as_array_mut) {
280 list.push(entry);
281 }
282 }
283 None => agents.push(json!({
284 "agent": m.agent,
285 "host": m.host,
286 "channel": "github",
287 "status": "open PR",
288 "goal": null,
289 "repo": repo,
290 "branch": null,
291 "git_checked_ms": null,
292 "matches": [entry],
293 })),
294 }
295 }
296}
297
298pub(super) fn this_host() -> String {
300 sysinfo::System::host_name().unwrap_or_default()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn pr(number: u64, title: &str, branch: &str, files: &[&str]) -> OpenPr {
308 let (agent, host) = title_author(title).map(|(a, h)| (a, Some(h))).unwrap_or(("someone".into(), None));
309 OpenPr { number, title: title.into(), branch: branch.into(), files: files.iter().map(|f| f.to_string()).collect(), agent, host }
310 }
311
312 #[test]
313 fn the_title_prefix_names_the_agent() {
314 assert_eq!(title_author("AgentY@narko: fix(x): y"), Some(("AgentY".into(), "narko".into())));
315 assert_eq!(title_author("Manoz@Area54: chore: z"), Some(("Manoz".into(), "Area54".into())));
316 assert_eq!(title_author("chore(deps): bump serde"), None);
317 assert_eq!(title_author("Bump a@b from 1 to 2"), None);
318 assert_eq!(title_author("a b@c: x"), None);
319 }
320
321 #[test]
322 fn reads_gh_output_crediting_a_pr_without_the_prefix_to_its_author() {
323 let text = r#"[
324 {"number": 12, "title": "Agent4@narko: feat: x", "headRefName": "agent4/x", "author": {"login": "app/muxagent"},
325 "files": [{"path": "crates/a.rs", "additions": 1, "deletions": 0}]},
326 {"number": 13, "title": "Bump serde from 1.0 to 1.1", "headRefName": "dependabot/x",
327 "author": {"login": "app/dependabot"}, "files": [{"path": "Cargo.lock"}]}
328 ]"#;
329 let prs = parse_pr_list(text).unwrap();
330 assert_eq!(prs[0], pr(12, "Agent4@narko: feat: x", "agent4/x", &["crates/a.rs"]));
331 assert_eq!((prs[1].agent.as_str(), prs[1].host.as_deref()), ("app/dependabot", None));
332 assert_eq!(prs[1].files, vec!["Cargo.lock".to_string()]);
333 assert!(parse_pr_list("not json").is_err());
334 }
335
336 fn sample() -> Vec<OpenPr> {
337 vec![
338 pr(10, "AgentY@narko: mine", "agenty/x", &["crates/srv/a.rs"]),
339 pr(11, "Agent4@narko: presence", "agent4/p", &["crates/srv/a.rs", "crates/srv/b.rs"]),
340 pr(12, "Lark@area54: widgets catalog", "lark/w", &["frontend/w.ts"]),
341 pr(13, "AgentY@area54: same name, other computer", "agenty/y", &["crates/srv/a.rs"]),
342 ]
343 }
344
345 #[test]
346 fn a_path_matches_prs_changing_it_or_anything_under_it() {
347 let asked = Asked { path: Some("crates/srv"), ..Default::default() };
348 let got = pr_matches(&sample(), &asked, "AgentY", "narko").unwrap();
349 let names: Vec<_> = got.iter().map(|m| (m.agent.as_str(), m.host.as_deref())).collect();
350 assert_eq!(names, vec![("Agent4", Some("narko")), ("AgentY", Some("area54"))], "my own PR is left out, my namesake isn't");
351 assert_eq!(got[0].detail, "PR #11 (Agent4@narko: presence) changes crates/srv/a.rs, crates/srv/b.rs");
352 let file = Asked { path: Some("crates/srv/b.rs"), ..Default::default() };
353 assert_eq!(pr_matches(&sample(), &file, "AgentY", "narko").unwrap().len(), 1);
354 let not_a_prefix = Asked { path: Some("crates/sr"), ..Default::default() };
355 assert!(pr_matches(&sample(), ¬_a_prefix, "AgentY", "narko").unwrap().is_empty());
356 }
357
358 #[test]
359 fn a_pr_number_finds_other_prs_changing_the_same_files() {
360 let asked = Asked { pr: Some(10), ..Default::default() };
361 let got = pr_matches(&sample(), &asked, "Someone", "else").unwrap();
362 assert_eq!(got.iter().map(|m| m.detail.as_str()).collect::<Vec<_>>(), vec![
363 "PR #11 (Agent4@narko: presence) also changes crates/srv/a.rs",
364 "PR #13 (AgentY@area54: same name, other computer) also changes crates/srv/a.rs",
365 ]);
366 assert!(pr_matches(&sample(), &Asked { pr: Some(99), ..Default::default() }, "x", "y").is_err());
367 }
368
369 #[test]
372 fn a_pr_without_the_prefix_can_be_asked_about_and_is_listed() {
373 let mut prs = sample();
374 prs.push(pr(30, "Bump serde", "dependabot/serde", &["crates/srv/a.rs"]));
375 let asked = Asked { pr: Some(30), ..Default::default() };
376 let got = pr_matches(&prs, &asked, "AgentY", "narko").unwrap();
377 assert_eq!(got.len(), 2, "#11 and #13 share its file; my own #10 is left out");
378 let by_path = pr_matches(&prs, &Asked { path: Some("crates/srv/a.rs"), ..Default::default() }, "AgentY", "narko").unwrap();
379 assert!(by_path.iter().any(|m| m.agent == "someone" && m.host.is_none()));
380 }
381
382 #[test]
383 fn a_branch_a_query_or_the_whole_repository() {
384 let branch = Asked { branch: Some("lark/w"), ..Default::default() };
385 assert_eq!(pr_matches(&sample(), &branch, "AgentY", "narko").unwrap()[0].agent, "Lark");
386 let query = Asked { query: Some("Widgets CATALOG"), ..Default::default() };
387 assert_eq!(pr_matches(&sample(), &query, "AgentY", "narko").unwrap().len(), 1);
388 let all = pr_matches(&sample(), &Asked { path: Some(""), ..Default::default() }, "AgentY", "narko").unwrap();
389 assert_eq!(all.len(), 3);
390 assert_eq!(all[1].detail, "PR #12 (Lark@area54: widgets catalog) changes 1 files");
391 }
392
393 #[test]
395 fn every_selector_given_counts() {
396 let asked = Asked { path: Some("crates/srv/b.rs"), branch: Some("lark/w"), query: Some("presence"), ..Default::default() };
397 let got = pr_matches(&sample(), &asked, "AgentY", "narko").unwrap();
398 let details: Vec<_> = got.iter().map(|m| m.detail.as_str()).collect();
399 assert_eq!(details, vec![
400 "PR #11 (Agent4@narko: presence) changes crates/srv/b.rs; matches the query",
401 "PR #12 (Lark@area54: widgets catalog) is on branch lark/w",
402 ]);
403 }
404
405 #[test]
406 fn many_files_are_counted() {
407 let p = pr(20, "A@h: big", "a/b", &["d/1", "d/2", "d/3", "d/4", "d/5"]);
408 let got = pr_matches(&[p], &Asked { path: Some("d"), ..Default::default() }, "x", "y").unwrap();
409 assert_eq!(got[0].detail, "PR #20 (A@h: big) changes d/1, d/2, d/3 and 2 more");
410 }
411
412 #[test]
413 fn matches_join_a_local_agent_or_stand_alone() {
414 let mut body = json!({ "agents": [
415 { "agent": "Agent4", "channel": "local-main", "matches": [{ "kind": "dirty", "detail": "x" }] }
416 ]});
417 let matches = vec![
418 PrMatch { agent: "agent4".into(), host: Some("narko".into()), detail: "PR #11".into() },
419 PrMatch { agent: "Agent4".into(), host: Some("area54".into()), detail: "PR #14".into() },
420 PrMatch { agent: "app/dependabot".into(), host: None, detail: "PR #15".into() },
421 ];
422 merge_into(&mut body, matches, "narko", "o/r");
423 let agents = body["agents"].as_array().unwrap();
424 assert_eq!(agents.len(), 3);
425 assert_eq!(agents[2]["host"], Value::Null);
426 assert_eq!(agents[0]["matches"][1], json!({ "kind": "pr", "detail": "PR #11", "since_ms": null }));
427 assert_eq!(agents[1]["host"], "area54");
428 assert_eq!(agents[1]["channel"], "github");
429 }
430}