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.
| Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae' | 1 | //! Coordinating work over REST and MCP: sessions of people's own harnesses |
| 2 | //! (the MCP `session` tool), and claims and an issue's whole context | |
| 3 | //! (actions of the `issue` tool). | |
| 4 | //! | |
| 5 | //! The work service keeps them and decides who may see and change them | |
| 6 | //! (`g1t_contracts::work::coordination`): anyone who can read the | |
| 7 | //! repository reads them; taking part needs a confirmed email address and a | |
| 8 | //! role that may open issues and pull requests; only the person whose | |
| 9 | //! token started a session, or made a claim, changes it. | |
| 10 | ||
| 11 | use g1t_contracts::work::*; | |
| 12 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | use serde_json::{Value, json}; | |
| 14 | use worker::Result; | |
| 15 | ||
| 16 | use crate::operations::{Services, repo_path}; | |
| 17 | ||
| 18 | /// One operation on sessions, claims or an issue's context. | |
| 19 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | pub enum CoordinationOp { | |
| 21 | StartSession, | |
| 22 | UpdateSession, | |
| 23 | EndSession, | |
| 24 | GetSession, | |
| 25 | ListSessions, | |
| 26 | RecordSessionEntries, | |
| 27 | ClaimIssue, | |
| 28 | ReleaseClaim, | |
| 29 | HandOffIssue, | |
| 30 | ListClaims, | |
| 31 | GetWork, | |
| 32 | } | |
| 33 | ||
| 34 | impl CoordinationOp { | |
| 35 | /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a | |
| 36 | /// test checks against this. | |
| 37 | pub const ALL: [CoordinationOp; 11] = [ | |
| 38 | CoordinationOp::StartSession, | |
| 39 | CoordinationOp::UpdateSession, | |
| 40 | CoordinationOp::EndSession, | |
| 41 | CoordinationOp::GetSession, | |
| 42 | CoordinationOp::ListSessions, | |
| 43 | CoordinationOp::RecordSessionEntries, | |
| 44 | CoordinationOp::ClaimIssue, | |
| 45 | CoordinationOp::ReleaseClaim, | |
| 46 | CoordinationOp::HandOffIssue, | |
| 47 | CoordinationOp::ListClaims, | |
| 48 | CoordinationOp::GetWork, | |
| 49 | ]; | |
| 50 | ||
| 51 | pub fn name(self) -> &'static str { | |
| 52 | match self { | |
| 53 | CoordinationOp::StartSession => "start_session", | |
| 54 | CoordinationOp::UpdateSession => "update_session", | |
| 55 | CoordinationOp::EndSession => "end_session", | |
| 56 | CoordinationOp::GetSession => "get_session", | |
| 57 | CoordinationOp::ListSessions => "list_sessions", | |
| 58 | CoordinationOp::RecordSessionEntries => "record_session_entries", | |
| 59 | CoordinationOp::ClaimIssue => "claim_issue", | |
| 60 | CoordinationOp::ReleaseClaim => "release_claim", | |
| 61 | CoordinationOp::HandOffIssue => "hand_off_issue", | |
| 62 | CoordinationOp::ListClaims => "list_claims", | |
| 63 | CoordinationOp::GetWork => "get_work", | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /// For the API reference. | |
| 68 | pub fn title(self) -> &'static str { | |
| 69 | match self { | |
| 70 | CoordinationOp::StartSession => "Start a session", | |
| 71 | CoordinationOp::UpdateSession => "Update a session", | |
| 72 | CoordinationOp::EndSession => "End a session", | |
| 73 | CoordinationOp::GetSession => "Get a session", | |
| 74 | CoordinationOp::ListSessions => "List sessions", | |
| 75 | CoordinationOp::RecordSessionEntries => "Record entries in a session", | |
| 76 | CoordinationOp::ClaimIssue => "Claim an issue", | |
| 77 | CoordinationOp::ReleaseClaim => "Release a claim", | |
| 78 | CoordinationOp::HandOffIssue => "Hand off an issue", | |
| 79 | CoordinationOp::ListClaims => "List claims", | |
| 80 | CoordinationOp::GetWork => "Get an issue's work context", | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | /// Whether it only reads. | |
| 85 | pub fn reads(self) -> bool { | |
| 86 | matches!( | |
| 87 | self, | |
| 88 | CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork | |
| 89 | ) | |
| 90 | } | |
| 91 | ||
| 92 | pub fn description(self) -> &'static str { | |
| 93 | match self { | |
| 94 | CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue too. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open issues.", | |
| 95 | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 2 hours; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. Returns the session; one ended with end_session is refused with `409`.", | |
| 96 | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason`. A session.ended event. Ending one already ended returns it as it is. Its claims are not released: release them, hand the issue off, or let them expire.", | |
| 97 | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. Anyone who can read the repository may.", | |
| 98 | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended, and `issue` or `pull` for those attached to one. Anyone who can read the repository may.", | |
| 99 | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result or note), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. Recording is a heartbeat. Returns how many were `recorded` and how many were `duplicates`.", | |
| 100 | CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Returns your `claim`, the `others`, and `blocked_by`: each issue this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`.", | |
| 101 | CoordinationOp::ReleaseClaim => "Release your claim on an issue, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the issue closes, and expire when not renewed. Refused with `404` when you hold no active claim on it.", | |
| 102 | CoordinationOp::HandOffIssue => "Hand an issue you claimed off: your claim ends as handed_off, with `note` saying where the work stands, for `to` (a username) or, without one, for anyone. A work.handoff_requested event, which whoever follows the issue hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.", | |
| 103 | CoordinationOp::ListClaims => "A repository's claims, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `issue` for one issue's. Anyone who can read the repository may.", | |
| 104 | CoordinationOp::GetWork => "Everything about an issue an agent needs before working on it, in one call: the `issue`, its `definition_of_done` (the items under its \"Definition of done\" heading) and the `required_checks` every pull request into the default branch must pass; active `claims`; `sessions` on it or its pull requests; its `pulls`, each with its status, required checks as they stand on its head, whether it merges cleanly, the other pull requests it overlaps and its place in the merge queue; `blocked_by` (each issue it waits on, with claims and pull requests) and `blocks` (the issues waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.", | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | pub fn input(self) -> Value { | |
| 109 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 110 | let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." }); | |
| 111 | let number = json!({ "type": "integer", "description": "The issue's number." }); | |
| 112 | let issue = json!({ "type": "integer", "description": "The issue it works on." }); | |
| 113 | let pull = json!({ "type": "integer", "description": "The pull request it works on." }); | |
| 114 | let fields = |more: Value| -> Value { | |
| 115 | let mut properties = json!({ | |
| 116 | "repo": repo, | |
| 117 | "issue": issue, | |
| 118 | "pull": pull, | |
| 119 | "branch": { "type": "string", "description": "The branch it works on." }, | |
| 120 | "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." }, | |
| 121 | "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." }, | |
| 122 | }); | |
| 123 | if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) { | |
| 124 | all.extend(more); | |
| 125 | } | |
| 126 | properties | |
| 127 | }; | |
| 128 | let (properties, required): (Value, &[&str]) = match self { | |
| 129 | CoordinationOp::StartSession => ( | |
| 130 | fields(json!({ | |
| 131 | "harness": { | |
| 132 | "type": "string", | |
| 133 | "enum": Harness::ALL.map(Harness::as_str), | |
| 134 | "description": "The harness the session runs in.", | |
| 135 | }, | |
| 136 | "harness_session_id": { | |
| 137 | "type": "string", | |
| 138 | "description": "The harness's own id for the session, such as Claude Code's session_id. Starting again with it returns the same session.", | |
| 139 | }, | |
| 140 | })), | |
| 141 | &["repo", "harness"], | |
| 142 | ), | |
| 143 | CoordinationOp::UpdateSession => (fields(json!({ "id": id })), &["repo", "id"]), | |
| 144 | CoordinationOp::EndSession => ( | |
| 145 | json!({ | |
| 146 | "repo": repo, | |
| 147 | "id": id, | |
| 148 | "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." }, | |
| 149 | }), | |
| 150 | &["repo", "id"], | |
| 151 | ), | |
| 152 | CoordinationOp::GetSession => ( | |
| 153 | json!({ | |
| 154 | "repo": repo, | |
| 155 | "id": id, | |
| 156 | "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." }, | |
| 157 | }), | |
| 158 | &["repo", "id"], | |
| 159 | ), | |
| 160 | CoordinationOp::ListSessions => ( | |
| 161 | json!({ | |
| 162 | "repo": repo, | |
| 163 | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, | |
| 164 | "issue": { "type": "integer", "description": "Only sessions on this issue." }, | |
| 165 | "pull": { "type": "integer", "description": "Only sessions on this pull request." }, | |
| 166 | }), | |
| 167 | &["repo"], | |
| 168 | ), | |
| 169 | CoordinationOp::RecordSessionEntries => ( | |
| 170 | json!({ | |
| 171 | "repo": repo, | |
| 172 | "id": id, | |
| 173 | "entries": { | |
| 174 | "type": "array", | |
| 175 | "description": "Up to 200 entries, oldest first.", | |
| 176 | "items": { | |
| 177 | "type": "object", | |
| 178 | "properties": { | |
| 179 | "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note"] }, | |
| 180 | "text": { "type": "string" }, | |
| 181 | "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." }, | |
| 182 | "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." }, | |
| 183 | "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." }, | |
| 184 | }, | |
| 185 | "required": ["kind", "text"], | |
| 186 | }, | |
| 187 | }, | |
| 188 | }), | |
| 189 | &["repo", "id", "entries"], | |
| 190 | ), | |
| 191 | CoordinationOp::ClaimIssue => ( | |
| 192 | json!({ | |
| 193 | "repo": repo, | |
| 194 | "number": number, | |
| 195 | "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." }, | |
| 196 | "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." }, | |
| 197 | "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." }, | |
| 198 | }), | |
| 199 | &["repo", "number"], | |
| 200 | ), | |
| 201 | CoordinationOp::ReleaseClaim => ( | |
| 202 | json!({ | |
| 203 | "repo": repo, | |
| 204 | "number": number, | |
| 205 | "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." }, | |
| 206 | }), | |
| 207 | &["repo", "number"], | |
| 208 | ), | |
| 209 | CoordinationOp::HandOffIssue => ( | |
| 210 | json!({ | |
| 211 | "repo": repo, | |
| 212 | "number": number, | |
| 213 | "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." }, | |
| 214 | "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." }, | |
| 215 | }), | |
| 216 | &["repo", "number", "note"], | |
| 217 | ), | |
| 218 | CoordinationOp::ListClaims => ( | |
| 219 | json!({ | |
| 220 | "repo": repo, | |
| 221 | "state": { | |
| 222 | "type": "string", | |
| 223 | "enum": ["active", "released", "expired", "handed_off"], | |
| 224 | "description": "Claims in this state; active if left out.", | |
| 225 | }, | |
| 226 | "issue": { "type": "integer", "description": "Only claims on this issue." }, | |
| 227 | }), | |
| 228 | &["repo"], | |
| 229 | ), | |
| 230 | CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]), | |
| 231 | }; | |
| 232 | json!({ "type": "object", "properties": properties, "required": required }) | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | fn text(input: &Value, key: &str) -> Option<String> { | |
| 237 | input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned) | |
| 238 | } | |
| 239 | ||
| 240 | /// A whole number given as a number, as digits, or as `#12`. | |
| 241 | fn number(input: &Value, key: &str) -> Option<u32> { | |
| 242 | match &input[key] { | |
| 243 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 244 | Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(), | |
| 245 | _ => None, | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | /// An optional whole number: none when left out, refused when not one. | |
| 250 | fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> { | |
| 251 | match &input[key] { | |
| 252 | Value::Null => Ok(None), | |
| 253 | Value::String(text) if text.trim().is_empty() => Ok(None), | |
| 254 | _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")), | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | /// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or | |
| 259 | /// `#12`. None when left out; refused when it holds anything else. | |
| 260 | pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> { | |
| 261 | let items = match &input["blocked_by"] { | |
| 262 | Value::Null => return Ok(None), | |
| 263 | Value::Array(items) => items, | |
| 264 | _ => return Err("blocked_by is a list of issue numbers.".to_owned()), | |
| 265 | }; | |
| 266 | items | |
| 267 | .iter() | |
| 268 | .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number."))) | |
| 269 | .collect::<std::result::Result<Vec<u32>, String>>() | |
| 270 | .map(Some) | |
| 271 | } | |
| 272 | ||
| 273 | pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 274 | let Some(repo) = repo_path(input) else { | |
| 275 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 276 | }; | |
| 277 | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); | |
| 278 | let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) { | |
| 279 | (Ok(issue), Ok(pull)) => (issue, pull), | |
| 280 | (Err(message), _) | (_, Err(message)) => return invalid(message), | |
| 281 | }; | |
| 282 | let actor = || viewer.clone().unwrap_or_default(); | |
| 283 | let id = || text(input, "id").unwrap_or_default(); | |
| 284 | let issue_number = number(input, "number").unwrap_or_default(); | |
| 285 | let work = &services.work; | |
| 286 | if matches!( | |
| 287 | op, | |
| 288 | CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries | |
| 289 | ) && id().is_empty() | |
| 290 | { | |
| 291 | return invalid("Name the session by its id (ses_…).".to_owned()); | |
| 292 | } | |
| 293 | match op { | |
| 294 | CoordinationOp::StartSession => { | |
| 295 | let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else { | |
| 296 | let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect(); | |
| 297 | return invalid(format!("harness is one of {}.", names.join(", "))); | |
| 298 | }; | |
| 299 | let args = StartSessionArgs { | |
| 300 | actor: actor(), | |
| 301 | repo, | |
| 302 | harness, | |
| 303 | harness_session_id: text(input, "harness_session_id"), | |
| 304 | issue, | |
| 305 | pull, | |
| 306 | branch: text(input, "branch"), | |
| 307 | title: text(input, "title"), | |
| 308 | head_commit: text(input, "head_commit"), | |
| 309 | }; | |
| 310 | g1t_kit::call(work, "start_session", &args).await | |
| 311 | } | |
| 312 | CoordinationOp::UpdateSession => { | |
| 313 | let args = UpdateSessionArgs { | |
| 314 | actor: actor(), | |
| 315 | repo, | |
| 316 | id: id(), | |
| 317 | issue, | |
| 318 | pull, | |
| 319 | branch: text(input, "branch"), | |
| 320 | head_commit: text(input, "head_commit"), | |
| 321 | title: text(input, "title"), | |
| 322 | }; | |
| 323 | g1t_kit::call(work, "update_session", &args).await | |
| 324 | } | |
| 325 | CoordinationOp::EndSession => { | |
| 326 | let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason") }; | |
| 327 | g1t_kit::call(work, "end_session", &args).await | |
| 328 | } | |
| 329 | CoordinationOp::GetSession => { | |
| 330 | let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() }; | |
| 331 | g1t_kit::call(work, "get_work_session", &args).await | |
| 332 | } | |
| 333 | CoordinationOp::ListSessions => { | |
| 334 | let state = match text(input, "state") { | |
| 335 | None => None, | |
| 336 | Some(state) => match SessionState::parse(&state) { | |
| 337 | Some(state) => Some(state), | |
| 338 | None => return invalid(format!("state is active, idle or ended, not {state}.")), | |
| 339 | }, | |
| 340 | }; | |
| 341 | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull }; | |
| 342 | g1t_kit::call(work, "list_work_sessions", &args).await | |
| 343 | } | |
| 344 | CoordinationOp::RecordSessionEntries => { | |
| 345 | let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(input["entries"].clone()) { | |
| 346 | Ok(entries) => entries, | |
| 347 | Err(_) => { | |
| 348 | return invalid( | |
| 349 | "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result or note, with tool, commit and key as needed.".to_owned(), | |
| 350 | ); | |
| 351 | } | |
| 352 | }; | |
| 353 | let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries }; | |
| 354 | g1t_kit::call(work, "record_session_entries", &args).await | |
| 355 | } | |
| 356 | CoordinationOp::ClaimIssue => { | |
| 357 | let ttl_minutes = match optional_number(input, "ttl_minutes") { | |
| 358 | Ok(minutes) => minutes, | |
| 359 | Err(message) => return invalid(message), | |
| 360 | }; | |
| 361 | let args = ClaimArgs { | |
| 362 | actor: actor(), | |
| 363 | repo, | |
| 364 | number: issue_number, | |
| 365 | session_id: text(input, "session_id"), | |
| 366 | note: text(input, "note"), | |
| 367 | ttl_minutes, | |
| 368 | }; | |
| 369 | g1t_kit::call(work, "claim_issue", &args).await | |
| 370 | } | |
| 371 | CoordinationOp::ReleaseClaim => { | |
| 372 | let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason") }; | |
| 373 | g1t_kit::call(work, "release_claim", &args).await | |
| 374 | } | |
| 375 | CoordinationOp::HandOffIssue => { | |
| 376 | let Some(note) = text(input, "note") else { | |
| 377 | return invalid("Say where the work stands in note, for whoever picks it up.".to_owned()); | |
| 378 | }; | |
| 379 | let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note }; | |
| 380 | g1t_kit::call(work, "hand_off_issue", &args).await | |
| 381 | } | |
| 382 | CoordinationOp::ListClaims => { | |
| 383 | let state = match text(input, "state") { | |
| 384 | None => None, | |
| 385 | Some(state) => match ClaimState::parse(&state) { | |
| 386 | Some(state) => Some(state), | |
| 387 | None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")), | |
| 388 | }, | |
| 389 | }; | |
| 390 | let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue }; | |
| 391 | g1t_kit::call(work, "list_claims", &args).await | |
| 392 | } | |
| 393 | CoordinationOp::GetWork => { | |
| 394 | let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number }; | |
| 395 | g1t_kit::call(work, "get_work", &args).await | |
| 396 | } | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | #[cfg(test)] | |
| 401 | mod tests { | |
| 402 | use super::*; | |
| 403 | use crate::operations::Op; | |
| 404 | use g1t_contracts::scopes::{Level, Scope, scope_for}; | |
| 405 | ||
| 406 | #[test] | |
| 407 | fn each_operation_is_described_scoped_and_listed() { | |
| 408 | for op in CoordinationOp::ALL { | |
| 409 | assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name()); | |
| 410 | assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op))); | |
| 411 | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); | |
| 412 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 413 | let scope = scope_for(op.name()).unwrap(); | |
| 414 | assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name()); | |
| 415 | let sessions = op.name().contains("session"); | |
| 416 | let expected = match (sessions, op.reads()) { | |
| 417 | (true, true) => Scope::PullRequestsRead, | |
| 418 | (true, false) => Scope::PullRequestsWrite, | |
| 419 | (false, true) => Scope::IssuesRead, | |
| 420 | (false, false) => Scope::IssuesWrite, | |
| 421 | }; | |
| 422 | assert_eq!(scope, expected, "{}", op.name()); | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | #[test] | |
| 427 | fn routes_reach_each_operation_with_its_input() { | |
| 428 | let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| { | |
| 429 | let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); | |
| 430 | let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap(); | |
| 431 | (route.op, input) | |
| 432 | }; | |
| 433 | let coordination = |op| Op::Coordination(op); | |
| 434 | let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" })); | |
| 435 | assert_eq!(op, coordination(CoordinationOp::StartSession)); | |
| 436 | assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" })); | |
| 437 | let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({})); | |
| 438 | assert_eq!(op, coordination(CoordinationOp::UpdateSession)); | |
| 439 | assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" })); | |
| 440 | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({})); | |
| 441 | assert_eq!(op, coordination(CoordinationOp::EndSession)); | |
| 442 | let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null); | |
| 443 | assert_eq!(op, coordination(CoordinationOp::GetSession)); | |
| 444 | assert_eq!(input["after"], "40"); | |
| 445 | let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null); | |
| 446 | assert_eq!(op, coordination(CoordinationOp::ListSessions)); | |
| 447 | assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" })); | |
| 448 | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] })); | |
| 449 | assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries)); | |
| 450 | let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" })); | |
| 451 | assert_eq!(op, coordination(CoordinationOp::ClaimIssue)); | |
| 452 | assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" })); | |
| 453 | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({})); | |
| 454 | assert_eq!(op, coordination(CoordinationOp::ReleaseClaim)); | |
| 455 | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" })); | |
| 456 | assert_eq!(op, coordination(CoordinationOp::HandOffIssue)); | |
| 457 | let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null); | |
| 458 | assert_eq!(op, coordination(CoordinationOp::ListClaims)); | |
| 459 | let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null); | |
| 460 | assert_eq!(op, coordination(CoordinationOp::GetWork)); | |
| 461 | assert_eq!(input, json!({ "number": 4, "repo": "acme/web" })); | |
| 462 | } | |
| 463 | ||
| 464 | #[test] | |
| 465 | fn mcp_actions_reach_each_operation() { | |
| 466 | use crate::tools::{Tool, resolve}; | |
| 467 | let call = |tool: &str, action: &str| resolve(Tool::by_name(tool).unwrap(), &json!({ "action": action, "repo": "a/b", "id": "ses_1", "number": 4, "harness": "codex", "entries": [], "note": "x" })); | |
| 468 | assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession))); | |
| 469 | assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession))); | |
| 470 | assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries))); | |
| 471 | assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue))); | |
| 472 | assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); | |
| 473 | } | |
| 474 | ||
| 475 | #[test] | |
| 476 | fn numbers_come_as_numbers_digits_or_with_a_hash() { | |
| 477 | assert_eq!(blocked_by(&json!({})), Ok(None)); | |
| 478 | assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5]))); | |
| 479 | assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![]))); | |
| 480 | assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err()); | |
| 481 | assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err()); | |
| 482 | assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None)); | |
| 483 | assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err()); | |
| 484 | } | |
| 485 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.