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