Skip to content

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

Commit

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
syntaqxcommitted Parent7e18867Browse files
21 files+2003−8765 files not counted0/21 viewed
+33−23
11 //! `get_workspace_activity`: what happened across a workspace's
22 //! 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
44 //! opened and closed, releases, deployments and pushes, each with its
55 //! repository, who did it, its title where it has one and its address on
66 //! the site. "What shipped today?" is one call.
4646 pub const KINDS: &[(&str, &[&str])] = &[
4747 ("merged", &["pull.merged"]),
4848 ("pulls", &["pull.opened", "pull.merged", "pull.closed"]),
49− ("issues", &["issue.opened", "issue.closed"]),
49+ ("work_items", &["work_item.opened", "work_item.closed"]),
5050 ("releases", &["release.published"]),
5151 ("deployments", &["deployment.succeeded", "deployment.failed"]),
5252 ("pushes", &["git.push"]),
6868
6969 /// What is read when no kinds are given: everything but comments, which
7070 /// 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"];
7272
7373 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."
7575 }
7676
7777 pub fn input() -> Value {
8484 "kinds": {
8585 "type": "array",
8686 "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.",
8888 },
8989 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." },
9090 },
202202 shown["title"] = json!(title);
203203 }
204204 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);
207207 }
208208 if event.kind == "pull.merged" {
209209 merged += 1;
210210 }
211211 }
212− "issue.opened" | "issue.closed" => {
212+ "work_item.opened" | "work_item.closed" => {
213213 let Some(number) = number else { continue };
214214 shown["number"] = json!(number);
215215 if let Some(title) = text(data, "title") {
218218 if let Some(reason) = text(data, "reason") {
219219 shown["reason"] = json!(reason);
220220 }
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+ }
223230 }
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}"));
229233 }
230234 "release.published" => {
231235 let tag = text(data, "tagName").unwrap_or_default();
280284 }
281285 }
282286 "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 {
284288 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+ };
286295 }
287296 if let Some(verdict) = text(data, "verdict") {
288297 shown["verdict"] = json!(verdict);
889898 let events = [
890899 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" })),
891900 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 })),
893902 event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })),
894903 event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })),
895904 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 } })),
897906 // Outside the span, and a repository the caller cannot read.
898907 event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })),
899908 event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })),
926935 assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0");
927936 assert_eq!(
928937 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 })
930939 );
931940 assert_eq!(listed[3]["default_branch"], true);
932941 assert_eq!(listed[3]["actor"], "bo");
935944 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 })
936945 );
937946 assert_eq!(listed[5]["resolved_by"], 7);
947+ assert_eq!(listed[5]["url"], "https://g1t.sh/acme/-/work/3");
938948 assert_eq!(shown["complete"], true);
939949 }
940950
941951 #[test]
942− fn an_issue_a_commit_closed_names_the_commit() {
952+ fn a_work_item_a_commit_closed_names_the_commit() {
943953 let repos = [repo("rep_1", "web")];
944954 let events = [event(
945− "issue.closed",
955+ "work_item.closed",
946956 "rep_1",
947957 "2026-10-12T10:00:00Z",
948958 "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" } }),
950960 )];
951961 let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned())]);
952962 let shown = shape(&Found {
+5−4
133133 pub target: String,
134134 pub branch: Option<String>,
135135 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>,
137138 pub error: Option<String>,
138139 pub updated_at: String,
139140 }
145146 target: update.target,
146147 branch: update.branch,
147148 pull: update.pull,
148− issue: update.issue,
149+ item: update.item,
149150 error: update.error,
150151 updated_at: update.updated_at,
151152 }
278279 "severity": "high",
279280 "fixedVersion": null,
280281 "status": "open",
281− "issue": null,
282+ "item": null,
282283 "foundAt": "2026-10-01T12:00:00Z",
283284 "fixedAt": null,
284285 "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" },
286287 }))
287288 .unwrap()
288289 }
+6−6
346346
347347 #[test]
348348 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);
350350 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();
353353 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);
357357 }
358358
359359 #[test]
+147−110
11 //! 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).
44 //!
55 //! 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.
1012
1113 use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs};
1214 use g1t_contracts::work::*;
1416 use serde_json::{Value, json};
1517 use worker::Result;
1618
17−use crate::operations::{Services, repo_path};
19+use g1t_contracts::repos::RepoPath;
20+
21+use crate::operations::{Services, item_workspace, repo_path};
1822
19−/// One operation on sessions, claims or an issue's context.
23+/// One operation on sessions, claims or a work item's context.
2024 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2125 pub enum CoordinationOp {
2226 StartSession,
2529 GetSession,
2630 ListSessions,
2731 RecordSessionEntries,
28− ClaimIssue,
32+ ClaimWorkItem,
2933 ReleaseClaim,
30− HandOffIssue,
34+ HandOffWorkItem,
3135 ListClaims,
3236 GetWork,
3337 StartAgentSession,
4347 CoordinationOp::GetSession,
4448 CoordinationOp::ListSessions,
4549 CoordinationOp::RecordSessionEntries,
46− CoordinationOp::ClaimIssue,
50+ CoordinationOp::ClaimWorkItem,
4751 CoordinationOp::ReleaseClaim,
48− CoordinationOp::HandOffIssue,
52+ CoordinationOp::HandOffWorkItem,
4953 CoordinationOp::ListClaims,
5054 CoordinationOp::GetWork,
5155 CoordinationOp::StartAgentSession,
5963 CoordinationOp::GetSession => "get_session",
6064 CoordinationOp::ListSessions => "list_sessions",
6165 CoordinationOp::RecordSessionEntries => "record_session_entries",
62− CoordinationOp::ClaimIssue => "claim_issue",
66+ CoordinationOp::ClaimWorkItem => "claim_work_item",
6367 CoordinationOp::ReleaseClaim => "release_claim",
64− CoordinationOp::HandOffIssue => "hand_off_issue",
68+ CoordinationOp::HandOffWorkItem => "hand_off_work_item",
6569 CoordinationOp::ListClaims => "list_claims",
6670 CoordinationOp::GetWork => "get_work",
6771 CoordinationOp::StartAgentSession => "start_agent_session",
7781 CoordinationOp::GetSession => "Get a session",
7882 CoordinationOp::ListSessions => "List sessions",
7983 CoordinationOp::RecordSessionEntries => "Record entries in a session",
80− CoordinationOp::ClaimIssue => "Claim an issue",
84+ CoordinationOp::ClaimWorkItem => "Claim a work item",
8185 CoordinationOp::ReleaseClaim => "Release a claim",
82− CoordinationOp::HandOffIssue => "Hand off an issue",
86+ CoordinationOp::HandOffWorkItem => "Hand off a work item",
8387 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",
8690 }
8791 }
8892
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+
89108 /// Whether it only reads.
90109 pub fn reads(self) -> bool {
91110 matches!(
96115
97116 pub fn description(self) -> &'static str {
98117 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.",
111130 }
112131 }
113132
114133 pub fn input(self) -> Value {
115134 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
116135 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." });
119140 let pull = json!({ "type": "integer", "description": "The pull request it works on." });
120141 let fields = |more: Value| -> Value {
121142 let mut properties = json!({
122143 "repo": repo,
123− "issue": issue,
144+ "item": item,
124145 "pull": pull,
125146 "branch": { "type": "string", "description": "The branch it works on." },
126147 "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." },
131152 }
132153 properties
133154 };
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." });
135156 let subagent = json!({
136157 "agent_type": { "type": "string", "description": "For a subagent: its kind, as the harness names it (Explore, security-reviewer)." },
137158 "task": { "type": "string", "description": "For a subagent: the task it was given, in a few words. Up to 200 characters." },
187208 json!({
188209 "repo": repo,
189210 "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." },
191212 "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." },
192213 "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." },
193214 "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." },
220241 "items": {
221242 "type": "object",
222243 "properties": {
223− "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "issue"] },
244+ "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "item"] },
224245 "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." },
225246 },
226247 "required": ["kind", "value"],
229250 }),
230251 &["repo", "id"],
231252 ),
232− CoordinationOp::ClaimIssue => (
253+ CoordinationOp::ClaimWorkItem => (
233254 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." },
239261 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
240262 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
241263 }),
242− &["repo"],
264+ &[],
243265 ),
244266 CoordinationOp::ReleaseClaim => (
245267 json!({
246− "repo": repo,
268+ "workspace": workspace,
269+ "repo": checkout,
247270 "number": number,
248271 "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
249272 "session_id": session_id,
250273 }),
251− &["repo", "number"],
274+ &["number"],
252275 ),
253− CoordinationOp::HandOffIssue => (
276+ CoordinationOp::HandOffWorkItem => (
254277 json!({
255− "repo": repo,
278+ "workspace": workspace,
279+ "repo": checkout,
256280 "number": number,
257281 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
258282 "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
259283 "session_id": session_id,
260284 }),
261− &["repo", "number", "note"],
285+ &["number", "note"],
262286 ),
263287 CoordinationOp::ListClaims => (
264288 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." },
266291 "state": {
267292 "type": "string",
268293 "enum": ["active", "released", "expired", "handed_off"],
269294 "description": "Claims in this state; active if left out.",
270295 },
271− "issue": { "type": "integer", "description": "Only claims on this issue." },
296+ "item": { "type": "integer", "description": "Only claims on this work item." },
272297 }),
273− &["repo"],
298+ &[],
274299 ),
275− CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]),
300+ CoordinationOp::GetWork => (json!({ "workspace": workspace, "repo": checkout, "number": number }), &["number"]),
276301 CoordinationOp::StartAgentSession => (
277302 json!({
278303 "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." },
283308 "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)." },
284309 "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." },
285310 }),
312337 }
313338 }
314339
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.
317343 pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
318344 let items = match &input["blocked_by"] {
319345 Value::Null => return Ok(None),
320346 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()),
322348 };
323349 items
324350 .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.")))
326352 .collect::<std::result::Result<Vec<u32>, String>>()
327353 .map(Some)
328354 }
329355
330356 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() },
333362 };
334363 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),
337372 (Err(message), _) | (_, Err(message)) => return invalid(message),
338373 };
339374 let actor = || viewer.clone().unwrap_or_default();
340375 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();
342377 let work = &services.work;
343378 if matches!(
344379 op,
358393 repo,
359394 harness,
360395 harness_session_id: text(input, "harness_session_id"),
361− issue,
396+ item,
362397 pull,
363398 branch: text(input, "branch"),
364399 title: text(input, "title"),
376411 actor: actor(),
377412 repo,
378413 id: id(),
379− issue,
414+ item,
380415 pull,
381416 branch: text(input, "branch"),
382417 head_commit: text(input, "head_commit"),
406441 },
407442 };
408443 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") };
410445 g1t_kit::call(work, "list_work_sessions", &args).await
411446 }
412447 CoordinationOp::RecordSessionEntries => {
426461 touched => touched.clone(),
427462 }) {
428463 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()),
430465 };
431466 let acknowledge: Vec<String> = match serde_json::from_value(input.get("acknowledge").cloned().unwrap_or(json!([]))) {
432467 Ok(ids) => ids,
435470 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched, acknowledge };
436471 g1t_kit::call(work, "record_session_entries", &args).await
437472 }
438− CoordinationOp::ClaimIssue => {
473+ CoordinationOp::ClaimWorkItem => {
439474 let ttl_minutes = match optional_number(input, "ttl_minutes") {
440475 Ok(minutes) => minutes,
441476 Err(message) => return invalid(message),
442477 };
443478 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());
446481 }
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());
449484 }
450485 let args = ClaimArgs {
451486 actor: actor(),
452− repo,
453− number: issue_number,
487+ workspace,
488+ repo: given,
489+ number: item_number,
454490 title,
455491 body: text(input, "body"),
456492 session_id: text(input, "session_id"),
457493 note: text(input, "note"),
458494 ttl_minutes,
459495 };
460− g1t_kit::call(work, "claim_issue", &args).await
496+ g1t_kit::call(work, "claim_work_item", &args).await
461497 }
462498 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") };
464500 g1t_kit::call(work, "release_claim", &args).await
465501 }
466− CoordinationOp::HandOffIssue => {
502+ CoordinationOp::HandOffWorkItem => {
467503 let Some(note) = text(input, "note") else {
468504 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
469505 };
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
472508 }
473509 CoordinationOp::ListClaims => {
474510 let state = match text(input, "state") {
478514 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
479515 },
480516 };
481− let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
517+ let args = ListClaimsArgs { viewer: viewer.clone(), workspace, repo: given, state, item };
482518 g1t_kit::call(work, "list_claims", &args).await
483519 }
484520 CoordinationOp::GetWork => {
485− let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
521+ let args = WorkItemViewArgs { workspace, number: item_number, viewer: viewer.clone() };
486522 g1t_kit::call(work, "get_work", &args).await
487523 }
488524 CoordinationOp::StartAgentSession => {
493529 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
494530 },
495531 };
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());
498534 }
499535 let handoff = match &input["handoff"] {
500536 Value::Bool(handoff) => Some(*handoff),
505541 let args = StartAgentSessionArgs {
506542 actor: actor(),
507543 repo,
508− issue,
544+ item,
509545 pull: None,
510546 asked: None,
511547 title: text(input, "title"),
531567 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
532568 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
533569 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());
535571 let scope = scope_for(op.name()).unwrap();
536572 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
537573 let sessions = op.name().contains("session");
538574 let expected = match (sessions, op.reads()) {
539575 (true, true) => Scope::PullRequestsRead,
540576 (true, false) => Scope::PullRequestsWrite,
541− (false, true) => Scope::IssuesRead,
542− (false, false) => Scope::IssuesWrite,
577+ (false, true) => Scope::WorkRead,
578+ (false, false) => Scope::WorkWrite,
543579 };
544580 assert_eq!(scope, expected, "{}", op.name());
545581 }
564600 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
565601 assert_eq!(op, coordination(CoordinationOp::GetSession));
566602 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);
568604 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" }));
570606 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
571607 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!({}));
580616 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);
584620 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);
586623 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" }));
588625 }
589626
590627 #[test]
594631 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
595632 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
596633 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)));
601638 }
602639
603640 #[test]
607644 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
608645 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
609646 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());
612649 }
613650 }
+2−2
126126 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).",
127127 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.",
128128 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.",
130130 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.",
131131 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.",
132132 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.",
203203 "workspace": workspace,
204204 "query": {
205205 "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).",
207207 },
208208 }),
209209 vec!["workspace", "query"],
+1−1
8181 for item in list(&input["numbers"]) {
8282 match item.parse::<u32>() {
8383 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.")),
8585 }
8686 }
8787 numbers.sort_unstable();
+11−8
172172 fn index(addresses: &addresses::Addresses) -> Value {
173173 let api = &addresses.api;
174174 let repo = format!("{api}/repos/{{owner}}/{{name}}");
175+ let workspace = format!("{api}/workspaces/{{workspace}}");
175176 json!({
176177 "documentation_url": "https://docs.g1t.sh/reference/api/",
177178 "openapi_url": format!("{api}/openapi.json"),
182183 "search_url": format!("{api}/search{{?q,type,page,per_page}}"),
183184 "repository_url": repo,
184185 "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}}"),
189192 "pulls_url": format!("{repo}/pulls{{?state}}"),
190193 "pull_url": format!("{repo}/pulls/{{number}}"),
191194 "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"),
197200 "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site),
198201 "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"),
199202 "context_url": format!("{repo}/context{{?reference}}"),
200− "import_issue_url": format!("{repo}/issues/import"),
203+ "import_work_items_url": format!("{workspace}/work/import"),
201204 "hooks_url": format!("{api}/hooks/{{integration}}"),
202205 })
203206 }
297300 })
298301 }
299302
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
301304 /// request's checks were the workflows run on it.
302305 /// The run's own token, in the body, is the credential: it was given to
303306 /// that sandbox and to nothing else.
838841 #[test]
839842 fn snake_case_wins_when_both_are_given() {
840843 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 })
843846 );
844847 }
845848 }
+7−7
1010
1111 const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
1212
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.
1818 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.
2020 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.
2121 Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs.";
2222
139139 .collect();
140140 let mut card = json!({
141141 "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.",
143143 "endpoint": addresses.mcp,
144144 "transport": "streamable-http",
145145 "protocol_versions": SUPPORTED_VERSIONS,
+22−5
11 //! 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
33 //! service keeps all of it (`g1t_contracts::inbox`); this is its public
44 //! shape, which follows the inbox's own: a thread is one item about one
55 //! thing, brought back to the top as things happen to it.
1111 use g1t_contracts::inbox::*;
1212 use g1t_contracts::repos::{GetArgs, Repo, RepoPath};
1313 use g1t_contracts::time::{parse_rfc3339, rfc3339};
14+use g1t_contracts::work::{WorkItemDetail, WorkItemViewArgs};
1415 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
1516 use serde_json::{Value, json};
1617 use worker::Result;
2627 }
2728
2829 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.";
3031
3132 fn text(input: &Value, key: &str) -> Option<String> {
3233 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
169170 })
170171 }
171172
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.
174177 async fn subscription_args(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<SubscriptionArgs>> {
175178 if let Some(id) = text(input, "id") {
176179 return Ok(Outcome::Ok(SubscriptionArgs {
179182 ..SubscriptionArgs::default()
180183 }));
181184 }
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+ }
182198 let (Some(found), Some(number)) = (readable_repo(services, viewer, input).await?, whole(input, "number")) else {
183199 return Ok(Outcome::fail(FailureCode::Invalid, NO_SUBJECT));
184200 };
188204 id: None,
189205 repo_id: Some(repo.id),
190206 number: Some(number),
207+ item_id: None,
191208 }),
192209 Outcome::Fail(failure) => Outcome::Fail(failure),
193210 })
335352 };
336353 match found {
337354 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."),
339356 }
340357 }
341358 Op::GetRepoSubscription | Op::SetRepoSubscription | Op::DeleteRepoSubscription => {
+98−69
3737 ),
3838 (
3939 "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.",
4141 &[
4242 Op::ListNotifications,
4343 Op::MarkNotificationsRead,
362362 ],
363363 ),
364364 (
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.",
367367 &[
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,
378384 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,
389390 Op::Coordination(CoordinationOp::GetWork),
390− Op::Coordination(CoordinationOp::ClaimIssue),
391+ Op::Coordination(CoordinationOp::ClaimWorkItem),
391392 Op::Coordination(CoordinationOp::ReleaseClaim),
392− Op::Coordination(CoordinationOp::HandOffIssue),
393+ Op::Coordination(CoordinationOp::HandOffWorkItem),
393394 Op::Coordination(CoordinationOp::ListClaims),
394395 ],
395396 ),
396397 (
397398 "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.",
399400 &[
400401 Op::ListLabels,
401402 Op::CreateLabel,
411412 ),
412413 (
413414 "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.",
415416 &[
416417 Op::ListPullRequests,
417418 Op::CreatePullRequest,
433434 Op::ClosePullRequest,
434435 Op::ReopenPullRequest,
435436 Op::GetMergeQueue,
437+ Op::AddComment,
438+ Op::EditComment,
439+ Op::DeleteComment,
440+ Op::React,
436441 ],
437442 ),
438443 (
439444 "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.",
441446 &[
442447 Op::Coordination(CoordinationOp::StartSession),
443448 Op::Coordination(CoordinationOp::UpdateSession),
452457 ),
453458 (
454459 "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.",
456461 &[Op::ListDecisions, Op::RecordDecision, Op::Why],
457462 ),
458463 (
459464 "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.",
461466 &[Op::Search],
462467 ),
463468 (
619624 ),
620625 (
621626 "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).",
623628 &[
624629 Op::ListIntegrations,
625630 Op::ConnectIntegration,
627632 Op::DisconnectIntegration,
628633 Op::TestIntegration,
629634 Op::GetContext,
630− Op::ImportIssue,
631635 ],
632636 ),
633637 (
729733 Op::ListCheckNames => "List check names",
730734 Op::GetMergeQueue => "Get the merge queue",
731735 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",
738747 Op::ListLabels => "List labels",
739748 Op::CreateLabel => "Create a label",
740749 Op::UpdateLabel => "Update a label",
741750 Op::DeleteLabel => "Delete a label",
742751 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",
747756 Op::ListMilestones => "List milestones",
748757 Op::GetMilestone => "Get a milestone",
749758 Op::CreateMilestone => "Create a milestone",
750759 Op::UpdateMilestone => "Update a milestone",
751760 Op::DeleteMilestone => "Delete a milestone",
752761 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",
757771 Op::UploadAttachment => "Upload an attachment",
758772 Op::ReviewPullRequest => "Review a pull request",
759773 Op::ListPullRequests => "List pull requests",
795809 Op::GetAskSettings => "Get Ask settings",
796810 Op::SetAskSettings => "Set Ask settings",
797811 Op::GetContext => "Look up a ticket",
798− Op::ImportIssue => "Import an issue",
812+ Op::ImportWorkItems => "Import a work item",
799813 Op::ListWebhooks => "List webhooks",
800814 Op::CreateWebhook => "Create a webhook",
801815 Op::UpdateWebhook => "Update a webhook",
9891003 ("GET", ":attempt") => "get_workflow_run_attempt".to_owned(),
9901004 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
9911005 ("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.
9941008 (_, "notifications") if route.path.starts_with("/repos/") => match op {
9951009 Op::ListNotifications => "list_repo_notifications".to_owned(),
9961010 _ => "mark_repo_notifications_read".to_owned(),
9971011 },
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(),
10021016 },
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(),
10051024 ("DELETE", "saved") => "unsave_thread".to_owned(),
10061025 ("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(),
10111028 _ => op.name().to_owned(),
10121029 };
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+ {
10141035 format!("{base}_for_workspace")
10151036 } else {
10161037 base
10311052 "list_runs_of_workflow" => "List a workflow's runs",
10321053 "list_repo_notifications" => "List a repository's notifications",
10331054 "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",
10371062 "unsave_thread" => "Unsave a thread",
10381063 "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",
10411065 _ => title(route.op),
10421066 };
10431067 if id.ends_with("_for_workspace") {
10541078 // so a `name` in the body, such as a check run's, is the body's own.
10551079 let stands_for_repo =
10561080 |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));
10581087 let all_properties = op.properties();
10591088 let mut properties = all_properties.clone();
10601089 properties.retain(|name, _| !covered(name));
13871416 "message": { "type": "string" },
13881417 "needed_scope": {
13891418 "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`.",
13911420 },
13921421 },
13931422 },
14511480 let document = document();
14521481 let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
14531482 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());
14551484 assert!(body.get("repo").is_none() && body.get("number").is_none());
14561485 let list = &document["paths"]["/repos"]["get"];
14571486 assert_eq!(list["parameters"][0]["name"], "q");
14711500
14721501 #[test]
14731502 fn titles_read_as_sentences() {
1474− assert_eq!(title(Op::CreateIssue), "Create an issue");
1503+ assert_eq!(title(Op::OpenWorkItem), "Open a work item");
14751504 assert_eq!(title(Op::Whoami), "Get the current user");
14761505 }
14771506
+639−272
A large diff (1,533 lines) is not shown until you ask for it.
+2−2
11 //! 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
33 //! collaborator, whoever `whoami` is), carries `display_username` beside
44 //! `username`: `username` stays the lowercased key, stable for anything
55 //! that matches on it, and `display_username` is the case its owner chose
100100 #[test]
101101 fn every_person_in_an_answer_is_found_once() {
102102 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" }] },
104104 "git": { "username": "ana", "password": "your g1t access token" },
105105 "count": 3,
106106 });
+867−244
A large diff (2,073 lines) is not shown until you ask for it.
+13−8
180180 Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent),
181181 Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent),
182182 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),
188193 Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent),
189194 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),
191196 Op::CreateLabel | Op::UpdateLabel => through::<work::Label>(op, sent),
192197 Op::ListMilestones => through::<Vec<work::Milestone>>(op, sent),
193198 Op::CreateMilestone | Op::UpdateMilestone => through::<work::Milestone>(op, sent),
264269 Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent),
265270 Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent),
266271 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),
269274 Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent),
270275 Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent),
271276 Op::Coordination(CoordinationOp::StartAgentSession) => through::<g1t_contracts::agent_sessions::AgentSessionStarted>(op, sent),
+139−104
123123 route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]),
124124 route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]),
125125 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, &[]),
129132 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
130133 // Stars: yours, and who starred a repository.
131134 route("GET", "/user/starred", Op::About(AboutOp::ListStarred), &[]),
583586 ("wait", "wait"),
584587 ],
585588 ),
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.
601602 route(
602603 "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")],
606607 ),
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), &[]),
646641 route(
647642 "GET",
648− "/repos/:owner/:name/claims",
643+ "/workspaces/:workspace/claims",
649644 Op::Coordination(CoordinationOp::ListClaims),
650− &[("state", "state"), ("issue", "issue")],
645+ &[("state", "state"), ("item", "item"), ("repo", "repo")],
651646 ),
652647 route("POST", "/repos/:owner/:name/sessions", Op::Coordination(CoordinationOp::StartSession), &[]),
653648 route(
654649 "GET",
655650 "/repos/:owner/:name/sessions",
656651 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")],
658653 ),
659654 route("POST", "/repos/:owner/:name/agent-sessions", Op::Coordination(CoordinationOp::StartAgentSession), &[]),
660655 route("GET", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::GetSession), &[("after", "after")]),
10601055 route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]),
10611056 route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]),
10621057 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.
10731064 route("POST", "/repos/:owner/:name/attachments", Op::UploadAttachment, &[]),
10741065 route(
10751066 "GET",
11311122 Op::RecordSession,
11321123 &[],
11331124 ),
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.
11361127 route("GET", "/repos/:owner/:name/pulls/:number/decisions", Op::ListDecisions, &[]),
11371128 route("POST", "/repos/:owner/:name/pulls/:number/decisions", Op::RecordDecision, &[]),
1138− route("GET", "/repos/:owner/:name/issues/:number/decisions", Op::ListDecisions, &[]),
11391129 route("GET", "/repos/:owner/:name/why", Op::Why, &[("path", "path"), ("line", "line"), ("ref", "ref")]),
11401130 route(
11411131 "POST",
11961186 pub fn no_content(&self) -> bool {
11971187 matches!(
11981188 self.op,
1199− Op::DeleteComment | Op::DeleteCommitComment | Op::Caches(CachesOp::DeleteCache)
1189+ Op::DeleteComment | Op::DeleteWorkItemComment | Op::DeleteCommitComment | Op::Caches(CachesOp::DeleteCache)
12001190 )
12011191 }
12021192
13301320 input.remove("until");
13311321 }
13321322 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.
13341324 input.insert(
13351325 "number".to_owned(),
13361326 number.parse::<u32>().unwrap_or(0).into(),
13961386 "POST",
13971387 "/repos/flagon-io/hello/pulls/14/merge",
13981388 &[],
1399− json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
1389+ json!({ "keep_item_open": true, "number": 99, "repo": "someone/else" }),
14001390 )
14011391 .unwrap();
14021392 assert_eq!(route.op, Op::MergePullRequest);
14031393 // What the path names wins over the body.
14041394 assert_eq!(
14051395 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" })
14071397 );
14081398 }
14091399
15161506 }
15171507
15181508 #[test]
1519− fn notifications_are_addressed_as_threads_and_by_issue() {
1509+ fn notifications_are_addressed_as_threads_and_by_number() {
15201510 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
15211511 assert_eq!(route.op, Op::MarkThreadDone);
15221512 assert_eq!(input, json!({ "id": "ntf_1" }));
15301520 let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap();
15311521 assert_eq!(route.op, Op::ListNotifications);
15321522 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();
15341527 assert_eq!(route.op, Op::SetThreadSubscription);
15351528 assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" }));
15361529 assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos);
15381531
15391532 #[test]
15401533 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();
15421535 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();
15511544 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();
15531546 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());
15551551 let (route, input) = resolve("PATCH", "/repos/acme/web/pulls/9", &[], json!({ "base": "release" })).unwrap();
15561552 assert_eq!(route.op, Op::UpdatePullRequest);
15571553 assert_eq!(input, json!({ "base": "release", "number": 9, "repo": "acme/web" }));
15811577 assert_eq!(input, json!({ "body": "Better", "comment_id": "cc_1", "repo": "acme/web" }));
15821578 let (route, _) = resolve("DELETE", "/repos/acme/web/comments/cc_1", &[], Value::Null).unwrap();
15831579 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);
15861582 }
15871583
15881584 #[test]
15961592 assert_eq!(route.op, Op::UpdatePullRequest);
15971593 assert_eq!(input, json!({ "state": "open", "number": 9, "repo": "acme/web" }));
15981594 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();
16001596 assert_eq!(route.op, Op::EditComment);
16011597 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();
16031599 assert_eq!(route.op, Op::DeleteComment);
16041600 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);
16081603 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+ }
16111646 }
16121647
16131648 #[test]
17191754
17201755 #[test]
17211756 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());
17231758 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());
17251760 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
17261761 }
17271762
+11−11
152152 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.",
153153 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.",
154154 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.",
157157 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.",
158158 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.",
159159 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.",
275275 TelemetryOp::UpdateError => {
276276 add("fingerprint", text("The error group's fingerprint, as list_errors gives it."));
277277 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." }));
279279 required.push("fingerprint");
280280 }
281281 TelemetryOp::ListMetrics
509509 }
510510 out.insert("state".into(), state.into());
511511 }
512− match input.get("issue_number") {
512+ match input.get("item_number") {
513513 None => {}
514514 Some(Value::Null) => {
515− out.insert("issue_number".into(), Value::Null);
515+ out.insert("item".into(), Value::Null);
516516 }
517517 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());
520520 }
521521 }
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());
524524 }
525525 "set_error"
526526 }
761761
762762 #[test]
763763 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();
765765 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");
767767 assert!(!args.contains_key("state"));
768768 assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1" }), None).is_err());
769769 assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "state": "fixed" }), None).is_err());
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.