Serve work items over REST and MCP
Work items are a workspace's: /workspaces/{workspace}/work and its item routes (links, labels, comments, reactions, decisions, claims, hand-offs, the work context and subscriptions), workspace labels and milestones, and /repos/{owner}/{name}/work for the items linked to a repository. #N references resolve at /repos/{owner}/{name}/refs/{n} and /workspaces/{workspace}/refs/{n}. The repository issue, label and milestone routes are gone; pull requests keep their comments, reactions and subscriptions under /pulls. MCP's issue tool is now work_item, labels and milestones are actions of the workspace tool, pull_request create takes item and comments on pull requests, and the server instructions say work items. A call that names only a repository works in the repository's workspace.
- G1T-Session:
- ses_01m566353xebrscb7pf471bnn1
| 1 | 1 | //! `get_workspace_activity`: what happened across a workspace's | |
| 2 | 2 | //! repositories over a span, newest first, as one list a person (or Ask | |
| 3 | − | //! g1t, or an agent) can read: pull requests merged and opened, issues | |
| 3 | + | //! g1t, or an agent) can read: pull requests merged and opened, work items | |
| 4 | 4 | //! opened and closed, releases, deployments and pushes, each with its | |
| 5 | 5 | //! repository, who did it, its title where it has one and its address on | |
| 6 | 6 | //! the site. "What shipped today?" is one call. | |
| ⋯ | |||
| 46 | 46 | pub const KINDS: &[(&str, &[&str])] = &[ | |
| 47 | 47 | ("merged", &["pull.merged"]), | |
| 48 | 48 | ("pulls", &["pull.opened", "pull.merged", "pull.closed"]), | |
| 49 | − | ("issues", &["issue.opened", "issue.closed"]), | |
| 49 | + | ("work_items", &["work_item.opened", "work_item.closed"]), | |
| 50 | 50 | ("releases", &["release.published"]), | |
| 51 | 51 | ("deployments", &["deployment.succeeded", "deployment.failed"]), | |
| 52 | 52 | ("pushes", &["git.push"]), | |
| ⋯ | |||
| 68 | 68 | ||
| 69 | 69 | /// What is read when no kinds are given: everything but comments, which | |
| 70 | 70 | /// would crowd out the rest on a busy day. | |
| 71 | − | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 71 | + | const DEFAULT_KINDS: &[&str] = &["pulls", "work_items", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 72 | 72 | ||
| 73 | 73 | pub fn description() -> &'static str { | |
| 74 | − | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run, and `production` true for a run of a deploy workflow on the default branch: a repository that deploys itself). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments (projects' production deployments and successful deploy runs on default branches), failed ones, default-branch pushes and failed runs, and in `shipped` the landed commits by where they are. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author` as git wrote it, `username` when the author's address is a g1t account's, `time`, `url`), and, where the repository deploys, `production`: `live` (a successful deploy carried it, named in `deploy` with its `url`), `deploying`, `failed` (its deploy failed and none since succeeded) or `not_deployed`. That is what shipped: what reached the default branch, and which of it is live. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." | |
| 74 | + | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, work items opened and closed (linked to the repositories, numbered in the workspace), releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run, and `production` true for a run of a deploy workflow on the default branch: a repository that deploys itself). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments (projects' production deployments and successful deploy runs on default branches), failed ones, default-branch pushes and failed runs, and in `shipped` the landed commits by where they are. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author` as git wrote it, `username` when the author's address is a g1t account's, `time`, `url`), and, where the repository deploys, `production`: `live` (a successful deploy carried it, named in `deploy` with its `url`), `deploying`, `failed` (its deploy failed and none since succeeded) or `not_deployed`. That is what shipped: what reached the default branch, and which of it is live. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, work_items, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | pub fn input() -> Value { | |
| ⋯ | |||
| 84 | 84 | "kinds": { | |
| 85 | 85 | "type": "array", | |
| 86 | 86 | "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() }, | |
| 87 | − | "description": "What to read: merged (merged pull requests only), pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.", | |
| 87 | + | "description": "What to read: merged (merged pull requests only), pulls, work_items, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.", | |
| 88 | 88 | }, | |
| 89 | 89 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." }, | |
| 90 | 90 | }, | |
| ⋯ | |||
| 202 | 202 | shown["title"] = json!(title); | |
| 203 | 203 | } | |
| 204 | 204 | shown["url"] = json!(format!("{page}/pull/{number}")); | |
| 205 | − | if let Some(issue) = data.get("issue").and_then(Value::as_u64) { | |
| 206 | − | shown["issue"] = json!(issue); | |
| 205 | + | if let Some(item) = data.get("item").and_then(Value::as_u64) { | |
| 206 | + | shown["item"] = json!(item); | |
| 207 | 207 | } | |
| 208 | 208 | if event.kind == "pull.merged" { | |
| 209 | 209 | merged += 1; | |
| 210 | 210 | } | |
| 211 | 211 | } | |
| 212 | − | "issue.opened" | "issue.closed" => { | |
| 212 | + | "work_item.opened" | "work_item.closed" => { | |
| 213 | 213 | let Some(number) = number else { continue }; | |
| 214 | 214 | shown["number"] = json!(number); | |
| 215 | 215 | if let Some(title) = text(data, "title") { | |
| ⋯ | |||
| 218 | 218 | if let Some(reason) = text(data, "reason") { | |
| 219 | 219 | shown["reason"] = json!(reason); | |
| 220 | 220 | } | |
| 221 | − | if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) { | |
| 222 | − | shown["resolved_by"] = json!(pull); | |
| 221 | + | // What landed it here: a merged pull request, or a commit | |
| 222 | + | // pushed to the default branch. | |
| 223 | + | if let Some(resolution) = data.get("resolution") { | |
| 224 | + | if let Some(pull) = resolution.get("pull").and_then(Value::as_u64) { | |
| 225 | + | shown["resolved_by"] = json!(pull); | |
| 226 | + | } | |
| 227 | + | if let Some(commit) = text(resolution, "commit") { | |
| 228 | + | shown["resolved_commit"] = json!(commit); | |
| 229 | + | } | |
| 223 | 230 | } | |
| 224 | − | // Closed by a commit pushed to the default branch. | |
| 225 | − | if let Some(commit) = text(data, "commit") { | |
| 226 | − | shown["resolved_commit"] = json!(commit); | |
| 227 | − | } | |
| 228 | − | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 231 | + | let workspace = text(data, "workspace").unwrap_or(repo.namespace.as_str()); | |
| 232 | + | shown["url"] = json!(format!("{site}/{workspace}/-/work/{number}")); | |
| 229 | 233 | } | |
| 230 | 234 | "release.published" => { | |
| 231 | 235 | let tag = text(data, "tagName").unwrap_or_default(); | |
| ⋯ | |||
| 280 | 284 | } | |
| 281 | 285 | } | |
| 282 | 286 | "review.completed" | "comment.created" => { | |
| 283 | − | if let Some(number) = number.or_else(|| data.get("issue").and_then(Value::as_u64).map(|n| n as u32)) { | |
| 287 | + | if let Some(number) = number { | |
| 284 | 288 | shown["number"] = json!(number); | |
| 285 | − | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 289 | + | // On a work item, its page in the workspace; else the | |
| 290 | + | // pull request's. | |
| 291 | + | shown["url"] = match (text(data, "itemId"), text(data, "workspace")) { | |
| 292 | + | (Some(_), Some(workspace)) => json!(format!("{site}/{workspace}/-/work/{number}")), | |
| 293 | + | _ => json!(format!("{page}/pull/{number}")), | |
| 294 | + | }; | |
| 286 | 295 | } | |
| 287 | 296 | if let Some(verdict) = text(data, "verdict") { | |
| 288 | 297 | shown["verdict"] = json!(verdict); | |
| ⋯ | |||
| 889 | 898 | let events = [ | |
| 890 | 899 | event("deployment.succeeded", "rep_1", "2026-10-12T15:00:00Z", "usr_1", json!({ "kind": "production", "project": "web", "commit": "abcdef1234567890", "path": "/acme/web/deployments/dpl_1", "triggeredBy": "ana" })), | |
| 891 | 900 | event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })), | |
| 892 | − | event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "issue": 3 })), | |
| 901 | + | event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "item": 3 })), | |
| 893 | 902 | event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })), | |
| 894 | 903 | event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })), | |
| 895 | 904 | event("workflow.completed", "rep_1", "2026-10-12T11:30:00Z", "usr_1", json!({ "runId": "run_257", "workflow": "Deploy", "number": 257, "conclusion": "failure", "ref": "refs/heads/main", "sha": "fedcba9876543210" })), | |
| 896 | − | event("issue.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 3, "title": "Login fails", "reason": "completed", "resolvedBy": 7 })), | |
| 905 | + | event("work_item.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "workspace": "acme", "number": 3, "title": "Login fails", "reason": "completed", "resolution": { "repo": "acme/web", "pull": 7 } })), | |
| 897 | 906 | // Outside the span, and a repository the caller cannot read. | |
| 898 | 907 | event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })), | |
| 899 | 908 | event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })), | |
| ⋯ | |||
| 926 | 935 | assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0"); | |
| 927 | 936 | assert_eq!( | |
| 928 | 937 | listed[2], | |
| 929 | − | json!({ "kind": "pull_merged", "time": "2026-10-12T13:00:00Z", "repo": "acme/web", "actor": "ana", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7", "issue": 3 }) | |
| 938 | + | json!({ "kind": "pull_merged", "time": "2026-10-12T13:00:00Z", "repo": "acme/web", "actor": "ana", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7", "item": 3 }) | |
| 930 | 939 | ); | |
| 931 | 940 | assert_eq!(listed[3]["default_branch"], true); | |
| 932 | 941 | assert_eq!(listed[3]["actor"], "bo"); | |
| ⋯ | |||
| 935 | 944 | json!({ "kind": "workflow_completed", "time": "2026-10-12T11:30:00Z", "repo": "acme/web", "actor": "ana", "workflow": "Deploy", "run_number": 257, "conclusion": "failure", "branch": "main", "commit": "fedcba987654", "run_id": "run_257", "url": "https://g1t.sh/acme/web/actions/runs/run_257", "production": true }) | |
| 936 | 945 | ); | |
| 937 | 946 | assert_eq!(listed[5]["resolved_by"], 7); | |
| 947 | + | assert_eq!(listed[5]["url"], "https://g1t.sh/acme/-/work/3"); | |
| 938 | 948 | assert_eq!(shown["complete"], true); | |
| 939 | 949 | } | |
| 940 | 950 | ||
| 941 | 951 | #[test] | |
| 942 | − | fn an_issue_a_commit_closed_names_the_commit() { | |
| 952 | + | fn a_work_item_a_commit_closed_names_the_commit() { | |
| 943 | 953 | let repos = [repo("rep_1", "web")]; | |
| 944 | 954 | let events = [event( | |
| 945 | − | "issue.closed", | |
| 955 | + | "work_item.closed", | |
| 946 | 956 | "rep_1", | |
| 947 | 957 | "2026-10-12T10:00:00Z", | |
| 948 | 958 | "usr_1", | |
| 949 | − | json!({ "number": 3, "title": "Login fails", "reason": "completed", "commit": "0123456789abcdef0123456789abcdef01234567" }), | |
| 959 | + | json!({ "workspace": "acme", "number": 3, "title": "Login fails", "reason": "completed", "resolution": { "repo": "acme/web", "commit": "0123456789abcdef0123456789abcdef01234567" } }), | |
| 950 | 960 | )]; | |
| 951 | 961 | let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned())]); | |
| 952 | 962 | let shown = shape(&Found { | |
| 133 | 133 | pub target: String, | |
| 134 | 134 | pub branch: Option<String>, | |
| 135 | 135 | pub pull: Option<u32>, | |
| 136 | − | pub issue: Option<u32>, | |
| 136 | + | /// The work item opened for it, by number in the workspace. | |
| 137 | + | pub item: Option<u32>, | |
| 137 | 138 | pub error: Option<String>, | |
| 138 | 139 | pub updated_at: String, | |
| 139 | 140 | } | |
| ⋯ | |||
| 145 | 146 | target: update.target, | |
| 146 | 147 | branch: update.branch, | |
| 147 | 148 | pull: update.pull, | |
| 148 | − | issue: update.issue, | |
| 149 | + | item: update.item, | |
| 149 | 150 | error: update.error, | |
| 150 | 151 | updated_at: update.updated_at, | |
| 151 | 152 | } | |
| ⋯ | |||
| 278 | 279 | "severity": "high", | |
| 279 | 280 | "fixedVersion": null, | |
| 280 | 281 | "status": "open", | |
| 281 | − | "issue": null, | |
| 282 | + | "item": null, | |
| 282 | 283 | "foundAt": "2026-10-01T12:00:00Z", | |
| 283 | 284 | "fixedAt": null, | |
| 284 | 285 | "state": "open", | |
| 285 | − | "update": { "state": "open", "target": "4.17.21", "branch": "g1t/security/lodash-4.17.21", "pull": 12, "issue": null, "error": null, "updatedAt": "2026-10-01T12:05:00Z" }, | |
| 286 | + | "update": { "state": "open", "target": "4.17.21", "branch": "g1t/security/lodash-4.17.21", "pull": 12, "item": null, "error": null, "updatedAt": "2026-10-01T12:05:00Z" }, | |
| 286 | 287 | })) | |
| 287 | 288 | .unwrap() | |
| 288 | 289 | } | |
| 346 | 346 | ||
| 347 | 347 | #[test] | |
| 348 | 348 | fn a_token_without_the_scope_is_refused_and_told_which() { | |
| 349 | − | let reader = with_token(Some(&[Scope::IssuesRead]), false); | |
| 349 | + | let reader = with_token(Some(&[Scope::WorkRead]), false); | |
| 350 | 350 | let input = json!({ "repo": "acme/rocket", "title": "x" }); | |
| 351 | − | assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed); | |
| 352 | − | let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap(); | |
| 351 | + | assert!(token_decision(Op::GetWorkItem, &reader, &input).unwrap().allowed); | |
| 352 | + | let refused = token_decision(Op::OpenWorkItem, &reader, &input).unwrap(); | |
| 353 | 353 | assert!(!refused.allowed); | |
| 354 | − | assert!(refused.reason.unwrap().contains("issues:write")); | |
| 355 | − | assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite)); | |
| 356 | − | assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None); | |
| 354 | + | assert!(refused.reason.unwrap().contains("work:write")); | |
| 355 | + | assert_eq!(missing_scope(Op::OpenWorkItem, &Some(reader.clone()), &input), Some(Scope::WorkWrite)); | |
| 356 | + | assert_eq!(missing_scope(Op::GetWorkItem, &Some(reader), &input), None); | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | 359 | #[test] |
| 1 | 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). | |
| 2 | + | //! (the MCP `session` tool), and claims and a work item's whole context | |
| 3 | + | //! (actions of the `work_item` tool). | |
| 4 | 4 | //! | |
| 5 | 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. | |
| 6 | + | //! (`g1t_contracts::work::coordination`): anyone who may see the work item | |
| 7 | + | //! or read the repository reads them; taking part needs a confirmed email | |
| 8 | + | //! address and a role that may open work items and pull requests; only the | |
| 9 | + | //! person whose token started a session, or made a claim, changes it. | |
| 10 | + | //! Sessions are a repository's, where they are checked out; claims and | |
| 11 | + | //! work contexts are of a workspace's work items. | |
| 10 | 12 | ||
| 11 | 13 | use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs}; | |
| 12 | 14 | use g1t_contracts::work::*; | |
| ⋯ | |||
| 14 | 16 | use serde_json::{Value, json}; | |
| 15 | 17 | use worker::Result; | |
| 16 | 18 | ||
| 17 | − | use crate::operations::{Services, repo_path}; | |
| 19 | + | use g1t_contracts::repos::RepoPath; | |
| 20 | + | ||
| 21 | + | use crate::operations::{Services, item_workspace, repo_path}; | |
| 18 | 22 | ||
| 19 | − | /// One operation on sessions, claims or an issue's context. | |
| 23 | + | /// One operation on sessions, claims or a work item's context. | |
| 20 | 24 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 21 | 25 | pub enum CoordinationOp { | |
| 22 | 26 | StartSession, | |
| ⋯ | |||
| 25 | 29 | GetSession, | |
| 26 | 30 | ListSessions, | |
| 27 | 31 | RecordSessionEntries, | |
| 28 | − | ClaimIssue, | |
| 32 | + | ClaimWorkItem, | |
| 29 | 33 | ReleaseClaim, | |
| 30 | − | HandOffIssue, | |
| 34 | + | HandOffWorkItem, | |
| 31 | 35 | ListClaims, | |
| 32 | 36 | GetWork, | |
| 33 | 37 | StartAgentSession, | |
| ⋯ | |||
| 43 | 47 | CoordinationOp::GetSession, | |
| 44 | 48 | CoordinationOp::ListSessions, | |
| 45 | 49 | CoordinationOp::RecordSessionEntries, | |
| 46 | − | CoordinationOp::ClaimIssue, | |
| 50 | + | CoordinationOp::ClaimWorkItem, | |
| 47 | 51 | CoordinationOp::ReleaseClaim, | |
| 48 | − | CoordinationOp::HandOffIssue, | |
| 52 | + | CoordinationOp::HandOffWorkItem, | |
| 49 | 53 | CoordinationOp::ListClaims, | |
| 50 | 54 | CoordinationOp::GetWork, | |
| 51 | 55 | CoordinationOp::StartAgentSession, | |
| ⋯ | |||
| 59 | 63 | CoordinationOp::GetSession => "get_session", | |
| 60 | 64 | CoordinationOp::ListSessions => "list_sessions", | |
| 61 | 65 | CoordinationOp::RecordSessionEntries => "record_session_entries", | |
| 62 | − | CoordinationOp::ClaimIssue => "claim_issue", | |
| 66 | + | CoordinationOp::ClaimWorkItem => "claim_work_item", | |
| 63 | 67 | CoordinationOp::ReleaseClaim => "release_claim", | |
| 64 | − | CoordinationOp::HandOffIssue => "hand_off_issue", | |
| 68 | + | CoordinationOp::HandOffWorkItem => "hand_off_work_item", | |
| 65 | 69 | CoordinationOp::ListClaims => "list_claims", | |
| 66 | 70 | CoordinationOp::GetWork => "get_work", | |
| 67 | 71 | CoordinationOp::StartAgentSession => "start_agent_session", | |
| ⋯ | |||
| 77 | 81 | CoordinationOp::GetSession => "Get a session", | |
| 78 | 82 | CoordinationOp::ListSessions => "List sessions", | |
| 79 | 83 | CoordinationOp::RecordSessionEntries => "Record entries in a session", | |
| 80 | − | CoordinationOp::ClaimIssue => "Claim an issue", | |
| 84 | + | CoordinationOp::ClaimWorkItem => "Claim a work item", | |
| 81 | 85 | CoordinationOp::ReleaseClaim => "Release a claim", | |
| 82 | − | CoordinationOp::HandOffIssue => "Hand off an issue", | |
| 86 | + | CoordinationOp::HandOffWorkItem => "Hand off a work item", | |
| 83 | 87 | CoordinationOp::ListClaims => "List claims", | |
| 84 | − | CoordinationOp::GetWork => "Get an issue's work context", | |
| 85 | − | CoordinationOp::StartAgentSession => "Start an agent session on an issue", | |
| 88 | + | CoordinationOp::GetWork => "Get a work item's work context", | |
| 89 | + | CoordinationOp::StartAgentSession => "Start an agent session on a work item", | |
| 86 | 90 | } | |
| 87 | 91 | } | |
| 88 | 92 | ||
| 93 | + | /// Whether it is about one repository: sessions are where they run; | |
| 94 | + | /// claims and work contexts are of a workspace's work items, and take a | |
| 95 | + | /// repository only to name its workspace (or, for a claim, where it is | |
| 96 | + | /// made from). | |
| 97 | + | pub fn needs_repo(self) -> bool { | |
| 98 | + | !matches!( | |
| 99 | + | self, | |
| 100 | + | CoordinationOp::ClaimWorkItem | |
| 101 | + | | CoordinationOp::ReleaseClaim | |
| 102 | + | | CoordinationOp::HandOffWorkItem | |
| 103 | + | | CoordinationOp::ListClaims | |
| 104 | + | | CoordinationOp::GetWork | |
| 105 | + | ) | |
| 106 | + | } | |
| 107 | + | ||
| 89 | 108 | /// Whether it only reads. | |
| 90 | 109 | pub fn reads(self) -> bool { | |
| 91 | 110 | matches!( | |
| ⋯ | |||
| 96 | 115 | ||
| 97 | 116 | pub fn description(self) -> &'static str { | |
| 98 | 117 | match self { | |
| 99 | − | 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. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's issue, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. 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.", | |
| 100 | − | 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 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.", | |
| 101 | − | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the issue off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.", | |
| 102 | − | 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. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and issues it and they touched; `links`, the issues and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the issues and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may.", | |
| 103 | − | 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; `issue` or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.", | |
| 104 | − | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `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. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or issue (one it named); each is kept once, ties the session to the pull requests and issues it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair and again when a file it was not announced for joins. 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. `acknowledge` accepts up to 100 exact pending update IDs this session has considered; reading alone never acknowledges. Recording is a heartbeat. Returns how many were `recorded`, how many were `duplicates`, and how many of the `acknowledge` IDs were `acknowledged` (fewer than given means some were not this session's updates).", | |
| 105 | − | 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`. For work that has no issue yet, leave out `number` and give `title` (and `body`): the issue is opened first (an issue.opened event), then claimed, the session named by `session_id` is attached to it, and `issue` is the issue opened. The session is checked first, so a claim it would refuse opens nothing.", | |
| 106 | − | 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.", | |
| 107 | − | 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.", | |
| 108 | − | 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.", | |
| 109 | − | 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.", | |
| 110 | − | CoordinationOp::StartAgentSession => "Start work on an issue in a session of its own, on the person's own Claude: g1t runs no model and bills nothing for it. Give `issue`, or `title` and `body` to open a new issue first; `instruction` adds to what the session is asked; `mode` (plan, implement or review) says what kind of work it is. With the workspace's Anthropic AI provider (an owner adds it in the workspace's AI settings), g1t starts a Claude Managed Agents session with that key: g1t's MCP server attached with a credential for you and this repository only that lasts the session and is kept in a vault on the Anthropic account (never in the prompt), g1t's skills (work-on-issue, coordinate, fix-integration) next to the account's own, and the session, its subagents (threads), what they touched and what they reported recorded here as a g1t session (`harness` claude_managed_agents). Returns `session`, its `session_url` on g1t, `provider_session_url`, and `prompt`. With `handoff` true, or without an Anthropic AI provider when `handoff` is not false, nothing is started: `command` is a Claude Code command line, and `prompt` what to paste, for someone with g1t's Claude Code plugin, whose session then attaches to the issue as it starts.", | |
| 118 | + | 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 work item, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `item` and `pull` attach it to a work item of the repository's workspace and a pull request of the repository; a pull request for a work item attaches that work item too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's work item, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. 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 work items.", | |
| 119 | + | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item 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 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.", | |
| 120 | + | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the work item off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.", | |
| 121 | + | 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. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may.", | |
| 122 | + | 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; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.", | |
| 123 | + | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `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. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or item (a work item it named); each is kept once, ties the session to the pull requests and work items it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair and again when a file it was not announced for joins. 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. `acknowledge` accepts up to 100 exact pending update IDs this session has considered; reading alone never acknowledges. Recording is a heartbeat. Returns how many were `recorded`, how many were `duplicates`, and how many of the `acknowledge` IDs were `acknowledged` (fewer than given means some were not this session's updates).", | |
| 124 | + | CoordinationOp::ClaimWorkItem => "Claim an open work item: say you are working on it. A claim is advisory, never a lock: anyone may claim a work item 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. Name the work item by `workspace` and `number`; `repo`, the repository of your checkout, names the workspace when `workspace` is left out and says where the claim is made from. Returns your `claim`, the `others`, and `blocked_by`: each work item 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`. For work that has no work item yet, leave out `number` and give `title` (and `body`): the work item is opened first (filed from `repo` when given; a work_item.opened event), then claimed, the session named by `session_id` is attached to it, and `item` is the work item opened. The session is checked first, so a claim it would refuse opens nothing.", | |
| 125 | + | CoordinationOp::ReleaseClaim => "Release your claim on a work item, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the work item closes (reason item_closed), and expire when not renewed. Refused with `404` when you hold no active claim on it.", | |
| 126 | + | CoordinationOp::HandOffWorkItem => "Hand a work item 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 work item hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.", | |
| 127 | + | CoordinationOp::ListClaims => "A workspace's claims on the work items you may see, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `item` for one work item's. With `repo`, those made from its checkouts or on work items linked to it.", | |
| 128 | + | CoordinationOp::GetWork => "Everything about a work item an agent needs before working on it, in one call: the `item`, its `definition_of_done` (the items under its \"Definition of done\" heading); `repos`, each repository it is linked to (origin, targets, touched) with its `role`, whether the work `landed` there, the `required_checks` every pull request into its default branch must pass, and its `pulls` for the work item, 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; `missing`, the targets it has not landed in yet; `suggested_targets`, repositories of the workspace it may belong in, likeliest first, each with `why` (link_work_item with role target confirms one); active `claims`; `sessions` on it or its pull requests; `blocked_by` (each work item it waits on, with claims and pull requests) and `blocks` (the work items waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.", | |
| 129 | + | CoordinationOp::StartAgentSession => "Start work on a work item in a session of its own, on the person's own Claude: g1t runs no model and bills nothing for it. Give `item` (a work item of the repository's workspace), or `title` and `body` to open a new work item first, filed from the repository; `instruction` adds to what the session is asked; `mode` (plan, implement or review) says what kind of work it is. With the workspace's Anthropic AI provider (an owner adds it in the workspace's AI settings), g1t starts a Claude Managed Agents session with that key: g1t's MCP server attached with a credential for you and this repository only that lasts the session and is kept in a vault on the Anthropic account (never in the prompt), g1t's skills (work-on-item, coordinate, fix-integration) next to the account's own, and the session, its subagents (threads), what they touched and what they reported recorded here as a g1t session (`harness` claude_managed_agents). Returns `session`, its `session_url` on g1t, `provider_session_url`, and `prompt`. With `handoff` true, or without an Anthropic AI provider when `handoff` is not false, nothing is started: `command` is a Claude Code command line, and `prompt` what to paste, for someone with g1t's Claude Code plugin, whose session then attaches to the work item as it starts.", | |
| 111 | 130 | } | |
| 112 | 131 | } | |
| 113 | 132 | ||
| 114 | 133 | pub fn input(self) -> Value { | |
| 115 | 134 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 116 | 135 | let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." }); | |
| 117 | − | let number = json!({ "type": "integer", "description": "The issue's number." }); | |
| 118 | − | let issue = json!({ "type": "integer", "description": "The issue it works on." }); | |
| 136 | + | let number = json!({ "type": "integer", "description": "The work item's number in its workspace." }); | |
| 137 | + | let workspace = json!({ "type": "string", "description": "The workspace's slug. Left out, repo's workspace." }); | |
| 138 | + | let checkout = json!({ "type": "string", "description": "The repository of your checkout, as \"owner/name\": names the workspace when workspace is left out." }); | |
| 139 | + | let item = json!({ "type": "integer", "description": "The work item it works on, by its number in the repository's workspace." }); | |
| 119 | 140 | let pull = json!({ "type": "integer", "description": "The pull request it works on." }); | |
| 120 | 141 | let fields = |more: Value| -> Value { | |
| 121 | 142 | let mut properties = json!({ | |
| 122 | 143 | "repo": repo, | |
| 123 | − | "issue": issue, | |
| 144 | + | "item": item, | |
| 124 | 145 | "pull": pull, | |
| 125 | 146 | "branch": { "type": "string", "description": "The branch it works on." }, | |
| 126 | 147 | "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." }, | |
| ⋯ | |||
| 131 | 152 | } | |
| 132 | 153 | properties | |
| 133 | 154 | }; | |
| 134 | − | let session_id = json!({ "type": "string", "description": "The session (ses_…) whose claim it is, when you have several on the issue, such as sibling subagents." }); | |
| 155 | + | let session_id = json!({ "type": "string", "description": "The session (ses_…) whose claim it is, when you have several on the work item, such as sibling subagents." }); | |
| 135 | 156 | let subagent = json!({ | |
| 136 | 157 | "agent_type": { "type": "string", "description": "For a subagent: its kind, as the harness names it (Explore, security-reviewer)." }, | |
| 137 | 158 | "task": { "type": "string", "description": "For a subagent: the task it was given, in a few words. Up to 200 characters." }, | |
| ⋯ | |||
| 187 | 208 | json!({ | |
| 188 | 209 | "repo": repo, | |
| 189 | 210 | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, | |
| 190 | − | "issue": { "type": "integer", "description": "Only sessions on this issue: attached, or tied to it any other way." }, | |
| 211 | + | "item": { "type": "integer", "description": "Only sessions on this work item: attached, or tied to it any other way." }, | |
| 191 | 212 | "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." }, | |
| 192 | 213 | "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." }, | |
| 193 | 214 | "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." }, | |
| ⋯ | |||
| 220 | 241 | "items": { | |
| 221 | 242 | "type": "object", | |
| 222 | 243 | "properties": { | |
| 223 | − | "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "issue"] }, | |
| 244 | + | "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "item"] }, | |
| 224 | 245 | "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." }, | |
| 225 | 246 | }, | |
| 226 | 247 | "required": ["kind", "value"], | |
| ⋯ | |||
| 229 | 250 | }), | |
| 230 | 251 | &["repo", "id"], | |
| 231 | 252 | ), | |
| 232 | − | CoordinationOp::ClaimIssue => ( | |
| 253 | + | CoordinationOp::ClaimWorkItem => ( | |
| 233 | 254 | json!({ | |
| 234 | − | "repo": repo, | |
| 235 | − | "number": { "type": "integer", "description": "The issue's number. Left out, give title (and body) to open one and claim it." }, | |
| 236 | − | "title": { "type": "string", "description": "Without number: the title of a new issue to open and claim." }, | |
| 237 | − | "body": { "type": "string", "description": "Without number: the new issue's description." }, | |
| 238 | − | "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim. With title, it is attached to the new issue." }, | |
| 255 | + | "workspace": workspace, | |
| 256 | + | "repo": checkout, | |
| 257 | + | "number": { "type": "integer", "description": "The work item's number. Left out, give title (and body) to open one and claim it." }, | |
| 258 | + | "title": { "type": "string", "description": "Without number: the title of a new work item to open and claim, filed from repo when given." }, | |
| 259 | + | "body": { "type": "string", "description": "Without number: the new work item's description." }, | |
| 260 | + | "session_id": { "type": "string", "description": "One of your sessions (ses_…): its heartbeats renew the claim. With title, it is attached to the new work item." }, | |
| 239 | 261 | "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." }, | |
| 240 | 262 | "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." }, | |
| 241 | 263 | }), | |
| 242 | − | &["repo"], | |
| 264 | + | &[], | |
| 243 | 265 | ), | |
| 244 | 266 | CoordinationOp::ReleaseClaim => ( | |
| 245 | 267 | json!({ | |
| 246 | − | "repo": repo, | |
| 268 | + | "workspace": workspace, | |
| 269 | + | "repo": checkout, | |
| 247 | 270 | "number": number, | |
| 248 | 271 | "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." }, | |
| 249 | 272 | "session_id": session_id, | |
| 250 | 273 | }), | |
| 251 | − | &["repo", "number"], | |
| 274 | + | &["number"], | |
| 252 | 275 | ), | |
| 253 | − | CoordinationOp::HandOffIssue => ( | |
| 276 | + | CoordinationOp::HandOffWorkItem => ( | |
| 254 | 277 | json!({ | |
| 255 | − | "repo": repo, | |
| 278 | + | "workspace": workspace, | |
| 279 | + | "repo": checkout, | |
| 256 | 280 | "number": number, | |
| 257 | 281 | "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." }, | |
| 258 | 282 | "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." }, | |
| 259 | 283 | "session_id": session_id, | |
| 260 | 284 | }), | |
| 261 | − | &["repo", "number", "note"], | |
| 285 | + | &["number", "note"], | |
| 262 | 286 | ), | |
| 263 | 287 | CoordinationOp::ListClaims => ( | |
| 264 | 288 | json!({ | |
| 265 | − | "repo": repo, | |
| 289 | + | "workspace": workspace, | |
| 290 | + | "repo": { "type": "string", "description": "Only claims made from this repository's checkouts or on work items linked to it, \"owner/name\". Names the workspace when workspace is left out." }, | |
| 266 | 291 | "state": { | |
| 267 | 292 | "type": "string", | |
| 268 | 293 | "enum": ["active", "released", "expired", "handed_off"], | |
| 269 | 294 | "description": "Claims in this state; active if left out.", | |
| 270 | 295 | }, | |
| 271 | − | "issue": { "type": "integer", "description": "Only claims on this issue." }, | |
| 296 | + | "item": { "type": "integer", "description": "Only claims on this work item." }, | |
| 272 | 297 | }), | |
| 273 | − | &["repo"], | |
| 298 | + | &[], | |
| 274 | 299 | ), | |
| 275 | − | CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]), | |
| 300 | + | CoordinationOp::GetWork => (json!({ "workspace": workspace, "repo": checkout, "number": number }), &["number"]), | |
| 276 | 301 | CoordinationOp::StartAgentSession => ( | |
| 277 | 302 | json!({ | |
| 278 | 303 | "repo": repo, | |
| 279 | − | "issue": { "type": "integer", "description": "The issue to work on. Left out, give title (and body) to open one." }, | |
| 280 | − | "title": { "type": "string", "description": "Without issue: the title of a new issue to open and work on." }, | |
| 281 | − | "body": { "type": "string", "description": "Without issue: the new issue's description." }, | |
| 282 | − | "instruction": { "type": "string", "description": "What to do beyond what the issue says. Up to 4,000 characters." }, | |
| 304 | + | "item": { "type": "integer", "description": "The work item to work on, by its number in the repository's workspace. Left out, give title (and body) to open one." }, | |
| 305 | + | "title": { "type": "string", "description": "Without item: the title of a new work item to open and work on, filed from the repository." }, | |
| 306 | + | "body": { "type": "string", "description": "Without item: the new work item's description." }, | |
| 307 | + | "instruction": { "type": "string", "description": "What to do beyond what the work item says. Up to 4,000 characters." }, | |
| 283 | 308 | "mode": { "type": "string", "enum": ["plan", "implement", "review"], "description": "What kind of work: plan (say how, change nothing), implement (the default) or review (review its pull request)." }, | |
| 284 | 309 | "handoff": { "type": "boolean", "description": "true for a Claude Code command and prompt to run yourself instead of a session on the workspace's Anthropic account; false to refuse when the workspace has no Anthropic AI provider. Left out, a session when the workspace can, else the hand-off." }, | |
| 285 | 310 | }), | |
| ⋯ | |||
| 312 | 337 | } | |
| 313 | 338 | } | |
| 314 | 339 | ||
| 315 | − | /// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or | |
| 316 | − | /// `#12`. None when left out; refused when it holds anything else. | |
| 340 | + | /// `blocked_by` for `update_work_item`: work item numbers, as numbers, | |
| 341 | + | /// digits or `#12`. None when left out; refused when it holds anything | |
| 342 | + | /// else. | |
| 317 | 343 | pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> { | |
| 318 | 344 | let items = match &input["blocked_by"] { | |
| 319 | 345 | Value::Null => return Ok(None), | |
| 320 | 346 | Value::Array(items) => items, | |
| 321 | − | _ => return Err("blocked_by is a list of issue numbers.".to_owned()), | |
| 347 | + | _ => return Err("blocked_by is a list of work item numbers.".to_owned()), | |
| 322 | 348 | }; | |
| 323 | 349 | items | |
| 324 | 350 | .iter() | |
| 325 | − | .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number."))) | |
| 351 | + | .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not a work item number."))) | |
| 326 | 352 | .collect::<std::result::Result<Vec<u32>, String>>() | |
| 327 | 353 | .map(Some) | |
| 328 | 354 | } | |
| 329 | 355 | ||
| 330 | 356 | pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 331 | − | let Some(repo) = repo_path(input) else { | |
| 332 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 357 | + | let given = repo_path(input); | |
| 358 | + | let repo = match given.clone() { | |
| 359 | + | Some(repo) => repo, | |
| 360 | + | None if op.needs_repo() => return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")), | |
| 361 | + | None => RepoPath { namespace: String::new(), name: String::new() }, | |
| 333 | 362 | }; | |
| 334 | 363 | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); | |
| 335 | − | let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) { | |
| 336 | − | (Ok(issue), Ok(pull)) => (issue, pull), | |
| 364 | + | // A claim or a work context names its work item's workspace, or a | |
| 365 | + | // repository of it. | |
| 366 | + | let workspace = item_workspace(input); | |
| 367 | + | if !op.needs_repo() && workspace.is_empty() { | |
| 368 | + | return invalid("Give the workspace by its slug, or a repository of it as \"owner/name\".".to_owned()); | |
| 369 | + | } | |
| 370 | + | let (item, pull) = match (optional_number(input, "item"), optional_number(input, "pull")) { | |
| 371 | + | (Ok(item), Ok(pull)) => (item, pull), | |
| 337 | 372 | (Err(message), _) | (_, Err(message)) => return invalid(message), | |
| 338 | 373 | }; | |
| 339 | 374 | let actor = || viewer.clone().unwrap_or_default(); | |
| 340 | 375 | let id = || text(input, "id").unwrap_or_default(); | |
| 341 | − | let issue_number = number(input, "number").unwrap_or_default(); | |
| 376 | + | let item_number = number(input, "number").unwrap_or_default(); | |
| 342 | 377 | let work = &services.work; | |
| 343 | 378 | if matches!( | |
| 344 | 379 | op, | |
| ⋯ | |||
| 358 | 393 | repo, | |
| 359 | 394 | harness, | |
| 360 | 395 | harness_session_id: text(input, "harness_session_id"), | |
| 361 | − | issue, | |
| 396 | + | item, | |
| 362 | 397 | pull, | |
| 363 | 398 | branch: text(input, "branch"), | |
| 364 | 399 | title: text(input, "title"), | |
| ⋯ | |||
| 376 | 411 | actor: actor(), | |
| 377 | 412 | repo, | |
| 378 | 413 | id: id(), | |
| 379 | − | issue, | |
| 414 | + | item, | |
| 380 | 415 | pull, | |
| 381 | 416 | branch: text(input, "branch"), | |
| 382 | 417 | head_commit: text(input, "head_commit"), | |
| ⋯ | |||
| 406 | 441 | }, | |
| 407 | 442 | }; | |
| 408 | 443 | let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true"); | |
| 409 | − | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull, subagents, path: text(input, "path") }; | |
| 444 | + | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, item, pull, subagents, path: text(input, "path") }; | |
| 410 | 445 | g1t_kit::call(work, "list_work_sessions", &args).await | |
| 411 | 446 | } | |
| 412 | 447 | CoordinationOp::RecordSessionEntries => { | |
| ⋯ | |||
| 426 | 461 | touched => touched.clone(), | |
| 427 | 462 | }) { | |
| 428 | 463 | Ok(touched) => touched, | |
| 429 | − | Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or issue.".to_owned()), | |
| 464 | + | Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or item.".to_owned()), | |
| 430 | 465 | }; | |
| 431 | 466 | let acknowledge: Vec<String> = match serde_json::from_value(input.get("acknowledge").cloned().unwrap_or(json!([]))) { | |
| 432 | 467 | Ok(ids) => ids, | |
| ⋯ | |||
| 435 | 470 | let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched, acknowledge }; | |
| 436 | 471 | g1t_kit::call(work, "record_session_entries", &args).await | |
| 437 | 472 | } | |
| 438 | − | CoordinationOp::ClaimIssue => { | |
| 473 | + | CoordinationOp::ClaimWorkItem => { | |
| 439 | 474 | let ttl_minutes = match optional_number(input, "ttl_minutes") { | |
| 440 | 475 | Ok(minutes) => minutes, | |
| 441 | 476 | Err(message) => return invalid(message), | |
| 442 | 477 | }; | |
| 443 | 478 | let title = text(input, "title"); | |
| 444 | − | if issue_number == 0 && title.is_none() { | |
| 445 | − | return invalid("Name the issue by its number, or give a title (and body) to open one.".to_owned()); | |
| 479 | + | if item_number == 0 && title.is_none() { | |
| 480 | + | return invalid("Name the work item by its number, or give a title (and body) to open one.".to_owned()); | |
| 446 | 481 | } | |
| 447 | − | if issue_number != 0 && title.is_some() { | |
| 448 | − | return invalid("Give the issue's number or a title for a new one, not both.".to_owned()); | |
| 482 | + | if item_number != 0 && title.is_some() { | |
| 483 | + | return invalid("Give the work item's number or a title for a new one, not both.".to_owned()); | |
| 449 | 484 | } | |
| 450 | 485 | let args = ClaimArgs { | |
| 451 | 486 | actor: actor(), | |
| 452 | − | repo, | |
| 453 | − | number: issue_number, | |
| 487 | + | workspace, | |
| 488 | + | repo: given, | |
| 489 | + | number: item_number, | |
| 454 | 490 | title, | |
| 455 | 491 | body: text(input, "body"), | |
| 456 | 492 | session_id: text(input, "session_id"), | |
| 457 | 493 | note: text(input, "note"), | |
| 458 | 494 | ttl_minutes, | |
| 459 | 495 | }; | |
| 460 | − | g1t_kit::call(work, "claim_issue", &args).await | |
| 496 | + | g1t_kit::call(work, "claim_work_item", &args).await | |
| 461 | 497 | } | |
| 462 | 498 | CoordinationOp::ReleaseClaim => { | |
| 463 | − | let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") }; | |
| 499 | + | let args = ReleaseArgs { actor: actor(), workspace, repo: given, number: item_number, reason: text(input, "reason"), session_id: text(input, "session_id") }; | |
| 464 | 500 | g1t_kit::call(work, "release_claim", &args).await | |
| 465 | 501 | } | |
| 466 | − | CoordinationOp::HandOffIssue => { | |
| 502 | + | CoordinationOp::HandOffWorkItem => { | |
| 467 | 503 | let Some(note) = text(input, "note") else { | |
| 468 | 504 | return invalid("Say where the work stands in note, for whoever picks it up.".to_owned()); | |
| 469 | 505 | }; | |
| 470 | − | let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note, session_id: text(input, "session_id") }; | |
| 471 | − | g1t_kit::call(work, "hand_off_issue", &args).await | |
| 506 | + | let args = HandOffArgs { actor: actor(), workspace, repo: given, number: item_number, to: text(input, "to"), note, session_id: text(input, "session_id") }; | |
| 507 | + | g1t_kit::call(work, "hand_off_work_item", &args).await | |
| 472 | 508 | } | |
| 473 | 509 | CoordinationOp::ListClaims => { | |
| 474 | 510 | let state = match text(input, "state") { | |
| ⋯ | |||
| 478 | 514 | None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")), | |
| 479 | 515 | }, | |
| 480 | 516 | }; | |
| 481 | − | let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue }; | |
| 517 | + | let args = ListClaimsArgs { viewer: viewer.clone(), workspace, repo: given, state, item }; | |
| 482 | 518 | g1t_kit::call(work, "list_claims", &args).await | |
| 483 | 519 | } | |
| 484 | 520 | CoordinationOp::GetWork => { | |
| 485 | − | let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number }; | |
| 521 | + | let args = WorkItemViewArgs { workspace, number: item_number, viewer: viewer.clone() }; | |
| 486 | 522 | g1t_kit::call(work, "get_work", &args).await | |
| 487 | 523 | } | |
| 488 | 524 | CoordinationOp::StartAgentSession => { | |
| ⋯ | |||
| 493 | 529 | None => return invalid(format!("mode is plan, implement or review, not {mode}.")), | |
| 494 | 530 | }, | |
| 495 | 531 | }; | |
| 496 | − | if issue.is_none() && text(input, "title").is_none() { | |
| 497 | − | return invalid("Name the issue, or give a title (and body) for a new one.".to_owned()); | |
| 532 | + | if item.is_none() && text(input, "title").is_none() { | |
| 533 | + | return invalid("Name the work item, or give a title (and body) for a new one.".to_owned()); | |
| 498 | 534 | } | |
| 499 | 535 | let handoff = match &input["handoff"] { | |
| 500 | 536 | Value::Bool(handoff) => Some(*handoff), | |
| ⋯ | |||
| 505 | 541 | let args = StartAgentSessionArgs { | |
| 506 | 542 | actor: actor(), | |
| 507 | 543 | repo, | |
| 508 | − | issue, | |
| 544 | + | item, | |
| 509 | 545 | pull: None, | |
| 510 | 546 | asked: None, | |
| 511 | 547 | title: text(input, "title"), | |
| ⋯ | |||
| 531 | 567 | assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name()); | |
| 532 | 568 | assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op))); | |
| 533 | 569 | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); | |
| 534 | − | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 570 | + | assert_eq!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), op.needs_repo(), "{}", op.name()); | |
| 535 | 571 | let scope = scope_for(op.name()).unwrap(); | |
| 536 | 572 | assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name()); | |
| 537 | 573 | let sessions = op.name().contains("session"); | |
| 538 | 574 | let expected = match (sessions, op.reads()) { | |
| 539 | 575 | (true, true) => Scope::PullRequestsRead, | |
| 540 | 576 | (true, false) => Scope::PullRequestsWrite, | |
| 541 | − | (false, true) => Scope::IssuesRead, | |
| 542 | − | (false, false) => Scope::IssuesWrite, | |
| 577 | + | (false, true) => Scope::WorkRead, | |
| 578 | + | (false, false) => Scope::WorkWrite, | |
| 543 | 579 | }; | |
| 544 | 580 | assert_eq!(scope, expected, "{}", op.name()); | |
| 545 | 581 | } | |
| ⋯ | |||
| 564 | 600 | let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null); | |
| 565 | 601 | assert_eq!(op, coordination(CoordinationOp::GetSession)); | |
| 566 | 602 | assert_eq!(input["after"], "40"); | |
| 567 | − | let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null); | |
| 603 | + | let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("item", "4")], Value::Null); | |
| 568 | 604 | assert_eq!(op, coordination(CoordinationOp::ListSessions)); | |
| 569 | − | assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" })); | |
| 605 | + | assert_eq!(input, json!({ "state": "idle", "item": "4", "repo": "acme/web" })); | |
| 570 | 606 | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] })); | |
| 571 | 607 | assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries)); | |
| 572 | − | let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" })); | |
| 573 | − | assert_eq!(op, coordination(CoordinationOp::ClaimIssue)); | |
| 574 | − | assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" })); | |
| 575 | − | // Work with no issue yet: one is opened and claimed. | |
| 576 | − | let (op, input) = resolve("POST", "/repos/acme/web/claims", &[], json!({ "title": "Fix previews" })); | |
| 577 | − | assert_eq!(op, coordination(CoordinationOp::ClaimIssue)); | |
| 578 | − | assert_eq!(input, json!({ "title": "Fix previews", "repo": "acme/web" })); | |
| 579 | − | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({})); | |
| 608 | + | let (op, input) = resolve("POST", "/workspaces/acme/work/4/claim", &[], json!({ "note": "on it", "repo": "acme/web" })); | |
| 609 | + | assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem)); | |
| 610 | + | assert_eq!(input, json!({ "note": "on it", "number": 4, "workspace": "acme", "repo": "acme/web" })); | |
| 611 | + | // Work with no work item yet: one is opened and claimed. | |
| 612 | + | let (op, input) = resolve("POST", "/workspaces/acme/claims", &[], json!({ "title": "Fix previews" })); | |
| 613 | + | assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem)); | |
| 614 | + | assert_eq!(input, json!({ "title": "Fix previews", "workspace": "acme" })); | |
| 615 | + | let (op, _) = resolve("POST", "/workspaces/acme/work/4/release", &[], json!({})); | |
| 580 | 616 | assert_eq!(op, coordination(CoordinationOp::ReleaseClaim)); | |
| 581 | − | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" })); | |
| 582 | − | assert_eq!(op, coordination(CoordinationOp::HandOffIssue)); | |
| 583 | − | let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null); | |
| 617 | + | let (op, _) = resolve("POST", "/workspaces/acme/work/4/handoff", &[], json!({ "note": "half done" })); | |
| 618 | + | assert_eq!(op, coordination(CoordinationOp::HandOffWorkItem)); | |
| 619 | + | let (op, input) = resolve("GET", "/workspaces/acme/claims", &[("state", "active"), ("repo", "acme/web")], Value::Null); | |
| 584 | 620 | assert_eq!(op, coordination(CoordinationOp::ListClaims)); | |
| 585 | − | let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null); | |
| 621 | + | assert_eq!(input, json!({ "state": "active", "repo": "acme/web", "workspace": "acme" })); | |
| 622 | + | let (op, input) = resolve("GET", "/workspaces/acme/work/4/work", &[], Value::Null); | |
| 586 | 623 | assert_eq!(op, coordination(CoordinationOp::GetWork)); | |
| 587 | − | assert_eq!(input, json!({ "number": 4, "repo": "acme/web" })); | |
| 624 | + | assert_eq!(input, json!({ "number": 4, "workspace": "acme" })); | |
| 588 | 625 | } | |
| 589 | 626 | ||
| 590 | 627 | #[test] | |
| ⋯ | |||
| 594 | 631 | assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession))); | |
| 595 | 632 | assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession))); | |
| 596 | 633 | assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries))); | |
| 597 | − | assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue))); | |
| 598 | − | let claim_new = resolve(Tool::by_name("issue").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" })); | |
| 599 | − | assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimIssue))); | |
| 600 | − | assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); | |
| 634 | + | assert_eq!(call("work_item", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimWorkItem))); | |
| 635 | + | let claim_new = resolve(Tool::by_name("work_item").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" })); | |
| 636 | + | assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimWorkItem))); | |
| 637 | + | assert_eq!(call("work_item", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); | |
| 601 | 638 | } | |
| 602 | 639 | ||
| 603 | 640 | #[test] | |
| ⋯ | |||
| 607 | 644 | assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![]))); | |
| 608 | 645 | assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err()); | |
| 609 | 646 | assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err()); | |
| 610 | − | assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None)); | |
| 611 | − | assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err()); | |
| 647 | + | assert_eq!(optional_number(&json!({ "item": "" }), "item"), Ok(None)); | |
| 648 | + | assert!(optional_number(&json!({ "item": "soon" }), "item").is_err()); | |
| 612 | 649 | } | |
| 613 | 650 | } | |
| 126 | 126 | FoliosOp::GetAccess => "Who can open an artifact and how: its owner, each person, agent and team it is shared with (principal, role, and inherited_from when the access comes from a doc it is under), general_access (none, workspace or link) with general_role, whether it follows its space or parent (inherit), agent_mode, and whether you may change it (can_share).", | |
| 127 | 127 | FoliosOp::ListTemplates => "The templates an artifact can start from: the built-in ones and those saved in the workspace, each with its id, kind, name, description and body (Markdown, or a JSON spec). Narrow with kind.", | |
| 128 | 128 | FoliosOp::ListSpaces => "The spaces in your Artifacts sidebar: the General space, open spaces you joined, your teams' spaces and Members-only spaces you are in. Each with its id, slug, name, kind (workspace for an open space, team or private), your role in it (viewer_role) and how many artifacts it holds you can open.", | |
| 129 | − | FoliosOp::QueryDataset => "Run a dataset query (issues, pull requests, workflow runs, deployments, spend or agent sessions) as you, over what you can read: rows of the measure, grouped and filtered as asked, with truncated when more rows matched, and partial true when some of it was left out because you cannot read it. Answers that dashboards are not here yet until they ship.", | |
| 129 | + | FoliosOp::QueryDataset => "Run a dataset query (work items, pull requests, workflow runs, deployments, spend or agent sessions) as you, over what you can read: rows of the measure, grouped and filtered as asked, with truncated when more rows matched, and partial true when some of it was left out because you cannot read it. Answers that dashboards are not here yet until they ship.", | |
| 130 | 130 | FoliosOp::Create => "Make an artifact. kind is doc (the default); slides, designs and dashboards answer that they are not here yet. It lands in space (a slug or id you can edit in), under parent_id (a doc you can edit), or, with neither, in your Private, where only you can open it. Start it from markdown, or from a template (template_id), with an optional title and icon. You own it. Returns the artifact.", | |
| 131 | 131 | FoliosOp::Update => "Rename an artifact (title), change its icon (null clears it), or move it: to space (a slug or id; `private` for your Private, yours only), under parent_id (a doc), before before_id among its new siblings. Moving changes who can open it when it follows its space or parent. Takes the edit role on it, and on where it goes. Returns the artifact.", | |
| 132 | 132 | FoliosOp::Edit => "Change an artifact's content. For a doc: markdown with a target: append (add to the end), document (replace it all), section (with heading: that heading and everything under it) or blocks (from_block through to_block, block ids from its content). With the edit role the change is made, as a new version; with the comment role, or suggest_only, it is filed as a suggestion its editors accept or reject. note says why; marks_current says it brings the doc up to date with the code it cites. Returns mode (applied or suggested), version_id or the suggestion, and the artifact.", | |
| ⋯ | |||
| 203 | 203 | "workspace": workspace, | |
| 204 | 204 | "query": { | |
| 205 | 205 | "type": "object", | |
| 206 | − | "description": "dataset (issues, pull_requests, workflow_runs, deployments, spend or agent_sessions), measure ({ op: count, sum, avg, p50, p95 or rate, field where it takes one }), and optional group_by, interval (day, week or month), time, filters ([{ field, op, value }], at most 10), range (7d, 30d, 90d, or { from, to }, at most 366 days) and limit (at most 100).", | |
| 206 | + | "description": "dataset (work_items, pull_requests, workflow_runs, deployments, spend or agent_sessions), measure ({ op: count, sum, avg, p50, p95 or rate, field where it takes one }), and optional group_by, interval (day, week or month), time, filters ([{ field, op, value }], at most 10), range (7d, 30d, 90d, or { from, to }, at most 366 days) and limit (at most 100).", | |
| 207 | 207 | }, | |
| 208 | 208 | }), | |
| 209 | 209 | vec!["workspace", "query"], | |
| 81 | 81 | for item in list(&input["numbers"]) { | |
| 82 | 82 | match item.parse::<u32>() { | |
| 83 | 83 | Ok(number) if number > 0 => numbers.push(number), | |
| 84 | − | _ => return Err(format!("numbers are issue and pull request numbers, and {item} is not one.")), | |
| 84 | + | _ => return Err(format!("numbers are work item and pull request numbers, and {item} is not one.")), | |
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 | numbers.sort_unstable(); |
| 172 | 172 | fn index(addresses: &addresses::Addresses) -> Value { | |
| 173 | 173 | let api = &addresses.api; | |
| 174 | 174 | let repo = format!("{api}/repos/{{owner}}/{{name}}"); | |
| 175 | + | let workspace = format!("{api}/workspaces/{{workspace}}"); | |
| 175 | 176 | json!({ | |
| 176 | 177 | "documentation_url": "https://docs.g1t.sh/reference/api/", | |
| 177 | 178 | "openapi_url": format!("{api}/openapi.json"), | |
| ⋯ | |||
| 182 | 183 | "search_url": format!("{api}/search{{?q,type,page,per_page}}"), | |
| 183 | 184 | "repository_url": repo, | |
| 184 | 185 | "repository_events_url": format!("{repo}/events{{?before}}"), | |
| 185 | − | "labels_url": format!("{repo}/labels"), | |
| 186 | − | "issues_url": format!("{repo}/issues{{?state,label}}"), | |
| 187 | − | "issue_url": format!("{repo}/issues/{{number}}"), | |
| 188 | − | "issue_comments_url": format!("{repo}/issues/{{number}}/comments"), | |
| 186 | + | "repository_work_url": format!("{repo}/work{{?state,label,role}}"), | |
| 187 | + | "work_url": format!("{workspace}/work{{?state,label,milestone,assignee,repo,role}}"), | |
| 188 | + | "work_item_url": format!("{workspace}/work/{{number}}"), | |
| 189 | + | "work_item_comments_url": format!("{workspace}/work/{{number}}/comments"), | |
| 190 | + | "labels_url": format!("{workspace}/labels"), | |
| 191 | + | "milestones_url": format!("{workspace}/milestones{{?state}}"), | |
| 189 | 192 | "pulls_url": format!("{repo}/pulls{{?state}}"), | |
| 190 | 193 | "pull_url": format!("{repo}/pulls/{{number}}"), | |
| 191 | 194 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| ⋯ | |||
| 197 | 200 | "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site), | |
| 198 | 201 | "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"), | |
| 199 | 202 | "context_url": format!("{repo}/context{{?reference}}"), | |
| 200 | − | "import_issue_url": format!("{repo}/issues/import"), | |
| 203 | + | "import_work_items_url": format!("{workspace}/work/import"), | |
| 201 | 204 | "hooks_url": format!("{api}/hooks/{{integration}}"), | |
| 202 | 205 | }) | |
| 203 | 206 | } | |
| ⋯ | |||
| 297 | 300 | }) | |
| 298 | 301 | } | |
| 299 | 302 | ||
| 300 | − | /// A sandbox reporting on a run of an issue's commands, from before a pull | |
| 303 | + | /// A sandbox reporting on a run of a work item's commands, from before a pull | |
| 301 | 304 | /// request's checks were the workflows run on it. | |
| 302 | 305 | /// The run's own token, in the body, is the credential: it was given to | |
| 303 | 306 | /// that sandbox and to nothing else. | |
| ⋯ | |||
| 838 | 841 | #[test] | |
| 839 | 842 | fn snake_case_wins_when_both_are_given() { | |
| 840 | 843 | assert_eq!( | |
| 841 | − | snake_case_keys(json!({ "keep_issue_open": true, "keepIssueOpen": false })), | |
| 842 | − | json!({ "keep_issue_open": true }) | |
| 844 | + | snake_case_keys(json!({ "keep_item_open": true, "keepItemOpen": false })), | |
| 845 | + | json!({ "keep_item_open": true }) | |
| 843 | 846 | ); | |
| 844 | 847 | } | |
| 845 | 848 | } | |
| 10 | 10 | ||
| 11 | 11 | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 12 | 12 | ||
| 13 | − | const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, issues, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers. | |
| 14 | − | Tools are resources, each with an `action`: search, repository, issue, pull_request, session, workflow, observability, package, secret, webhook, access, team, workspace, ai, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 15 | − | Find a repository: account whoami lists your workspaces; repository list or search finds one. | |
| 16 | − | Work on an issue: issue work (everything about it: what done means, who is on it, its pull requests, what it waits on), issue claim (advisory: it tells you who else is on it), then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. Merging closes the pull request's issue; a commit pushed straight to the default branch closes one only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`, and issue close with commit closes one after the fact. | |
| 17 | − | Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session get between work steps to read pending updates. Reading does not acknowledge: consider relevant changes, record a public decision summary, then session record with acknowledge containing only the exact update IDs considered. Keep your own session ID explicit, especially when sharing a checkout. Separate scopes may proceed concurrently; agree ownership before overlapping edits. session end when done; issue release or handoff (with a note) when you stop. | |
| 13 | + | const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, work items, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\" and belong to a workspace. A work item is any tracked work (a feature, a bug, a chore) of a workspace, linked to the repositories it concerns: where it was filed (origin), where the work is meant to land (targets) and where it touched. A workspace numbers its work items and new pull requests in one sequence; pull requests live in their repository. | |
| 14 | + | Tools are resources, each with an `action`: search, repository, work_item, pull_request, session, workflow, observability, package, secret, webhook, access, team, workspace, ai, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 15 | + | Find a repository: account whoami lists your workspaces; repository list or search finds one. Find work: work_item list in a workspace (or with repo, the work items linked to it). | |
| 16 | + | Work on a work item: work_item work (everything about it: what done means, each repository with its pull requests and whether the work landed there, the targets still missing and suggested ones, who is on it, what it waits on), work_item claim (advisory: it tells you who else is on it), then pull_request create in a target repository with the work item's number as `item`: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. Merging lands the work item in that repository; it closes once it has landed in every target (or, with none, once anything that closes it lands). A commit pushed straight to a default branch lands it only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`; `Part of #N` ties work to it without closing it; and work_item close with commit (and repo) lands one after the fact. When the work must land in another repository too, work_item link it as a target. | |
| 17 | + | Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session get between work steps to read pending updates. Reading does not acknowledge: consider relevant changes, record a public decision summary, then session record with acknowledge containing only the exact update IDs considered. Keep your own session ID explicit, especially when sharing a checkout. Separate scopes may proceed concurrently; agree ownership before overlapping edits. session end when done; work_item release or handoff (with a note) when you stop. | |
| 18 | 18 | Record public summaries only, never private chain-of-thought, credentials or secrets. As you work, pull_request record_session appends user-facing decisions, evidence and tool activity to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is. | |
| 19 | − | Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its issue and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. | |
| 19 | + | Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its work item and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. | |
| 20 | 20 | Security: security code_alerts, vulnerability_alerts and secret_alerts show what to fix; fix it in your pull request, which the Code scanning and Dependency review checks judge. | |
| 21 | 21 | Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs."; | |
| 22 | 22 | ||
| ⋯ | |||
| 139 | 139 | .collect(); | |
| 140 | 140 | let mut card = json!({ | |
| 141 | 141 | "name": "g1t", | |
| 142 | − | "description": "The g1t MCP server: issues, pull requests and sessions for agents.", | |
| 142 | + | "description": "The g1t MCP server: work items, pull requests and sessions for agents.", | |
| 143 | 143 | "endpoint": addresses.mcp, | |
| 144 | 144 | "transport": "streamable-http", | |
| 145 | 145 | "protocol_versions": SUPPORTED_VERSIONS, | |
| 1 | 1 | //! Notifications: a person's inbox, its threads, their subscriptions to | |
| 2 | − | //! issues and pull requests, and how they watch repositories. The events | |
| 2 | + | //! work items and pull requests, and how they watch repositories. The events | |
| 3 | 3 | //! service keeps all of it (`g1t_contracts::inbox`); this is its public | |
| 4 | 4 | //! shape, which follows the inbox's own: a thread is one item about one | |
| 5 | 5 | //! thing, brought back to the top as things happen to it. | |
| ⋯ | |||
| 11 | 11 | use g1t_contracts::inbox::*; | |
| 12 | 12 | use g1t_contracts::repos::{GetArgs, Repo, RepoPath}; | |
| 13 | 13 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 14 | + | use g1t_contracts::work::{WorkItemDetail, WorkItemViewArgs}; | |
| 14 | 15 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 15 | 16 | use serde_json::{Value, json}; | |
| 16 | 17 | use worker::Result; | |
| ⋯ | |||
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | const NO_THREAD: &str = "No such notification thread."; | |
| 29 | − | const NO_SUBJECT: &str = "Name a thread by id, or an issue or pull request by repo and number."; | |
| 30 | + | const NO_SUBJECT: &str = "Name a thread by id, a pull request by repo and number, or a work item by workspace and number."; | |
| 30 | 31 | ||
| 31 | 32 | fn text(input: &Value, key: &str) -> Option<String> { | |
| 32 | 33 | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) | |
| ⋯ | |||
| 169 | 170 | }) | |
| 170 | 171 | } | |
| 171 | 172 | ||
| 172 | − | /// Which issue or pull request a subscription call names: a thread's id, | |
| 173 | − | /// or a repository and number. Checks the viewer can read the repository. | |
| 173 | + | /// Which work item or pull request a subscription call names: a thread's | |
| 174 | + | /// id, a repository and a pull request's number, or a workspace and a work | |
| 175 | + | /// item's number. Checks the viewer can read the repository, or see the | |
| 176 | + | /// work item. | |
| 174 | 177 | async fn subscription_args(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<SubscriptionArgs>> { | |
| 175 | 178 | if let Some(id) = text(input, "id") { | |
| 176 | 179 | return Ok(Outcome::Ok(SubscriptionArgs { | |
| ⋯ | |||
| 179 | 182 | ..SubscriptionArgs::default() | |
| 180 | 183 | })); | |
| 181 | 184 | } | |
| 185 | + | if let (Some(workspace), None, Some(number)) = (text(input, "workspace"), text(input, "repo"), whole(input, "number")) { | |
| 186 | + | let asked = WorkItemViewArgs { workspace: workspace.to_lowercase(), number, viewer: viewer.clone() }; | |
| 187 | + | let found: Outcome<WorkItemDetail> = g1t_kit::call(&services.work, "get_work_item", &asked).await?; | |
| 188 | + | return Ok(match found { | |
| 189 | + | Outcome::Ok(detail) => Outcome::Ok(SubscriptionArgs { | |
| 190 | + | viewer: viewer.clone(), | |
| 191 | + | item_id: Some(detail.item.id), | |
| 192 | + | number: Some(number), | |
| 193 | + | ..SubscriptionArgs::default() | |
| 194 | + | }), | |
| 195 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 196 | + | }); | |
| 197 | + | } | |
| 182 | 198 | let (Some(found), Some(number)) = (readable_repo(services, viewer, input).await?, whole(input, "number")) else { | |
| 183 | 199 | return Ok(Outcome::fail(FailureCode::Invalid, NO_SUBJECT)); | |
| 184 | 200 | }; | |
| ⋯ | |||
| 188 | 204 | id: None, | |
| 189 | 205 | repo_id: Some(repo.id), | |
| 190 | 206 | number: Some(number), | |
| 207 | + | item_id: None, | |
| 191 | 208 | }), | |
| 192 | 209 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 193 | 210 | }) | |
| ⋯ | |||
| 335 | 352 | }; | |
| 336 | 353 | match found { | |
| 337 | 354 | Some(subscription) => ok(&subscription), | |
| 338 | − | None => failed(FailureCode::NotFound, "No such issue or pull request, or it is a thread nobody subscribes to."), | |
| 355 | + | None => failed(FailureCode::NotFound, "No such work item or pull request, or it is a thread nobody subscribes to."), | |
| 339 | 356 | } | |
| 340 | 357 | } | |
| 341 | 358 | Op::GetRepoSubscription | Op::SetRepoSubscription | Op::DeleteRepoSubscription => { | |
| 37 | 37 | ), | |
| 38 | 38 | ( | |
| 39 | 39 | "Notifications", | |
| 40 | − | "Your inbox: a thread for each thing you were told about (an issue, a pull request, a workflow on a branch, a deployment), why you were told, and what you subscribe to and watch. Your own: personal tokens and sessions only.", | |
| 40 | + | "Your inbox: a thread for each thing you were told about (a work item, a pull request, a workflow on a branch, a deployment), why you were told, and what you subscribe to and watch. Your own: personal tokens and sessions only.", | |
| 41 | 41 | &[ | |
| 42 | 42 | Op::ListNotifications, | |
| 43 | 43 | Op::MarkNotificationsRead, | |
| ⋯ | |||
| 362 | 362 | ], | |
| 363 | 363 | ), | |
| 364 | 364 | ( | |
| 365 | − | "Issues", | |
| 366 | − | "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", | |
| 365 | + | "Work items", | |
| 366 | + | "A workspace's tracked work (features, bugs, chores), numbered in the workspace's sequence with its new pull requests, and linked to the repositories it concerns: its origin (where it was filed), its targets (where the work is meant to land) and those it touched. A work item closes once what closes it has landed in every target. With comments, reactions, labels and decisions; and what `#N` names where it is written.", | |
| 367 | 367 | &[ | |
| 368 | − | Op::ListIssues, | |
| 369 | − | Op::CreateIssue, | |
| 370 | − | Op::GetIssue, | |
| 371 | − | Op::UpdateIssue, | |
| 372 | − | Op::CloseIssue, | |
| 373 | − | Op::ReopenIssue, | |
| 374 | − | Op::AddComment, | |
| 375 | − | Op::EditComment, | |
| 376 | − | Op::DeleteComment, | |
| 377 | − | Op::React, | |
| 368 | + | Op::ListWorkItems, | |
| 369 | + | Op::OpenWorkItem, | |
| 370 | + | Op::GetWorkItem, | |
| 371 | + | Op::UpdateWorkItem, | |
| 372 | + | Op::CloseWorkItem, | |
| 373 | + | Op::ReopenWorkItem, | |
| 374 | + | Op::ListWorkItemLinks, | |
| 375 | + | Op::LinkWorkItem, | |
| 376 | + | Op::UnlinkWorkItem, | |
| 377 | + | Op::ListRepoWorkItems, | |
| 378 | + | Op::ImportWorkItems, | |
| 379 | + | Op::ResolveRef, | |
| 380 | + | Op::AddWorkItemComment, | |
| 381 | + | Op::EditWorkItemComment, | |
| 382 | + | Op::DeleteWorkItemComment, | |
| 383 | + | Op::ReactToWorkItem, | |
| 378 | 384 | Op::UploadAttachment, | |
| 379 | − | Op::ListIssueLabels, | |
| 380 | − | Op::AddIssueLabels, | |
| 381 | − | Op::SetIssueLabels, | |
| 382 | − | Op::RemoveIssueLabels, | |
| 383 | − | ], | |
| 384 | − | ), | |
| 385 | − | ( | |
| 386 | − | "Claims and work", | |
| 387 | − | "Coordinating work on issues: an advisory, expiring claim that says you are on one (never a lock), handing it off with a note, and everything an agent needs before starting on an issue in one call. What an issue is blocked by is set with update_issue.", | |
| 388 | − | &[ | |
| 385 | + | Op::ListWorkItemLabels, | |
| 386 | + | Op::AddWorkItemLabels, | |
| 387 | + | Op::SetWorkItemLabels, | |
| 388 | + | Op::RemoveWorkItemLabels, | |
| 389 | + | Op::ListWorkItemDecisions, | |
| 389 | 390 | Op::Coordination(CoordinationOp::GetWork), | |
| 390 | − | Op::Coordination(CoordinationOp::ClaimIssue), | |
| 391 | + | Op::Coordination(CoordinationOp::ClaimWorkItem), | |
| 391 | 392 | Op::Coordination(CoordinationOp::ReleaseClaim), | |
| 392 | − | Op::Coordination(CoordinationOp::HandOffIssue), | |
| 393 | + | Op::Coordination(CoordinationOp::HandOffWorkItem), | |
| 393 | 394 | Op::Coordination(CoordinationOp::ListClaims), | |
| 394 | 395 | ], | |
| 395 | 396 | ), | |
| 396 | 397 | ( | |
| 397 | 398 | "Labels and milestones", | |
| 398 | − | "A repository's labels, which issues and pull requests carry by name, and its milestones, which gather them under a goal and a due date.", | |
| 399 | + | "A workspace's labels, which its work items and pull requests carry by name, and its milestones, which gather them under a goal and a due date.", | |
| 399 | 400 | &[ | |
| 400 | 401 | Op::ListLabels, | |
| 401 | 402 | Op::CreateLabel, | |
| ⋯ | |||
| 411 | 412 | ), | |
| 412 | 413 | ( | |
| 413 | 414 | "Pull requests", | |
| 414 | − | "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it. It merges by a merge commit, a squash, a rebase or a fast-forward, now or with auto-merge once everything it needs is met.", | |
| 415 | + | "A proposed change in its own fork or on a branch, with its comments and reactions. Several can be made for one work item, in its repositories; each merged one lands it in its repository. It merges by a merge commit, a squash, a rebase or a fast-forward, now or with auto-merge once everything it needs is met.", | |
| 415 | 416 | &[ | |
| 416 | 417 | Op::ListPullRequests, | |
| 417 | 418 | Op::CreatePullRequest, | |
| ⋯ | |||
| 433 | 434 | Op::ClosePullRequest, | |
| 434 | 435 | Op::ReopenPullRequest, | |
| 435 | 436 | Op::GetMergeQueue, | |
| 437 | + | Op::AddComment, | |
| 438 | + | Op::EditComment, | |
| 439 | + | Op::DeleteComment, | |
| 440 | + | Op::React, | |
| 436 | 441 | ], | |
| 437 | 442 | ), | |
| 438 | 443 | ( | |
| 439 | 444 | "Sessions", | |
| 440 | − | "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to an issue and a pull request, and recording prompts, reasoning, the tools that ran, the skills used and what it touched. Each subagent is a session of its own under the one that started it. A pull request's session shows what was recorded on it. A session can also be started on an issue in your own Claude, on the workspace's Anthropic account.", | |
| 445 | + | "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to a work item and a pull request, and recording prompts, reasoning, the tools that ran, the skills used and what it touched. Each subagent is a session of its own under the one that started it. A pull request's session shows what was recorded on it. A session can also be started on a work item in your own Claude, on the workspace's Anthropic account.", | |
| 441 | 446 | &[ | |
| 442 | 447 | Op::Coordination(CoordinationOp::StartSession), | |
| 443 | 448 | Op::Coordination(CoordinationOp::UpdateSession), | |
| ⋯ | |||
| 452 | 457 | ), | |
| 453 | 458 | ( | |
| 454 | 459 | "Decisions and why", | |
| 455 | − | "Decision records: why the work is the way it is, and how sure that is (claimed, tested by a check or the merge queue, or approved by a person). And why a line of code is the way it is: its commit, pull request, issue, decisions and session.", | |
| 460 | + | "Decision records: why the work is the way it is, and how sure that is (claimed, tested by a check or the merge queue, or approved by a person). And why a line of code is the way it is: its commit, pull request, work item, decisions and session.", | |
| 456 | 461 | &[Op::ListDecisions, Op::RecordDecision, Op::Why], | |
| 457 | 462 | ), | |
| 458 | 463 | ( | |
| 459 | 464 | "Search", | |
| 460 | − | "One search across all of g1t: repositories, code, issues, pull requests, people and workspaces. Public content for everyone, and private content in workspaces you belong to.", | |
| 465 | + | "One search across all of g1t: repositories, code, work items, pull requests, people and workspaces. Public content for everyone, and private content in workspaces you belong to.", | |
| 461 | 466 | &[Op::Search], | |
| 462 | 467 | ), | |
| 463 | 468 | ( | |
| ⋯ | |||
| 619 | 624 | ), | |
| 620 | 625 | ( | |
| 621 | 626 | "Integrations", | |
| 622 | − | "A workspace's connections to outside systems: the alert sources that open issues (Sentry, Datadog, webhooks) and the issue trackers whose tickets agents read (Jira, Linear).", | |
| 627 | + | "A workspace's connections to outside systems: the alert sources that open work items (Sentry, Datadog, webhooks) and the issue trackers whose tickets agents read (Jira, Linear).", | |
| 623 | 628 | &[ | |
| 624 | 629 | Op::ListIntegrations, | |
| 625 | 630 | Op::ConnectIntegration, | |
| ⋯ | |||
| 627 | 632 | Op::DisconnectIntegration, | |
| 628 | 633 | Op::TestIntegration, | |
| 629 | 634 | Op::GetContext, | |
| 630 | − | Op::ImportIssue, | |
| 631 | 635 | ], | |
| 632 | 636 | ), | |
| 633 | 637 | ( | |
| ⋯ | |||
| 729 | 733 | Op::ListCheckNames => "List check names", | |
| 730 | 734 | Op::GetMergeQueue => "Get the merge queue", | |
| 731 | 735 | Op::Search => "Search g1t", | |
| 732 | − | Op::ListIssues => "List issues", | |
| 733 | − | Op::GetIssue => "Get an issue", | |
| 734 | − | Op::CreateIssue => "Create an issue", | |
| 735 | − | Op::UpdateIssue => "Update an issue", | |
| 736 | − | Op::CloseIssue => "Close an issue", | |
| 737 | − | Op::ReopenIssue => "Reopen an issue", | |
| 736 | + | Op::ListWorkItems => "List a workspace's work items", | |
| 737 | + | Op::ListRepoWorkItems => "List a repository's work items", | |
| 738 | + | Op::GetWorkItem => "Get a work item", | |
| 739 | + | Op::OpenWorkItem => "Open a work item", | |
| 740 | + | Op::UpdateWorkItem => "Update a work item", | |
| 741 | + | Op::CloseWorkItem => "Close a work item", | |
| 742 | + | Op::ReopenWorkItem => "Reopen a work item", | |
| 743 | + | Op::ListWorkItemLinks => "List a work item's repositories", | |
| 744 | + | Op::LinkWorkItem => "Link a repository to a work item", | |
| 745 | + | Op::UnlinkWorkItem => "Unlink a repository from a work item", | |
| 746 | + | Op::ResolveRef => "Resolve a reference", | |
| 738 | 747 | Op::ListLabels => "List labels", | |
| 739 | 748 | Op::CreateLabel => "Create a label", | |
| 740 | 749 | Op::UpdateLabel => "Update a label", | |
| 741 | 750 | Op::DeleteLabel => "Delete a label", | |
| 742 | 751 | Op::AddDefaultLabels => "Add the default labels", | |
| 743 | − | Op::ListIssueLabels => "List an issue's labels", | |
| 744 | − | Op::AddIssueLabels => "Add labels to an issue", | |
| 745 | − | Op::SetIssueLabels => "Set an issue's labels", | |
| 746 | − | Op::RemoveIssueLabels => "Remove labels from an issue", | |
| 752 | + | Op::ListWorkItemLabels => "List a work item's labels", | |
| 753 | + | Op::AddWorkItemLabels => "Add labels to a work item", | |
| 754 | + | Op::SetWorkItemLabels => "Set a work item's labels", | |
| 755 | + | Op::RemoveWorkItemLabels => "Remove labels from a work item", | |
| 747 | 756 | Op::ListMilestones => "List milestones", | |
| 748 | 757 | Op::GetMilestone => "Get a milestone", | |
| 749 | 758 | Op::CreateMilestone => "Create a milestone", | |
| 750 | 759 | Op::UpdateMilestone => "Update a milestone", | |
| 751 | 760 | Op::DeleteMilestone => "Delete a milestone", | |
| 752 | 761 | Op::UpdatePullRequest => "Update a pull request", | |
| 753 | − | Op::AddComment => "Add a comment", | |
| 754 | − | Op::EditComment => "Edit a comment", | |
| 755 | − | Op::DeleteComment => "Delete a comment", | |
| 756 | − | Op::React => "React with an emoji", | |
| 762 | + | Op::AddComment => "Comment on a pull request", | |
| 763 | + | Op::EditComment => "Edit a pull request comment", | |
| 764 | + | Op::DeleteComment => "Delete a pull request comment", | |
| 765 | + | Op::React => "React to a pull request", | |
| 766 | + | Op::AddWorkItemComment => "Comment on a work item", | |
| 767 | + | Op::EditWorkItemComment => "Edit a work item comment", | |
| 768 | + | Op::DeleteWorkItemComment => "Delete a work item comment", | |
| 769 | + | Op::ReactToWorkItem => "React to a work item", | |
| 770 | + | Op::ListWorkItemDecisions => "List a work item's decisions", | |
| 757 | 771 | Op::UploadAttachment => "Upload an attachment", | |
| 758 | 772 | Op::ReviewPullRequest => "Review a pull request", | |
| 759 | 773 | Op::ListPullRequests => "List pull requests", | |
| ⋯ | |||
| 795 | 809 | Op::GetAskSettings => "Get Ask settings", | |
| 796 | 810 | Op::SetAskSettings => "Set Ask settings", | |
| 797 | 811 | Op::GetContext => "Look up a ticket", | |
| 798 | − | Op::ImportIssue => "Import an issue", | |
| 812 | + | Op::ImportWorkItems => "Import a work item", | |
| 799 | 813 | Op::ListWebhooks => "List webhooks", | |
| 800 | 814 | Op::CreateWebhook => "Create a webhook", | |
| 801 | 815 | Op::UpdateWebhook => "Update a webhook", | |
| ⋯ | |||
| 989 | 1003 | ("GET", ":attempt") => "get_workflow_run_attempt".to_owned(), | |
| 990 | 1004 | ("PATCH", ":setting") => "update_actions_variable".to_owned(), | |
| 991 | 1005 | ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(), | |
| 992 | − | // One repository's notifications, and an issue's subscription by | |
| 993 | − | // its number rather than a thread's id. | |
| 1006 | + | // One repository's notifications, and a work item's or a pull | |
| 1007 | + | // request's subscription by its number rather than a thread's id. | |
| 994 | 1008 | (_, "notifications") if route.path.starts_with("/repos/") => match op { | |
| 995 | 1009 | Op::ListNotifications => "list_repo_notifications".to_owned(), | |
| 996 | 1010 | _ => "mark_repo_notifications_read".to_owned(), | |
| 997 | 1011 | }, | |
| 998 | − | (method, "subscription") if route.path.contains("/issues/:number/") => match method { | |
| 999 | − | "GET" => "get_issue_subscription".to_owned(), | |
| 1000 | − | "PUT" => "set_issue_subscription".to_owned(), | |
| 1001 | − | _ => "delete_issue_subscription".to_owned(), | |
| 1012 | + | (method, "subscription") if route.path.contains("/work/:number/") => match method { | |
| 1013 | + | "GET" => "get_work_item_subscription".to_owned(), | |
| 1014 | + | "PUT" => "set_work_item_subscription".to_owned(), | |
| 1015 | + | _ => "delete_work_item_subscription".to_owned(), | |
| 1002 | 1016 | }, | |
| 1003 | − | // One label off an issue, by its name in the path. | |
| 1004 | − | ("DELETE", ":label") if route.path.contains("/issues/:number/") => "remove_issue_label".to_owned(), | |
| 1017 | + | (method, "subscription") if route.path.contains("/pulls/:number/") => match method { | |
| 1018 | + | "GET" => "get_pull_request_subscription".to_owned(), | |
| 1019 | + | "PUT" => "set_pull_request_subscription".to_owned(), | |
| 1020 | + | _ => "delete_pull_request_subscription".to_owned(), | |
| 1021 | + | }, | |
| 1022 | + | // One label off a work item, by its name in the path. | |
| 1023 | + | ("DELETE", ":label") if route.path.contains("/work/:number/") => "remove_work_item_label".to_owned(), | |
| 1005 | 1024 | ("DELETE", "saved") => "unsave_thread".to_owned(), | |
| 1006 | 1025 | ("DELETE", "snooze") => "unsnooze_thread".to_owned(), | |
| 1007 | − | // An issue's decisions, by its number. | |
| 1008 | − | ("GET", "decisions") if route.path.contains("/issues/:number/") => "list_issue_decisions".to_owned(), | |
| 1009 | − | // A claim that opens its issue first. | |
| 1010 | − | ("POST", "claims") => "claim_new_issue".to_owned(), | |
| 1026 | + | // A claim that opens its work item first. | |
| 1027 | + | ("POST", "claims") => "claim_new_work_item".to_owned(), | |
| 1011 | 1028 | _ => op.name().to_owned(), | |
| 1012 | 1029 | }; | |
| 1013 | − | if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) { | |
| 1030 | + | // A work item's own addresses are only ever a workspace's. | |
| 1031 | + | if route.path.starts_with("/workspaces/") | |
| 1032 | + | && !route.path.contains("/work/") | |
| 1033 | + | && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) | |
| 1034 | + | { | |
| 1014 | 1035 | format!("{base}_for_workspace") | |
| 1015 | 1036 | } else { | |
| 1016 | 1037 | base | |
| ⋯ | |||
| 1031 | 1052 | "list_runs_of_workflow" => "List a workflow's runs", | |
| 1032 | 1053 | "list_repo_notifications" => "List a repository's notifications", | |
| 1033 | 1054 | "mark_repo_notifications_read" => "Mark a repository's notifications read", | |
| 1034 | − | "get_issue_subscription" => "Get your subscription to an issue", | |
| 1035 | − | "set_issue_subscription" => "Subscribe to an issue", | |
| 1036 | − | "delete_issue_subscription" => "Unsubscribe from an issue", | |
| 1055 | + | "get_work_item_subscription" => "Get your subscription to a work item", | |
| 1056 | + | "set_work_item_subscription" => "Subscribe to a work item", | |
| 1057 | + | "delete_work_item_subscription" => "Unsubscribe from a work item", | |
| 1058 | + | "get_pull_request_subscription" => "Get your subscription to a pull request", | |
| 1059 | + | "set_pull_request_subscription" => "Subscribe to a pull request", | |
| 1060 | + | "delete_pull_request_subscription" => "Unsubscribe from a pull request", | |
| 1061 | + | "remove_work_item_label" => "Remove a label from a work item", | |
| 1037 | 1062 | "unsave_thread" => "Unsave a thread", | |
| 1038 | 1063 | "unsnooze_thread" => "Bring a snoozed thread back", | |
| 1039 | − | "list_issue_decisions" => "List an issue's decisions", | |
| 1040 | − | "claim_new_issue" => "Open an issue and claim it", | |
| 1064 | + | "claim_new_work_item" => "Open a work item and claim it", | |
| 1041 | 1065 | _ => title(route.op), | |
| 1042 | 1066 | }; | |
| 1043 | 1067 | if id.ends_with("_for_workspace") { | |
| ⋯ | |||
| 1054 | 1078 | // so a `name` in the body, such as a check run's, is the body's own. | |
| 1055 | 1079 | let stands_for_repo = | |
| 1056 | 1080 | |name: &str| matches!(name, "owner" | "name") && path_params.contains(&"owner") && path_params.contains(&"name"); | |
| 1057 | − | let covered = |name: &str| name == "repo" || (path_params.contains(&name) && !stands_for_repo(name)); | |
| 1081 | + | // `repo` is the path's `owner` and `name`; at a workspace's address, a | |
| 1082 | + | // work item operation takes it in the body or query (a repository of | |
| 1083 | + | // the workspace: an origin, a filter or one to link), and others not. | |
| 1084 | + | let repo_in_body = !path_params.contains(&"owner") | |
| 1085 | + | && (op.in_a_workspace() || matches!(op, Op::Coordination(coordination) if !coordination.needs_repo())); | |
| 1086 | + | let covered = |name: &str| (name == "repo" && !repo_in_body) || (path_params.contains(&name) && !stands_for_repo(name)); | |
| 1058 | 1087 | let all_properties = op.properties(); | |
| 1059 | 1088 | let mut properties = all_properties.clone(); | |
| 1060 | 1089 | properties.retain(|name, _| !covered(name)); | |
| ⋯ | |||
| 1387 | 1416 | "message": { "type": "string" }, | |
| 1388 | 1417 | "needed_scope": { | |
| 1389 | 1418 | "type": "string", | |
| 1390 | − | "description": "On a 403 for an access token without the scope the call needs: that scope, such as `issues:write`.", | |
| 1419 | + | "description": "On a 403 for an access token without the scope the call needs: that scope, such as `work:write`.", | |
| 1391 | 1420 | }, | |
| 1392 | 1421 | }, | |
| 1393 | 1422 | }, | |
| ⋯ | |||
| 1451 | 1480 | let document = document(); | |
| 1452 | 1481 | let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"]; | |
| 1453 | 1482 | let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"]; | |
| 1454 | − | assert!(body.get("keep_issue_open").is_some()); | |
| 1483 | + | assert!(body.get("keep_item_open").is_some()); | |
| 1455 | 1484 | assert!(body.get("repo").is_none() && body.get("number").is_none()); | |
| 1456 | 1485 | let list = &document["paths"]["/repos"]["get"]; | |
| 1457 | 1486 | assert_eq!(list["parameters"][0]["name"], "q"); | |
| ⋯ | |||
| 1471 | 1500 | ||
| 1472 | 1501 | #[test] | |
| 1473 | 1502 | fn titles_read_as_sentences() { | |
| 1474 | − | assert_eq!(title(Op::CreateIssue), "Create an issue"); | |
| 1503 | + | assert_eq!(title(Op::OpenWorkItem), "Open a work item"); | |
| 1475 | 1504 | assert_eq!(title(Op::Whoami), "Get the current user"); | |
| 1476 | 1505 | } | |
| 1477 | 1506 | ||
| 1 | 1 | //! Usernames as their owners wrote them. Every person in an answer, the | |
| 2 | − | //! REST API's and MCP's alike (an issue's `author`, a member, a | |
| 2 | + | //! REST API's and MCP's alike (a work item's `author`, a member, a | |
| 3 | 3 | //! collaborator, whoever `whoami` is), carries `display_username` beside | |
| 4 | 4 | //! `username`: `username` stays the lowercased key, stable for anything | |
| 5 | 5 | //! that matches on it, and `display_username` is the case its owner chose | |
| ⋯ | |||
| 100 | 100 | #[test] | |
| 101 | 101 | fn every_person_in_an_answer_is_found_once() { | |
| 102 | 102 | let answer = json!({ | |
| 103 | − | "issue": { "author": { "id": "usr_1", "username": "ana" }, "assignees": [{ "username": "bo" }, { "username": "ana" }] }, | |
| 103 | + | "item": { "author": { "id": "usr_1", "username": "ana" }, "assignees": [{ "username": "bo" }, { "username": "ana" }] }, | |
| 104 | 104 | "git": { "username": "ana", "password": "your g1t access token" }, | |
| 105 | 105 | "count": 3, | |
| 106 | 106 | }); | |
| 180 | 180 | Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent), | |
| 181 | 181 | Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent), | |
| 182 | 182 | Op::Why => through::<g1t_contracts::decisions::Why>(op, sent), | |
| 183 | − | Op::ListIssues => through::<Vec<work::Issue>>(op, sent), | |
| 184 | − | Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => { | |
| 185 | − | through::<work::Issue>(op, sent) | |
| 186 | − | } | |
| 187 | − | Op::GetIssue => through::<work::IssueDetail>(op, sent), | |
| 183 | + | Op::ListWorkItems | Op::ListRepoWorkItems => through::<Vec<work::WorkItem>>(op, sent), | |
| 184 | + | Op::OpenWorkItem | |
| 185 | + | | Op::UpdateWorkItem | |
| 186 | + | | Op::CloseWorkItem | |
| 187 | + | | Op::ReopenWorkItem | |
| 188 | + | | Op::LinkWorkItem | |
| 189 | + | | Op::UnlinkWorkItem => through::<work::WorkItem>(op, sent), | |
| 190 | + | Op::GetWorkItem => through::<work::WorkItemDetail>(op, sent), | |
| 191 | + | Op::ResolveRef => through::<work::RefTarget>(op, sent), | |
| 192 | + | Op::ListWorkItemDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent), | |
| 188 | 193 | Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent), | |
| 189 | 194 | Op::UpdatePullRequest => through::<work::Pull>(op, sent), | |
| 190 | − | Op::ListLabels | Op::AddDefaultLabels | Op::ListIssueLabels => through::<Vec<work::Label>>(op, sent), | |
| 195 | + | Op::ListLabels | Op::AddDefaultLabels | Op::ListWorkItemLabels => through::<Vec<work::Label>>(op, sent), | |
| 191 | 196 | Op::CreateLabel | Op::UpdateLabel => through::<work::Label>(op, sent), | |
| 192 | 197 | Op::ListMilestones => through::<Vec<work::Milestone>>(op, sent), | |
| 193 | 198 | Op::CreateMilestone | Op::UpdateMilestone => through::<work::Milestone>(op, sent), | |
| ⋯ | |||
| 264 | 269 | Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent), | |
| 265 | 270 | Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent), | |
| 266 | 271 | Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent), | |
| 267 | − | Op::Coordination(CoordinationOp::ClaimIssue) => through::<work::Claimed>(op, sent), | |
| 268 | − | Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent), | |
| 272 | + | Op::Coordination(CoordinationOp::ClaimWorkItem) => through::<work::Claimed>(op, sent), | |
| 273 | + | Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffWorkItem) => through::<work::Claim>(op, sent), | |
| 269 | 274 | Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent), | |
| 270 | 275 | Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent), | |
| 271 | 276 | Op::Coordination(CoordinationOp::StartAgentSession) => through::<g1t_contracts::agent_sessions::AgentSessionStarted>(op, sent), | |
| 123 | 123 | route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]), | |
| 124 | 124 | route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]), | |
| 125 | 125 | route("DELETE", "/repos/:owner/:name/subscription", Op::DeleteRepoSubscription, &[]), | |
| 126 | − | route("GET", "/repos/:owner/:name/issues/:number/subscription", Op::GetThreadSubscription, &[]), | |
| 127 | − | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 128 | − | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 126 | + | route("GET", "/repos/:owner/:name/pulls/:number/subscription", Op::GetThreadSubscription, &[]), | |
| 127 | + | route("PUT", "/repos/:owner/:name/pulls/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 128 | + | route("DELETE", "/repos/:owner/:name/pulls/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 129 | + | route("GET", "/workspaces/:workspace/work/:number/subscription", Op::GetThreadSubscription, &[]), | |
| 130 | + | route("PUT", "/workspaces/:workspace/work/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 131 | + | route("DELETE", "/workspaces/:workspace/work/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 129 | 132 | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 130 | 133 | // Stars: yours, and who starred a repository. | |
| 131 | 134 | route("GET", "/user/starred", Op::About(AboutOp::ListStarred), &[]), | |
| ⋯ | |||
| 583 | 586 | ("wait", "wait"), | |
| 584 | 587 | ], | |
| 585 | 588 | ), | |
| 586 | − | route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 587 | − | route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]), | |
| 588 | − | route("POST", "/repos/:owner/:name/labels/defaults", Op::AddDefaultLabels, &[]), | |
| 589 | − | route("PATCH", "/repos/:owner/:name/labels/:label", Op::UpdateLabel, &[]), | |
| 590 | − | route("DELETE", "/repos/:owner/:name/labels/:label", Op::DeleteLabel, &[]), | |
| 591 | − | route("GET", "/repos/:owner/:name/issues/:number/labels", Op::ListIssueLabels, &[]), | |
| 592 | − | route("POST", "/repos/:owner/:name/issues/:number/labels", Op::AddIssueLabels, &[]), | |
| 593 | − | route("PUT", "/repos/:owner/:name/issues/:number/labels", Op::SetIssueLabels, &[]), | |
| 594 | − | route("DELETE", "/repos/:owner/:name/issues/:number/labels", Op::RemoveIssueLabels, &[]), | |
| 595 | − | route("DELETE", "/repos/:owner/:name/issues/:number/labels/:label", Op::RemoveIssueLabels, &[]), | |
| 596 | − | route("GET", "/repos/:owner/:name/milestones", Op::ListMilestones, &[("state", "state")]), | |
| 597 | − | route("POST", "/repos/:owner/:name/milestones", Op::CreateMilestone, &[]), | |
| 598 | − | route("GET", "/repos/:owner/:name/milestones/:milestone", Op::GetMilestone, &[]), | |
| 599 | − | route("PATCH", "/repos/:owner/:name/milestones/:milestone", Op::UpdateMilestone, &[]), | |
| 600 | − | route("DELETE", "/repos/:owner/:name/milestones/:milestone", Op::DeleteMilestone, &[]), | |
| 589 | + | // A workspace's labels and milestones, which its work items and pull | |
| 590 | + | // requests carry. | |
| 591 | + | route("GET", "/workspaces/:workspace/labels", Op::ListLabels, &[]), | |
| 592 | + | route("POST", "/workspaces/:workspace/labels", Op::CreateLabel, &[]), | |
| 593 | + | route("POST", "/workspaces/:workspace/labels/defaults", Op::AddDefaultLabels, &[]), | |
| 594 | + | route("PATCH", "/workspaces/:workspace/labels/:label", Op::UpdateLabel, &[]), | |
| 595 | + | route("DELETE", "/workspaces/:workspace/labels/:label", Op::DeleteLabel, &[]), | |
| 596 | + | route("GET", "/workspaces/:workspace/milestones", Op::ListMilestones, &[("state", "state")]), | |
| 597 | + | route("POST", "/workspaces/:workspace/milestones", Op::CreateMilestone, &[]), | |
| 598 | + | route("GET", "/workspaces/:workspace/milestones/:milestone", Op::GetMilestone, &[]), | |
| 599 | + | route("PATCH", "/workspaces/:workspace/milestones/:milestone", Op::UpdateMilestone, &[]), | |
| 600 | + | route("DELETE", "/workspaces/:workspace/milestones/:milestone", Op::DeleteMilestone, &[]), | |
| 601 | + | // Work items: a workspace's tracked work, linked to its repositories. | |
| 601 | 602 | route( | |
| 602 | 603 | "GET", | |
| 603 | − | "/repos/:owner/:name/issues", | |
| 604 | − | Op::ListIssues, | |
| 605 | − | &[("state", "state"), ("label", "label"), ("milestone", "milestone")], | |
| 604 | + | "/workspaces/:workspace/work", | |
| 605 | + | Op::ListWorkItems, | |
| 606 | + | &[("state", "state"), ("label", "label"), ("milestone", "milestone"), ("assignee", "assignee"), ("repo", "repo"), ("role", "role")], | |
| 606 | 607 | ), | |
| 607 | − | route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]), | |
| 608 | − | route( | |
| 609 | − | "GET", | |
| 610 | − | "/repos/:owner/:name/issues/:number", | |
| 611 | − | Op::GetIssue, | |
| 612 | − | &[], | |
| 613 | − | ), | |
| 614 | − | route( | |
| 615 | − | "PATCH", | |
| 616 | − | "/repos/:owner/:name/issues/:number", | |
| 617 | − | Op::UpdateIssue, | |
| 618 | − | &[], | |
| 619 | − | ), | |
| 620 | − | route( | |
| 621 | − | "POST", | |
| 622 | − | "/repos/:owner/:name/issues/:number/close", | |
| 623 | − | Op::CloseIssue, | |
| 624 | − | &[], | |
| 625 | − | ), | |
| 626 | − | route( | |
| 627 | − | "POST", | |
| 628 | − | "/repos/:owner/:name/issues/:number/reopen", | |
| 629 | − | Op::ReopenIssue, | |
| 630 | − | &[], | |
| 631 | − | ), | |
| 632 | − | route( | |
| 633 | − | "POST", | |
| 634 | − | "/repos/:owner/:name/issues/import", | |
| 635 | − | Op::ImportIssue, | |
| 636 | − | &[], | |
| 637 | − | ), | |
| 638 | − | // Coordinating work: claims on issues, an issue's whole context, and | |
| 639 | − | // sessions of people's own harnesses. | |
| 640 | − | route("GET", "/repos/:owner/:name/issues/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]), | |
| 641 | − | route("POST", "/repos/:owner/:name/issues/:number/claim", Op::Coordination(CoordinationOp::ClaimIssue), &[]), | |
| 642 | − | // Work with no issue yet: open one from a title and claim it. | |
| 643 | − | route("POST", "/repos/:owner/:name/claims", Op::Coordination(CoordinationOp::ClaimIssue), &[]), | |
| 644 | − | route("POST", "/repos/:owner/:name/issues/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]), | |
| 645 | − | route("POST", "/repos/:owner/:name/issues/:number/handoff", Op::Coordination(CoordinationOp::HandOffIssue), &[]), | |
| 608 | + | route("POST", "/workspaces/:workspace/work", Op::OpenWorkItem, &[]), | |
| 609 | + | route("POST", "/workspaces/:workspace/work/import", Op::ImportWorkItems, &[]), | |
| 610 | + | route("PATCH", "/workspaces/:workspace/work/comments/:comment_id", Op::EditWorkItemComment, &[]), | |
| 611 | + | route("DELETE", "/workspaces/:workspace/work/comments/:comment_id", Op::DeleteWorkItemComment, &[]), | |
| 612 | + | route("GET", "/workspaces/:workspace/work/:number", Op::GetWorkItem, &[]), | |
| 613 | + | route("PATCH", "/workspaces/:workspace/work/:number", Op::UpdateWorkItem, &[]), | |
| 614 | + | route("POST", "/workspaces/:workspace/work/:number/close", Op::CloseWorkItem, &[]), | |
| 615 | + | route("POST", "/workspaces/:workspace/work/:number/reopen", Op::ReopenWorkItem, &[]), | |
| 616 | + | route("GET", "/workspaces/:workspace/work/:number/links", Op::ListWorkItemLinks, &[]), | |
| 617 | + | route("POST", "/workspaces/:workspace/work/:number/links", Op::LinkWorkItem, &[]), | |
| 618 | + | route("DELETE", "/workspaces/:workspace/work/:number/links", Op::UnlinkWorkItem, &[("repo", "repo")]), | |
| 619 | + | route("GET", "/workspaces/:workspace/work/:number/labels", Op::ListWorkItemLabels, &[]), | |
| 620 | + | route("POST", "/workspaces/:workspace/work/:number/labels", Op::AddWorkItemLabels, &[]), | |
| 621 | + | route("PUT", "/workspaces/:workspace/work/:number/labels", Op::SetWorkItemLabels, &[]), | |
| 622 | + | route("DELETE", "/workspaces/:workspace/work/:number/labels", Op::RemoveWorkItemLabels, &[]), | |
| 623 | + | route("DELETE", "/workspaces/:workspace/work/:number/labels/:label", Op::RemoveWorkItemLabels, &[]), | |
| 624 | + | route("POST", "/workspaces/:workspace/work/:number/comments", Op::AddWorkItemComment, &[]), | |
| 625 | + | route("POST", "/workspaces/:workspace/work/:number/reactions", Op::ReactToWorkItem, &[]), | |
| 626 | + | route("GET", "/workspaces/:workspace/work/:number/decisions", Op::ListWorkItemDecisions, &[]), | |
| 627 | + | // The work items linked to a repository: its Work tab. Read only. | |
| 628 | + | route("GET", "/repos/:owner/:name/work", Op::ListRepoWorkItems, &[("state", "state"), ("label", "label"), ("milestone", "milestone"), ("assignee", "assignee"), ("role", "role")]), | |
| 629 | + | // What `#N` names, read in a repository or a workspace: how an old | |
| 630 | + | // link to a repository's issue finds the work item it became. | |
| 631 | + | route("GET", "/repos/:owner/:name/refs/:number", Op::ResolveRef, &[]), | |
| 632 | + | route("GET", "/workspaces/:workspace/refs/:number", Op::ResolveRef, &[]), | |
| 633 | + | // Coordinating work: claims on work items, a work item's whole context, | |
| 634 | + | // and sessions of people's own harnesses. | |
| 635 | + | route("GET", "/workspaces/:workspace/work/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]), | |
| 636 | + | route("POST", "/workspaces/:workspace/work/:number/claim", Op::Coordination(CoordinationOp::ClaimWorkItem), &[]), | |
| 637 | + | // Work with no work item yet: open one from a title and claim it. | |
| 638 | + | route("POST", "/workspaces/:workspace/claims", Op::Coordination(CoordinationOp::ClaimWorkItem), &[]), | |
| 639 | + | route("POST", "/workspaces/:workspace/work/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]), | |
| 640 | + | route("POST", "/workspaces/:workspace/work/:number/handoff", Op::Coordination(CoordinationOp::HandOffWorkItem), &[]), | |
| 646 | 641 | route( | |
| 647 | 642 | "GET", | |
| 648 | − | "/repos/:owner/:name/claims", | |
| 643 | + | "/workspaces/:workspace/claims", | |
| 649 | 644 | Op::Coordination(CoordinationOp::ListClaims), | |
| 650 | − | &[("state", "state"), ("issue", "issue")], | |
| 645 | + | &[("state", "state"), ("item", "item"), ("repo", "repo")], | |
| 651 | 646 | ), | |
| 652 | 647 | route("POST", "/repos/:owner/:name/sessions", Op::Coordination(CoordinationOp::StartSession), &[]), | |
| 653 | 648 | route( | |
| 654 | 649 | "GET", | |
| 655 | 650 | "/repos/:owner/:name/sessions", | |
| 656 | 651 | Op::Coordination(CoordinationOp::ListSessions), | |
| 657 | − | &[("state", "state"), ("issue", "issue"), ("pull", "pull"), ("path", "path"), ("subagents", "subagents")], | |
| 652 | + | &[("state", "state"), ("item", "item"), ("pull", "pull"), ("path", "path"), ("subagents", "subagents")], | |
| 658 | 653 | ), | |
| 659 | 654 | route("POST", "/repos/:owner/:name/agent-sessions", Op::Coordination(CoordinationOp::StartAgentSession), &[]), | |
| 660 | 655 | route("GET", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::GetSession), &[("after", "after")]), | |
| ⋯ | |||
| 1060 | 1055 | route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]), | |
| 1061 | 1056 | route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]), | |
| 1062 | 1057 | route("DELETE", "/workspaces/:workspace/actions/runner-groups/:id", Op::DeleteRunnerGroup, &[]), | |
| 1063 | − | route( | |
| 1064 | − | "POST", | |
| 1065 | − | "/repos/:owner/:name/issues/:number/comments", | |
| 1066 | − | Op::AddComment, | |
| 1067 | − | &[], | |
| 1068 | − | ), | |
| 1069 | − | route("PATCH", "/repos/:owner/:name/issues/comments/:comment_id", Op::EditComment, &[]), | |
| 1070 | − | route("DELETE", "/repos/:owner/:name/issues/comments/:comment_id", Op::DeleteComment, &[]), | |
| 1071 | − | route("POST", "/repos/:owner/:name/issues/:number/reactions", Op::React, &[]), | |
| 1072 | − | // An image to show in an issue's, a pull request's or a comment's Markdown. | |
| 1058 | + | // Comments on pull requests, and reactions to them. | |
| 1059 | + | route("POST", "/repos/:owner/:name/pulls/:number/comments", Op::AddComment, &[]), | |
| 1060 | + | route("PATCH", "/repos/:owner/:name/pulls/comments/:comment_id", Op::EditComment, &[]), | |
| 1061 | + | route("DELETE", "/repos/:owner/:name/pulls/comments/:comment_id", Op::DeleteComment, &[]), | |
| 1062 | + | route("POST", "/repos/:owner/:name/pulls/:number/reactions", Op::React, &[]), | |
| 1063 | + | // An image to show in a work item's, a pull request's or a comment's Markdown. | |
| 1073 | 1064 | route("POST", "/repos/:owner/:name/attachments", Op::UploadAttachment, &[]), | |
| 1074 | 1065 | route( | |
| 1075 | 1066 | "GET", | |
| ⋯ | |||
| 1131 | 1122 | Op::RecordSession, | |
| 1132 | 1123 | &[], | |
| 1133 | 1124 | ), | |
| 1134 | − | // Decision records, on a pull request or an issue (with its pull | |
| 1135 | − | // requests'), and why a line is the way it is. | |
| 1125 | + | // Decision records on a pull request (a work item's are with it), and | |
| 1126 | + | // why a line is the way it is. | |
| 1136 | 1127 | route("GET", "/repos/:owner/:name/pulls/:number/decisions", Op::ListDecisions, &[]), | |
| 1137 | 1128 | route("POST", "/repos/:owner/:name/pulls/:number/decisions", Op::RecordDecision, &[]), | |
| 1138 | − | route("GET", "/repos/:owner/:name/issues/:number/decisions", Op::ListDecisions, &[]), | |
| 1139 | 1129 | route("GET", "/repos/:owner/:name/why", Op::Why, &[("path", "path"), ("line", "line"), ("ref", "ref")]), | |
| 1140 | 1130 | route( | |
| 1141 | 1131 | "POST", | |
| ⋯ | |||
| 1196 | 1186 | pub fn no_content(&self) -> bool { | |
| 1197 | 1187 | matches!( | |
| 1198 | 1188 | self.op, | |
| 1199 | − | Op::DeleteComment | Op::DeleteCommitComment | Op::Caches(CachesOp::DeleteCache) | |
| 1189 | + | Op::DeleteComment | Op::DeleteWorkItemComment | Op::DeleteCommitComment | Op::Caches(CachesOp::DeleteCache) | |
| 1200 | 1190 | ) | |
| 1201 | 1191 | } | |
| 1202 | 1192 | ||
| ⋯ | |||
| 1330 | 1320 | input.remove("until"); | |
| 1331 | 1321 | } | |
| 1332 | 1322 | if let Some(number) = param("number") { | |
| 1333 | − | // Not a number: zero, which no issue or pull request has. | |
| 1323 | + | // Not a number: zero, which no work item or pull request has. | |
| 1334 | 1324 | input.insert( | |
| 1335 | 1325 | "number".to_owned(), | |
| 1336 | 1326 | number.parse::<u32>().unwrap_or(0).into(), | |
| ⋯ | |||
| 1396 | 1386 | "POST", | |
| 1397 | 1387 | "/repos/flagon-io/hello/pulls/14/merge", | |
| 1398 | 1388 | &[], | |
| 1399 | − | json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }), | |
| 1389 | + | json!({ "keep_item_open": true, "number": 99, "repo": "someone/else" }), | |
| 1400 | 1390 | ) | |
| 1401 | 1391 | .unwrap(); | |
| 1402 | 1392 | assert_eq!(route.op, Op::MergePullRequest); | |
| 1403 | 1393 | // What the path names wins over the body. | |
| 1404 | 1394 | assert_eq!( | |
| 1405 | 1395 | input, | |
| 1406 | − | json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" }) | |
| 1396 | + | json!({ "keep_item_open": true, "number": 14, "repo": "flagon-io/hello" }) | |
| 1407 | 1397 | ); | |
| 1408 | 1398 | } | |
| 1409 | 1399 | ||
| ⋯ | |||
| 1516 | 1506 | } | |
| 1517 | 1507 | ||
| 1518 | 1508 | #[test] | |
| 1519 | − | fn notifications_are_addressed_as_threads_and_by_issue() { | |
| 1509 | + | fn notifications_are_addressed_as_threads_and_by_number() { | |
| 1520 | 1510 | let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap(); | |
| 1521 | 1511 | assert_eq!(route.op, Op::MarkThreadDone); | |
| 1522 | 1512 | assert_eq!(input, json!({ "id": "ntf_1" })); | |
| ⋯ | |||
| 1530 | 1520 | let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap(); | |
| 1531 | 1521 | assert_eq!(route.op, Op::ListNotifications); | |
| 1532 | 1522 | assert_eq!(input, json!({ "all": "true", "per_page": "50", "repo": "acme/rocket" })); | |
| 1533 | − | let (route, input) = resolve("PUT", "/repos/acme/rocket/issues/7/subscription", &[], json!({ "ignored": true })).unwrap(); | |
| 1523 | + | let (route, input) = resolve("PUT", "/workspaces/acme/work/7/subscription", &[], json!({ "ignored": true })).unwrap(); | |
| 1524 | + | assert_eq!(route.op, Op::SetThreadSubscription); | |
| 1525 | + | assert_eq!(input, json!({ "ignored": true, "number": 7, "workspace": "acme" })); | |
| 1526 | + | let (route, input) = resolve("PUT", "/repos/acme/rocket/pulls/7/subscription", &[], json!({ "ignored": true })).unwrap(); | |
| 1534 | 1527 | assert_eq!(route.op, Op::SetThreadSubscription); | |
| 1535 | 1528 | assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" })); | |
| 1536 | 1529 | assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos); | |
| ⋯ | |||
| 1538 | 1531 | ||
| 1539 | 1532 | #[test] | |
| 1540 | 1533 | fn labels_and_milestones_are_named_in_the_path() { | |
| 1541 | − | let (route, input) = resolve("PATCH", "/repos/acme/web/labels/good%20first%20issue", &[], json!({ "color": "7057ff" })).unwrap(); | |
| 1534 | + | let (route, input) = resolve("PATCH", "/workspaces/acme/labels/good%20first%20issue", &[], json!({ "color": "7057ff" })).unwrap(); | |
| 1542 | 1535 | assert_eq!(route.op, Op::UpdateLabel); | |
| 1543 | − | assert_eq!(input, json!({ "color": "7057ff", "label": "good first issue", "repo": "acme/web" })); | |
| 1544 | − | let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels/bug", &[], Value::Null).unwrap(); | |
| 1545 | − | assert_eq!(route.op, Op::RemoveIssueLabels); | |
| 1546 | − | assert_eq!(input, json!({ "label": "bug", "number": 7, "repo": "acme/web" })); | |
| 1547 | − | let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels", &[], Value::Null).unwrap(); | |
| 1548 | − | assert_eq!(route.op, Op::RemoveIssueLabels); | |
| 1549 | − | assert_eq!(input, json!({ "number": 7, "repo": "acme/web" })); | |
| 1550 | − | let (route, _) = resolve("POST", "/repos/acme/web/labels/defaults", &[], Value::Null).unwrap(); | |
| 1536 | + | assert_eq!(input, json!({ "color": "7057ff", "label": "good first issue", "workspace": "acme" })); | |
| 1537 | + | let (route, input) = resolve("DELETE", "/workspaces/acme/work/7/labels/bug", &[], Value::Null).unwrap(); | |
| 1538 | + | assert_eq!(route.op, Op::RemoveWorkItemLabels); | |
| 1539 | + | assert_eq!(input, json!({ "label": "bug", "number": 7, "workspace": "acme" })); | |
| 1540 | + | let (route, input) = resolve("DELETE", "/workspaces/acme/work/7/labels", &[], Value::Null).unwrap(); | |
| 1541 | + | assert_eq!(route.op, Op::RemoveWorkItemLabels); | |
| 1542 | + | assert_eq!(input, json!({ "number": 7, "workspace": "acme" })); | |
| 1543 | + | let (route, _) = resolve("POST", "/workspaces/acme/labels/defaults", &[], Value::Null).unwrap(); | |
| 1551 | 1544 | assert_eq!(route.op, Op::AddDefaultLabels); | |
| 1552 | − | let (route, input) = resolve("PATCH", "/repos/acme/web/milestones/3", &[], json!({ "state": "closed" })).unwrap(); | |
| 1545 | + | let (route, input) = resolve("PATCH", "/workspaces/acme/milestones/3", &[], json!({ "state": "closed" })).unwrap(); | |
| 1553 | 1546 | assert_eq!(route.op, Op::UpdateMilestone); | |
| 1554 | − | assert_eq!(input, json!({ "state": "closed", "milestone": 3, "repo": "acme/web" })); | |
| 1547 | + | assert_eq!(input, json!({ "state": "closed", "milestone": 3, "workspace": "acme" })); | |
| 1548 | + | // A repository's labels and milestones are its workspace's now. | |
| 1549 | + | assert!(resolve("GET", "/repos/acme/web/labels", &[], Value::Null).is_none()); | |
| 1550 | + | assert!(resolve("GET", "/repos/acme/web/milestones", &[], Value::Null).is_none()); | |
| 1555 | 1551 | let (route, input) = resolve("PATCH", "/repos/acme/web/pulls/9", &[], json!({ "base": "release" })).unwrap(); | |
| 1556 | 1552 | assert_eq!(route.op, Op::UpdatePullRequest); | |
| 1557 | 1553 | assert_eq!(input, json!({ "base": "release", "number": 9, "repo": "acme/web" })); | |
| ⋯ | |||
| 1581 | 1577 | assert_eq!(input, json!({ "body": "Better", "comment_id": "cc_1", "repo": "acme/web" })); | |
| 1582 | 1578 | let (route, _) = resolve("DELETE", "/repos/acme/web/comments/cc_1", &[], Value::Null).unwrap(); | |
| 1583 | 1579 | assert!(route.op == Op::DeleteCommitComment && route.no_content()); | |
| 1584 | − | // An issue's comments keep theirs. | |
| 1585 | − | assert_eq!(op("PATCH", "/repos/acme/web/issues/comments/cmt_1"), Op::EditComment); | |
| 1580 | + | // A pull request's comments keep theirs. | |
| 1581 | + | assert_eq!(op("PATCH", "/repos/acme/web/pulls/comments/cmt_1"), Op::EditComment); | |
| 1586 | 1582 | } | |
| 1587 | 1583 | ||
| 1588 | 1584 | #[test] | |
| ⋯ | |||
| 1596 | 1592 | assert_eq!(route.op, Op::UpdatePullRequest); | |
| 1597 | 1593 | assert_eq!(input, json!({ "state": "open", "number": 9, "repo": "acme/web" })); | |
| 1598 | 1594 | let (route, input) = | |
| 1599 | − | resolve("PATCH", "/repos/acme/web/issues/comments/cmt_1", &[], json!({ "body": "Better" })).unwrap(); | |
| 1595 | + | resolve("PATCH", "/repos/acme/web/pulls/comments/cmt_1", &[], json!({ "body": "Better" })).unwrap(); | |
| 1600 | 1596 | assert_eq!(route.op, Op::EditComment); | |
| 1601 | 1597 | assert_eq!(input, json!({ "body": "Better", "comment_id": "cmt_1", "repo": "acme/web" })); | |
| 1602 | − | let (route, input) = resolve("DELETE", "/repos/acme/web/issues/comments/cmt_1", &[], Value::Null).unwrap(); | |
| 1598 | + | let (route, input) = resolve("DELETE", "/repos/acme/web/pulls/comments/cmt_1", &[], Value::Null).unwrap(); | |
| 1603 | 1599 | assert_eq!(route.op, Op::DeleteComment); | |
| 1604 | 1600 | assert_eq!(input, json!({ "comment_id": "cmt_1", "repo": "acme/web" })); | |
| 1605 | − | // Still an issue's comments, labels and subscription. | |
| 1606 | − | assert_eq!(op("POST", "/repos/acme/web/issues/7/comments"), Op::AddComment); | |
| 1607 | − | assert_eq!(op("POST", "/repos/acme/web/issues/7/reactions"), Op::React); | |
| 1601 | + | assert_eq!(op("POST", "/repos/acme/web/pulls/7/comments"), Op::AddComment); | |
| 1602 | + | assert_eq!(op("POST", "/repos/acme/web/pulls/7/reactions"), Op::React); | |
| 1608 | 1603 | assert_eq!(op("POST", "/repos/acme/web/attachments"), Op::UploadAttachment); | |
| 1609 | − | assert_eq!(op("DELETE", "/repos/acme/web/issues/7/labels"), Op::RemoveIssueLabels); | |
| 1610 | − | assert_eq!(op("DELETE", "/repos/acme/web/issues/7/subscription"), Op::DeleteThreadSubscription); | |
| 1604 | + | assert_eq!(op("DELETE", "/repos/acme/web/pulls/7/subscription"), Op::DeleteThreadSubscription); | |
| 1605 | + | // A work item's are in its workspace. | |
| 1606 | + | let (route, input) = | |
| 1607 | + | resolve("PATCH", "/workspaces/acme/work/comments/cmt_1", &[], json!({ "body": "Better" })).unwrap(); | |
| 1608 | + | assert_eq!(route.op, Op::EditWorkItemComment); | |
| 1609 | + | assert_eq!(input, json!({ "body": "Better", "comment_id": "cmt_1", "workspace": "acme" })); | |
| 1610 | + | assert_eq!(op("DELETE", "/workspaces/acme/work/comments/cmt_1"), Op::DeleteWorkItemComment); | |
| 1611 | + | assert_eq!(op("POST", "/workspaces/acme/work/7/comments"), Op::AddWorkItemComment); | |
| 1612 | + | assert_eq!(op("POST", "/workspaces/acme/work/7/reactions"), Op::ReactToWorkItem); | |
| 1613 | + | assert_eq!(op("DELETE", "/workspaces/acme/work/7/subscription"), Op::DeleteThreadSubscription); | |
| 1614 | + | } | |
| 1615 | + | ||
| 1616 | + | #[test] | |
| 1617 | + | fn work_items_are_addressed_by_workspace_and_number() { | |
| 1618 | + | let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op; | |
| 1619 | + | let query = [("state".to_owned(), "open".to_owned()), ("repo".to_owned(), "acme/web".to_owned()), ("role".to_owned(), "target".to_owned())]; | |
| 1620 | + | let (route, input) = resolve("GET", "/workspaces/acme/work", &query, Value::Null).unwrap(); | |
| 1621 | + | assert_eq!(route.op, Op::ListWorkItems); | |
| 1622 | + | assert_eq!(input, json!({ "state": "open", "repo": "acme/web", "role": "target", "workspace": "acme" })); | |
| 1623 | + | assert_eq!(op("POST", "/workspaces/acme/work"), Op::OpenWorkItem); | |
| 1624 | + | assert_eq!(op("POST", "/workspaces/acme/work/import"), Op::ImportWorkItems); | |
| 1625 | + | let (route, input) = resolve("GET", "/workspaces/acme/work/12", &[], Value::Null).unwrap(); | |
| 1626 | + | assert_eq!(route.op, Op::GetWorkItem); | |
| 1627 | + | assert_eq!(input, json!({ "number": 12, "workspace": "acme" })); | |
| 1628 | + | assert_eq!(op("PATCH", "/workspaces/acme/work/12"), Op::UpdateWorkItem); | |
| 1629 | + | assert_eq!(op("POST", "/workspaces/acme/work/12/close"), Op::CloseWorkItem); | |
| 1630 | + | assert_eq!(op("POST", "/workspaces/acme/work/12/reopen"), Op::ReopenWorkItem); | |
| 1631 | + | assert_eq!(op("GET", "/workspaces/acme/work/12/links"), Op::ListWorkItemLinks); | |
| 1632 | + | assert_eq!(op("POST", "/workspaces/acme/work/12/links"), Op::LinkWorkItem); | |
| 1633 | + | let (route, input) = resolve("DELETE", "/workspaces/acme/work/12/links", &[("repo".to_owned(), "acme/api".to_owned())], Value::Null).unwrap(); | |
| 1634 | + | assert_eq!(route.op, Op::UnlinkWorkItem); | |
| 1635 | + | assert_eq!(input, json!({ "repo": "acme/api", "number": 12, "workspace": "acme" })); | |
| 1636 | + | assert_eq!(op("GET", "/workspaces/acme/work/12/decisions"), Op::ListWorkItemDecisions); | |
| 1637 | + | assert_eq!(op("GET", "/repos/acme/web/work"), Op::ListRepoWorkItems); | |
| 1638 | + | let (route, input) = resolve("GET", "/repos/acme/web/refs/7", &[], Value::Null).unwrap(); | |
| 1639 | + | assert_eq!(route.op, Op::ResolveRef); | |
| 1640 | + | assert_eq!(input, json!({ "number": 7, "repo": "acme/web" })); | |
| 1641 | + | assert_eq!(op("GET", "/workspaces/acme/refs/7"), Op::ResolveRef); | |
| 1642 | + | // The repository's issues are gone, with no aliases. | |
| 1643 | + | for path in ["/repos/acme/web/issues", "/repos/acme/web/issues/7", "/repos/acme/web/issues/7/comments"] { | |
| 1644 | + | assert!(resolve("GET", path, &[], Value::Null).is_none(), "{path}"); | |
| 1645 | + | } | |
| 1611 | 1646 | } | |
| 1612 | 1647 | ||
| 1613 | 1648 | #[test] | |
| ⋯ | |||
| 1719 | 1754 | ||
| 1720 | 1755 | #[test] | |
| 1721 | 1756 | fn method_and_shape_must_match() { | |
| 1722 | − | assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none()); | |
| 1757 | + | assert!(resolve("GET", "/workspaces/a/work/1/close", &[], Value::Null).is_none()); | |
| 1723 | 1758 | assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none()); | |
| 1724 | − | assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none()); | |
| 1759 | + | assert!(resolve("GET", "/workspaces/a/work/1/extra", &[], Value::Null).is_none()); | |
| 1725 | 1760 | assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some()); | |
| 1726 | 1761 | } | |
| 1727 | 1762 | ||
| 152 | 152 | TelemetryOp::ListLogs => "Search a project's logs, newest first. q finds lines holding all of its words, or a phrase in quotes. level narrows to levels (trace, debug, info, warn, error, fatal, comma-separated); trace_id and request_id to one trace's or one request's lines; environment, service and deployment_id to where they ran; from and to to a period. Each line has its time_us, severity, body, attributes, trace_id, span_id and request_id. A page holds limit; pass next back as cursor for the next. Needs a role on the project's repository.", | |
| 153 | 153 | TelemetryOp::ListTraces => "A project's traces, newest first: each its root span's name, start_us, duration_us, span_count, error_count, the services it crossed and its HTTP status. Filter by words in q, errors_only, min_duration_ms, environment and service, between from and to. A page holds limit; pass next back as cursor for the next. Needs a role on the project's repository.", | |
| 154 | 154 | TelemetryOp::GetTrace => "One trace by its id (32 hex characters): every span, with its parent, kind, timing, status, attributes, resource, events and links, and the log lines written under it. Needs a role on the project's repository.", | |
| 155 | − | TelemetryOp::ListErrors => "A project's errors, grouped: the same exception (its name, its message with numbers and ids taken out, and the top frame of its stack) is one group, with its count, first and last time seen, the deployment it was first seen on (what likely brought it in), its last request and trace, its state (open, resolved or ignored) and the issue made for it. Most recently seen first, at most 200. Filter by state, environment and service, between from and to. Needs a role on the project's repository.", | |
| 156 | − | TelemetryOp::UpdateError => "Resolve, ignore or reopen an error group (state), or link it to the issue made for it (issue_number; null unlinks it). A resolved group that happens again opens again. Needs the Triage role or higher.", | |
| 155 | + | TelemetryOp::ListErrors => "A project's errors, grouped: the same exception (its name, its message with numbers and ids taken out, and the top frame of its stack) is one group, with its count, first and last time seen, the deployment it was first seen on (what likely brought it in), its last request and trace, its state (open, resolved or ignored) and the work item made for it. Most recently seen first, at most 200. Filter by state, environment and service, between from and to. Needs a role on the project's repository.", | |
| 156 | + | TelemetryOp::UpdateError => "Resolve, ignore or reopen an error group (state), or link it to the work item made for it (item_number; null unlinks it). A resolved group that happens again opens again. Needs the Triage role or higher.", | |
| 157 | 157 | TelemetryOp::ListMetrics => "The metrics a project's software has sent: each with its name, kind (gauge, sum, histogram or summary), unit, description, the services that send it and when it last did. Needs a role on the project's repository.", | |
| 158 | 158 | TelemetryOp::GetMetric => "One metric over time, one value per step: gauges averaged in each step (or min or max), sums added (a cumulative sum becomes its increase), histograms their mean or a percentile (p50, p95, p99) from their buckets. group_by splits it into a series per value of an attribute (or service). The last 24 hours unless from and to say. Needs a role on the project's repository.", | |
| 159 | 159 | TelemetryOp::GetSettings => "How long a project's requests, logs and spans are kept (retention_days; charts are kept longer, as per-minute counts) and whether its apps on g1t.page send telemetry (deployments). Needs the Admin role.", | |
| ⋯ | |||
| 275 | 275 | TelemetryOp::UpdateError => { | |
| 276 | 276 | add("fingerprint", text("The error group's fingerprint, as list_errors gives it.")); | |
| 277 | 277 | add("state", json!({ "type": "string", "enum": ERROR_STATES, "description": "Its state now: resolved, ignored, or open again." })); | |
| 278 | − | add("issue_number", json!({ "type": ["integer", "null"], "description": "The issue made for it; null unlinks it." })); | |
| 278 | + | add("item_number", json!({ "type": ["integer", "null"], "description": "The work item made for it, by its number in the workspace; null unlinks it." })); | |
| 279 | 279 | required.push("fingerprint"); | |
| 280 | 280 | } | |
| 281 | 281 | TelemetryOp::ListMetrics | |
| ⋯ | |||
| 509 | 509 | } | |
| 510 | 510 | out.insert("state".into(), state.into()); | |
| 511 | 511 | } | |
| 512 | − | match input.get("issue_number") { | |
| 512 | + | match input.get("item_number") { | |
| 513 | 513 | None => {} | |
| 514 | 514 | Some(Value::Null) => { | |
| 515 | − | out.insert("issue_number".into(), Value::Null); | |
| 515 | + | out.insert("item".into(), Value::Null); | |
| 516 | 516 | } | |
| 517 | 517 | Some(_) => { | |
| 518 | − | let issue = number(input, "issue_number")?.ok_or("issue_number must be a whole number, or null.")?; | |
| 519 | − | out.insert("issue_number".into(), issue.into()); | |
| 518 | + | let item = number(input, "item_number")?.ok_or("item_number must be a whole number, or null.")?; | |
| 519 | + | out.insert("item".into(), item.into()); | |
| 520 | 520 | } | |
| 521 | 521 | } | |
| 522 | − | if !out.contains_key("state") && !out.contains_key("issue_number") { | |
| 523 | − | return Err("Give a state, or an issue_number.".to_owned()); | |
| 522 | + | if !out.contains_key("state") && !out.contains_key("item") { | |
| 523 | + | return Err("Give a state, or an item_number.".to_owned()); | |
| 524 | 524 | } | |
| 525 | 525 | "set_error" | |
| 526 | 526 | } | |
| ⋯ | |||
| 761 | 761 | ||
| 762 | 762 | #[test] | |
| 763 | 763 | fn changes_say_only_what_they_change() { | |
| 764 | − | let (method, args) = super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "issue_number": null }), None).unwrap(); | |
| 764 | + | let (method, args) = super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "item_number": null }), None).unwrap(); | |
| 765 | 765 | assert_eq!(method, "set_error"); | |
| 766 | − | assert_eq!(args.get("issue_number"), Some(&Value::Null), "null unlinks the issue"); | |
| 766 | + | assert_eq!(args.get("item"), Some(&Value::Null), "null unlinks the work item"); | |
| 767 | 767 | assert!(!args.contains_key("state")); | |
| 768 | 768 | assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1" }), None).is_err()); | |
| 769 | 769 | assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "state": "fixed" }), None).is_err()); | |
Comments
Sign in to comment.