1use super::*;
9
10fn who_query(arguments: &Value, agent: &str, block_id: &str) -> Vec<(&'static str, String)> {
13 let mut q: Vec<(&'static str, String)> = Vec::new();
14 for key in ["path", "repo", "branch", "query"] {
15 if let Some(v) = arguments.get(key).and_then(|v| v.as_str()).map(str::trim).filter(|v| !v.is_empty()) {
16 q.push((key, v.to_string()));
17 }
18 }
19 if !agent.trim().is_empty() {
20 q.push(("agent", agent.trim().to_string()));
21 }
22 if !block_id.trim().is_empty() {
23 q.push(("block", block_id.trim().to_string()));
24 }
25 q
26}
27
28pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
29 let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
30 match name {
31 "WhoIsWorkingOn" => {
32 if local_url.is_empty() || auth_key.is_empty() {
33 anyhow::bail!(
34 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
35 Is this agent pane opened via AgentMux?"
36 );
37 }
38 let agent = std::env::var("AGENTMUX_AGENT_ID").unwrap_or_default();
39 let (pr_repo, pr) = match arguments.get("pr").filter(|v| !v.is_null()) {
40 Some(v) => {
41 let (repo, n) = pr_ref(v)
42 .ok_or_else(|| anyhow::anyhow!("pr must be a PR number (12, \"#12\") or its GitHub URL"))?;
43 (repo, Some(n))
44 }
45 None => (None, None),
46 };
47 let mut query = who_query(arguments, &agent, block_id);
48 if let Some(url_repo) = pr_repo {
50 match arg(arguments, "repo") {
51 Some(r) if normalize_repo(r) != normalize_repo(&url_repo) => {
52 anyhow::bail!("pr is a PR of {url_repo}, but repo is {r}: pass one or the other");
53 }
54 Some(_) => {}
55 None => query.push(("repo", url_repo)),
56 }
57 }
58 let url = format!("{}/api/v1/work-facts/who", local_url.trim_end_matches('/'));
59 let mut body = srv_get_json(client, &url, auth_key, &query, "WhoIsWorkingOn").await?;
60 add_open_prs(&mut body, arguments, pr, &agent, |path| {
61 let mut q = query.clone();
65 q.retain(|(k, _)| matches!(*k, "repo" | "agent" | "block"));
66 q.push(("path", path));
67 let url = url.clone();
68 async move { srv_get_json(client, &url, auth_key, &q, "WhoIsWorkingOn").await.ok() }
69 })
70 .await;
71 Ok(serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()))
72 }
73 "ClaimWork" | "ReleaseWork" => {
74 if local_url.is_empty() || auth_key.is_empty() {
75 anyhow::bail!(
76 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
77 Is this agent pane opened via AgentMux?"
78 );
79 }
80 let agent = std::env::var("AGENTMUX_AGENT_ID").unwrap_or_default();
81 let base = local_url.trim_end_matches('/');
82 let (url, body) = if name == "ClaimWork" {
83 (format!("{base}/api/v1/work-claims"), claim_body(arguments, &agent, block_id))
84 } else {
85 (format!("{base}/api/v1/work-claims/release"), release_body(arguments, &agent, block_id))
86 };
87 let answer = srv_post_json(client, &url, auth_key, &body, name).await?;
88 Ok(serde_json::to_string_pretty(&answer).unwrap_or_else(|_| answer.to_string()))
89 }
90 _ => Err(not_in_family()),
91 }
92}
93
94fn pr_ref(v: &Value) -> Option<(Option<String>, u64)> {
99 if let Some(n) = v.as_u64() {
100 return (n > 0).then_some((None, n));
101 }
102 let s = v.as_str()?.trim();
103 let number = |t: &str| t.parse::<u64>().ok().filter(|n| *n > 0);
104 let Some((before, after)) = s.split_once("/pull/") else {
105 return number(s.trim_start_matches('#')).map(|n| (None, n));
106 };
107 let digits: String = after.chars().take_while(char::is_ascii_digit).collect();
108 let rest = &after[digits.len()..];
109 if !(rest.is_empty() || rest.starts_with(['/', '#', '?'])) {
110 return None;
111 }
112 let mut parts = before.rsplit('/');
113 let (repo, owner) = (parts.next()?, parts.next()?);
114 if repo.is_empty() || owner.is_empty() || owner.contains(':') {
115 return None;
116 }
117 Some((Some(format!("{owner}/{repo}")), number(&digits)?))
118}
119
120fn normalize_repo(repo: &str) -> String {
123 let s = repo.trim().trim_end_matches('/').trim_end_matches(".git").replace(':', "/");
124 let mut parts = s.rsplit('/').filter(|p| !p.is_empty());
125 match (parts.next(), parts.next()) {
126 (Some(name), Some(owner)) => format!("{owner}/{name}").to_ascii_lowercase(),
127 _ => s.to_ascii_lowercase(),
128 }
129}
130
131const PR_FILES_CHECKED: usize = 10;
133
134async fn add_open_prs<F, Fut>(body: &mut Value, arguments: &Value, pr: Option<u64>, me: &str, ask_path: F)
140where
141 F: Fn(String) -> Fut,
142 Fut: std::future::Future<Output = Option<Value>>,
143{
144 let repo = body.pointer("/asked/repo").and_then(Value::as_str).map(str::to_string);
145 let Some(repo) = repo.filter(|r| r.contains('/') && !r.starts_with("local:")) else {
146 if pr.is_some() {
147 body["pr_error"] = json!("pr needs a repository on GitHub (owner/repo)");
148 }
149 return;
150 };
151 let mut prs = match open_prs::open_prs(&repo).await {
152 Ok(prs) => prs,
153 Err(e) => {
154 body["pr_error"] = json!(e);
155 return;
156 }
157 };
158 if let Some(n) = pr.filter(|n| !prs.iter().any(|p| p.number == *n)) {
160 match open_prs::open_pr(&repo, n).await {
161 Ok(Some(p)) => prs.push(p),
162 Ok(None) => {}
163 Err(e) => {
164 body["pr_error"] = json!(e);
165 return;
166 }
167 }
168 }
169 let host = open_prs::this_host();
170 let path = body.pointer("/asked/path").and_then(Value::as_str).map(str::to_string);
171 let asked = open_prs::Asked {
172 path: path.as_deref(),
173 branch: arg(arguments, "branch"),
174 query: arg(arguments, "query"),
175 pr,
176 };
177 let matches = match open_prs::pr_matches(&prs, &asked, me, &host) {
178 Ok(m) => m,
179 Err(e) => {
180 body["pr_error"] = json!(e);
181 return;
182 }
183 };
184 if let Some(files) = pr.and_then(|n| prs.iter().find(|p| p.number == n)).map(|p| p.files.clone()) {
185 let mut agents: Vec<Value> = Vec::new();
186 for file in files.iter().take(PR_FILES_CHECKED) {
187 if let Some(answer) = ask_path(file.clone()).await {
188 merge_agents(&mut agents, answer.get("agents").and_then(Value::as_array).cloned().unwrap_or_default());
189 }
190 }
191 body["agents"] = Value::Array(agents);
192 if files.len() > PR_FILES_CHECKED {
193 body["pr_note"] = json!(format!(
194 "the PR changes {} files; agents on this computer were checked against the first {PR_FILES_CHECKED}",
195 files.len()
196 ));
197 }
198 }
199 open_prs::merge_into(body, matches, &host, &repo);
200}
201
202fn merge_agents(into: &mut Vec<Value>, from: Vec<Value>) {
205 for agent in from {
206 let key = |a: &Value| (a.get("agent").cloned(), a.get("channel").cloned());
207 match into.iter_mut().find(|a| key(a) == key(&agent)) {
208 Some(existing) => {
209 let new = agent.get("matches").and_then(Value::as_array).cloned().unwrap_or_default();
210 if let Some(list) = existing.get_mut("matches").and_then(Value::as_array_mut) {
211 for m in new {
212 if !list.contains(&m) {
213 list.push(m);
214 }
215 }
216 }
217 }
218 None => into.push(agent),
219 }
220 }
221}
222
223fn arg<'a>(arguments: &'a Value, key: &str) -> Option<&'a str> {
225 arguments.get(key).and_then(|v| v.as_str()).map(str::trim).filter(|v| !v.is_empty())
226}
227
228fn claim_body(arguments: &Value, agent: &str, block_id: &str) -> Value {
231 let mut body = serde_json::Map::new();
232 for key in ["path", "repo", "branch", "topic", "note"] {
233 if let Some(v) = arg(arguments, key) {
234 body.insert(key.into(), json!(v));
235 }
236 }
237 if let Some(m) = arguments.get("ttl_minutes").and_then(|v| v.as_u64()) {
238 body.insert("ttl_minutes".into(), json!(m));
239 }
240 body.insert("agent".into(), json!(agent.trim()));
241 body.insert("block".into(), json!(block_id.trim()));
242 Value::Object(body)
243}
244
245fn release_body(arguments: &Value, agent: &str, block_id: &str) -> Value {
247 json!({ "id": arg(arguments, "id"), "agent": agent.trim(), "block": block_id.trim() })
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn a_pr_is_a_number_a_hash_number_or_a_url_naming_its_repository() {
256 assert_eq!(pr_ref(&json!(12)), Some((None, 12)));
257 assert_eq!(pr_ref(&json!("12")), Some((None, 12)));
258 assert_eq!(pr_ref(&json!("#12")), Some((None, 12)));
259 let url = |u: &str| pr_ref(&json!(u));
260 assert_eq!(url("https://github.com/o/r/pull/4683"), Some((Some("o/r".into()), 4683)));
261 assert_eq!(url("https://github.com/o/r/pull/4683/"), Some((Some("o/r".into()), 4683)));
262 assert_eq!(url("https://github.com/o/r/pull/4683/files"), Some((Some("o/r".into()), 4683)));
263 assert_eq!(url("https://github.com/o/r/pull/4683#issuecomment-1"), Some((Some("o/r".into()), 4683)));
264 assert_eq!(url("https://github.com/o/r/pull/4683x"), None);
265 assert_eq!(url("https://github.com/o/r/issues/12"), None);
266 assert_eq!(url("main"), None);
267 assert_eq!(pr_ref(&json!(0)), None);
268 }
269
270 #[test]
271 fn repositories_are_compared_whole_not_by_suffix() {
272 assert_eq!(normalize_repo("Foo/Bar"), "foo/bar");
273 assert_eq!(normalize_repo("https://github.com/foo/bar.git"), "foo/bar");
274 assert_eq!(normalize_repo("git@github.com:foo/bar"), "foo/bar");
275 assert_ne!(normalize_repo("notfoo/bar"), normalize_repo("foo/bar"));
276 }
277
278 #[test]
279 fn answers_for_several_files_merge_by_agent() {
280 let mut agents = Vec::new();
281 merge_agents(&mut agents, vec![json!({ "agent": "A", "channel": "c", "matches": [{ "kind": "dirty", "detail": "x" }] })]);
282 merge_agents(&mut agents, vec![
283 json!({ "agent": "A", "channel": "c", "matches": [{ "kind": "dirty", "detail": "x" }, { "kind": "edited", "detail": "y" }] }),
284 json!({ "agent": "B", "channel": "c", "matches": [] }),
285 ]);
286 assert_eq!(agents.len(), 2);
287 assert_eq!(agents[0]["matches"].as_array().unwrap().len(), 2);
288 }
289
290 #[test]
291 fn only_given_arguments_and_the_callers_identity_are_sent() {
292 let q = who_query(&json!({ "path": " crates/srv ", "query": "", "repo": null, "pr": 12 }), "AgentY", "blk");
293 assert_eq!(
294 q,
295 vec![("path", "crates/srv".to_string()), ("agent", "AgentY".to_string()), ("block", "blk".to_string())]
296 );
297 assert!(who_query(&json!({}), "", "").is_empty());
298 }
299
300 #[test]
301 fn claims_send_only_what_was_given_plus_the_callers_identity() {
302 let body = claim_body(
303 &json!({ "path": " crates/srv ", "topic": "", "note": "presence work", "ttl_minutes": 45, "repo": null }),
304 "AgentY",
305 "blk",
306 );
307 assert_eq!(
308 body,
309 json!({ "path": "crates/srv", "note": "presence work", "ttl_minutes": 45, "agent": "AgentY", "block": "blk" })
310 );
311 assert_eq!(release_body(&json!({}), "AgentY", "blk"), json!({ "id": null, "agent": "AgentY", "block": "blk" }));
312 assert_eq!(release_body(&json!({ "id": "c1" }), "AgentY", "")["id"], "c1");
313 }
314
315 #[tokio::test]
316 async fn without_agentmux_env_it_says_so() {
317 let client = reqwest::Client::new();
318 let loops = LoopRegistry::default();
319 let counter = std::sync::atomic::AtomicU64::new(0);
320 let cx = ToolCtx { local_url: "", auth_key: "", block_id: "", client: &client, loops: &loops, loop_counter: &counter };
321 for tool in ["WhoIsWorkingOn", "ClaimWork", "ReleaseWork"] {
322 let err = call(tool, &json!({ "path": "a" }), &cx).await.unwrap_err();
323 assert!(err.to_string().contains("AGENTMUX_LOCAL_URL"), "{tool}");
324 }
325 }
326}