Skip to content

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

g1t.sh
485 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The API and MCP server coordinate work: start_session, update_session (an empty body is a heartbeat), end_session, get_session, list_sessions and record_session_entries at /repos/{owner}/{name}/sessions, as the new session tool's start, update, end, get, list and record; claim_issue, release_claim, hand_off_issue and get_work at /repos/{owner}/{name}/issues/{number}/claim, /release, /handoff and /work, and list_claims at /repos/{owner}/{name}/claims, as the issue tool's claim, release, handoff, work and list_claims; and update_issue takes blocked_by. Sessions need pull_requests scopes and claims and get_work issues scopes, reads are open to anyone who can read the repository, the reference has an example of each, and the OpenAPI copy and the CLI's table of operations are regenerated.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
11use g1t_contracts::work::*;
12use g1t_contracts::{FailureCode, Outcome, Viewer};
13use serde_json::{Value, json};
14use worker::Result;
15
16use crate::operations::{Services, repo_path};
17
18/// One operation on sessions, claims or an issue's context.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub 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
34impl 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
236fn 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`.
241fn 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.
250fn 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.
260pub 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
273pub 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)]
401mod 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}