Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
851 linesCodeBlameRaw

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.

g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1//! The tools Ask g1t answers with, and what it may do with them.
2//!
3//! **g1t's own tools.** Ask is an MCP client of g1t's own MCP server: it
4//! lists the tools there and calls them, so the model gets exactly the tool
5//! definitions any agent gets, and every call goes through the API's own
6//! permission checks, audit log and rate limits. It calls them with a
7//! credential identity makes for the one answer (`create_ask_token`): the
8//! person's own access, read scopes only, for the one workspace, minutes
9//! long, revoked when the answer ends. The model never sees it, nor does
10//! the browser. The server lists only the actions the credential may use,
11//! and refuses any other.
12//!
13//! **What Ask may do** is decided here, per call, before anything runs
14//! ([`Toolbox::decide`]): a read runs; anything else is refused. Ask never
15//! does the work itself: when someone asks for work, it hands it to a
16//! session in their own harness. Starting that session is a [`Kind::Dispatch`]
17//! (and so is opening the issue the work hangs off): it will run only after
18//! the person confirms it, which is the next step and not built yet.
19//!
20//! **g1t-native tools**, that are not operations of the MCP server, are
21//! registered in [`native`]: `start_session` goes there when sessions can
22//! be started on the workspace's own Anthropic key.
23//!
24//! The rest is plain functions, tested on their own: a step's label, link
25//! and summary for the person watching, a result trimmed to fit, and the
26//! pages an answer drew on.
27
28use std::collections::{BTreeMap, HashSet};
29
30use g1t_contracts::integrations::{ASK_MAX_SOURCES, AskEvent, AskSource};
31use serde_json::{Map, Value, json};
32use worker::Method;
33
34use crate::ask::ToolSpec;
35use crate::http;
36
37/// The MCP protocol version Ask speaks.
38const PROTOCOL: &str = "2025-06-18";
39
40/// g1t's MCP server, as the person asking.
41#[derive(Clone)]
42pub struct Mcp {
43 pub url: String,
44 pub token: String,
45}
46
47/// A tool as the MCP server lists it.
48#[derive(Clone, Debug, PartialEq)]
49pub struct Listed {
50 pub spec: ToolSpec,
51 /// Every action the caller may use only reads.
52 pub read_only: bool,
53 /// The actions offered, from the schema's `action` enum.
54 pub actions: Vec<String>,
55 /// The action a call without one runs.
56 pub default_action: Option<String>,
57}
58
59impl Mcp {
60 async fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
61 let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
62 let authorization = format!("Bearer {}", self.token);
63 let headers = [
64 ("authorization", authorization.as_str()),
65 ("content-type", "application/json"),
66 ("accept", "application/json, text/event-stream"),
67 ("mcp-protocol-version", PROTOCOL),
68 ];
69 let answer = http::send(Method::Post, &self.url, &headers, Some(body))
70 .await
71 .map_err(|error| format!("g1t could not be reached: {error}"))?;
72 if !answer.ok() {
73 return Err(match answer.status {
74 401 => "g1t did not accept Ask's credential.".to_owned(),
75 429 => "g1t is busy: too many requests. Try again in a minute.".to_owned(),
76 status => format!("g1t answered {status}."),
77 });
78 }
79 let said = answer.json();
80 if let Some(message) = said["error"]["message"].as_str() {
81 return Err(message.to_owned());
82 }
83 Ok(said["result"].clone())
84 }
85
86 /// The tools the person may use, as the server lists them for them.
87 pub async fn list(&self) -> Result<Vec<Listed>, String> {
88 let result = self.rpc("tools/list", json!({})).await?;
89 Ok(listed(&result))
90 }
91
92 /// Runs a tool: its result as text, and whether it is a refusal or an
93 /// error.
94 pub async fn call(&self, name: &str, arguments: &Value) -> Result<(String, bool), String> {
95 let result = self.rpc("tools/call", json!({ "name": name, "arguments": arguments })).await?;
96 let text: Vec<&str> = result["content"]
97 .as_array()
98 .into_iter()
99 .flatten()
100 .filter(|part| part["type"] == "text")
101 .filter_map(|part| part["text"].as_str())
102 .collect();
103 Ok((text.join("\n"), result["isError"].as_bool() == Some(true)))
104 }
105}
106
107/// `tools/list`'s result read.
108pub fn listed(result: &Value) -> Vec<Listed> {
109 result["tools"]
110 .as_array()
111 .into_iter()
112 .flatten()
113 .filter_map(|tool| {
114 let name = tool["name"].as_str().filter(|name| !name.is_empty())?;
115 let schema = tool["inputSchema"].clone();
116 let action = &schema["properties"]["action"];
117 Some(Listed {
118 spec: ToolSpec {
119 name: name.to_owned(),
120 description: tool["description"].as_str().unwrap_or_default().to_owned(),
121 input_schema: if schema.is_object() { schema.clone() } else { json!({ "type": "object" }) },
122 },
123 read_only: tool["annotations"]["readOnlyHint"].as_bool() == Some(true),
124 actions: action["enum"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(),
125 default_action: action["default"].as_str().map(str::to_owned),
126 })
127 })
128 .collect()
129}
130
131/// What a tool does, for deciding whether Ask may run it.
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub enum Kind {
134 /// Reads: runs.
135 Read,
136 /// Hands work off: starts a session in the person's own harness, or
137 /// opens the issue the work hangs off. Runs only once the person
138 /// confirms it: the answer shows it as a proposal to start.
139 Dispatch,
140 /// Anything else Ask does not do.
141 Change,
142}
143
144/// Whether a call runs.
145#[derive(Debug, PartialEq)]
146pub enum Decision {
147 Run,
148 /// Needs the person's yes first.
149 Confirm,
150 Refuse(String),
151}
152
153/// Where a tool comes from.
154#[derive(Clone, Debug, PartialEq)]
155enum Origin {
156 Mcp { actions: Vec<String>, default_action: Option<String> },
157 Native,
158}
159
160#[derive(Clone, Debug)]
161struct Entry {
162 origin: Origin,
163 kind: Kind,
164}
165
166/// The tools one answer may use.
167#[derive(Clone, Debug, Default)]
168pub struct Toolbox {
169 specs: Vec<ToolSpec>,
170 entries: BTreeMap<String, Entry>,
171}
172
173/// A g1t-native tool: one that is not an operation of the MCP server.
174pub struct Native {
175 pub spec: ToolSpec,
176 pub kind: Kind,
177}
178
179/// The g1t-native tool that hands work off: Ask proposes a session and the
180/// person starts it from the card the answer shows. The click is the
181/// confirmation, so the model never starts anything itself.
182pub const PROPOSE_SESSION: &str = "propose_session";
183
184/// The g1t-native tools Ask offers: `propose_session`, a
185/// [`Kind::Dispatch`] that the loop turns into a proposal event rather
186/// than running.
187pub fn native() -> Vec<Native> {
188 vec![Native {
189 spec: ToolSpec {
190 name: PROPOSE_SESSION.to_owned(),
191 description: "Propose handing work to a coding session in the person's own harness (their Claude, with their skills and subagents), tied to an issue. Use it when they ask for work to be done: planning, implementing, fixing or reviewing. It starts nothing: the person sees a card to start the session on their workspace's Anthropic account, or to copy a Claude Code command to run it themselves. Give `issue` when the work has one (read it first), else `title` and `body` for a new issue. Propose one session per piece of work."
192 .to_owned(),
193 input_schema: json!({
194 "type": "object",
195 "properties": {
196 "repo": { "type": "string", "description": "The repository, owner/name." },
197 "issue": { "type": "integer", "description": "The issue the work belongs to." },
198 "title": { "type": "string", "description": "A new issue's title, when there is no issue yet." },
199 "body": { "type": "string", "description": "A new issue's description: what to do and what done means." },
200 "mode": { "type": "string", "enum": ["plan", "implement", "review"], "description": "plan: work out how and post the plan, no code. implement: make the change and open a pull request. review: review the issue's pull request." },
201 "instruction": { "type": "string", "description": "Anything the session should know beyond the issue." }
202 },
203 "required": ["repo", "mode"]
204 }),
205 },
206 kind: Kind::Dispatch,
207 }]
208}
209
210/// A proposal from a `propose_session` call, or what is wrong with it.
211pub fn proposal(id: &str, arguments: &Value) -> Result<AskEvent, String> {
212 let text = |key: &str| arguments[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned);
213 let repo = text("repo").filter(|repo| repo.split('/').count() == 2).ok_or("Give the repository as owner/name.")?;
214 let issue = arguments["issue"].as_u64().filter(|number| *number > 0);
215 let title = text("title");
216 if issue.is_none() && title.is_none() {
217 return Err("Give the issue the work belongs to, or a title and body for a new one.".to_owned());
218 }
219 let mode = match text("mode").as_deref() {
220 Some(mode @ ("plan" | "implement" | "review")) => mode.to_owned(),
221 _ => return Err("Give a mode: plan, implement or review.".to_owned()),
222 };
223 let clip = |value: Option<String>, max: usize| value.map(|value| value.chars().take(max).collect::<String>());
224 Ok(AskEvent::Proposal {
225 id: id.to_owned(),
226 repo,
227 issue,
228 title: if issue.is_some() { None } else { clip(title, 200) },
229 body: if issue.is_some() { None } else { clip(text("body"), 8_000) },
230 mode,
231 instruction: clip(text("instruction"), 4_000),
232 })
233}
234
235/// Runs a g1t-native tool. `propose_session` never runs here: the loop
236/// turns it into a proposal.
237pub async fn run_native(name: &str, _arguments: &Value) -> Result<(String, bool), String> {
238 Err(format!("{name} is not something Ask can do."))
239}
240
241impl Toolbox {
242 /// From what the MCP server listed, and the native tools.
243 pub fn new(listed: Vec<Listed>, natives: Vec<Native>) -> Self {
244 let mut toolbox = Toolbox::default();
245 for tool in listed {
246 // A read-only credential lists reads only; a tool that says
247 // otherwise is not one Ask uses.
248 let kind = if tool.read_only { Kind::Read } else { Kind::Change };
249 if kind != Kind::Read {
250 continue;
251 }
252 toolbox.entries.insert(
253 tool.spec.name.clone(),
254 Entry { origin: Origin::Mcp { actions: tool.actions, default_action: tool.default_action }, kind },
255 );
256 toolbox.specs.push(tool.spec);
257 }
258 for tool in natives {
259 if toolbox.entries.contains_key(&tool.spec.name) {
260 continue;
261 }
262 toolbox.entries.insert(tool.spec.name.clone(), Entry { origin: Origin::Native, kind: tool.kind });
263 toolbox.specs.push(tool.spec);
264 }
265 toolbox
266 }
267
268 pub fn specs(&self) -> &[ToolSpec] {
269 &self.specs
270 }
271
272 pub fn is_empty(&self) -> bool {
273 self.specs.is_empty()
274 }
275
276 pub fn is_native(&self, name: &str) -> bool {
277 self.entries.get(name).is_some_and(|entry| entry.origin == Origin::Native)
278 }
279
280 /// Whether a call the model asked for runs.
281 pub fn decide(&self, name: &str, arguments: &Value) -> Decision {
282 let Some(entry) = self.entries.get(name) else {
283 return Decision::Refuse(format!("There is no tool {name}."));
284 };
285 if let Origin::Mcp { actions, default_action } = &entry.origin {
286 let action = arguments["action"].as_str().map(str::to_owned).or_else(|| default_action.clone());
287 match action {
288 Some(action) if actions.is_empty() || actions.contains(&action) => {}
289 Some(action) => {
290 return Decision::Refuse(format!(
291 "{name} {action} is not something Ask can do: it reads g1t and hands work off, and changes nothing itself. Its actions: {}.",
292 actions.join(", ")
293 ));
294 }
295 None if actions.is_empty() => {}
296 None => return Decision::Refuse(format!("Give an action: one of {}.", actions.join(", "))),
297 }
298 }
299 match entry.kind {
300 Kind::Read => Decision::Run,
301 Kind::Dispatch => Decision::Confirm,
302 Kind::Change => Decision::Refuse(format!("{name} changes g1t, which Ask does not do.")),
303 }
304 }
305}
306
307// --- What the person watching sees ------------------------------------------
308
309/// The repository or workspace a call is about, as given.
310fn target(arguments: &Value) -> Option<String> {
311 let repo = arguments["repo"].as_str().or_else(|| arguments["repository"].as_str()).filter(|repo| repo.contains('/'));
312 repo.or_else(|| arguments["workspace"].as_str()).map(str::to_owned).filter(|target| !target.is_empty())
313}
314
315fn noun(tool: &str, plural: bool) -> String {
316 let (one, many) = match tool {
317 "pull_request" => ("pull request", "pull requests"),
318 "issue" => ("issue", "issues"),
319 "repository" => ("repository", "repositories"),
320 "workflow" => ("workflow", "workflows"),
321 "workspace" => ("workspace", "workspaces"),
322 "notifications" => ("notification", "notifications"),
323 "package" => ("package", "packages"),
324 "team" => ("team", "teams"),
325 "artifact" => ("artifact", "artifacts"),
326 "security" => ("security alert", "security alerts"),
327 "observability" => ("production telemetry", "production telemetry"),
328 other => return other.replace('_', " "),
329 };
330 (if plural { many } else { one }).to_owned()
331}
332
333/// One line saying what a call does: "Read pull request #7 in acme/web".
334pub fn label(tool: &str, arguments: &Value) -> String {
335 let action = arguments["action"].as_str().unwrap_or_default();
336 let place = target(arguments).map(|target| format!(" in {target}")).unwrap_or_default();
337 if tool == "search" && action != "ticket" {
338 let query = arguments["query"].as_str().or_else(|| arguments["q"].as_str()).unwrap_or_default();
339 let query: String = query.chars().take(80).collect();
340 return format!("Searched g1t for \u{201c}{query}\u{201d}");
341 }
342 if tool == PROPOSE_SESSION {
343 let what = arguments["issue"].as_u64().map(|number| format!("issue #{number}")).unwrap_or_else(|| "a new issue".to_owned());
344 let repo = arguments["repo"].as_str().unwrap_or("the repository");
345 return format!("Proposed a session on {what} in {repo}");
346 }
347 if tool == "workspace" && action == "activity" {
348 let since = arguments["since"].as_str().map(|since| format!(" since {}", since.trim_end_matches("T00:00:00Z"))).unwrap_or_default();
349 return format!("Read activity{place}{since}");
350 }
351 let number = arguments["number"].as_u64().map(|number| format!(" #{number}")).unwrap_or_default();
352 let (verb, rest) = match action {
353 "" | "get" => ("Read", None),
354 "list" => ("Listed", None),
355 _ => match action.split_once('_') {
356 Some(("list", rest)) => ("Listed", Some(rest)),
357 Some(("get", rest)) => ("Read", Some(rest)),
358 _ => ("Read", Some(action)),
359 },
360 };
361 let what = match rest {
362 None => noun(tool, action == "list" && number.is_empty()),
363 Some(rest) if number.is_empty() => rest.replace('_', " "),
364 Some(rest) => format!("{} {}", noun(tool, false), rest.replace('_', " ")),
365 };
366 // "Read pull request #7 changes" reads better as "Read changes of pull request #7".
367 let what = match (rest, number.is_empty()) {
368 (Some(rest), false) => format!("{} of {}{number}", rest.replace('_', " "), noun(tool, false)),
369 _ => format!("{what}{number}"),
370 };
371 format!("{verb} {what}{place}")
372}
373
374/// The page on the site a call is about, when it names one.
375pub fn link(site: &str, tool: &str, arguments: &Value) -> Option<String> {
376 let site = site.trim_end_matches('/');
377 let action = arguments["action"].as_str().unwrap_or_default();
378 if let Some(repo) = arguments["repo"].as_str().filter(|repo| repo.contains('/') && !repo.contains(' ')) {
379 let repo = repo.trim_matches('/');
380 let number = arguments["number"].as_u64();
381 return Some(match (tool, number) {
382 ("pull_request", Some(number)) => format!("{site}/{repo}/pull/{number}"),
383 ("pull_request", None) => format!("{site}/{repo}/pulls"),
384 ("issue", Some(number)) => format!("{site}/{repo}/issues/{number}"),
385 ("issue", None) => format!("{site}/{repo}/issues"),
386 ("workflow", _) => match arguments["run_id"].as_str().or_else(|| arguments["run_id"].as_u64().map(|_| "")) {
387 Some("") | None => format!("{site}/{repo}/actions"),
388 Some(run) => format!("{site}/{repo}/actions/runs/{run}"),
389 },
390 ("repository", _) if action.contains("release") => format!("{site}/{repo}/releases"),
391 _ => format!("{site}/{repo}"),
392 });
393 }
394 arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}"))
395}
396
397/// One line saying what a call found, for the person watching.
398pub fn summary(text: &str, failed: bool) -> String {
399 if failed {
400 let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("It failed.");
401 return first.chars().take(160).collect();
402 }
403 let Ok(value) = serde_json::from_str::<Value>(text) else {
404 return "Done".to_owned();
405 };
406 let count = |n: usize| if n == 1 { "1 result".to_owned() } else { format!("{n} results") };
407 match &value {
408 Value::Array(items) => count(items.len()),
409 Value::Object(fields) => {
410 // The list an answer is about, where it has one.
411 for key in ["events", "items", "results", "pulls", "issues", "runs", "workflow_runs", "jobs", "chunks", "entries", "repositories", "threads"] {
412 if let Some(items) = fields.get(key).and_then(Value::as_array) {
413 return count(items.len());
414 }
415 }
416 match fields.get("title").and_then(Value::as_str) {
417 Some(title) => title.chars().take(120).collect(),
418 None => "Done".to_owned(),
419 }
420 }
421 _ => "Done".to_owned(),
422 }
423}
424
425// --- What the model is given --------------------------------------------------
426
427/// Keys that cost the model tokens and tell it nothing.
428const NOISE: &[&str] = &["avatar_url", "gravatar_id", "node_id", "avatar", "etag"];
429
430/// Which end of a long result matters: the start of a list (newest first),
431/// or the end of a log, where a failed job says why.
432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
433pub enum Keep {
434 Start,
435 End,
436}
437
438impl Keep {
439 /// The end a call's result is read from.
440 pub fn of(tool: &str, arguments: &Value) -> Keep {
441 if tool == "workflow" && arguments["action"].as_str().is_some_and(|action| action.contains("log")) {
442 Keep::End
443 } else {
444 Keep::Start
445 }
446 }
447}
448
449/// A result as the model is given it: compact JSON, at most `max`
450/// characters. Long texts and long lists are trimmed first, each saying
451/// how much was left out, so what is kept is still whole and readable; only
452/// then is the text cut, with a note saying how to read the rest. `keep`
453/// says which end of a list or text to keep.
454pub fn trim_result(text: &str, max: usize, keep: Keep) -> String {
455 let max = max.max(200);
456 let Ok(value) = serde_json::from_str::<Value>(text) else {
457 return cut(text, max, keep);
458 };
459 let value = strip(value);
460 let compact = value.to_string();
461 if compact.chars().count() <= max {
462 return compact;
463 }
464 // Trim texts and lists, harder each time, until it fits.
465 for (chars, items) in [(4_000, 100), (1_500, 50), (600, 25), (300, 12), (160, 6), (100, 3)] {
466 let trimmed = shorten(&value, chars, items, keep).to_string();
467 if trimmed.chars().count() <= max {
468 return trimmed;
469 }
470 }
471 cut(&shorten(&value, 100, 3, keep).to_string(), max, keep)
472}
473
474fn cut(text: &str, max: usize, keep: Keep) -> String {
475 let total = text.chars().count();
476 if total <= max {
477 return text.to_owned();
478 }
479 let room = max.saturating_sub(120);
480 let left = total - room;
481 match keep {
482 Keep::Start => {
483 let kept: String = text.chars().take(room).collect();
484 format!("{kept}\n[Cut: {left} more characters not shown. Ask for less: a narrower filter, a shorter span or one item.]")
485 }
486 Keep::End => {
487 let kept: String = text.chars().skip(left).collect();
488 format!("[Cut: the first {left} characters not shown.]\n{kept}")
489 }
490 }
491}
492
493fn strip(value: Value) -> Value {
494 match value {
495 Value::Object(fields) => Value::Object(
496 fields
497 .into_iter()
498 .filter(|(key, value)| !NOISE.contains(&key.as_str()) && !value.as_str().is_some_and(|text| text.contains("{?")))
499 .map(|(key, value)| (key, strip(value)))
500 .collect(),
501 ),
502 Value::Array(items) => Value::Array(items.into_iter().map(strip).collect()),
503 other => other,
504 }
505}
506
507fn shorten(value: &Value, chars: usize, items: usize, keep: Keep) -> Value {
508 match value {
509 Value::String(text) if text.chars().count() > chars => {
510 let total = text.chars().count();
511 match keep {
512 Keep::Start => {
513 let kept: String = text.chars().take(chars).collect();
514 json!(format!("{kept}… [{} more characters]", total - chars))
515 }
516 Keep::End => {
517 let kept: String = text.chars().skip(total - chars).collect();
518 json!(format!("[{} characters before] …{kept}", total - chars))
519 }
520 }
521 }
522 Value::Array(list) if list.len() > items => {
523 let note = json!(format!("[{} more not shown: ask for fewer, or another page]", list.len() - items));
524 let shortened = |item: &Value| shorten(item, chars, items, keep);
525 match keep {
526 Keep::Start => {
527 let mut kept: Vec<Value> = list.iter().take(items).map(shortened).collect();
528 kept.push(note);
529 Value::Array(kept)
530 }
531 Keep::End => {
532 let mut kept = vec![note];
533 kept.extend(list.iter().skip(list.len() - items).map(shortened));
534 Value::Array(kept)
535 }
536 }
537 }
538 Value::Array(list) => Value::Array(list.iter().map(|item| shorten(item, chars, items, keep)).collect()),
539 Value::Object(fields) => Value::Object(
540 fields.iter().map(|(key, value)| (key.clone(), shorten(value, chars, items, keep))).collect::<Map<_, _>>(),
541 ),
542 other => other.clone(),
543 }
544}
545
546// --- Sources -------------------------------------------------------------------
547
548/// The pages on g1t an answer read, kept in the order first read.
549#[derive(Default)]
550pub struct Sources {
551 found: Vec<(String, String, bool)>,
552 seen: HashSet<String>,
553}
554
555/// The most pages kept from what one answer read.
556const MOST_READ: usize = 400;
557
558impl Sources {
559 fn add(&mut self, url: String, title: String, direct: bool) {
560 if self.found.len() >= MOST_READ {
561 return;
562 }
563 let url = url.trim_end_matches('/').to_owned();
564 if self.seen.insert(url.clone()) {
565 self.found.push((url, title, direct));
566 } else if direct && let Some(found) = self.found.iter_mut().find(|found| found.0 == url) {
567 found.2 = true;
568 }
569 }
570
571 /// Notes the pages a call's result names: each item's own `url` or
572 /// `html_url` on the site, a repository, and the issues and pull
573 /// requests of the repository it was about. `direct` for a call about
574 /// one thing (its link), which is a source whether cited or not.
575 pub fn read(&mut self, site: &str, tool: &str, arguments: &Value, text: &str, direct: Option<(&str, &str)>) {
576 let site = site.trim_end_matches('/');
577 if let Some((url, title)) = direct {
578 self.add(url.to_owned(), title.to_owned(), true);
579 }
580 let Ok(value) = serde_json::from_str::<Value>(text) else {
581 return;
582 };
583 let repo = arguments["repo"].as_str().filter(|repo| repo.contains('/')).map(|repo| repo.trim_matches('/').to_owned());
584 self.walk(site, tool, repo.as_deref(), &value, 0);
585 }
586
587 fn walk(&mut self, site: &str, tool: &str, repo: Option<&str>, value: &Value, depth: usize) {
588 if depth > 6 {
589 return;
590 }
591 match value {
592 Value::Array(items) => items.iter().for_each(|item| self.walk(site, tool, repo, item, depth + 1)),
593 Value::Object(fields) => {
594 let text = |key: &str| fields.get(key).and_then(Value::as_str).filter(|text| !text.is_empty());
595 let number = fields.get("number").and_then(Value::as_u64);
596 let title = text("title").or_else(|| text("name")).or_else(|| text("full_name")).or_else(|| text("tag"));
597 let own = text("html_url").or_else(|| text("url")).filter(|url| url.starts_with(site));
598 if let Some(url) = own {
599 let title = match (title, number) {
600 (Some(title), Some(number)) => format!("#{number} {title}"),
601 (Some(title), None) => title.to_owned(),
602 (None, _) => url.trim_start_matches(site).trim_start_matches('/').to_owned(),
603 };
604 self.add(url.to_owned(), title, false);
605 } else if let (Some(repo), Some(number), Some(title)) = (repo, number, text("title")) {
606 let section = if tool == "pull_request" || fields.contains_key("merged_at") { "pull" } else { "issues" };
607 self.add(format!("{site}/{repo}/{section}/{number}"), format!("#{number} {title}"), false);
608 } else if let (Some(namespace), Some(name)) = (text("namespace"), text("name"))
609 && fields.contains_key("default_branch")
610 {
611 self.add(format!("{site}/{namespace}/{name}"), format!("{namespace}/{name}"), false);
612 }
613 for (key, value) in fields {
614 if matches!(value, Value::Array(_) | Value::Object(_)) && key != "author" && key != "user" {
615 self.walk(site, tool, repo, value, depth + 1);
616 }
617 }
618 }
619 _ => {}
620 }
621 }
622
623 /// What the answer drew on: the pages it links to, in the order read,
624 /// then those it read directly, at most [`ASK_MAX_SOURCES`].
625 pub fn finish(&self, site: &str, answer: &str) -> Vec<AskSource> {
626 let site = site.trim_end_matches('/');
627 let cites = |url: &str| {
628 let path = url.trim_start_matches(site);
629 answer.contains(url) || (!path.is_empty() && path != "/" && contains_path(answer, path))
630 };
631 let mut sources: Vec<AskSource> = self
632 .found
633 .iter()
634 .filter(|(url, _, _)| cites(url))
635 .map(|(url, title, _)| AskSource { title: title.clone(), url: url.clone(), cited: true })
636 .collect();
637 for (url, title, direct) in &self.found {
638 if *direct && !sources.iter().any(|source| &source.url == url) {
639 sources.push(AskSource { title: title.clone(), url: url.clone(), cited: false });
640 }
641 }
642 sources.truncate(ASK_MAX_SOURCES);
643 sources
644 }
645}
646
647/// Whether `answer` names `path` as a whole path, not the start of a
648/// longer one (`/acme/web/pull/7` is not cited by `/acme/web/pull/70`).
649fn contains_path(answer: &str, path: &str) -> bool {
650 answer.match_indices(path).any(|(at, _)| {
651 let next = answer[at + path.len()..].chars().next();
652 !next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_')
653 })
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 fn listing() -> Value {
661 json!({ "tools": [
662 {
663 "name": "pull_request",
664 "description": "Pull requests",
665 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "changes"] } }, "required": ["action"] },
666 "annotations": { "readOnlyHint": true },
667 },
668 {
669 "name": "search",
670 "description": "Find things",
671 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["code", "ticket"], "default": "code" } } },
672 "annotations": { "readOnlyHint": true },
673 },
674 {
675 "name": "issue",
676 "description": "Issues",
677 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "create"] } } },
678 "annotations": { "readOnlyHint": false },
679 },
680 ] })
681 }
682
683 #[test]
684 fn the_servers_tools_are_read_as_they_are_listed() {
685 let tools = listed(&listing());
686 assert_eq!(tools.len(), 3);
687 assert_eq!(tools[0].spec.name, "pull_request");
688 assert_eq!(tools[0].spec.input_schema["required"], json!(["action"]));
689 assert_eq!(tools[0].actions, vec!["list", "get", "changes"]);
690 assert_eq!(tools[1].default_action.as_deref(), Some("code"));
691 assert!(!tools[2].read_only);
692 assert!(listed(&json!({})).is_empty());
693 }
694
695 #[test]
696 fn ask_runs_reads_and_refuses_everything_else() {
697 let toolbox = Toolbox::new(listed(&listing()), native());
698 // A tool that is not read-only is not offered at all.
699 let names: Vec<&str> = toolbox.specs().iter().map(|spec| spec.name.as_str()).collect();
700 assert_eq!(names, vec!["pull_request", "search", PROPOSE_SESSION]);
701 assert_eq!(toolbox.decide("pull_request", &json!({ "action": "get", "repo": "a/b", "number": 1 })), Decision::Run);
702 assert_eq!(toolbox.decide("search", &json!({ "query": "x" })), Decision::Run, "the default action");
703 // An action the server did not list for this credential.
704 let Decision::Refuse(why) = toolbox.decide("pull_request", &json!({ "action": "merge" })) else { panic!() };
705 assert!(why.contains("changes nothing itself"), "{why}");
706 assert!(matches!(toolbox.decide("pull_request", &json!({})), Decision::Refuse(_)));
707 assert!(matches!(toolbox.decide("issue", &json!({ "action": "create" })), Decision::Refuse(_)));
708 assert!(matches!(toolbox.decide("nope", &json!({})), Decision::Refuse(_)));
709 }
710
711 #[test]
712 fn a_dispatch_waits_for_the_persons_yes() {
713 let start = Native {
714 spec: ToolSpec { name: "start_session".into(), description: "Start a session".into(), input_schema: json!({ "type": "object" }) },
715 kind: Kind::Dispatch,
716 };
717 let toolbox = Toolbox::new(Vec::new(), vec![start]);
718 assert!(toolbox.is_native("start_session"));
719 assert_eq!(toolbox.decide("start_session", &json!({ "repo": "a/b", "number": 3 })), Decision::Confirm);
720 }
721
722 #[test]
723 fn a_proposed_session_is_a_card_the_person_starts() {
724 let toolbox = Toolbox::new(Vec::new(), native());
725 assert!(toolbox.is_native(PROPOSE_SESSION));
726 let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " });
727 assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm);
728 let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction } = proposal("proposal_0_0", &on_issue).unwrap() else {
729 panic!()
730 };
731 assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement"));
732 assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name")));
733 let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" });
734 let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() };
735 assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route")));
736 assert!(proposal("p", &json!({ "repo": "acme/web", "mode": "plan" })).is_err(), "no issue and no title");
737 assert!(proposal("p", &json!({ "repo": "acme", "issue": 1, "mode": "plan" })).is_err(), "not owner/name");
738 assert!(proposal("p", &json!({ "repo": "acme/web", "issue": 1, "mode": "ship" })).is_err(), "no such mode");
739 assert_eq!(label(PROPOSE_SESSION, &on_issue), "Proposed a session on issue #12 in acme/web");
740 }
741
742 #[test]
743 fn steps_say_what_they_do_in_a_line() {
744 assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web");
745 assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web");
746 assert_eq!(
747 label("pull_request", &json!({ "action": "changes", "repo": "acme/web", "number": 7 })),
748 "Read changes of pull request #7 in acme/web"
749 );
750 assert_eq!(label("workflow", &json!({ "action": "list_runs", "repo": "acme/web" })), "Listed runs in acme/web");
751 assert_eq!(label("workflow", &json!({ "action": "job_logs", "repo": "acme/web" })), "Read job logs in acme/web");
752 assert_eq!(
753 label("workspace", &json!({ "action": "activity", "workspace": "acme", "since": "2026-10-12T00:00:00Z" })),
754 "Read activity in acme since 2026-10-12"
755 );
756 assert_eq!(label("search", &json!({ "query": "login bug" })), "Searched g1t for \u{201c}login bug\u{201d}");
757 assert_eq!(label("repository", &json!({ "action": "list" })), "Listed repositories");
758 }
759
760 #[test]
761 fn steps_link_to_the_page_they_read() {
762 let site = "https://g1t.sh/";
763 assert_eq!(link(site, "pull_request", &json!({ "repo": "acme/web", "number": 7 })).unwrap(), "https://g1t.sh/acme/web/pull/7");
764 assert_eq!(link(site, "issue", &json!({ "action": "list", "repo": "acme/web" })).unwrap(), "https://g1t.sh/acme/web/issues");
765 assert_eq!(link(site, "workflow", &json!({ "action": "get_run", "repo": "acme/web", "run_id": "run_1" })).unwrap(), "https://g1t.sh/acme/web/actions/runs/run_1");
766 assert_eq!(link(site, "workspace", &json!({ "action": "activity", "workspace": "acme" })).unwrap(), "https://g1t.sh/acme");
767 assert_eq!(link(site, "search", &json!({ "query": "x" })), None);
768 }
769
770 #[test]
771 fn steps_say_what_they_found() {
772 assert_eq!(summary("[1,2,3]", false), "3 results");
773 assert_eq!(summary("{\"events\":[{}],\"summary\":{}}", false), "1 result");
774 assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login");
775 assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write");
776 assert_eq!(summary("plain", false), "Done");
777 }
778
779 #[test]
780 fn big_results_are_trimmed_not_dropped() {
781 let small = json!({ "title": "x", "avatar_url": "https://a", "url_template": "https://x/{?q}" }).to_string();
782 assert_eq!(trim_result(&small, 1_000, Keep::Start), "{\"title\":\"x\"}");
783 // A long list keeps its first items, whole, and says how many more.
784 let items: Vec<Value> = (0..400).map(|n| json!({ "number": n, "title": format!("Pull request {n}"), "body": "b".repeat(3_000) })).collect();
785 let big = json!({ "items": items }).to_string();
786 let kept = trim_result(&big, 16_000, Keep::Start);
787 assert!(kept.chars().count() <= 16_000, "{}", kept.len());
788 let read: Value = serde_json::from_str(&kept).expect("still JSON");
789 let list = read["items"].as_array().unwrap();
790 assert_eq!(list[0]["number"], 0);
791 assert!(list.last().unwrap().as_str().unwrap().contains("more not shown"));
792 assert!(list[0]["body"].as_str().unwrap().contains("more characters"));
793 // Text that is not JSON is cut with a note.
794 let log = "line\n".repeat(10_000);
795 let kept = trim_result(&log, 1_000, Keep::Start);
796 assert!(kept.chars().count() <= 1_000);
797 assert!(kept.contains("[Cut:"));
798 }
799
800 #[test]
801 fn a_logs_end_is_kept_where_a_failure_says_why() {
802 assert_eq!(Keep::of("workflow", &json!({ "action": "job_logs" })), Keep::End);
803 assert_eq!(Keep::of("workflow", &json!({ "action": "list_runs" })), Keep::Start);
804 assert_eq!(Keep::of("pull_request", &json!({ "action": "list" })), Keep::Start);
805 let mut chunks: Vec<Value> = (0..2_000).map(|seq| json!({ "seq": seq, "step": 3, "text": format!("building part {seq}\n") })).collect();
806 chunks.push(json!({ "seq": 2_000, "step": 4, "text": "##[error]No space left on device\n" }));
807 let log = json!({ "chunks": chunks, "done": true }).to_string();
808 let kept = trim_result(&log, 4_000, Keep::End);
809 assert!(kept.chars().count() <= 4_000);
810 let read: Value = serde_json::from_str(&kept).expect("still JSON");
811 let kept_chunks = read["chunks"].as_array().unwrap();
812 assert!(kept_chunks[0].as_str().unwrap().contains("more not shown"));
813 assert!(kept_chunks.last().unwrap()["text"].as_str().unwrap().contains("No space left on device"));
814 // Plain text keeps its end too.
815 let text = format!("{}error: disk full", "noise\n".repeat(1_000));
816 let kept = trim_result(&text, 500, Keep::End);
817 assert!(kept.ends_with("error: disk full"));
818 assert!(kept.starts_with("[Cut: the first"));
819 }
820
821 #[test]
822 fn sources_are_the_pages_the_answer_cites_then_those_it_read() {
823 let site = "https://g1t.sh";
824 let mut sources = Sources::default();
825 let activity = json!({ "events": [
826 { "kind": "pull_merged", "repo": "acme/web", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7" },
827 { "kind": "release_published", "repo": "acme/api", "tag": "v1.2.0", "title": "Faster", "url": "https://g1t.sh/acme/api/releases/tag/v1.2.0" },
828 { "kind": "pull_merged", "repo": "acme/web", "number": 70, "title": "Other", "url": "https://g1t.sh/acme/web/pull/70" },
829 ] });
830 sources.read(site, "workspace", &json!({ "action": "activity", "workspace": "acme" }), &activity.to_string(), None);
831 let list = json!([{ "number": 9, "title": "Docs", "merged_at": null }]);
832 sources.read(site, "pull_request", &json!({ "action": "list", "repo": "acme/docs" }), &list.to_string(), None);
833 sources.read(
834 site,
835 "issue",
836 &json!({ "action": "get", "repo": "acme/web", "number": 3 }),
837 "{\"issue\":{\"title\":\"Login fails\"}}",
838 Some(("https://g1t.sh/acme/web/issues/3", "Read issue #3 in acme/web")),
839 );
840 let answer = "Shipped: [Fix login](https://g1t.sh/acme/web/pull/7) and acme/docs [#9](/acme/docs/pull/9).";
841 let found = sources.finish(site, answer);
842 assert_eq!(
843 found,
844 vec![
845 AskSource { title: "#7 Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true },
846 AskSource { title: "#9 Docs".into(), url: "https://g1t.sh/acme/docs/pull/9".into(), cited: true },
847 AskSource { title: "Read issue #3 in acme/web".into(), url: "https://g1t.sh/acme/web/issues/3".into(), cited: false },
848 ]
849 );
850 }
851}