A repository's events can be followed: list_events takes after, an event id, and returns the events the log wrote after it oldest first, with types and numbers (comma lists in a query, arrays in a body or MCP call), since and limit (at most 200) alongside, and with wait (seconds, at most 25) a call that finds nothing holds on, asking the events service about once a second, until a matching event is written or the wait ends. The log numbers events in the order it writes them (seq, migration 0010), and after follows that order rather than ids, which are made where events are published and can reach the log out of order, so a follower never skips one; a cursor the log does not have starts at the next id it does. Holding a request costs the Worker wall time, not CPU time, and at most 26 subrequests. The docs, the OpenAPI document and the CLI's operation table describe the new parameters.
12 files+373−210/12 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); | |
| 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 |
| 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>, | |
| 3721 | 3721 | Param { name: "owner", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 3722 | 3722 | Param { name: "name", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 3723 | 3723 | Param { name: "before", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 3724 | + | Param { name: "after", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 3725 | + | Param { name: "types", location: In::Query, kind: "array", required: false, values: &[] }, | |
| 3726 | + | Param { name: "numbers", location: In::Query, kind: "array", required: false, values: &[] }, | |
| 3727 | + | Param { name: "since", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 3728 | + | Param { name: "limit", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 3729 | + | Param { name: "wait", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 3724 | 3730 | ], | |
| 3725 | 3731 | body: &[], | |
| 3726 | 3732 | }, |
| 1 | + | -- The order the log wrote events in. Ids are made when an event is | |
| 2 | + | -- published, on whichever Worker published it, and events reach the log | |
| 3 | + | -- through a queue, so an event can be written after one with a later id. | |
| 4 | + | -- Following the log with an `after` cursor reads by this instead: what is | |
| 5 | + | -- written later always has a higher number, so a follower never skips one. | |
| 6 | + | -- Each insert takes the highest so far plus one; the log has one writer | |
| 7 | + | -- at a time. Events written before this have none and are not followed. | |
| 8 | + | ALTER TABLE events ADD COLUMN seq INTEGER; | |
| 9 | + | CREATE INDEX IF NOT EXISTS events_seq ON events (seq); | |
| 10 | + | CREATE INDEX IF NOT EXISTS events_repo_seq ON events (repo_id, seq); |
| 147 | 147 | send(&js::binding(&self.env, "BUS")?, &events).await | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | − | /// Newest first. Callers must have checked that the viewer may see the | |
| 150 | + | /// Newest first, or with `after` oldest first in the order the log | |
| 151 | + | /// wrote them. Callers must have checked that the viewer may see the | |
| 151 | 152 | /// repository asked about. | |
| 152 | 153 | async fn list(&self, a: ListArgs) -> Result<Vec<Event>> { | |
| 153 | 154 | let (filter, binds) = list_filter(&a); | |
| ⋯ | |||
| 159 | 160 | }) | |
| 160 | 161 | .collect(); | |
| 161 | 162 | values.push(a.limit.unwrap_or(DEFAULT_PAGE).min(MAX_PAGE).into()); | |
| 163 | + | let order = if a.after.is_some() { "seq ASC" } else { "id DESC" }; | |
| 162 | 164 | let rows = self | |
| 163 | 165 | .db | |
| 164 | 166 | .prepare(format!( | |
| 165 | − | "SELECT * FROM events {filter} ORDER BY id DESC LIMIT ?" | |
| 167 | + | "SELECT * FROM events {filter} ORDER BY {order} LIMIT ?" | |
| 166 | 168 | )) | |
| 167 | 169 | .bind(&values)? | |
| 168 | 170 | .all() | |
| ⋯ | |||
| 185 | 187 | self.db | |
| 186 | 188 | .prepare( | |
| 187 | 189 | // Redelivered batches must not duplicate log rows. | |
| 188 | − | "INSERT OR IGNORE INTO events (id, type, source, time, repo_id, actor, data) | |
| 189 | − | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 190 | + | // `seq` is the order of writing, which `after` follows | |
| 191 | + | // (0010_events_seq.sql): the log has one writer. | |
| 192 | + | "INSERT OR IGNORE INTO events (id, type, source, time, repo_id, actor, data, seq) | |
| 193 | + | VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM events))", | |
| 190 | 194 | ) | |
| 191 | 195 | .bind(&[ | |
| 192 | 196 | event.id.as_str().into(), | |
| ⋯ | |||
| 446 | 450 | } | |
| 447 | 451 | } | |
| 448 | 452 | ||
| 453 | + | /// The condition of an `after` cursor, binding its id twice. | |
| 454 | + | const AFTER: &str = concat!( | |
| 455 | + | "seq > COALESCE(", | |
| 456 | + | "(SELECT seq FROM events WHERE id = ?), ", | |
| 457 | + | "(SELECT seq FROM events WHERE id > ? AND seq IS NOT NULL ORDER BY id LIMIT 1) - 1, ", | |
| 458 | + | "(SELECT MAX(seq) FROM events), 0)" | |
| 459 | + | ); | |
| 460 | + | ||
| 449 | 461 | /// A value bound to a `?` in [`list_filter`]'s clause. | |
| 450 | 462 | #[derive(Debug, PartialEq)] | |
| 451 | 463 | enum Bind { | |
| ⋯ | |||
| 496 | 508 | conditions.push("id < ?".to_owned()); | |
| 497 | 509 | values.push(Bind::Text(before.clone())); | |
| 498 | 510 | } | |
| 511 | + | if let Some(after) = &a.after { | |
| 512 | + | // What the log wrote after the cursor's event. A cursor the log | |
| 513 | + | // does not have (yet: an id from a webhook can arrive before its | |
| 514 | + | // row is written) starts at the first event with a later id, and | |
| 515 | + | // one past them all at the end of the log. | |
| 516 | + | conditions.push(AFTER.to_owned()); | |
| 517 | + | values.push(Bind::Text(after.clone())); | |
| 518 | + | values.push(Bind::Text(after.clone())); | |
| 519 | + | } | |
| 499 | 520 | let filter = if conditions.is_empty() { | |
| 500 | 521 | String::new() | |
| 501 | 522 | } else { | |
| ⋯ | |||
| 545 | 566 | ] | |
| 546 | 567 | ); | |
| 547 | 568 | } | |
| 569 | + | ||
| 570 | + | #[test] | |
| 571 | + | fn an_after_cursor_reads_what_the_log_wrote_later_with_every_other_filter() { | |
| 572 | + | let (filter, binds) = list_filter(&ListArgs { | |
| 573 | + | repo_id: Some("rep_1".into()), | |
| 574 | + | types: vec!["work.claimed".into(), "integration.failed".into()], | |
| 575 | + | numbers: vec![7], | |
| 576 | + | since: Some("2026-10-12T00:00:00Z".into()), | |
| 577 | + | after: Some("evt_9".into()), | |
| 578 | + | ..ListArgs::default() | |
| 579 | + | }); | |
| 580 | + | assert_eq!( | |
| 581 | + | filter, | |
| 582 | + | format!( | |
| 583 | + | "WHERE repo_id = ? AND type IN (?, ?) AND \ | |
| 584 | + | (json_extract(data, '$.number') IN (?) OR json_extract(data, '$.issue') IN (?)) AND time >= ? AND {AFTER}" | |
| 585 | + | ) | |
| 586 | + | ); | |
| 587 | + | // The cursor's id is bound once to find its place, and once for a | |
| 588 | + | // cursor the log does not have. | |
| 589 | + | assert_eq!(&binds[binds.len() - 2..], &[Bind::Text("evt_9".into()), Bind::Text("evt_9".into())]); | |
| 590 | + | assert_eq!(binds.len(), 8); | |
| 591 | + | assert_eq!(AFTER.matches('?').count(), 2); | |
| 592 | + | } | |
| 548 | 593 | } | |
Comments
Sign in to comment.