| 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 | } |