Skip to main content

agentmux_mcp/tools/
open_prs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Open PRs in `WhoIsWorkingOn` (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md
5//! §3.5). They come from GitHub, so they cover agents on every computer. They
6//! are read through this agent's own `gh-agent` (srv holds no GitHub
7//! credential), credited to the agent named by the `<Agent>@<host>: ` title
8//! prefix CI requires, and cached for 2 minutes per repository. A PR without
9//! that prefix (a person's or a bot's, which the prefix rule exempts) is
10//! credited to its GitHub author, with no host.
11
12use 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);
19/// `gh pr list --limit` is a cap on results, not a page size: high enough
20/// for every open PR of any repository here.
21const LIST_LIMIT: &str = "1000";
22const PR_FIELDS: &str = "number,title,headRefName,files,author";
23const GH_TIMEOUT: Duration = Duration::from_secs(20);
24/// Files named in one match's detail; the rest are counted.
25const 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    /// From the title prefix, else the PR's GitHub author.
39    pub agent: String,
40    /// From the title prefix; `None` without one.
41    pub host: Option<String>,
42}
43
44/// `AgentY@narko: fix(x): …` → `("AgentY", "narko")`.
45pub(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
52/// `gh pr list --json number,title,headRefName,files,author`'s output.
53pub(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
59/// One PR of `gh`'s JSON.
60fn 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
84/// PR `number` of `repo` read on its own (`gh pr view`), when it is open:
85/// for a PR asked about by number that the list didn't hold.
86pub(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
97/// Repository → when its PRs were read, and them.
98type 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
105/// The repository's open PRs, from the cache when it is under 2 minutes old.
106pub(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
123/// `gh-agent <args>`'s output, killed after [`GH_TIMEOUT`].
124fn 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        // Its own status lines start with `gh-agent:`; the error is the rest.
166        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/// What the caller asked, as srv resolved it (`asked`), plus `pr`.
173#[derive(Debug, Default)]
174pub(super) struct Asked<'a> {
175    /// Repository-relative; `""` or `None` is the whole repository.
176    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
202/// The open PRs that touch what was asked, each credited to its agent. The
203/// caller's own PRs (same agent and host) are left out. `Err` when `pr` names
204/// no open PR here.
205pub(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            // Every selector given, like srv's answer: a PR matching any one is in.
233            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
257/// Add `matches` to `body.agents` as `pr` matches: to the local agent of
258/// that name when the PR is from this computer, else as an agent of its own
259/// (`channel: "github"`).
260pub(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
298/// This computer's name, as the `<Agent>@<host>` prefix writes it.
299pub(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(), &not_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    /// A person's or a bot's PR (no prefix) can be asked about, and shows as
370    /// its author's.
371    #[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    /// Like srv, a PR matching any of the selectors given is in.
394    #[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}