Merge branch 'worktree-agent-ad531323a21f4c975'
26 files+1400−2210 files not counted0/26 viewed
| 1 | + | //! `list_events`: a repository's timeline, and following it. | |
| 2 | + | //! | |
| 3 | + | //! Without `after` it is the timeline, newest first, paged back with | |
| 4 | + | //! `before`. With `after` (an event id) it is what the log wrote after that | |
| 5 | + | //! event, oldest first, and the last event's id is the cursor for the next | |
| 6 | + | //! call. With `after` and `wait`, a call that finds nothing holds on until | |
| 7 | + | //! something matching is written or `wait` seconds pass, asking the events | |
| 8 | + | //! service about once a second, so an agent with no public address can | |
| 9 | + | //! follow a repository with one request at a time instead of a webhook. | |
| 10 | + | //! | |
| 11 | + | //! Holding a request costs a Worker wall time, not CPU time: between polls | |
| 12 | + | //! it awaits a timer, which is not billed or limited as CPU. Each poll is | |
| 13 | + | //! one subrequest, so a call makes at most `MAX_WAIT_SECONDS` + 1 of them, | |
| 14 | + | //! well under the per-request limit, and the hold stays far below the time | |
| 15 | + | //! clients and proxies give an idle response. | |
| 16 | + | ||
| 17 | + | use g1t_contracts::events::{Event, ListArgs}; | |
| 18 | + | use serde_json::Value; | |
| 19 | + | use worker::{Fetcher, Result}; | |
| 20 | + | ||
| 21 | + | /// The longest a call holds on, in seconds. | |
| 22 | + | pub const MAX_WAIT_SECONDS: u64 = 25; | |
| 23 | + | /// How often a held call asks again. | |
| 24 | + | const POLL_MS: u64 = 1_000; | |
| 25 | + | /// The most events one call returns; the events service's own page. | |
| 26 | + | const MAX_LIMIT: u32 = 200; | |
| 27 | + | ||
| 28 | + | /// What a `list_events` call asks for, read from its input. | |
| 29 | + | #[derive(Debug, Default, PartialEq)] | |
| 30 | + | pub struct Asked { | |
| 31 | + | pub after: Option<String>, | |
| 32 | + | pub before: Option<String>, | |
| 33 | + | pub types: Vec<String>, | |
| 34 | + | pub numbers: Vec<u32>, | |
| 35 | + | pub since: Option<String>, | |
| 36 | + | pub limit: Option<u32>, | |
| 37 | + | /// Milliseconds to hold on; zero without `after`. | |
| 38 | + | pub wait_ms: u64, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A list given as an array or as one comma-separated string. | |
| 42 | + | fn list(value: &Value) -> Vec<String> { | |
| 43 | + | let items: Vec<String> = match value { | |
| 44 | + | Value::String(text) => text.split(',').map(str::to_owned).collect(), | |
| 45 | + | Value::Array(items) => items | |
| 46 | + | .iter() | |
| 47 | + | .map(|item| match item { | |
| 48 | + | Value::String(text) => text.clone(), | |
| 49 | + | other => other.to_string(), | |
| 50 | + | }) | |
| 51 | + | .collect(), | |
| 52 | + | Value::Number(number) => vec![number.to_string()], | |
| 53 | + | _ => Vec::new(), | |
| 54 | + | }; | |
| 55 | + | items | |
| 56 | + | .into_iter() | |
| 57 | + | .map(|item| item.trim().trim_start_matches('#').to_owned()) | |
| 58 | + | .filter(|item| !item.is_empty()) | |
| 59 | + | .collect() | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 63 | + | input[key] | |
| 64 | + | .as_str() | |
| 65 | + | .map(str::trim) | |
| 66 | + | .filter(|value| !value.is_empty()) | |
| 67 | + | .map(str::to_owned) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn whole(value: &Value) -> Option<u64> { | |
| 71 | + | match value { | |
| 72 | + | Value::Number(number) => number.as_u64().or_else(|| number.as_f64().filter(|n| *n >= 0.0).map(|n| n as u64)), | |
| 73 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 74 | + | _ => None, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Reads a call's input, or says what is wrong with it. | |
| 79 | + | pub fn asked(input: &Value) -> std::result::Result<Asked, String> { | |
| 80 | + | let mut numbers = Vec::new(); | |
| 81 | + | for item in list(&input["numbers"]) { | |
| 82 | + | match item.parse::<u32>() { | |
| 83 | + | Ok(number) if number > 0 => numbers.push(number), | |
| 84 | + | _ => return Err(format!("numbers are issue and pull request numbers, and {item} is not one.")), | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | numbers.sort_unstable(); | |
| 88 | + | numbers.dedup(); | |
| 89 | + | let mut types = list(&input["types"]); | |
| 90 | + | types.sort(); | |
| 91 | + | types.dedup(); | |
| 92 | + | let limit = match &input["limit"] { | |
| 93 | + | Value::Null => None, | |
| 94 | + | value => match whole(value) { | |
| 95 | + | Some(limit) if limit > 0 => Some(u32::try_from(limit).unwrap_or(MAX_LIMIT).min(MAX_LIMIT)), | |
| 96 | + | _ => return Err("limit is a whole number from 1 to 200.".to_owned()), | |
| 97 | + | }, | |
| 98 | + | }; | |
| 99 | + | let after = text(input, "after"); | |
| 100 | + | let wait = match &input["wait"] { | |
| 101 | + | Value::Null => 0, | |
| 102 | + | value => match whole(value) { | |
| 103 | + | Some(seconds) => seconds.min(MAX_WAIT_SECONDS), | |
| 104 | + | None => return Err(format!("wait is a number of seconds, at most {MAX_WAIT_SECONDS}.")), | |
| 105 | + | }, | |
| 106 | + | }; | |
| 107 | + | let before = text(input, "before"); | |
| 108 | + | if after.is_some() && before.is_some() { | |
| 109 | + | return Err("Give after to follow the timeline or before to page back through it, not both.".to_owned()); | |
| 110 | + | } | |
| 111 | + | Ok(Asked { | |
| 112 | + | wait_ms: if after.is_some() { wait * 1_000 } else { 0 }, | |
| 113 | + | after, | |
| 114 | + | before, | |
| 115 | + | types, | |
| 116 | + | numbers, | |
| 117 | + | since: text(input, "since"), | |
| 118 | + | limit, | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | impl Asked { | |
| 123 | + | fn args(&self, repo_id: &str) -> ListArgs { | |
| 124 | + | ListArgs { | |
| 125 | + | repo_id: Some(repo_id.to_owned()), | |
| 126 | + | types: self.types.clone(), | |
| 127 | + | before: self.before.clone(), | |
| 128 | + | after: self.after.clone(), | |
| 129 | + | numbers: self.numbers.clone(), | |
| 130 | + | since: self.since.clone(), | |
| 131 | + | limit: self.limit, | |
| 132 | + | ..ListArgs::default() | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// How long to sleep before asking again, at `now`, for a call that holds | |
| 138 | + | /// on until `deadline`: `None` when it is time to answer with nothing. | |
| 139 | + | /// The last poll falls on the deadline itself. | |
| 140 | + | pub fn next_poll(now: u64, deadline: u64) -> Option<u64> { | |
| 141 | + | if now >= deadline { | |
| 142 | + | None | |
| 143 | + | } else { | |
| 144 | + | Some(POLL_MS.min(deadline - now)) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// The events of the repository `repo_id` as asked, holding on for them | |
| 149 | + | /// when asked to. | |
| 150 | + | pub async fn list_events(events: &Fetcher, repo_id: &str, asked: &Asked) -> Result<Vec<Event>> { | |
| 151 | + | let args = asked.args(repo_id); | |
| 152 | + | let deadline = g1t_kit::now_ms() + asked.wait_ms; | |
| 153 | + | loop { | |
| 154 | + | let found: Vec<Event> = g1t_kit::call(events, "list", &args).await?; | |
| 155 | + | if !found.is_empty() { | |
| 156 | + | return Ok(found); | |
| 157 | + | } | |
| 158 | + | match next_poll(g1t_kit::now_ms(), deadline) { | |
| 159 | + | Some(ms) => worker::Delay::from(std::time::Duration::from_millis(ms)).await, | |
| 160 | + | None => return Ok(found), | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[cfg(test)] | |
| 166 | + | mod tests { | |
| 167 | + | use super::*; | |
| 168 | + | use serde_json::json; | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn lists_come_as_commas_or_arrays() { | |
| 172 | + | let a = asked(&json!({ | |
| 173 | + | "types": "work.claimed, integration.failed,,work.claimed", | |
| 174 | + | "numbers": "12,#7, 12", | |
| 175 | + | })) | |
| 176 | + | .unwrap(); | |
| 177 | + | assert_eq!(a.types, vec!["integration.failed", "work.claimed"]); | |
| 178 | + | assert_eq!(a.numbers, vec![7, 12]); | |
| 179 | + | let b = asked(&json!({ "types": ["push"], "numbers": [3, "4"] })).unwrap(); | |
| 180 | + | assert_eq!(b.types, vec!["push"]); | |
| 181 | + | assert_eq!(b.numbers, vec![3, 4]); | |
| 182 | + | assert!(asked(&json!({ "numbers": "seven" })).is_err()); | |
| 183 | + | assert!(asked(&json!({ "numbers": [0] })).is_err()); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn wait_holds_only_a_follow_and_never_past_25_seconds() { | |
| 188 | + | assert_eq!(asked(&json!({ "wait": 10 })).unwrap().wait_ms, 0, "nothing to wait for without after"); | |
| 189 | + | assert_eq!(asked(&json!({ "after": "evt_1", "wait": "10" })).unwrap().wait_ms, 10_000); | |
| 190 | + | assert_eq!(asked(&json!({ "after": "evt_1", "wait": 600 })).unwrap().wait_ms, 25_000); | |
| 191 | + | assert_eq!(asked(&json!({ "after": "evt_1" })).unwrap().wait_ms, 0); | |
| 192 | + | assert!(asked(&json!({ "after": "evt_1", "wait": "soon" })).is_err()); | |
| 193 | + | assert!(asked(&json!({ "after": "evt_1", "wait": -1 })).is_err()); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[test] | |
| 197 | + | fn limits_are_kept_to_a_page() { | |
| 198 | + | assert_eq!(asked(&json!({ "limit": "500" })).unwrap().limit, Some(200)); | |
| 199 | + | assert_eq!(asked(&json!({ "limit": 20 })).unwrap().limit, Some(20)); | |
| 200 | + | assert_eq!(asked(&json!({})).unwrap().limit, None); | |
| 201 | + | assert!(asked(&json!({ "limit": 0 })).is_err()); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn after_and_before_do_not_mix() { | |
| 206 | + | assert!(asked(&json!({ "after": "evt_2", "before": "evt_1" })).is_err()); | |
| 207 | + | let a = asked(&json!({ "after": "evt_2", "since": "2026-10-12T00:00:00Z" })).unwrap(); | |
| 208 | + | let args = a.args("rep_1"); | |
| 209 | + | assert_eq!(args.after.as_deref(), Some("evt_2")); | |
| 210 | + | assert_eq!(args.since.as_deref(), Some("2026-10-12T00:00:00Z")); | |
| 211 | + | assert_eq!(args.repo_id.as_deref(), Some("rep_1")); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn polls_come_about_once_a_second_and_the_last_on_the_deadline() { | |
| 216 | + | let start = 1_000_000; | |
| 217 | + | let deadline = start + 25_000; | |
| 218 | + | let mut now = start; | |
| 219 | + | let mut polls = 1; // the first, before any sleep | |
| 220 | + | while let Some(ms) = next_poll(now, deadline) { | |
| 221 | + | assert!(ms > 0 && ms <= 1_000); | |
| 222 | + | now += ms; | |
| 223 | + | polls += 1; | |
| 224 | + | } | |
| 225 | + | assert_eq!(now, deadline); | |
| 226 | + | assert_eq!(polls, 26, "one call makes at most 26 polls"); | |
| 227 | + | assert_eq!(next_poll(5, 5), None); | |
| 228 | + | assert_eq!(next_poll(5, 4), None, "a slow poll past the deadline answers"); | |
| 229 | + | assert_eq!(next_poll(0, 300), Some(300)); | |
| 230 | + | } | |
| 231 | + | } |
| 25 | 25 | mod oidc; | |
| 26 | 26 | mod packages; | |
| 27 | 27 | mod folios; | |
| 28 | + | mod follow; | |
| 28 | 29 | mod people; | |
| 29 | 30 | mod openapi; | |
| 30 | 31 | mod pins; |
| 11 | 11 | }; | |
| 12 | 12 | use g1t_contracts::codeowners::CodeOwnersErrorsArgs; | |
| 13 | 13 | use g1t_contracts::identity::AgentScope; | |
| 14 | − | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs}; | |
| 14 | + | use g1t_contracts::events::Event; | |
| 15 | 15 | use g1t_contracts::identity::{CreateWorkspaceArgs, UpdateWorkspaceArgs, Workspace}; | |
| 16 | 16 | use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath}; | |
| 17 | 17 | use g1t_contracts::teams::{ | |
| ⋯ | |||
| 1466 | 1466 | "Land a pull request on its base, the branch it merges into (the default branch unless it names another). Merging needs the Write role or higher, and only once it is marked ready and it meets every rule that holds for its base (see rules and required_checks on get_pull_request: approvals, checks, deployments, merge windows and the rest, from the repository's and its workspace's rulesets); the refusal names the first rule not met. With ignore_checks, someone who may merge can bypass required checks where the rule allows it; with bypass_rules, someone a ruleset lists as a bypass actor merges past its rules, and it is recorded. Merging into the default branch resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded; merging into another branch leaves the issue open. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead, so pull the base into its fork or branch, push, and merge again. Check status in the result to see whether it has landed." | |
| 1467 | 1467 | } | |
| 1468 | 1468 | Op::ListEvents => { | |
| 1469 | − | "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first." | |
| 1469 | + | "The timeline of a repository: pushes, issues, pull requests, comments, sessions, claims and integration results, newest first; page back with before. To follow it, give after (an event id): the events written after that one come oldest first, and the last one's id is the next call's after. With after and wait (seconds, at most 25), a call that finds nothing holds on until a matching event is written or the wait ends, then returns what it has, perhaps nothing. Narrow it with types, numbers (issues and pull requests, matching events about them and comments on them), since and limit (50 by default, at most 200)." | |
| 1470 | 1470 | } | |
| 1471 | 1471 | Op::ListIntegrations => { | |
| 1472 | 1472 | "A workspace's integrations: its AI providers, the alert sources that open issues (Sentry, Datadog, webhooks), and the trackers whose tickets agents can read (Jira, Linear). Secrets are never returned. Members only." | |
| ⋯ | |||
| 2432 | 2432 | Op::ListEvents => object( | |
| 2433 | 2433 | json!({ | |
| 2434 | 2434 | "repo": repo_schema(), | |
| 2435 | − | "before": { "type": "string", "description": "Event id to page back from." }, | |
| 2435 | + | "before": { "type": "string", "description": "Event id to page back from: older events, newest first." }, | |
| 2436 | + | "after": { "type": "string", "description": "Event id to follow from: the events written after it, oldest first. Not with before." }, | |
| 2437 | + | "types": { | |
| 2438 | + | "type": "array", | |
| 2439 | + | "items": { "type": "string" }, | |
| 2440 | + | "description": "Only these event types (work.claimed, integration.failed, ...). In a query string, a comma-separated list.", | |
| 2441 | + | }, | |
| 2442 | + | "numbers": { | |
| 2443 | + | "type": "array", | |
| 2444 | + | "items": { "type": "integer" }, | |
| 2445 | + | "description": "Only events about these issues or pull requests, and comments, reviews and links on them. In a query string, a comma-separated list.", | |
| 2446 | + | }, | |
| 2447 | + | "since": { "type": "string", "description": "Only events at or after this time (RFC 3339)." }, | |
| 2448 | + | "limit": { "type": "integer", "minimum": 1, "maximum": 200, "description": "How many events at most; 50 by default." }, | |
| 2449 | + | "wait": { | |
| 2450 | + | "type": "integer", | |
| 2451 | + | "minimum": 0, | |
| 2452 | + | "maximum": 25, | |
| 2453 | + | "description": "With after: seconds to hold on for a matching event when there is none yet. At most 25.", | |
| 2454 | + | }, | |
| 2436 | 2455 | }), | |
| 2437 | 2456 | &["repo"], | |
| 2438 | 2457 | ), | |
| ⋯ | |||
| 4750 | 4769 | .await | |
| 4751 | 4770 | } | |
| 4752 | 4771 | Op::ListEvents => { | |
| 4772 | + | let asked = match crate::follow::asked(input) { | |
| 4773 | + | Ok(asked) => asked, | |
| 4774 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 4775 | + | }; | |
| 4753 | 4776 | let found: Outcome<Repo> = call( | |
| 4754 | 4777 | repos, | |
| 4755 | 4778 | "get", | |
| ⋯ | |||
| 4763 | 4786 | Outcome::Ok(repo) => repo, | |
| 4764 | 4787 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 4765 | 4788 | }; | |
| 4766 | − | let timeline: Vec<Event> = g1t_kit::call( | |
| 4767 | − | events, | |
| 4768 | − | "list", | |
| 4769 | − | &ListEventsArgs { | |
| 4770 | − | repo_id: Some(repo.id), | |
| 4771 | − | before: optional_text(input, "before"), | |
| 4772 | − | ..ListEventsArgs::default() | |
| 4773 | − | }, | |
| 4774 | − | ) | |
| 4775 | − | .await?; | |
| 4789 | + | let timeline: Vec<Event> = crate::follow::list_events(events, &repo.id, &asked).await?; | |
| 4776 | 4790 | ok(&timeline) | |
| 4777 | 4791 | } | |
| 4778 | 4792 | // Who has access: identity decides, from the repository as the | |
| 946 | 946 | } | |
| 947 | 947 | } | |
| 948 | 948 | ], | |
| 949 | − | "notes": "Returns 50 events a page. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`." | |
| 949 | + | "notes": "Returns 50 events a page, newest first. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`.\n\nTo follow a repository without a webhook, send `after` instead: the events written after that event come oldest first, and the last one's `id` is your next `after`. Add `wait` (seconds, at most 25) and a call that finds nothing holds on until a matching event is written or the time is up, then returns what it has, which may be an empty list; call again with the same `after`. Events come in the order g1t wrote them, so none is skipped when two arrive close together.\n\n```sh\ncurl -H \"Authorization: Bearer $G1T_TOKEN\" \\\n \"https://api.g1t.sh/repos/acme/rocket/events?after=evt_01m43smh3ve52vkcna207vqctf&types=work.claimed,integration.failed&numbers=12&wait=25\"\n```\n\n`types` and `numbers` are comma-separated lists in a query string, arrays in a body or an MCP call. `numbers` matches events about those issues and pull requests (`data.number`) and comments, reviews and links on them (`data.issue`). `since` is an RFC 3339 time. `after` and `before` cannot be given together." | |
| 950 | 950 | }, | |
| 951 | 951 | "list_issues": { | |
| 952 | 952 | "query": { |
| 530 | 530 | "GET", | |
| 531 | 531 | "/repos/:owner/:name/events", | |
| 532 | 532 | Op::ListEvents, | |
| 533 | − | &[("before", "before")], | |
| 533 | + | &[ | |
| 534 | + | ("before", "before"), | |
| 535 | + | ("after", "after"), | |
| 536 | + | ("types", "types"), | |
| 537 | + | ("numbers", "numbers"), | |
| 538 | + | ("since", "since"), | |
| 539 | + | ("limit", "limit"), | |
| 540 | + | ("wait", "wait"), | |
| 541 | + | ], | |
| 534 | 542 | ), | |
| 535 | 543 | route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 536 | 544 | route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]), | |
| ⋯ | |||
| 1335 | 1343 | } | |
| 1336 | 1344 | ||
| 1337 | 1345 | #[test] | |
| 1346 | + | fn events_are_followed_with_a_cursor_filters_and_a_wait() { | |
| 1347 | + | let query = [ | |
| 1348 | + | ("after".to_owned(), "evt_1".to_owned()), | |
| 1349 | + | ("types".to_owned(), "work.claimed,integration.failed".to_owned()), | |
| 1350 | + | ("numbers".to_owned(), "12,14".to_owned()), | |
| 1351 | + | ("wait".to_owned(), "25".to_owned()), | |
| 1352 | + | ("limit".to_owned(), "20".to_owned()), | |
| 1353 | + | ("since".to_owned(), "2026-10-12T00:00:00Z".to_owned()), | |
| 1354 | + | ]; | |
| 1355 | + | let (route, input) = resolve("GET", "/repos/acme/rocket/events", &query, Value::Null).unwrap(); | |
| 1356 | + | assert_eq!(route.op, Op::ListEvents); | |
| 1357 | + | let asked = crate::follow::asked(&input).unwrap(); | |
| 1358 | + | assert_eq!(asked.after.as_deref(), Some("evt_1")); | |
| 1359 | + | assert_eq!(asked.types, vec!["integration.failed", "work.claimed"]); | |
| 1360 | + | assert_eq!(asked.numbers, vec![12, 14]); | |
| 1361 | + | assert_eq!(asked.wait_ms, 25_000); | |
| 1362 | + | assert_eq!(asked.limit, Some(20)); | |
| 1363 | + | assert_eq!(asked.since.as_deref(), Some("2026-10-12T00:00:00Z")); | |
| 1364 | + | } | |
| 1365 | + | ||
| 1366 | + | #[test] | |
| 1338 | 1367 | fn notifications_are_addressed_as_threads_and_by_issue() { | |
| 1339 | 1368 | let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap(); | |
| 1340 | 1369 | assert_eq!(route.op, Op::MarkThreadDone); | |
| 281 | 281 | ||
| 282 | 282 | Your open pull requests and their checks, reviews asked of you, what is | |
| 283 | 283 | assigned to you, and failing checks, across every repository you can see. | |
| 284 | + | Inside a working copy it also lists your active sessions and claims in that | |
| 285 | + | repository. | |
| 286 | + | ||
| 287 | + | ## Coordinating work | |
| 288 | + | ||
| 289 | + | When several people and agents work in one repository, `g1t session` and | |
| 290 | + | `g1t work` say who is on what, so nobody starts work that is already under | |
| 291 | + | way and everyone hears what changes under them. Claims are advisory: a | |
| 292 | + | claim never stops anyone, it tells them. | |
| 293 | + | ||
| 294 | + | ### Sessions | |
| 295 | + | ||
| 296 | + | A session is one run of an agent's harness, such as one Claude Code | |
| 297 | + | session. Starting one remembers it for the working copy, and the commands | |
| 298 | + | after it use it: | |
| 299 | + | ||
| 300 | + | ```sh | |
| 301 | + | g1t session start --harness claude-code --issue 12 | |
| 302 | + | g1t session log "Split the parser before touching the lexer." | |
| 303 | + | g1t session attach --pull 14 --head-commit "$(git rev-parse HEAD)" | |
| 304 | + | g1t session heartbeat | |
| 305 | + | g1t session end --reason done | |
| 306 | + | ``` | |
| 307 | + | ||
| 308 | + | | Command | What it does | | |
| 309 | + | | --- | --- | | |
| 310 | + | | `g1t session start` | Starts a session and prints its id. `--harness` (`claude-code`, `codex`, `cursor`, `copilot`, `other`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. | | |
| 311 | + | | `g1t session heartbeat` | Says the session is still working, and renews its claims. A session with no heartbeat for 15 minutes reads as idle, and after 2 hours as ended. | | |
| 312 | + | | `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message` or `tool-call`, `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. | | |
| 313 | + | | `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. | | |
| 314 | + | | `g1t session end` | Ends it, with an optional `--reason`, and forgets it. | | |
| 315 | + | | `g1t session list` | The repository's sessions; `--state active`, `--issue`, `--pull`. | | |
| 316 | + | | `g1t session view [ID]` | A session and what it recorded; the current one without an id. | | |
| 317 | + | ||
| 318 | + | The id is kept in `.git/g1t/session`, in the working copy's own git | |
| 319 | + | directory, so each worktree has its own. `G1T_SESSION` overrides it, for | |
| 320 | + | a harness that runs several sessions in one working copy. | |
| 321 | + | ||
| 322 | + | ### Claims and context | |
| 323 | + | ||
| 324 | + | ```sh | |
| 325 | + | g1t work context 12 # everything about #12's work | |
| 326 | + | g1t work claim 12 --note "Taking the parser" --ttl 1h | |
| 327 | + | g1t work release 12 --reason done | |
| 328 | + | g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail." | |
| 329 | + | g1t work list # active claims and sessions here | |
| 330 | + | ``` | |
| 331 | + | ||
| 332 | + | `g1t work context` is the one call to make before starting: the issue and | |
| 333 | + | its definition of done, who has claimed it, its sessions, its pull | |
| 334 | + | requests with their checks, overlaps and place in the merge queue, what it | |
| 335 | + | waits on and what waits on it, its decisions, its last integration failure | |
| 336 | + | and its recent events. | |
| 337 | + | ||
| 338 | + | `g1t work claim` says you are working on an issue. It lasts 30 minutes | |
| 339 | + | unless `--ttl` says otherwise (at most `4h`), and a heartbeat of the session | |
| 340 | + | it was made in renews it. It is made in the current session, if there is | |
| 341 | + | one. If others have claimed the issue too, or it waits on issues that are | |
| 342 | + | not done, the answer says so. `g1t work handoff` ends your claim and asks | |
| 343 | + | `--to` someone, or anyone without it, to take the issue over, with a | |
| 344 | + | `--note` saying where it stands. | |
| 345 | + | ||
| 346 | + | ### Dependencies | |
| 347 | + | ||
| 348 | + | ```sh | |
| 349 | + | g1t work block 12 --by 9 --by 10 # #12 waits on #9 and #10 | |
| 350 | + | g1t work unblock 12 --by 9 | |
| 351 | + | ``` | |
| 352 | + | ||
| 353 | + | These change the issue's `blocked_by` through `update_issue`. An issue | |
| 354 | + | cannot wait on itself, and g1t refuses a cycle. | |
| 355 | + | ||
| 356 | + | ### Following events | |
| 357 | + | ||
| 358 | + | `g1t work events` prints the repository's recent events, oldest first. | |
| 359 | + | With `--follow` it keeps printing them as they happen, without a webhook or | |
| 360 | + | a public address, until you stop it with Ctrl-C: | |
| 361 | + | ||
| 362 | + | ```sh | |
| 363 | + | g1t work events --follow --issue 12 | |
| 364 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 365 | + | ``` | |
| 366 | + | ||
| 367 | + | | Flag | | | |
| 368 | + | | --- | --- | | |
| 369 | + | | `--type`, `-t` | Only events of this type, such as `work.claimed`; repeat for more. | | |
| 370 | + | | `--issue`, `-i` | Only events about this issue or pull request; repeat for more. | | |
| 371 | + | | `--since`, `-s` | Only events since then: `15m`, `2h`, `1d`, or an RFC 3339 time. | | |
| 372 | + | | `--after` | Start after this event id, to resume where an earlier follow stopped. | | |
| 373 | + | | `--limit`, `-L` | How many past events to show first; 20 by default. | | |
| 374 | + | ||
| 375 | + | Each event is one line: its time, type, issue or pull request, a summary | |
| 376 | + | and its id. With `--json`, each is one compact JSON object on a line of its | |
| 377 | + | own, so a program can read them as they come. Following asks | |
| 378 | + | [`list_events`](/reference/api/repositories/list-events/) with `after` and | |
| 379 | + | `wait`, so the server holds each request until something happens; when a | |
| 380 | + | call fails, `g1t` waits (1 second, then longer, up to 30) and resumes from | |
| 381 | + | the last event it printed. A refused token stops it with exit code `4`. | |
| 382 | + | ||
| 383 | + | ### Why a line is as it is | |
| 384 | + | ||
| 385 | + | ```sh | |
| 386 | + | g1t why src/parser.rs:120 | |
| 387 | + | g1t why src/parser.rs --ref main | |
| 388 | + | ``` | |
| 389 | + | ||
| 390 | + | `g1t why` follows a line to the commit that last changed it, the pull | |
| 391 | + | request that merged that commit, the issue the pull request was for, and | |
| 392 | + | the decisions and sessions recorded on them. A path is read from where you | |
| 393 | + | are in the working copy. | |
| 394 | + | ||
| 395 | + | ### In an agent's hooks | |
| 396 | + | ||
| 397 | + | These commands are made to be run by a harness's hooks in the background: | |
| 398 | + | they never ask anything, they fail at once with exit code `4` when there is | |
| 399 | + | no token, and they give up on a call after 15 seconds. Against a g1t server | |
| 400 | + | older than the CLI, a command it cannot do yet fails saying so. | |
| 284 | 401 | ||
| 285 | 402 | ## Scripting with JSON | |
| 286 | 403 |
| 169 | 169 | refused. To receive webhooks on your own machine while you build, use a | |
| 170 | 170 | tunnel such as Cloudflare Tunnel. | |
| 171 | 171 | ||
| 172 | + | ## Without a public address | |
| 173 | + | ||
| 174 | + | An agent on a laptop, or anything else that cannot take requests from the | |
| 175 | + | internet, can follow a repository's events instead: | |
| 176 | + | [`list_events`](/reference/api/repositories/list-events/) with `after` and | |
| 177 | + | `wait` holds each request until an event is written, so one request at a | |
| 178 | + | time hears about events within a second or so. `g1t work events --follow` | |
| 179 | + | does this for you ([The g1t CLI](/guides/cli/#following-events)). | |
| 180 | + | ||
| 172 | 181 | ## From the API | |
| 173 | 182 | ||
| 174 | 183 | The same routes, and the MCP server's `webhook` tool, work for a |
| 735 | 735 | ||
| 736 | 736 | ## g1t status | |
| 737 | 737 | ||
| 738 | − | What needs you: your pull requests, reviews asked of you, your issues, failing checks. | |
| 738 | + | What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here. | |
| 739 | 739 | ||
| 740 | 740 | ```text | |
| 741 | 741 | g1t status [OPTIONS] | |
| 742 | 742 | ``` | |
| 743 | 743 | ||
| 744 | − | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/). | |
| 744 | + | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/issues/list-claims/). | |
| 745 | + | ||
| 746 | + | ## g1t session | |
| 747 | + | ||
| 748 | + | Sessions: one run of an agent's harness, recorded as it works. | |
| 749 | + | ||
| 750 | + | ### g1t session start | |
| 751 | + | ||
| 752 | + | Start a session and remember it for this working copy. Prints its id. | |
| 753 | + | ||
| 754 | + | ```text | |
| 755 | + | g1t session start [OPTIONS] | |
| 756 | + | ``` | |
| 757 | + | ||
| 758 | + | | Argument or flag | | | |
| 759 | + | | --- | --- | | |
| 760 | + | | `-i, --issue <ISSUE>` | The issue it works on. | | |
| 761 | + | | `-p, --pull <PULL>` | The pull request it works on. | | |
| 762 | + | | `--harness <HARNESS>` | The harness running it. One of `claude-code`, `codex`, `cursor`, `copilot`, `other`. Default: `other`. | | |
| 763 | + | | `--harness-session-id <ID>` | The harness's own id for the run, which makes starting again return the same session. | | |
| 764 | + | | `-t, --title <TITLE>` | | | |
| 765 | + | | `-b, --branch <BRANCH>` | The branch it works on; the current one when left out. | | |
| 766 | + | ||
| 767 | + | ```text | |
| 768 | + | The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it. | |
| 769 | + | Starting again with the same --harness-session-id returns the same session. | |
| 770 | + | ``` | |
| 771 | + | ||
| 772 | + | Calls [`start_session`](/reference/api/sessions/start-session/). | |
| 773 | + | ||
| 774 | + | ### g1t session heartbeat | |
| 775 | + | ||
| 776 | + | Say the current session is still working; renews its claims. | |
| 777 | + | ||
| 778 | + | ```text | |
| 779 | + | g1t session heartbeat [OPTIONS] | |
| 780 | + | ``` | |
| 781 | + | ||
| 782 | + | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 783 | + | ||
| 784 | + | ### g1t session log | |
| 785 | + | ||
| 786 | + | Record a note, prompt or message in the current session. | |
| 787 | + | ||
| 788 | + | ```text | |
| 789 | + | g1t session log [OPTIONS] <TEXT> | |
| 790 | + | ``` | |
| 791 | + | ||
| 792 | + | | Argument or flag | | | |
| 793 | + | | --- | --- | | |
| 794 | + | | `<TEXT>` | What to record; - reads it from stdin. | | |
| 795 | + | | `-k, --kind <KIND>` | One of `note`, `prompt`, `message`, `tool-call`. Default: `note`. | | |
| 796 | + | | `--tool <TOOL>` | The tool, for a tool_call. | | |
| 797 | + | | `--key <KEY>` | A key that makes recording it again do nothing. | | |
| 798 | + | ||
| 799 | + | Calls [`record_session_entries`](/reference/api/sessions/record-session-entries/). | |
| 800 | + | ||
| 801 | + | ### g1t session attach | |
| 802 | + | ||
| 803 | + | Attach the current session to an issue, a pull request or a branch. | |
| 804 | + | ||
| 805 | + | ```text | |
| 806 | + | g1t session attach [OPTIONS] <--issue <ISSUE>|--pull <PULL>|--branch <BRANCH>|--head-commit <SHA>> | |
| 807 | + | ``` | |
| 808 | + | ||
| 809 | + | | Argument or flag | | | |
| 810 | + | | --- | --- | | |
| 811 | + | | `-i, --issue <ISSUE>` | | | |
| 812 | + | | `-p, --pull <PULL>` | | | |
| 813 | + | | `-b, --branch <BRANCH>` | | | |
| 814 | + | | `--head-commit <SHA>` | The commit it pushed last. | | |
| 815 | + | ||
| 816 | + | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 817 | + | ||
| 818 | + | ### g1t session end | |
| 819 | + | ||
| 820 | + | End the current session. | |
| 821 | + | ||
| 822 | + | ```text | |
| 823 | + | g1t session end [OPTIONS] | |
| 824 | + | ``` | |
| 825 | + | ||
| 826 | + | | Argument or flag | | | |
| 827 | + | | --- | --- | | |
| 828 | + | | `-r, --reason <REASON>` | Why it ended: done, abandoned, handed_off... | | |
| 829 | + | ||
| 830 | + | Calls [`end_session`](/reference/api/sessions/end-session/). | |
| 831 | + | ||
| 832 | + | ### g1t session list | |
| 833 | + | ||
| 834 | + | List the repository's sessions. | |
| 835 | + | ||
| 836 | + | ```text | |
| 837 | + | g1t session list [OPTIONS] | |
| 838 | + | ``` | |
| 839 | + | ||
| 840 | + | | Argument or flag | | | |
| 841 | + | | --- | --- | | |
| 842 | + | | `-s, --state <STATE>` | Only sessions in this state. One of `active`, `idle`, `ended`. | | |
| 843 | + | | `-i, --issue <ISSUE>` | | | |
| 844 | + | | `-p, --pull <PULL>` | | | |
| 845 | + | | `-L, --limit <LIMIT>` | Default: `20`. | | |
| 846 | + | ||
| 847 | + | Calls [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 848 | + | ||
| 849 | + | ### g1t session view | |
| 850 | + | ||
| 851 | + | Show a session and what it recorded; the current one when left out. | |
| 852 | + | ||
| 853 | + | ```text | |
| 854 | + | g1t session view [OPTIONS] [ID] | |
| 855 | + | ``` | |
| 856 | + | ||
| 857 | + | | Argument or flag | | | |
| 858 | + | | --- | --- | | |
| 859 | + | | `[ID]` | The session's id (ses_…); the current one when left out. | | |
| 860 | + | ||
| 861 | + | Calls [`get_session`](/reference/api/sessions/get-session/). | |
| 862 | + | ||
| 863 | + | ## g1t work | |
| 864 | + | ||
| 865 | + | Coordinate work on issues: context, claims, hand-offs, dependencies, events. | |
| 866 | + | ||
| 867 | + | ### g1t work context | |
| 868 | + | ||
| 869 | + | Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions. | |
| 870 | + | ||
| 871 | + | ```text | |
| 872 | + | g1t work context [OPTIONS] <NUMBER> | |
| 873 | + | ``` | |
| 874 | + | ||
| 875 | + | | Argument or flag | | | |
| 876 | + | | --- | --- | | |
| 877 | + | | `<NUMBER>` | The issue's number. | | |
| 878 | + | ||
| 879 | + | Calls [`get_work`](/reference/api/issues/get-work/). | |
| 880 | + | ||
| 881 | + | ### g1t work claim | |
| 882 | + | ||
| 883 | + | Claim an issue: say you are working on it. Others are told, never stopped. | |
| 884 | + | ||
| 885 | + | ```text | |
| 886 | + | g1t work claim [OPTIONS] <NUMBER> | |
| 887 | + | ``` | |
| 888 | + | ||
| 889 | + | | Argument or flag | | | |
| 890 | + | | --- | --- | | |
| 891 | + | | `<NUMBER>` | The issue's number. | | |
| 892 | + | | `-n, --note <NOTE>` | What you are doing about it. | | |
| 893 | + | | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). | | |
| 894 | + | ||
| 895 | + | Calls [`claim_issue`](/reference/api/issues/claim-issue/). | |
| 896 | + | ||
| 897 | + | ### g1t work release | |
| 898 | + | ||
| 899 | + | Release your claim on an issue. | |
| 900 | + | ||
| 901 | + | ```text | |
| 902 | + | g1t work release [OPTIONS] <NUMBER> | |
| 903 | + | ``` | |
| 904 | + | ||
| 905 | + | | Argument or flag | | | |
| 906 | + | | --- | --- | | |
| 907 | + | | `<NUMBER>` | | | |
| 908 | + | | `-r, --reason <REASON>` | Why: done, blocked, abandoned... | | |
| 909 | + | ||
| 910 | + | Calls [`release_claim`](/reference/api/issues/release-claim/). | |
| 911 | + | ||
| 912 | + | ### g1t work handoff | |
| 913 | + | ||
| 914 | + | Hand an issue over to someone else, or to anyone. | |
| 915 | + | ||
| 916 | + | ```text | |
| 917 | + | g1t work handoff [OPTIONS] --note <NOTE> <NUMBER> | |
| 918 | + | ``` | |
| 919 | + | ||
| 920 | + | | Argument or flag | | | |
| 921 | + | | --- | --- | | |
| 922 | + | | `<NUMBER>` | | | |
| 923 | + | | `--to <LOGIN>` | Who takes it over; anyone when left out. | | |
| 924 | + | | `-n, --note <NOTE>` | Where it stands and what is left, for whoever takes it. | | |
| 925 | + | ||
| 926 | + | Calls [`hand_off_issue`](/reference/api/issues/hand-off-issue/). | |
| 927 | + | ||
| 928 | + | ### g1t work list | |
| 929 | + | ||
| 930 | + | Active claims and sessions in the repository. | |
| 931 | + | ||
| 932 | + | ```text | |
| 933 | + | g1t work list [OPTIONS] | |
| 934 | + | ``` | |
| 935 | + | ||
| 936 | + | Calls [`list_claims`](/reference/api/issues/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 937 | + | ||
| 938 | + | ### g1t work block | |
| 939 | + | ||
| 940 | + | Say an issue waits on others: it is blocked by them. | |
| 941 | + | ||
| 942 | + | ```text | |
| 943 | + | g1t work block [OPTIONS] --by <NUMBER> <NUMBER> | |
| 944 | + | ``` | |
| 945 | + | ||
| 946 | + | | Argument or flag | | | |
| 947 | + | | --- | --- | | |
| 948 | + | | `<NUMBER>` | The issue that waits. | | |
| 949 | + | | `--by <NUMBER>` | The issue it waits on; repeat for more. | | |
| 950 | + | ||
| 951 | + | Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/). | |
| 952 | + | ||
| 953 | + | ### g1t work unblock | |
| 954 | + | ||
| 955 | + | Say an issue no longer waits on others. | |
| 956 | + | ||
| 957 | + | ```text | |
| 958 | + | g1t work unblock [OPTIONS] --by <NUMBER> <NUMBER> | |
| 959 | + | ``` | |
| 960 | + | ||
| 961 | + | | Argument or flag | | | |
| 962 | + | | --- | --- | | |
| 963 | + | | `<NUMBER>` | The issue that waits. | | |
| 964 | + | | `--by <NUMBER>` | The issue it waits on; repeat for more. | | |
| 965 | + | ||
| 966 | + | Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/). | |
| 967 | + | ||
| 968 | + | ### g1t work events | |
| 969 | + | ||
| 970 | + | The repository's events; --follow keeps printing them as they happen. | |
| 971 | + | ||
| 972 | + | ```text | |
| 973 | + | g1t work events [OPTIONS] | |
| 974 | + | ``` | |
| 975 | + | ||
| 976 | + | | Argument or flag | | | |
| 977 | + | | --- | --- | | |
| 978 | + | | `-f, --follow` | Keep printing events as they happen, until stopped. | | |
| 979 | + | | `-t, --type <TYPE>` | Only events of this type; repeat for more. | | |
| 980 | + | | `-i, --issue <NUMBER>` | Only events about this issue or pull request; repeat for more. | | |
| 981 | + | | `-s, --since <SINCE>` | Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. | | |
| 982 | + | | `--after <EVENT_ID>` | Start after this event id, as a follow that stopped would resume. | | |
| 983 | + | | `-L, --limit <LIMIT>` | How many past events to show first. Default: `20`. | | |
| 984 | + | ||
| 985 | + | ```text | |
| 986 | + | Examples: | |
| 987 | + | g1t work events --follow --issue 12 | |
| 988 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 989 | + | With --json, each event is one JSON object on a line of its own. | |
| 990 | + | ``` | |
| 991 | + | ||
| 992 | + | Calls [`list_events`](/reference/api/repositories/list-events/). | |
| 993 | + | ||
| 994 | + | ## g1t why | |
| 995 | + | ||
| 996 | + | Why a file or line is as it is: its commit, pull request, issue, decisions and sessions. | |
| 997 | + | ||
| 998 | + | ```text | |
| 999 | + | g1t why [OPTIONS] <TARGET> | |
| 1000 | + | ``` | |
| 1001 | + | ||
| 1002 | + | | Argument or flag | | | |
| 1003 | + | | --- | --- | | |
| 1004 | + | | `<TARGET>` | A file, or a line of it as PATH:LINE. | | |
| 1005 | + | | `--ref <REF>` | The branch, tag or commit to read it at; the default branch when left out. | | |
| 1006 | + | ||
| 1007 | + | Calls [`why`](/reference/api/repositories/why/). | |
| 745 | 1008 | ||
| 746 | 1009 | ## g1t api | |
| 747 | 1010 |
| 269 | 269 | | [`create_milestone`](/reference/api/labels-and-milestones/create-milestone/) | Create a milestone with `title`, `description` and `due_on` (`YYYY-MM-DD`). Write role. | `repo`, `title` | `issues:write` | | |
| 270 | 270 | | [`update_milestone`](/reference/api/labels-and-milestones/update-milestone/) | Change its `title`, `description`, `due_on` (`""` clears it) or `state` (`open` or `closed`). Write role. | `repo`, `milestone` | `issues:write` | | |
| 271 | 271 | | [`delete_milestone`](/reference/api/labels-and-milestones/delete-milestone/) | Delete a milestone; what was in it is in none. Write role. | `repo`, `milestone` | `issues:write` | | |
| 272 | − | | [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first. `before` pages back. | `repo` | `repo:read` | | |
| 272 | + | | [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first; `before` pages back. To follow it, give `after` (an event id): the events written after it come oldest first, and the last one's `id` is the next `after`. With `wait` (seconds, at most 25) a call that finds nothing holds on until a matching event is written. `types` and `numbers` (arrays) narrow it, with `since` and `limit` (at most 200). | `repo` | `repo:read` | | |
| 273 | 273 | | [`languages`](/reference/api/repository-insights/get-languages/) | Its [languages](/guides/managing-repositories/#languages) by bytes, each with `color` and `percent`, for the default branch's `commit`; `pending` while it is first read. | `repo` | `repo:read` | | |
| 274 | 274 | | [`contributors`](/reference/api/repository-insights/list-contributors/) | Its [contributors](/guides/managing-repositories/#contributors): `kind` (`user`, `g1t` or `author`), `commits` and `weeks`, and the repository's commits by week. | `repo` | `repo:read` | | |
| 275 | 275 | | [`license`](/reference/api/repository-insights/get-license/) | The license its `LICENSE` file holds: `spdx_id`, `name`, `path`. | `repo` | `repo:read` | |
Binary or large file; its contents are not shown.
| 564 | 564 | pub data: serde_json::Value, | |
| 565 | 565 | } | |
| 566 | 566 | ||
| 567 | − | /// `list`: events from the log, newest first. Returns `Vec<Event>`. | |
| 567 | + | /// `list`: events from the log, newest first, or with `after` oldest first. | |
| 568 | + | /// Returns `Vec<Event>`. | |
| 568 | 569 | #[derive(Debug, Default, Serialize, serde::Deserialize)] | |
| 569 | 570 | #[serde(rename_all = "camelCase")] | |
| 570 | 571 | pub struct ListArgs { | |
| ⋯ | |||
| 576 | 577 | /// Only events older than this event id. | |
| 577 | 578 | #[serde(default)] | |
| 578 | 579 | pub before: Option<String>, | |
| 580 | + | /// Only events the log recorded after this event id, and then oldest | |
| 581 | + | /// first: a cursor to follow the log with. Events are ordered by when | |
| 582 | + | /// the log wrote them, not by their ids, so one published a moment | |
| 583 | + | /// before another but written after it is not skipped. | |
| 584 | + | #[serde(default)] | |
| 585 | + | pub after: Option<String>, | |
| 579 | 586 | /// Only events by this account id. | |
| 580 | 587 | #[serde(default)] | |
| 581 | 588 | pub actor: Option<String>, | |
| 99 | 99 | Deploy(DeployCommand), | |
| 100 | 100 | /// A project's logs and errors. | |
| 101 | 101 | Logs(LogsArgs), | |
| 102 | − | /// What needs you: your pull requests, reviews asked of you, your issues, failing checks. | |
| 102 | + | /// What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here. | |
| 103 | 103 | Status(StatusArgs), | |
| 104 | + | /// Sessions: one run of an agent's harness, recorded as it works. | |
| 105 | + | #[command(subcommand)] | |
| 106 | + | Session(SessionCommand), | |
| 107 | + | /// Coordinate work on issues: context, claims, hand-offs, dependencies, events. | |
| 108 | + | #[command(subcommand)] | |
| 109 | + | Work(WorkCommand), | |
| 110 | + | /// Why a file or line is as it is: its commit, pull request, issue, decisions and sessions. | |
| 111 | + | Why(WhyArgs), | |
| 104 | 112 | /// Call any API operation or path, with your token. | |
| 105 | 113 | Api(ApiArgs), | |
| 106 | 114 | /// Open the repository, an issue or a pull request in the browser. | |
| ⋯ | |||
| 781 | 789 | pub limit: usize, | |
| 782 | 790 | } | |
| 783 | 791 | ||
| 792 | + | // --- session ---------------------------------------------------------- | |
| 793 | + | ||
| 794 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 795 | + | pub enum Harness { | |
| 796 | + | ClaudeCode, | |
| 797 | + | Codex, | |
| 798 | + | Cursor, | |
| 799 | + | Copilot, | |
| 800 | + | Other, | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | impl Harness { | |
| 804 | + | pub fn name(self) -> &'static str { | |
| 805 | + | match self { | |
| 806 | + | Harness::ClaudeCode => "claude_code", | |
| 807 | + | Harness::Codex => "codex", | |
| 808 | + | Harness::Cursor => "cursor", | |
| 809 | + | Harness::Copilot => "copilot", | |
| 810 | + | Harness::Other => "other", | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | #[derive(Debug, Subcommand)] | |
| 816 | + | pub enum SessionCommand { | |
| 817 | + | /// Start a session and remember it for this working copy. Prints its id. | |
| 818 | + | #[command(after_help = "\ | |
| 819 | + | The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it. | |
| 820 | + | Starting again with the same --harness-session-id returns the same session.")] | |
| 821 | + | Start(SessionStartArgs), | |
| 822 | + | /// Say the current session is still working; renews its claims. | |
| 823 | + | Heartbeat, | |
| 824 | + | /// Record a note, prompt or message in the current session. | |
| 825 | + | Log(SessionLogArgs), | |
| 826 | + | /// Attach the current session to an issue, a pull request or a branch. | |
| 827 | + | Attach(SessionAttachArgs), | |
| 828 | + | /// End the current session. | |
| 829 | + | End(SessionEndArgs), | |
| 830 | + | /// List the repository's sessions. | |
| 831 | + | List(SessionListArgs), | |
| 832 | + | /// Show a session and what it recorded; the current one when left out. | |
| 833 | + | View(SessionViewArgs), | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | #[derive(Debug, Args)] | |
| 837 | + | pub struct SessionStartArgs { | |
| 838 | + | /// The issue it works on. | |
| 839 | + | #[arg(short, long)] | |
| 840 | + | pub issue: Option<u64>, | |
| 841 | + | /// The pull request it works on. | |
| 842 | + | #[arg(short, long)] | |
| 843 | + | pub pull: Option<u64>, | |
| 844 | + | /// The harness running it. | |
| 845 | + | #[arg(long, value_enum, default_value_t = Harness::Other)] | |
| 846 | + | pub harness: Harness, | |
| 847 | + | /// The harness's own id for the run, which makes starting again return the same session. | |
| 848 | + | #[arg(long, value_name = "ID")] | |
| 849 | + | pub harness_session_id: Option<String>, | |
| 850 | + | #[arg(short, long)] | |
| 851 | + | pub title: Option<String>, | |
| 852 | + | /// The branch it works on; the current one when left out. | |
| 853 | + | #[arg(short, long)] | |
| 854 | + | pub branch: Option<String>, | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 858 | + | pub enum EntryKind { | |
| 859 | + | Note, | |
| 860 | + | Prompt, | |
| 861 | + | Message, | |
| 862 | + | ToolCall, | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | impl EntryKind { | |
| 866 | + | pub fn name(self) -> &'static str { | |
| 867 | + | match self { | |
| 868 | + | EntryKind::Note => "note", | |
| 869 | + | EntryKind::Prompt => "prompt", | |
| 870 | + | EntryKind::Message => "message", | |
| 871 | + | EntryKind::ToolCall => "tool_call", | |
| 872 | + | } | |
| 873 | + | } | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | #[derive(Debug, Args)] | |
| 877 | + | pub struct SessionLogArgs { | |
| 878 | + | /// What to record; - reads it from stdin. | |
| 879 | + | pub text: String, | |
| 880 | + | #[arg(short, long, value_enum, default_value_t = EntryKind::Note)] | |
| 881 | + | pub kind: EntryKind, | |
| 882 | + | /// The tool, for a tool_call. | |
| 883 | + | #[arg(long)] | |
| 884 | + | pub tool: Option<String>, | |
| 885 | + | /// A key that makes recording it again do nothing. | |
| 886 | + | #[arg(long)] | |
| 887 | + | pub key: Option<String>, | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | #[derive(Debug, Args)] | |
| 891 | + | #[command(group(clap::ArgGroup::new("what").required(true).multiple(true).args(["issue", "pull", "branch", "head_commit"])))] | |
| 892 | + | pub struct SessionAttachArgs { | |
| 893 | + | #[arg(short, long)] | |
| 894 | + | pub issue: Option<u64>, | |
| 895 | + | #[arg(short, long)] | |
| 896 | + | pub pull: Option<u64>, | |
| 897 | + | #[arg(short, long)] | |
| 898 | + | pub branch: Option<String>, | |
| 899 | + | /// The commit it pushed last. | |
| 900 | + | #[arg(long, value_name = "SHA")] | |
| 901 | + | pub head_commit: Option<String>, | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | #[derive(Debug, Args)] | |
| 905 | + | pub struct SessionEndArgs { | |
| 906 | + | /// Why it ended: done, abandoned, handed_off... | |
| 907 | + | #[arg(short, long)] | |
| 908 | + | pub reason: Option<String>, | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 912 | + | pub enum SessionState { | |
| 913 | + | Active, | |
| 914 | + | Idle, | |
| 915 | + | Ended, | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | #[derive(Debug, Args)] | |
| 919 | + | pub struct SessionListArgs { | |
| 920 | + | /// Only sessions in this state. | |
| 921 | + | #[arg(short, long, value_enum)] | |
| 922 | + | pub state: Option<SessionState>, | |
| 923 | + | #[arg(short, long)] | |
| 924 | + | pub issue: Option<u64>, | |
| 925 | + | #[arg(short, long)] | |
| 926 | + | pub pull: Option<u64>, | |
| 927 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 928 | + | pub limit: usize, | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | #[derive(Debug, Args)] | |
| 932 | + | pub struct SessionViewArgs { | |
| 933 | + | /// The session's id (ses_…); the current one when left out. | |
| 934 | + | pub id: Option<String>, | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // --- work ------------------------------------------------------------- | |
| 938 | + | ||
| 939 | + | #[derive(Debug, Subcommand)] | |
| 940 | + | pub enum WorkCommand { | |
| 941 | + | /// Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions. | |
| 942 | + | Context(WorkNumber), | |
| 943 | + | /// Claim an issue: say you are working on it. Others are told, never stopped. | |
| 944 | + | Claim(WorkClaimArgs), | |
| 945 | + | /// Release your claim on an issue. | |
| 946 | + | Release(WorkReleaseArgs), | |
| 947 | + | /// Hand an issue over to someone else, or to anyone. | |
| 948 | + | Handoff(WorkHandoffArgs), | |
| 949 | + | /// Active claims and sessions in the repository. | |
| 950 | + | List, | |
| 951 | + | /// Say an issue waits on others: it is blocked by them. | |
| 952 | + | Block(WorkBlockArgs), | |
| 953 | + | /// Say an issue no longer waits on others. | |
| 954 | + | Unblock(WorkBlockArgs), | |
| 955 | + | /// The repository's events; --follow keeps printing them as they happen. | |
| 956 | + | #[command(after_help = "\ | |
| 957 | + | Examples: | |
| 958 | + | g1t work events --follow --issue 12 | |
| 959 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 960 | + | With --json, each event is one JSON object on a line of its own.")] | |
| 961 | + | Events(WorkEventsArgs), | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | #[derive(Debug, Args)] | |
| 965 | + | pub struct WorkNumber { | |
| 966 | + | /// The issue's number. | |
| 967 | + | pub number: u64, | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | #[derive(Debug, Args)] | |
| 971 | + | pub struct WorkClaimArgs { | |
| 972 | + | /// The issue's number. | |
| 973 | + | pub number: u64, | |
| 974 | + | /// What you are doing about it. | |
| 975 | + | #[arg(short, long)] | |
| 976 | + | pub note: Option<String>, | |
| 977 | + | /// How long the claim lasts unless renewed: 30m, 2h (at most 4h). | |
| 978 | + | #[arg(long, value_name = "DURATION", value_parser = parse_ttl)] | |
| 979 | + | pub ttl: Option<u64>, | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | #[derive(Debug, Args)] | |
| 983 | + | pub struct WorkReleaseArgs { | |
| 984 | + | pub number: u64, | |
| 985 | + | /// Why: done, blocked, abandoned... | |
| 986 | + | #[arg(short, long)] | |
| 987 | + | pub reason: Option<String>, | |
| 988 | + | } | |
| 989 | + | ||
| 990 | + | #[derive(Debug, Args)] | |
| 991 | + | pub struct WorkHandoffArgs { | |
| 992 | + | pub number: u64, | |
| 993 | + | /// Who takes it over; anyone when left out. | |
| 994 | + | #[arg(long, value_name = "LOGIN")] | |
| 995 | + | pub to: Option<String>, | |
| 996 | + | /// Where it stands and what is left, for whoever takes it. | |
| 997 | + | #[arg(short, long)] | |
| 998 | + | pub note: String, | |
| 999 | + | } | |
| 1000 | + | ||
| 1001 | + | #[derive(Debug, Args)] | |
| 1002 | + | pub struct WorkBlockArgs { | |
| 1003 | + | /// The issue that waits. | |
| 1004 | + | pub number: u64, | |
| 1005 | + | /// The issue it waits on; repeat for more. | |
| 1006 | + | #[arg(long = "by", value_name = "NUMBER", required = true)] | |
| 1007 | + | pub by: Vec<u64>, | |
| 1008 | + | } | |
| 1009 | + | ||
| 1010 | + | #[derive(Debug, Args)] | |
| 1011 | + | pub struct WorkEventsArgs { | |
| 1012 | + | /// Keep printing events as they happen, until stopped. | |
| 1013 | + | #[arg(short, long)] | |
| 1014 | + | pub follow: bool, | |
| 1015 | + | /// Only events of this type; repeat for more. | |
| 1016 | + | #[arg(short = 't', long = "type", value_name = "TYPE")] | |
| 1017 | + | pub types: Vec<String>, | |
| 1018 | + | /// Only events about this issue or pull request; repeat for more. | |
| 1019 | + | #[arg(short, long = "issue", value_name = "NUMBER")] | |
| 1020 | + | pub issues: Vec<u64>, | |
| 1021 | + | /// Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. | |
| 1022 | + | #[arg(short, long)] | |
| 1023 | + | pub since: Option<String>, | |
| 1024 | + | /// Start after this event id, as a follow that stopped would resume. | |
| 1025 | + | #[arg(long, value_name = "EVENT_ID")] | |
| 1026 | + | pub after: Option<String>, | |
| 1027 | + | /// How many past events to show first. | |
| 1028 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 1029 | + | pub limit: usize, | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | /// `30m`, `2h`, `90` (minutes) as minutes, from 1 minute to 4 hours. | |
| 1033 | + | pub fn parse_ttl(text: &str) -> Result<u64, String> { | |
| 1034 | + | let text = text.trim(); | |
| 1035 | + | let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len())); | |
| 1036 | + | let wrong = || format!("`{text}` is not a duration: write it as 30m or 2h"); | |
| 1037 | + | let number: u64 = number.parse().map_err(|_| wrong())?; | |
| 1038 | + | let minutes = match unit { | |
| 1039 | + | "" | "m" | "min" => number, | |
| 1040 | + | "h" => number * 60, | |
| 1041 | + | _ => return Err(wrong()), | |
| 1042 | + | }; | |
| 1043 | + | if minutes == 0 || minutes > 240 { | |
| 1044 | + | return Err("a claim lasts from 1 minute to 4 hours".to_owned()); | |
| 1045 | + | } | |
| 1046 | + | Ok(minutes) | |
| 1047 | + | } | |
| 1048 | + | ||
| 1049 | + | // --- why -------------------------------------------------------------- | |
| 1050 | + | ||
| 1051 | + | #[derive(Debug, Args)] | |
| 1052 | + | pub struct WhyArgs { | |
| 1053 | + | /// A file, or a line of it as PATH:LINE. | |
| 1054 | + | pub target: String, | |
| 1055 | + | /// The branch, tag or commit to read it at; the default branch when left out. | |
| 1056 | + | #[arg(long = "ref", value_name = "REF")] | |
| 1057 | + | pub reference: Option<String>, | |
| 1058 | + | } | |
| 1059 | + | ||
| 784 | 1060 | // --- status, api, browse --------------------------------------------- | |
| 785 | 1061 | ||
| 786 | 1062 | #[derive(Debug, Args)] | |
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | /// The same client, giving up sooner: for commands an agent's hooks run | |
| 75 | + | /// in the background, which must not hang on a slow network. | |
| 76 | + | pub fn with_timeouts(mut self, connect: Duration, read: Duration) -> Client { | |
| 77 | + | self.agent = ureq::AgentBuilder::new() | |
| 78 | + | .timeout_connect(connect) | |
| 79 | + | .timeout_read(read) | |
| 80 | + | .timeout_write(read) | |
| 81 | + | .user_agent(&format!("g1t-cli/{}", env!("CARGO_PKG_VERSION"))) | |
| 82 | + | .redirects(5) | |
| 83 | + | .build(); | |
| 84 | + | self | |
| 85 | + | } | |
| 86 | + | ||
| 74 | 87 | pub fn has_token(&self) -> bool { | |
| 75 | 88 | self.token.is_some() | |
| 76 | 89 | } | |
| ⋯ | |||
| 175 | 188 | ) -> Result<Value> { | |
| 176 | 189 | let bytes = body.map(|b| serde_json::to_vec(b).expect("json")); | |
| 177 | 190 | let reply = self.raw(method, path, query, &[], bytes)?; | |
| 191 | + | if older_server(&reply) { | |
| 192 | + | return Err(Error::failed(format!( | |
| 193 | + | "{} has no {method} {path}: the g1t server is older than this CLI ({}), and cannot do this yet.", | |
| 194 | + | self.host.api, | |
| 195 | + | env!("CARGO_PKG_VERSION") | |
| 196 | + | ))); | |
| 197 | + | } | |
| 178 | 198 | if !(200..300).contains(&reply.status) { | |
| 179 | 199 | return Err(self.api_error(&reply)); | |
| 180 | 200 | } | |
| ⋯ | |||
| 231 | 251 | } | |
| 232 | 252 | } | |
| 233 | 253 | ||
| 254 | + | /// Whether the API answered that it has no such route at all, as a server | |
| 255 | + | /// older than the CLI does for an operation it does not have, rather than | |
| 256 | + | /// that the thing asked about was not found. | |
| 257 | + | pub fn older_server(reply: &Reply) -> bool { | |
| 258 | + | reply.status == 404 | |
| 259 | + | && reply | |
| 260 | + | .json() | |
| 261 | + | .is_some_and(|body| body["error"]["message"].as_str() == Some("No such endpoint.")) | |
| 262 | + | } | |
| 263 | + | ||
| 234 | 264 | /// One call to an operation, its path filled in as it is built. | |
| 235 | 265 | pub struct Call<'a> { | |
| 236 | 266 | client: &'a Client, | |
| 298 | 298 | fn list_ops(ctx: &Ctx, query: &str) -> Result<()> { | |
| 299 | 299 | let q = query.to_lowercase(); | |
| 300 | 300 | let mapped = crate::mapping::by_operation(); | |
| 301 | − | let found: Vec<&Operation> = ops::OPERATIONS | |
| 302 | − | .iter() | |
| 301 | + | let found: Vec<&Operation> = ops::all() | |
| 303 | 302 | .filter(|op| { | |
| 304 | 303 | q.is_empty() | |
| 305 | 304 | || op.id.contains(&q) |
| 11 | 11 | pub mod repo; | |
| 12 | 12 | pub mod run; | |
| 13 | 13 | pub mod secret; | |
| 14 | + | pub mod session; | |
| 14 | 15 | pub mod status; | |
| 16 | + | pub mod why; | |
| 17 | + | pub mod work; | |
| 15 | 18 | ||
| 16 | 19 | use clap::CommandFactory; | |
| 17 | 20 | ||
| ⋯ | |||
| 40 | 43 | Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command), | |
| 41 | 44 | Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args), | |
| 42 | 45 | Command::Status(_) => status::run(&Ctx::new(cli.global)?), | |
| 46 | + | Command::Session(command) => session::run(&Ctx::new(cli.global)?, command), | |
| 47 | + | Command::Work(command) => work::run(&Ctx::new(cli.global)?, command), | |
| 48 | + | Command::Why(args) => why::run(&Ctx::new(cli.global)?, args), | |
| 43 | 49 | Command::Api(args) => api::run(&Ctx::new(cli.global)?, args), | |
| 44 | 50 | Command::Browse(args) => browse::run(&Ctx::new(cli.global)?, args), | |
| 45 | 51 | } | |
| ⋯ | |||
| 54 | 60 | } | |
| 55 | 61 | } | |
| 56 | 62 | ||
| 63 | + | /// The repository and a signed-in client for the coordination commands | |
| 64 | + | /// (`g1t session`, `g1t work`, `g1t why`). Agents' hooks run them in the | |
| 65 | + | /// background, so they fail at once without a token (exit code 4), never | |
| 66 | + | /// ask anything, and give up on a call after `read` rather than hang. | |
| 67 | + | pub fn coordination_client( | |
| 68 | + | ctx: &Ctx, | |
| 69 | + | read: std::time::Duration, | |
| 70 | + | ) -> Result<(crate::repo::Repo, crate::client::Client)> { | |
| 71 | + | let (repo, client) = ctx.repo_client()?; | |
| 72 | + | if !client.has_token() { | |
| 73 | + | return Err(crate::context::not_signed_in(&client.host)); | |
| 74 | + | } | |
| 75 | + | Ok((repo, client.with_timeouts(std::time::Duration::from_secs(5), read))) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// How long a coordination command waits for an answer. | |
| 79 | + | pub const QUICK: std::time::Duration = std::time::Duration::from_secs(15); | |
| 80 | + | ||
| 81 | + | /// Who a session, claim or decision belongs to: its `actor` (a login, or | |
| 82 | + | /// an account with one), or its `user`. | |
| 83 | + | pub fn login_of(value: &serde_json::Value) -> String { | |
| 84 | + | for key in ["actor", "user", "author"] { | |
| 85 | + | let who = &value[key]; | |
| 86 | + | if let Some(login) = who | |
| 87 | + | .as_str() | |
| 88 | + | .or_else(|| who["username"].as_str()) | |
| 89 | + | .or_else(|| who["login"].as_str()) | |
| 90 | + | .filter(|login| !login.is_empty()) | |
| 91 | + | { | |
| 92 | + | return login.to_owned(); | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | String::new() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The object under `key` when the answer wraps it, or the answer itself. | |
| 99 | + | pub fn unwrap_key(value: serde_json::Value, key: &str) -> serde_json::Value { | |
| 100 | + | match value.get(key) { | |
| 101 | + | Some(inner) if inner.is_object() => inner.clone(), | |
| 102 | + | _ => value, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 57 | 106 | /// The items of a list answer, which comes bare or under a key. | |
| 58 | 107 | pub fn items(value: serde_json::Value, key: &str) -> Vec<serde_json::Value> { | |
| 59 | 108 | match value { | |
| 1 | + | //! `g1t session`: one run of an agent's harness, recorded as it works. | |
| 2 | + | //! | |
| 3 | + | //! The session a working copy is in is kept in `<git dir>/g1t/session`, | |
| 4 | + | //! where `<git dir>` is the worktree's own (`git rev-parse | |
| 5 | + | //! --absolute-git-dir`), so each worktree has its own; `G1T_SESSION` | |
| 6 | + | //! overrides it. `g1t work claim` attaches its claim to it. | |
| 7 | + | ||
| 8 | + | use serde_json::{Value, json}; | |
| 9 | + | use std::io::Read; | |
| 10 | + | use std::path::PathBuf; | |
| 11 | + | ||
| 12 | + | use crate::cli::{ | |
| 13 | + | SessionAttachArgs, SessionCommand, SessionEndArgs, SessionListArgs, SessionLogArgs, | |
| 14 | + | SessionStartArgs, SessionState, SessionViewArgs, | |
| 15 | + | }; | |
| 16 | + | use crate::client::Client; | |
| 17 | + | use crate::context::Ctx; | |
| 18 | + | use crate::error::{Error, Result}; | |
| 19 | + | use crate::output::{Cell, Output, ago, s}; | |
| 20 | + | use crate::repo::Repo; | |
| 21 | + | ||
| 22 | + | pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> { | |
| 23 | + | match command { | |
| 24 | + | SessionCommand::Start(args) => start(ctx, args), | |
| 25 | + | SessionCommand::Heartbeat => heartbeat(ctx), | |
| 26 | + | SessionCommand::Log(args) => log(ctx, args), | |
| 27 | + | SessionCommand::Attach(args) => attach(ctx, args), | |
| 28 | + | SessionCommand::End(args) => end(ctx, args), | |
| 29 | + | SessionCommand::List(args) => list(ctx, args), | |
| 30 | + | SessionCommand::View(args) => view(ctx, args), | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Where this working copy's session is kept: `<git dir>/g1t/session`. | |
| 35 | + | fn file() -> Option<PathBuf> { | |
| 36 | + | crate::git::run(&["rev-parse", "--absolute-git-dir"]) | |
| 37 | + | .ok() | |
| 38 | + | .filter(|dir| !dir.is_empty()) | |
| 39 | + | .map(|dir| PathBuf::from(dir).join("g1t").join("session")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The session id in `G1T_SESSION`, else the one kept in `file`. | |
| 43 | + | fn chosen(env: Option<String>, file: Option<PathBuf>) -> Option<String> { | |
| 44 | + | env.map(|id| id.trim().to_owned()) | |
| 45 | + | .filter(|id| !id.is_empty()) | |
| 46 | + | .or_else(|| { | |
| 47 | + | let text = std::fs::read_to_string(file?).ok()?; | |
| 48 | + | let id = text.lines().next()?.trim().to_owned(); | |
| 49 | + | (!id.is_empty()).then_some(id) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// The current session's id, if there is one. | |
| 54 | + | pub fn current() -> Option<String> { | |
| 55 | + | chosen(std::env::var("G1T_SESSION").ok(), file()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn required() -> Result<String> { | |
| 59 | + | current().ok_or_else(|| { | |
| 60 | + | Error::usage("No session here: start one with `g1t session start`, or set G1T_SESSION.") | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn remember(id: &str) -> Result<()> { | |
| 65 | + | let Some(path) = file() else { | |
| 66 | + | // Outside a working copy there is nowhere to keep it; G1T_SESSION does. | |
| 67 | + | return Ok(()); | |
| 68 | + | }; | |
| 69 | + | if let Some(dir) = path.parent() { | |
| 70 | + | std::fs::create_dir_all(dir)?; | |
| 71 | + | } | |
| 72 | + | std::fs::write(&path, format!("{id}\n"))?; | |
| 73 | + | Ok(()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Forgets the kept session, when it is `id`. | |
| 77 | + | fn forget(id: &str) { | |
| 78 | + | if let Some(path) = file() | |
| 79 | + | && chosen(None, Some(path.clone())).as_deref() == Some(id) | |
| 80 | + | { | |
| 81 | + | let _ = std::fs::remove_file(path); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The session in an answer that may wrap it (`{session, entries}`). | |
| 86 | + | fn session_of(found: Value) -> Value { | |
| 87 | + | super::unwrap_key(found, "session") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | fn start(ctx: &Ctx, args: SessionStartArgs) -> Result<()> { | |
| 91 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 92 | + | let branch = args.branch.clone().or_else(crate::git::current_branch); | |
| 93 | + | let head = crate::git::run(&["rev-parse", "HEAD"]).ok().filter(|h| !h.is_empty()); | |
| 94 | + | let found = client | |
| 95 | + | .op("start_session") | |
| 96 | + | .repo(&repo) | |
| 97 | + | .field("harness", args.harness.name()) | |
| 98 | + | .field_opt("harness_session_id", args.harness_session_id.clone()) | |
| 99 | + | .field_opt("issue", args.issue) | |
| 100 | + | .field_opt("pull", args.pull) | |
| 101 | + | .field_opt("branch", branch) | |
| 102 | + | .field_opt("title", args.title.clone()) | |
| 103 | + | .field_opt("head_commit", head) | |
| 104 | + | .send()?; | |
| 105 | + | let session = session_of(found); | |
| 106 | + | let id = s(&session, "id").to_owned(); | |
| 107 | + | if id.is_empty() { | |
| 108 | + | return Err(Error::failed("The API did not say which session it started.")); | |
| 109 | + | } | |
| 110 | + | remember(&id)?; | |
| 111 | + | if ctx.out.structured() { | |
| 112 | + | return ctx.out.emit(&session); | |
| 113 | + | } | |
| 114 | + | println!("{id}"); | |
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn heartbeat(ctx: &Ctx) -> Result<()> { | |
| 119 | + | let id = required()?; | |
| 120 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 121 | + | let found = client.op("update_session").repo(&repo).arg("id", &id).send()?; | |
| 122 | + | if ctx.out.structured() { | |
| 123 | + | return ctx.out.emit(&session_of(found)); | |
| 124 | + | } | |
| 125 | + | Ok(()) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> { | |
| 129 | + | let id = required()?; | |
| 130 | + | let text = if args.text == "-" { | |
| 131 | + | let mut text = String::new(); | |
| 132 | + | std::io::stdin().read_to_string(&mut text)?; | |
| 133 | + | text | |
| 134 | + | } else { | |
| 135 | + | args.text.clone() | |
| 136 | + | }; | |
| 137 | + | if text.trim().is_empty() { | |
| 138 | + | return Err(Error::usage("Nothing to record: give the text.")); | |
| 139 | + | } | |
| 140 | + | let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() }); | |
| 141 | + | if let Some(tool) = &args.tool { | |
| 142 | + | entry["tool"] = json!(tool); | |
| 143 | + | } | |
| 144 | + | if let Some(key) = &args.key { | |
| 145 | + | entry["key"] = json!(key); | |
| 146 | + | } | |
| 147 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 148 | + | let found = client | |
| 149 | + | .op("record_session_entries") | |
| 150 | + | .repo(&repo) | |
| 151 | + | .arg("id", &id) | |
| 152 | + | .field("entries", json!([entry])) | |
| 153 | + | .send()?; | |
| 154 | + | if ctx.out.structured() { | |
| 155 | + | return ctx.out.emit(&found); | |
| 156 | + | } | |
| 157 | + | Ok(()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Where a session is: `#12`, `pull #14`, its branch. | |
| 161 | + | fn place(session: &Value) -> String { | |
| 162 | + | let mut parts = Vec::new(); | |
| 163 | + | if let Some(issue) = session["issue"].as_u64() { | |
| 164 | + | parts.push(format!("#{issue}")); | |
| 165 | + | } | |
| 166 | + | if let Some(pull) = session["pull"].as_u64() { | |
| 167 | + | parts.push(format!("pull #{pull}")); | |
| 168 | + | } | |
| 169 | + | if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) { | |
| 170 | + | parts.push(branch.to_owned()); | |
| 171 | + | } | |
| 172 | + | parts.join(", ") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> { | |
| 176 | + | let id = required()?; | |
| 177 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 178 | + | let found = client | |
| 179 | + | .op("update_session") | |
| 180 | + | .repo(&repo) | |
| 181 | + | .arg("id", &id) | |
| 182 | + | .field_opt("issue", args.issue) | |
| 183 | + | .field_opt("pull", args.pull) | |
| 184 | + | .field_opt("branch", args.branch.clone()) | |
| 185 | + | .field_opt("head_commit", args.head_commit.clone()) | |
| 186 | + | .send()?; | |
| 187 | + | let session = session_of(found); | |
| 188 | + | if ctx.out.structured() { | |
| 189 | + | return ctx.out.emit(&session); | |
| 190 | + | } | |
| 191 | + | eprintln!("Session {id} is on {}.", place(&session)); | |
| 192 | + | Ok(()) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> { | |
| 196 | + | let id = required()?; | |
| 197 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 198 | + | let found = client | |
| 199 | + | .op("end_session") | |
| 200 | + | .repo(&repo) | |
| 201 | + | .arg("id", &id) | |
| 202 | + | .field_opt("reason", args.reason.clone()) | |
| 203 | + | .send()?; | |
| 204 | + | forget(&id); | |
| 205 | + | if ctx.out.structured() { | |
| 206 | + | return ctx.out.emit(&session_of(found)); | |
| 207 | + | } | |
| 208 | + | eprintln!("Ended session {id}."); | |
| 209 | + | Ok(()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | pub fn fetch_list( | |
| 213 | + | client: &Client, | |
| 214 | + | repo: &Repo, | |
| 215 | + | state: Option<&str>, | |
| 216 | + | issue: Option<u64>, | |
| 217 | + | pull: Option<u64>, | |
| 218 | + | ) -> Result<Vec<Value>> { | |
| 219 | + | let found = client | |
| 220 | + | .op("list_sessions") | |
| 221 | + | .repo(repo) | |
| 222 | + | .query_opt("state", state) | |
| 223 | + | .query_opt("issue", issue) | |
| 224 | + | .query_opt("pull", pull) | |
| 225 | + | .send()?; | |
| 226 | + | Ok(super::items(found, "sessions")) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> { | |
| 230 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 231 | + | let state = args.state.map(|state| match state { | |
| 232 | + | SessionState::Active => "active", | |
| 233 | + | SessionState::Idle => "idle", | |
| 234 | + | SessionState::Ended => "ended", | |
| 235 | + | }); | |
| 236 | + | let mut sessions = fetch_list(&client, &repo, state, args.issue, args.pull)?; | |
| 237 | + | sessions.truncate(args.limit); | |
| 238 | + | if ctx.out.structured() { | |
| 239 | + | return ctx.out.emit(&Value::Array(sessions)); | |
| 240 | + | } | |
| 241 | + | if sessions.is_empty() { | |
| 242 | + | eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching")); | |
| 243 | + | return Ok(()); | |
| 244 | + | } | |
| 245 | + | print_list(&ctx.out, &sessions, current().as_deref()) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> { | |
| 249 | + | let rows: Vec<Vec<Cell>> = sessions | |
| 250 | + | .iter() | |
| 251 | + | .map(|session| { | |
| 252 | + | let id = s(session, "id"); | |
| 253 | + | let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() }; | |
| 254 | + | vec![ | |
| 255 | + | Cell::new(marked), | |
| 256 | + | Cell::styled(s(session, "state"), out.state(s(session, "state"))), | |
| 257 | + | Cell::new(super::login_of(session)), | |
| 258 | + | Cell::new(s(session, "harness")), | |
| 259 | + | Cell::new(place(session)), | |
| 260 | + | Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))), | |
| 261 | + | ] | |
| 262 | + | }) | |
| 263 | + | .collect(); | |
| 264 | + | out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> { | |
| 268 | + | let id = match args.id { | |
| 269 | + | Some(id) => id, | |
| 270 | + | None => required()?, | |
| 271 | + | }; | |
| 272 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 273 | + | let found = client.op("get_session").repo(&repo).arg("id", &id).send()?; | |
| 274 | + | let entries = found | |
| 275 | + | .get("entries") | |
| 276 | + | .cloned() | |
| 277 | + | .unwrap_or_else(|| json!([])); | |
| 278 | + | let mut session = session_of(found); | |
| 279 | + | if let Some(map) = session.as_object_mut() { | |
| 280 | + | map.insert("entries".into(), entries); | |
| 281 | + | } | |
| 282 | + | if ctx.out.structured() { | |
| 283 | + | return ctx.out.emit(&session); | |
| 284 | + | } | |
| 285 | + | let out = &ctx.out; | |
| 286 | + | let title = s(&session, "title"); | |
| 287 | + | println!( | |
| 288 | + | "{} {}", | |
| 289 | + | out.bold(if title.is_empty() { s(&session, "id") } else { title }), | |
| 290 | + | out.dim(&format!("{repo} {}", s(&session, "id"))) | |
| 291 | + | ); | |
| 292 | + | let mut facts = vec![out.state(s(&session, "state"))]; | |
| 293 | + | let who = super::login_of(&session); | |
| 294 | + | if !who.is_empty() { | |
| 295 | + | facts.push(format!("{who} with {}", s(&session, "harness"))); | |
| 296 | + | } | |
| 297 | + | facts.push(format!("started {}", ago(s(&session, "started_at")))); | |
| 298 | + | if let Some(seen) = session["last_seen_at"].as_str() { | |
| 299 | + | facts.push(format!("last seen {}", ago(seen))); | |
| 300 | + | } | |
| 301 | + | println!("{}", facts.join(" · ")); | |
| 302 | + | let on = place(&session); | |
| 303 | + | if !on.is_empty() { | |
| 304 | + | println!("{} {on}", out.dim("On:")); | |
| 305 | + | } | |
| 306 | + | if let Some(head) = session["head_commit"].as_str() { | |
| 307 | + | println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head)); | |
| 308 | + | } | |
| 309 | + | if let Some(reason) = session["end_reason"].as_str() { | |
| 310 | + | println!("{} {reason}", out.dim("Ended:")); | |
| 311 | + | } | |
| 312 | + | let entries = session["entries"].as_array().cloned().unwrap_or_default(); | |
| 313 | + | if !entries.is_empty() { | |
| 314 | + | println!(); | |
| 315 | + | } | |
| 316 | + | for entry in &entries { | |
| 317 | + | let kind = s(entry, "kind"); | |
| 318 | + | let label = match entry["tool"].as_str() { | |
| 319 | + | Some(tool) => format!("{kind} {tool}"), | |
| 320 | + | None => kind.to_owned(), | |
| 321 | + | }; | |
| 322 | + | let text = s(entry, "text").trim(); | |
| 323 | + | let first = text.lines().next().unwrap_or(""); | |
| 324 | + | println!("{} {first}", out.dim(&format!("{label:>10}"))); | |
| 325 | + | } | |
| 326 | + | Ok(()) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[cfg(test)] | |
| 330 | + | mod tests { | |
| 331 | + | use super::*; | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn the_environment_overrides_the_kept_session() { | |
| 335 | + | let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id())); | |
| 336 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 337 | + | let path = dir.join("session"); | |
| 338 | + | std::fs::write(&path, "ses_kept\n").unwrap(); | |
| 339 | + | assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept")); | |
| 340 | + | assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env")); | |
| 341 | + | assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept")); | |
| 342 | + | std::fs::write(&path, "\n").unwrap(); | |
| 343 | + | assert_eq!(chosen(None, Some(path)).as_deref(), None); | |
| 344 | + | assert_eq!(chosen(None, None), None); | |
| 345 | + | let _ = std::fs::remove_dir_all(dir); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn a_session_says_where_it_is() { | |
| 350 | + | assert_eq!(place(&json!({ "issue": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x"); | |
| 351 | + | assert_eq!(place(&json!({ "issue": null, "branch": "" })), ""); | |
| 352 | + | } | |
| 353 | + | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…