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 | ||
| 28 | use std::collections::{BTreeMap, HashSet}; | |
| 29 | ||
| 30 | use g1t_contracts::integrations::{ASK_MAX_SOURCES, AskEvent, AskSource}; | |
| 31 | use serde_json::{Map, Value, json}; | |
| 32 | use worker::Method; | |
| 33 | ||
| 34 | use crate::ask::ToolSpec; | |
| 35 | use crate::http; | |
| 36 | ||
| 37 | /// The MCP protocol version Ask speaks. | |
| 38 | const PROTOCOL: &str = "2025-06-18"; | |
| 39 | ||
| 40 | /// g1t's MCP server, as the person asking. | |
| 41 | #[derive(Clone)] | |
| 42 | pub 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)] | |
| 49 | pub 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 | ||
| 59 | impl 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. | |
| 108 | pub 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)] | |
| 133 | pub 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)] | |
| 146 | pub 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)] | |
| 155 | enum Origin { | |
| 156 | Mcp { actions: Vec<String>, default_action: Option<String> }, | |
| 157 | Native, | |
| 158 | } | |
| 159 | ||
| 160 | #[derive(Clone, Debug)] | |
| 161 | struct Entry { | |
| 162 | origin: Origin, | |
| 163 | kind: Kind, | |
| 164 | } | |
| 165 | ||
| 166 | /// The tools one answer may use. | |
| 167 | #[derive(Clone, Debug, Default)] | |
| 168 | pub 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. | |
| 174 | pub 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. | |
| 182 | pub 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. | |
| 187 | pub 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. | |
| 211 | pub 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. | |
| 237 | pub async fn run_native(name: &str, _arguments: &Value) -> Result<(String, bool), String> { | |
| 238 | Err(format!("{name} is not something Ask can do.")) | |
| 239 | } | |
| 240 | ||
| 241 | impl 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. | |
| 310 | fn 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 | ||
| 315 | fn 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 | "memory" => ("memory", "memory"), | |
| 323 | "notifications" => ("notification", "notifications"), | |
| 324 | "package" => ("package", "packages"), | |
| 325 | "team" => ("team", "teams"), | |
| 326 | "artifact" => ("artifact", "artifacts"), | |
| 327 | "security" => ("security alert", "security alerts"), | |
| 328 | "observability" => ("production telemetry", "production telemetry"), | |
| 329 | other => return other.replace('_', " "), | |
| 330 | }; | |
| 331 | (if plural { many } else { one }).to_owned() | |
| 332 | } | |
| 333 | ||
| 334 | /// One line saying what a call does: "Read pull request #7 in acme/web". | |
| 335 | pub fn label(tool: &str, arguments: &Value) -> String { | |
| 336 | let action = arguments["action"].as_str().unwrap_or_default(); | |
| 337 | let place = target(arguments).map(|target| format!(" in {target}")).unwrap_or_default(); | |
| 338 | if tool == "search" && action != "ticket" { | |
| 339 | let query = arguments["query"].as_str().or_else(|| arguments["q"].as_str()).unwrap_or_default(); | |
| 340 | let query: String = query.chars().take(80).collect(); | |
| 341 | return format!("Searched g1t for \u{201c}{query}\u{201d}"); | |
| 342 | } | |
| 343 | if tool == PROPOSE_SESSION { | |
| 344 | let what = arguments["issue"].as_u64().map(|number| format!("issue #{number}")).unwrap_or_else(|| "a new issue".to_owned()); | |
| 345 | let repo = arguments["repo"].as_str().unwrap_or("the repository"); | |
| 346 | return format!("Proposed a session on {what} in {repo}"); | |
| 347 | } | |
| 348 | if tool == "workspace" && action == "activity" { | |
| 349 | let since = arguments["since"].as_str().map(|since| format!(" since {}", since.trim_end_matches("T00:00:00Z"))).unwrap_or_default(); | |
| 350 | return format!("Read activity{place}{since}"); | |
| 351 | } | |
| 352 | let number = arguments["number"].as_u64().map(|number| format!(" #{number}")).unwrap_or_default(); | |
| 353 | let (verb, rest) = match action { | |
| 354 | "" | "get" => ("Read", None), | |
| 355 | "list" => ("Listed", None), | |
| 356 | _ => match action.split_once('_') { | |
| 357 | Some(("list", rest)) => ("Listed", Some(rest)), | |
| 358 | Some(("get", rest)) => ("Read", Some(rest)), | |
| 359 | _ => ("Read", Some(action)), | |
| 360 | }, | |
| 361 | }; | |
| 362 | let what = match rest { | |
| 363 | None => noun(tool, action == "list" && number.is_empty()), | |
| 364 | Some(rest) if number.is_empty() => rest.replace('_', " "), | |
| 365 | Some(rest) => format!("{} {}", noun(tool, false), rest.replace('_', " ")), | |
| 366 | }; | |
| 367 | // "Read pull request #7 changes" reads better as "Read changes of pull request #7". | |
| 368 | let what = match (rest, number.is_empty()) { | |
| 369 | (Some(rest), false) => format!("{} of {}{number}", rest.replace('_', " "), noun(tool, false)), | |
| 370 | _ => format!("{what}{number}"), | |
| 371 | }; | |
| 372 | format!("{verb} {what}{place}") | |
| 373 | } | |
| 374 | ||
| 375 | /// The page on the site a call is about, when it names one. | |
| 376 | pub fn link(site: &str, tool: &str, arguments: &Value) -> Option<String> { | |
| 377 | let site = site.trim_end_matches('/'); | |
| 378 | let action = arguments["action"].as_str().unwrap_or_default(); | |
| 379 | if let Some(repo) = arguments["repo"].as_str().filter(|repo| repo.contains('/') && !repo.contains(' ')) { | |
| 380 | let repo = repo.trim_matches('/'); | |
| 381 | let number = arguments["number"].as_u64(); | |
| 382 | return Some(match (tool, number) { | |
| 383 | ("pull_request", Some(number)) => format!("{site}/{repo}/pull/{number}"), | |
| 384 | ("pull_request", None) => format!("{site}/{repo}/pulls"), | |
| 385 | ("issue", Some(number)) => format!("{site}/{repo}/issues/{number}"), | |
| 386 | ("issue", None) => format!("{site}/{repo}/issues"), | |
| 387 | ("workflow", _) => match arguments["run_id"].as_str().or_else(|| arguments["run_id"].as_u64().map(|_| "")) { | |
| 388 | Some("") | None => format!("{site}/{repo}/actions"), | |
| 389 | Some(run) => format!("{site}/{repo}/actions/runs/{run}"), | |
| 390 | }, | |
| 391 | ("repository", _) if action.contains("release") => format!("{site}/{repo}/releases"), | |
| 392 | _ => format!("{site}/{repo}"), | |
| 393 | }); | |
| 394 | } | |
| 395 | arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}")) | |
| 396 | } | |
| 397 | ||
| 398 | /// One line saying what a call found, for the person watching. | |
| 399 | pub fn summary(text: &str, failed: bool) -> String { | |
| 400 | if failed { | |
| 401 | let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("It failed."); | |
| 402 | return first.chars().take(160).collect(); | |
| 403 | } | |
| 404 | let Ok(value) = serde_json::from_str::<Value>(text) else { | |
| 405 | return "Done".to_owned(); | |
| 406 | }; | |
| 407 | let count = |n: usize| if n == 1 { "1 result".to_owned() } else { format!("{n} results") }; | |
| 408 | match &value { | |
| 409 | Value::Array(items) => count(items.len()), | |
| 410 | Value::Object(fields) => { | |
| 411 | // The list an answer is about, where it has one. | |
| 412 | for key in ["events", "items", "results", "pulls", "issues", "runs", "workflow_runs", "jobs", "chunks", "entries", "repositories", "threads"] { | |
| 413 | if let Some(items) = fields.get(key).and_then(Value::as_array) { | |
| 414 | return count(items.len()); | |
| 415 | } | |
| 416 | } | |
| 417 | match fields.get("title").and_then(Value::as_str) { | |
| 418 | Some(title) => title.chars().take(120).collect(), | |
| 419 | None => "Done".to_owned(), | |
| 420 | } | |
| 421 | } | |
| 422 | _ => "Done".to_owned(), | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | // --- What the model is given -------------------------------------------------- | |
| 427 | ||
| 428 | /// Keys that cost the model tokens and tell it nothing. | |
| 429 | const NOISE: &[&str] = &["avatar_url", "gravatar_id", "node_id", "avatar", "etag"]; | |
| 430 | ||
| 431 | /// Which end of a long result matters: the start of a list (newest first), | |
| 432 | /// or the end of a log, where a failed job says why. | |
| 433 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 434 | pub enum Keep { | |
| 435 | Start, | |
| 436 | End, | |
| 437 | } | |
| 438 | ||
| 439 | impl Keep { | |
| 440 | /// The end a call's result is read from. | |
| 441 | pub fn of(tool: &str, arguments: &Value) -> Keep { | |
| 442 | if tool == "workflow" && arguments["action"].as_str().is_some_and(|action| action.contains("log")) { | |
| 443 | Keep::End | |
| 444 | } else { | |
| 445 | Keep::Start | |
| 446 | } | |
| 447 | } | |
| 448 | } | |
| 449 | ||
| 450 | /// A result as the model is given it: compact JSON, at most `max` | |
| 451 | /// characters. Long texts and long lists are trimmed first, each saying | |
| 452 | /// how much was left out, so what is kept is still whole and readable; only | |
| 453 | /// then is the text cut, with a note saying how to read the rest. `keep` | |
| 454 | /// says which end of a list or text to keep. | |
| 455 | pub fn trim_result(text: &str, max: usize, keep: Keep) -> String { | |
| 456 | let max = max.max(200); | |
| 457 | let Ok(value) = serde_json::from_str::<Value>(text) else { | |
| 458 | return cut(text, max, keep); | |
| 459 | }; | |
| 460 | let value = strip(value); | |
| 461 | let compact = value.to_string(); | |
| 462 | if compact.chars().count() <= max { | |
| 463 | return compact; | |
| 464 | } | |
| 465 | // Trim texts and lists, harder each time, until it fits. | |
| 466 | for (chars, items) in [(4_000, 100), (1_500, 50), (600, 25), (300, 12), (160, 6), (100, 3)] { | |
| 467 | let trimmed = shorten(&value, chars, items, keep).to_string(); | |
| 468 | if trimmed.chars().count() <= max { | |
| 469 | return trimmed; | |
| 470 | } | |
| 471 | } | |
| 472 | cut(&shorten(&value, 100, 3, keep).to_string(), max, keep) | |
| 473 | } | |
| 474 | ||
| 475 | fn cut(text: &str, max: usize, keep: Keep) -> String { | |
| 476 | let total = text.chars().count(); | |
| 477 | if total <= max { | |
| 478 | return text.to_owned(); | |
| 479 | } | |
| 480 | let room = max.saturating_sub(120); | |
| 481 | let left = total - room; | |
| 482 | match keep { | |
| 483 | Keep::Start => { | |
| 484 | let kept: String = text.chars().take(room).collect(); | |
| 485 | format!("{kept}\n[Cut: {left} more characters not shown. Ask for less: a narrower filter, a shorter span or one item.]") | |
| 486 | } | |
| 487 | Keep::End => { | |
| 488 | let kept: String = text.chars().skip(left).collect(); | |
| 489 | format!("[Cut: the first {left} characters not shown.]\n{kept}") | |
| 490 | } | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 494 | fn strip(value: Value) -> Value { | |
| 495 | match value { | |
| 496 | Value::Object(fields) => Value::Object( | |
| 497 | fields | |
| 498 | .into_iter() | |
| 499 | .filter(|(key, value)| !NOISE.contains(&key.as_str()) && !value.as_str().is_some_and(|text| text.contains("{?"))) | |
| 500 | .map(|(key, value)| (key, strip(value))) | |
| 501 | .collect(), | |
| 502 | ), | |
| 503 | Value::Array(items) => Value::Array(items.into_iter().map(strip).collect()), | |
| 504 | other => other, | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | fn shorten(value: &Value, chars: usize, items: usize, keep: Keep) -> Value { | |
| 509 | match value { | |
| 510 | Value::String(text) if text.chars().count() > chars => { | |
| 511 | let total = text.chars().count(); | |
| 512 | match keep { | |
| 513 | Keep::Start => { | |
| 514 | let kept: String = text.chars().take(chars).collect(); | |
| 515 | json!(format!("{kept}… [{} more characters]", total - chars)) | |
| 516 | } | |
| 517 | Keep::End => { | |
| 518 | let kept: String = text.chars().skip(total - chars).collect(); | |
| 519 | json!(format!("[{} characters before] …{kept}", total - chars)) | |
| 520 | } | |
| 521 | } | |
| 522 | } | |
| 523 | Value::Array(list) if list.len() > items => { | |
| 524 | let note = json!(format!("[{} more not shown: ask for fewer, or another page]", list.len() - items)); | |
| 525 | let shortened = |item: &Value| shorten(item, chars, items, keep); | |
| 526 | match keep { | |
| 527 | Keep::Start => { | |
| 528 | let mut kept: Vec<Value> = list.iter().take(items).map(shortened).collect(); | |
| 529 | kept.push(note); | |
| 530 | Value::Array(kept) | |
| 531 | } | |
| 532 | Keep::End => { | |
| 533 | let mut kept = vec![note]; | |
| 534 | kept.extend(list.iter().skip(list.len() - items).map(shortened)); | |
| 535 | Value::Array(kept) | |
| 536 | } | |
| 537 | } | |
| 538 | } | |
| 539 | Value::Array(list) => Value::Array(list.iter().map(|item| shorten(item, chars, items, keep)).collect()), | |
| 540 | Value::Object(fields) => Value::Object( | |
| 541 | fields.iter().map(|(key, value)| (key.clone(), shorten(value, chars, items, keep))).collect::<Map<_, _>>(), | |
| 542 | ), | |
| 543 | other => other.clone(), | |
| 544 | } | |
| 545 | } | |
| 546 | ||
| 547 | // --- Sources ------------------------------------------------------------------- | |
| 548 | ||
| 549 | /// The pages on g1t an answer read, kept in the order first read. | |
| 550 | #[derive(Default)] | |
| 551 | pub struct Sources { | |
| 552 | found: Vec<(String, String, bool)>, | |
| 553 | seen: HashSet<String>, | |
| 554 | } | |
| 555 | ||
| 556 | /// The most pages kept from what one answer read. | |
| 557 | const MOST_READ: usize = 400; | |
| 558 | ||
| 559 | impl Sources { | |
| 560 | fn add(&mut self, url: String, title: String, direct: bool) { | |
| 561 | if self.found.len() >= MOST_READ { | |
| 562 | return; | |
| 563 | } | |
| 564 | let url = url.trim_end_matches('/').to_owned(); | |
| 565 | if self.seen.insert(url.clone()) { | |
| 566 | self.found.push((url, title, direct)); | |
| 567 | } else if direct && let Some(found) = self.found.iter_mut().find(|found| found.0 == url) { | |
| 568 | found.2 = true; | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | /// Notes the pages a call's result names: each item's own `url` or | |
| 573 | /// `html_url` on the site, a repository, and the issues and pull | |
| 574 | /// requests of the repository it was about. `direct` for a call about | |
| 575 | /// one thing (its link), which is a source whether cited or not. | |
| 576 | pub fn read(&mut self, site: &str, tool: &str, arguments: &Value, text: &str, direct: Option<(&str, &str)>) { | |
| 577 | let site = site.trim_end_matches('/'); | |
| 578 | if let Some((url, title)) = direct { | |
| 579 | self.add(url.to_owned(), title.to_owned(), true); | |
| 580 | } | |
| 581 | let Ok(value) = serde_json::from_str::<Value>(text) else { | |
| 582 | return; | |
| 583 | }; | |
| 584 | let repo = arguments["repo"].as_str().filter(|repo| repo.contains('/')).map(|repo| repo.trim_matches('/').to_owned()); | |
| 585 | self.walk(site, tool, repo.as_deref(), &value, 0); | |
| 586 | } | |
| 587 | ||
| 588 | fn walk(&mut self, site: &str, tool: &str, repo: Option<&str>, value: &Value, depth: usize) { | |
| 589 | if depth > 6 { | |
| 590 | return; | |
| 591 | } | |
| 592 | match value { | |
| 593 | Value::Array(items) => items.iter().for_each(|item| self.walk(site, tool, repo, item, depth + 1)), | |
| 594 | Value::Object(fields) => { | |
| 595 | let text = |key: &str| fields.get(key).and_then(Value::as_str).filter(|text| !text.is_empty()); | |
| 596 | let number = fields.get("number").and_then(Value::as_u64); | |
| 597 | let title = text("title").or_else(|| text("name")).or_else(|| text("full_name")).or_else(|| text("tag")); | |
| 598 | let own = text("html_url").or_else(|| text("url")).filter(|url| url.starts_with(site)); | |
| 599 | if let Some(url) = own { | |
| 600 | let title = match (title, number) { | |
| 601 | (Some(title), Some(number)) => format!("#{number} {title}"), | |
| 602 | (Some(title), None) => title.to_owned(), | |
| 603 | (None, _) => url.trim_start_matches(site).trim_start_matches('/').to_owned(), | |
| 604 | }; | |
| 605 | self.add(url.to_owned(), title, false); | |
| 606 | } else if let (Some(repo), Some(number), Some(title)) = (repo, number, text("title")) { | |
| 607 | let section = if tool == "pull_request" || fields.contains_key("merged_at") { "pull" } else { "issues" }; | |
| 608 | self.add(format!("{site}/{repo}/{section}/{number}"), format!("#{number} {title}"), false); | |
| 609 | } else if let (Some(namespace), Some(name)) = (text("namespace"), text("name")) | |
| 610 | && fields.contains_key("default_branch") | |
| 611 | { | |
| 612 | self.add(format!("{site}/{namespace}/{name}"), format!("{namespace}/{name}"), false); | |
| 613 | } | |
| 614 | for (key, value) in fields { | |
| 615 | if matches!(value, Value::Array(_) | Value::Object(_)) && key != "author" && key != "user" { | |
| 616 | self.walk(site, tool, repo, value, depth + 1); | |
| 617 | } | |
| 618 | } | |
| 619 | } | |
| 620 | _ => {} | |
| 621 | } | |
| 622 | } | |
| 623 | ||
| 624 | /// What the answer drew on: the pages it links to, in the order read, | |
| 625 | /// then those it read directly, at most [`ASK_MAX_SOURCES`]. | |
| 626 | pub fn finish(&self, site: &str, answer: &str) -> Vec<AskSource> { | |
| 627 | let site = site.trim_end_matches('/'); | |
| 628 | let cites = |url: &str| { | |
| 629 | let path = url.trim_start_matches(site); | |
| 630 | answer.contains(url) || (!path.is_empty() && path != "/" && contains_path(answer, path)) | |
| 631 | }; | |
| 632 | let mut sources: Vec<AskSource> = self | |
| 633 | .found | |
| 634 | .iter() | |
| 635 | .filter(|(url, _, _)| cites(url)) | |
| 636 | .map(|(url, title, _)| AskSource { title: title.clone(), url: url.clone(), cited: true }) | |
| 637 | .collect(); | |
| 638 | for (url, title, direct) in &self.found { | |
| 639 | if *direct && !sources.iter().any(|source| &source.url == url) { | |
| 640 | sources.push(AskSource { title: title.clone(), url: url.clone(), cited: false }); | |
| 641 | } | |
| 642 | } | |
| 643 | sources.truncate(ASK_MAX_SOURCES); | |
| 644 | sources | |
| 645 | } | |
| 646 | } | |
| 647 | ||
| 648 | /// Whether `answer` names `path` as a whole path, not the start of a | |
| 649 | /// longer one (`/acme/web/pull/7` is not cited by `/acme/web/pull/70`). | |
| 650 | fn contains_path(answer: &str, path: &str) -> bool { | |
| 651 | answer.match_indices(path).any(|(at, _)| { | |
| 652 | let next = answer[at + path.len()..].chars().next(); | |
| 653 | !next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_') | |
| 654 | }) | |
| 655 | } | |
| 656 | ||
| 657 | #[cfg(test)] | |
| 658 | mod tests { | |
| 659 | use super::*; | |
| 660 | ||
| 661 | fn listing() -> Value { | |
| 662 | json!({ "tools": [ | |
| 663 | { | |
| 664 | "name": "pull_request", | |
| 665 | "description": "Pull requests", | |
| 666 | "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "changes"] } }, "required": ["action"] }, | |
| 667 | "annotations": { "readOnlyHint": true }, | |
| 668 | }, | |
| 669 | { | |
| 670 | "name": "search", | |
| 671 | "description": "Find things", | |
| 672 | "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["code", "ticket"], "default": "code" } } }, | |
| 673 | "annotations": { "readOnlyHint": true }, | |
| 674 | }, | |
| 675 | { | |
| 676 | "name": "issue", | |
| 677 | "description": "Issues", | |
| 678 | "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "create"] } } }, | |
| 679 | "annotations": { "readOnlyHint": false }, | |
| 680 | }, | |
| 681 | ] }) | |
| 682 | } | |
| 683 | ||
| 684 | #[test] | |
| 685 | fn the_servers_tools_are_read_as_they_are_listed() { | |
| 686 | let tools = listed(&listing()); | |
| 687 | assert_eq!(tools.len(), 3); | |
| 688 | assert_eq!(tools[0].spec.name, "pull_request"); | |
| 689 | assert_eq!(tools[0].spec.input_schema["required"], json!(["action"])); | |
| 690 | assert_eq!(tools[0].actions, vec!["list", "get", "changes"]); | |
| 691 | assert_eq!(tools[1].default_action.as_deref(), Some("code")); | |
| 692 | assert!(!tools[2].read_only); | |
| 693 | assert!(listed(&json!({})).is_empty()); | |
| 694 | } | |
| 695 | ||
| 696 | #[test] | |
| 697 | fn ask_runs_reads_and_refuses_everything_else() { | |
| 698 | let toolbox = Toolbox::new(listed(&listing()), native()); | |
| 699 | // A tool that is not read-only is not offered at all. | |
| 700 | let names: Vec<&str> = toolbox.specs().iter().map(|spec| spec.name.as_str()).collect(); | |
| 701 | assert_eq!(names, vec!["pull_request", "search", PROPOSE_SESSION]); | |
| 702 | assert_eq!(toolbox.decide("pull_request", &json!({ "action": "get", "repo": "a/b", "number": 1 })), Decision::Run); | |
| 703 | assert_eq!(toolbox.decide("search", &json!({ "query": "x" })), Decision::Run, "the default action"); | |
| 704 | // An action the server did not list for this credential. | |
| 705 | let Decision::Refuse(why) = toolbox.decide("pull_request", &json!({ "action": "merge" })) else { panic!() }; | |
| 706 | assert!(why.contains("changes nothing itself"), "{why}"); | |
| 707 | assert!(matches!(toolbox.decide("pull_request", &json!({})), Decision::Refuse(_))); | |
| 708 | assert!(matches!(toolbox.decide("issue", &json!({ "action": "create" })), Decision::Refuse(_))); | |
| 709 | assert!(matches!(toolbox.decide("nope", &json!({})), Decision::Refuse(_))); | |
| 710 | } | |
| 711 | ||
| 712 | #[test] | |
| 713 | fn a_dispatch_waits_for_the_persons_yes() { | |
| 714 | let start = Native { | |
| 715 | spec: ToolSpec { name: "start_session".into(), description: "Start a session".into(), input_schema: json!({ "type": "object" }) }, | |
| 716 | kind: Kind::Dispatch, | |
| 717 | }; | |
| 718 | let toolbox = Toolbox::new(Vec::new(), vec![start]); | |
| 719 | assert!(toolbox.is_native("start_session")); | |
| 720 | assert_eq!(toolbox.decide("start_session", &json!({ "repo": "a/b", "number": 3 })), Decision::Confirm); | |
| 721 | } | |
| 722 | ||
| 723 | #[test] | |
| 724 | fn a_proposed_session_is_a_card_the_person_starts() { | |
| 725 | let toolbox = Toolbox::new(Vec::new(), native()); | |
| 726 | assert!(toolbox.is_native(PROPOSE_SESSION)); | |
| 727 | let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " }); | |
| 728 | assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm); | |
| 729 | let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction } = proposal("proposal_0_0", &on_issue).unwrap() else { | |
| 730 | panic!() | |
| 731 | }; | |
| 732 | assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement")); | |
| 733 | assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name"))); | |
| 734 | let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" }); | |
| 735 | let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() }; | |
| 736 | assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route"))); | |
| 737 | assert!(proposal("p", &json!({ "repo": "acme/web", "mode": "plan" })).is_err(), "no issue and no title"); | |
| 738 | assert!(proposal("p", &json!({ "repo": "acme", "issue": 1, "mode": "plan" })).is_err(), "not owner/name"); | |
| 739 | assert!(proposal("p", &json!({ "repo": "acme/web", "issue": 1, "mode": "ship" })).is_err(), "no such mode"); | |
| 740 | assert_eq!(label(PROPOSE_SESSION, &on_issue), "Proposed a session on issue #12 in acme/web"); | |
| 741 | } | |
| 742 | ||
| 743 | #[test] | |
| 744 | fn steps_say_what_they_do_in_a_line() { | |
| 745 | assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web"); | |
| 746 | assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web"); | |
| 747 | assert_eq!( | |
| 748 | label("pull_request", &json!({ "action": "changes", "repo": "acme/web", "number": 7 })), | |
| 749 | "Read changes of pull request #7 in acme/web" | |
| 750 | ); | |
| 751 | assert_eq!(label("workflow", &json!({ "action": "list_runs", "repo": "acme/web" })), "Listed runs in acme/web"); | |
| 752 | assert_eq!(label("workflow", &json!({ "action": "job_logs", "repo": "acme/web" })), "Read job logs in acme/web"); | |
| 753 | assert_eq!( | |
| 754 | label("workspace", &json!({ "action": "activity", "workspace": "acme", "since": "2026-10-12T00:00:00Z" })), | |
| 755 | "Read activity in acme since 2026-10-12" | |
| 756 | ); | |
| 757 | assert_eq!(label("search", &json!({ "query": "login bug" })), "Searched g1t for \u{201c}login bug\u{201d}"); | |
| 758 | assert_eq!(label("repository", &json!({ "action": "list" })), "Listed repositories"); | |
| 759 | } | |
| 760 | ||
| 761 | #[test] | |
| 762 | fn steps_link_to_the_page_they_read() { | |
| 763 | let site = "https://g1t.sh/"; | |
| 764 | assert_eq!(link(site, "pull_request", &json!({ "repo": "acme/web", "number": 7 })).unwrap(), "https://g1t.sh/acme/web/pull/7"); | |
| 765 | assert_eq!(link(site, "issue", &json!({ "action": "list", "repo": "acme/web" })).unwrap(), "https://g1t.sh/acme/web/issues"); | |
| 766 | 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"); | |
| 767 | assert_eq!(link(site, "workspace", &json!({ "action": "activity", "workspace": "acme" })).unwrap(), "https://g1t.sh/acme"); | |
| 768 | assert_eq!(link(site, "search", &json!({ "query": "x" })), None); | |
| 769 | } | |
| 770 | ||
| 771 | #[test] | |
| 772 | fn steps_say_what_they_found() { | |
| 773 | assert_eq!(summary("[1,2,3]", false), "3 results"); | |
| 774 | assert_eq!(summary("{\"events\":[{}],\"summary\":{}}", false), "1 result"); | |
| 775 | assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login"); | |
| 776 | assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write"); | |
| 777 | assert_eq!(summary("plain", false), "Done"); | |
| 778 | } | |
| 779 | ||
| 780 | #[test] | |
| 781 | fn big_results_are_trimmed_not_dropped() { | |
| 782 | let small = json!({ "title": "x", "avatar_url": "https://a", "url_template": "https://x/{?q}" }).to_string(); | |
| 783 | assert_eq!(trim_result(&small, 1_000, Keep::Start), "{\"title\":\"x\"}"); | |
| 784 | // A long list keeps its first items, whole, and says how many more. | |
| 785 | let items: Vec<Value> = (0..400).map(|n| json!({ "number": n, "title": format!("Pull request {n}"), "body": "b".repeat(3_000) })).collect(); | |
| 786 | let big = json!({ "items": items }).to_string(); | |
| 787 | let kept = trim_result(&big, 16_000, Keep::Start); | |
| 788 | assert!(kept.chars().count() <= 16_000, "{}", kept.len()); | |
| 789 | let read: Value = serde_json::from_str(&kept).expect("still JSON"); | |
| 790 | let list = read["items"].as_array().unwrap(); | |
| 791 | assert_eq!(list[0]["number"], 0); | |
| 792 | assert!(list.last().unwrap().as_str().unwrap().contains("more not shown")); | |
| 793 | assert!(list[0]["body"].as_str().unwrap().contains("more characters")); | |
| 794 | // Text that is not JSON is cut with a note. | |
| 795 | let log = "line\n".repeat(10_000); | |
| 796 | let kept = trim_result(&log, 1_000, Keep::Start); | |
| 797 | assert!(kept.chars().count() <= 1_000); | |
| 798 | assert!(kept.contains("[Cut:")); | |
| 799 | } | |
| 800 | ||
| 801 | #[test] | |
| 802 | fn a_logs_end_is_kept_where_a_failure_says_why() { | |
| 803 | assert_eq!(Keep::of("workflow", &json!({ "action": "job_logs" })), Keep::End); | |
| 804 | assert_eq!(Keep::of("workflow", &json!({ "action": "list_runs" })), Keep::Start); | |
| 805 | assert_eq!(Keep::of("pull_request", &json!({ "action": "list" })), Keep::Start); | |
| 806 | let mut chunks: Vec<Value> = (0..2_000).map(|seq| json!({ "seq": seq, "step": 3, "text": format!("building part {seq}\n") })).collect(); | |
| 807 | chunks.push(json!({ "seq": 2_000, "step": 4, "text": "##[error]No space left on device\n" })); | |
| 808 | let log = json!({ "chunks": chunks, "done": true }).to_string(); | |
| 809 | let kept = trim_result(&log, 4_000, Keep::End); | |
| 810 | assert!(kept.chars().count() <= 4_000); | |
| 811 | let read: Value = serde_json::from_str(&kept).expect("still JSON"); | |
| 812 | let kept_chunks = read["chunks"].as_array().unwrap(); | |
| 813 | assert!(kept_chunks[0].as_str().unwrap().contains("more not shown")); | |
| 814 | assert!(kept_chunks.last().unwrap()["text"].as_str().unwrap().contains("No space left on device")); | |
| 815 | // Plain text keeps its end too. | |
| 816 | let text = format!("{}error: disk full", "noise\n".repeat(1_000)); | |
| 817 | let kept = trim_result(&text, 500, Keep::End); | |
| 818 | assert!(kept.ends_with("error: disk full")); | |
| 819 | assert!(kept.starts_with("[Cut: the first")); | |
| 820 | } | |
| 821 | ||
| 822 | #[test] | |
| 823 | fn sources_are_the_pages_the_answer_cites_then_those_it_read() { | |
| 824 | let site = "https://g1t.sh"; | |
| 825 | let mut sources = Sources::default(); | |
| 826 | let activity = json!({ "events": [ | |
| 827 | { "kind": "pull_merged", "repo": "acme/web", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7" }, | |
| 828 | { "kind": "release_published", "repo": "acme/api", "tag": "v1.2.0", "title": "Faster", "url": "https://g1t.sh/acme/api/releases/tag/v1.2.0" }, | |
| 829 | { "kind": "pull_merged", "repo": "acme/web", "number": 70, "title": "Other", "url": "https://g1t.sh/acme/web/pull/70" }, | |
| 830 | ] }); | |
| 831 | sources.read(site, "workspace", &json!({ "action": "activity", "workspace": "acme" }), &activity.to_string(), None); | |
| 832 | let list = json!([{ "number": 9, "title": "Docs", "merged_at": null }]); | |
| 833 | sources.read(site, "pull_request", &json!({ "action": "list", "repo": "acme/docs" }), &list.to_string(), None); | |
| 834 | sources.read( | |
| 835 | site, | |
| 836 | "issue", | |
| 837 | &json!({ "action": "get", "repo": "acme/web", "number": 3 }), | |
| 838 | "{\"issue\":{\"title\":\"Login fails\"}}", | |
| 839 | Some(("https://g1t.sh/acme/web/issues/3", "Read issue #3 in acme/web")), | |
| 840 | ); | |
| 841 | let answer = "Shipped: [Fix login](https://g1t.sh/acme/web/pull/7) and acme/docs [#9](/acme/docs/pull/9)."; | |
| 842 | let found = sources.finish(site, answer); | |
| 843 | assert_eq!( | |
| 844 | found, | |
| 845 | vec![ | |
| 846 | AskSource { title: "#7 Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true }, | |
| 847 | AskSource { title: "#9 Docs".into(), url: "https://g1t.sh/acme/docs/pull/9".into(), cited: true }, | |
| 848 | AskSource { title: "Read issue #3 in acme/web".into(), url: "https://g1t.sh/acme/web/issues/3".into(), cited: false }, | |
| 849 | ] | |
| 850 | ); | |
| 851 | } | |
| 852 | } |