Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 1 | //! `get_workspace_activity`: what happened across a workspace's |
| 2 | //! repositories over a span, newest first, as one list a person (or Ask | |
| 3 | //! g1t, or an agent) can read: pull requests merged and opened, issues | |
| 4 | //! opened and closed, releases, deployments and pushes, each with its | |
| 5 | //! repository, who did it, its title where it has one and its address on | |
| 6 | //! the site. "What shipped today?" is one call. | |
| 7 | //! | |
| 8 | //! The repositories are those of the workspace the caller can read, as the | |
| 9 | //! repos service lists them for them; the events service reads their events | |
| 10 | //! as one list (`repo_ids`). Pull requests' titles, which their events do | |
| 11 | //! not carry, come from the work service's newest pull requests of the | |
| 12 | //! repositories that had any; people's names from identity. Everything | |
| 13 | //! after the reads is [`shape`], a plain function, tested on its own. | |
| 14 | ||
| 15 | use std::collections::{BTreeSet, HashMap}; | |
| 16 | ||
| 17 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS}; | |
| 18 | use g1t_contracts::identity::UsernamesArgs; | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 19 | use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath}; |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 20 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; |
| 21 | use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls}; | |
| 22 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 23 | use serde_json::{Value, json}; | |
| 24 | use worker::Result; | |
| 25 | ||
| 26 | use crate::operations::Services; | |
| 27 | ||
| 28 | /// The span when none is given: the last day. | |
| 29 | const DEFAULT_SPAN_MS: u64 = 24 * 60 * 60 * 1000; | |
| 30 | /// The longest span one call reads. | |
| 31 | const MAX_SPAN_MS: u64 = 31 * 24 * 60 * 60 * 1000; | |
| 32 | pub const DEFAULT_LIMIT: u32 = 100; | |
| 33 | pub const MAX_LIMIT: u32 = 200; | |
| 34 | ||
| 35 | /// The kinds a caller may ask for, and the events each is. | |
| 36 | pub const KINDS: &[(&str, &[&str])] = &[ | |
| 37 | ("merged", &["pull.merged"]), | |
| 38 | ("pulls", &["pull.opened", "pull.merged", "pull.closed"]), | |
| 39 | ("issues", &["issue.opened", "issue.closed"]), | |
| 40 | ("releases", &["release.published"]), | |
| 41 | ("deployments", &["deployment.succeeded", "deployment.failed"]), | |
| 42 | ("pushes", &["git.push"]), | |
| 43 | ("reviews", &["review.completed"]), | |
| 44 | ("comments", &["comment.created"]), | |
| 45 | ("packages", &["package.published"]), | |
| 46 | ("runs", &["workflow.completed"]), | |
| 47 | ]; | |
| 48 | ||
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 49 | /// The most repositories whose default-branch commits are read, and the |
| 50 | /// most commits that `landed` lists across them. | |
| 51 | const LANDED_REPOS: usize = 8; | |
| 52 | const LANDED_MAX: usize = 40; | |
| 53 | /// How far back each of those repositories' history is read. | |
| 54 | const LANDED_LOG: u32 = 60; | |
| 55 | ||
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 56 | /// What is read when no kinds are given: everything but comments, which |
| 57 | /// would crowd out the rest on a busy day. | |
| 58 | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 59 | ||
| 60 | pub fn description() -> &'static str { | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 61 | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments, default-branch pushes and failed runs: what shipped, and what broke. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author`, `time`, `url`): what shipped where it was pushed rather than merged. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 62 | } |
| 63 | ||
| 64 | pub fn input() -> Value { | |
| 65 | json!({ | |
| 66 | "type": "object", | |
| 67 | "properties": { | |
| 68 | "workspace": { "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }, | |
| 69 | "since": { "type": "string", "description": "RFC 3339: the start of the span, inclusive. The last 24 hours when left out; for \"today\", midnight UTC." }, | |
| 70 | "until": { "type": "string", "description": "RFC 3339: the end of the span, exclusive. Now when left out." }, | |
| 71 | "kinds": { | |
| 72 | "type": "array", | |
| 73 | "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() }, | |
| 74 | "description": "What to read: merged (merged pull requests only), pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.", | |
| 75 | }, | |
| 76 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." }, | |
| 77 | }, | |
| 78 | "required": ["workspace"], | |
| 79 | }) | |
| 80 | } | |
| 81 | ||
| 82 | /// The span asked for, in milliseconds, or why it cannot be read. | |
| 83 | pub fn span(input: &Value, now: u64) -> std::result::Result<(u64, u64), String> { | |
| 84 | let at = |key: &str| -> std::result::Result<Option<u64>, String> { | |
| 85 | match input.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) { | |
| 86 | None => Ok(None), | |
| 87 | Some(text) => parse_rfc3339(text).map(Some).ok_or_else(|| format!("{key} must be an RFC 3339 time, such as 2026-10-12T00:00:00Z.")), | |
| 88 | } | |
| 89 | }; | |
| 90 | let until = at("until")?.unwrap_or(now).min(now); | |
| 91 | let since = at("since")?.unwrap_or(until.saturating_sub(DEFAULT_SPAN_MS)); | |
| 92 | if since >= until { | |
| 93 | return Err("since must be before until.".to_owned()); | |
| 94 | } | |
| 95 | if until - since > MAX_SPAN_MS { | |
| 96 | return Err("A span can be at most 31 days. Narrow since and until.".to_owned()); | |
| 97 | } | |
| 98 | Ok((since, until)) | |
| 99 | } | |
| 100 | ||
| 101 | /// The event types the `kinds` asked for read, or the unknown kind. | |
| 102 | pub fn event_types(input: &Value) -> std::result::Result<Vec<&'static str>, String> { | |
| 103 | // An array, or over REST a comma-separated list. | |
| 104 | let asked: Vec<String> = match input.get("kinds") { | |
| 105 | Some(Value::Array(kinds)) => kinds.iter().filter_map(Value::as_str).map(str::to_owned).collect(), | |
| 106 | Some(Value::String(kinds)) => kinds.split(',').map(str::to_owned).collect(), | |
| 107 | _ => Vec::new(), | |
| 108 | } | |
| 109 | .into_iter() | |
| 110 | .map(|kind| kind.trim().to_lowercase()) | |
| 111 | .filter(|kind| !kind.is_empty()) | |
| 112 | .collect(); | |
| 113 | let names: Vec<&str> = if asked.is_empty() { DEFAULT_KINDS.to_vec() } else { asked.iter().map(String::as_str).collect() }; | |
| 114 | let mut types = BTreeSet::new(); | |
| 115 | for name in names { | |
| 116 | let Some((_, kinds)) = KINDS.iter().find(|(kind, _)| *kind == name) else { | |
| 117 | let known: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect(); | |
| 118 | return Err(format!("There is no kind {name}. Kinds: {}.", known.join(", "))); | |
| 119 | }; | |
| 120 | types.extend(kinds.iter().copied()); | |
| 121 | } | |
| 122 | Ok(types.into_iter().collect()) | |
| 123 | } | |
| 124 | ||
| 125 | /// The most events asked for: a number, or over REST its text. | |
| 126 | pub fn limit(input: &Value) -> u32 { | |
| 127 | let asked = match input.get("limit") { | |
| 128 | Some(Value::String(text)) => text.trim().parse::<u64>().ok(), | |
| 129 | Some(value) => value.as_u64(), | |
| 130 | None => None, | |
| 131 | }; | |
| 132 | asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32) | |
| 133 | } | |
| 134 | ||
| 135 | fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> { | |
| 136 | data.get(key).and_then(Value::as_str).filter(|text| !text.is_empty()) | |
| 137 | } | |
| 138 | ||
| 139 | /// What the reads found, shaped into the answer. | |
| 140 | pub struct Found<'a> { | |
| 141 | pub workspace: &'a str, | |
| 142 | pub site: &'a str, | |
| 143 | pub since: u64, | |
| 144 | pub until: u64, | |
| 145 | pub repos: &'a [Repo], | |
| 146 | /// Newest first, as the events service lists them. | |
| 147 | pub events: &'a [Event], | |
| 148 | /// Whether the events service had more than was read. | |
| 149 | pub more: bool, | |
| 150 | /// `(repo id, number)` to a pull request's title. | |
| 151 | pub pull_titles: &'a HashMap<(String, u32), String>, | |
| 152 | /// Account id to username. | |
| 153 | pub usernames: &'a HashMap<String, String>, | |
| 154 | } | |
| 155 | ||
| 156 | pub fn shape(found: &Found) -> Value { | |
| 157 | let site = found.site.trim_end_matches('/'); | |
| 158 | let by_id: HashMap<&str, &Repo> = found.repos.iter().map(|repo| (repo.id.as_str(), repo)).collect(); | |
| 159 | let mut events = Vec::new(); | |
| 160 | let (mut merged, mut releases, mut production, mut landed, mut failed) = (0u32, 0u32, 0u32, 0u32, 0u32); | |
| 161 | let mut runs_failed = 0u32; | |
| 162 | for event in found.events { | |
| 163 | let Some(repo) = event.repo_id.as_deref().and_then(|id| by_id.get(id)) else { | |
| 164 | continue; | |
| 165 | }; | |
| 166 | let at = parse_rfc3339(&event.time).unwrap_or_default(); | |
| 167 | if at < found.since || at >= found.until { | |
| 168 | continue; | |
| 169 | } | |
| 170 | let full = format!("{}/{}", repo.namespace, repo.name); | |
| 171 | let page = format!("{site}/{full}"); | |
| 172 | let data = &event.data; | |
| 173 | let number = data.get("number").and_then(Value::as_u64).map(|n| n as u32); | |
| 174 | let actor = event | |
| 175 | .actor | |
| 176 | .as_deref() | |
| 177 | .and_then(|id| found.usernames.get(id).cloned()) | |
| 178 | .or_else(|| data.get("author").and_then(|author| text(author, "username")).map(str::to_owned)) | |
| 179 | .or_else(|| text(data, "triggeredBy").map(str::to_owned)); | |
| 180 | let mut shown = json!({ "kind": event.kind.replace('.', "_"), "time": event.time, "repo": full }); | |
| 181 | if let Some(actor) = actor { | |
| 182 | shown["actor"] = json!(actor); | |
| 183 | } | |
| 184 | match event.kind.as_str() { | |
| 185 | "pull.opened" | "pull.merged" | "pull.closed" => { | |
| 186 | let Some(number) = number else { continue }; | |
| 187 | shown["number"] = json!(number); | |
| 188 | if let Some(title) = found.pull_titles.get(&(repo.id.clone(), number)) { | |
| 189 | shown["title"] = json!(title); | |
| 190 | } | |
| 191 | shown["url"] = json!(format!("{page}/pull/{number}")); | |
| 192 | if let Some(issue) = data.get("issue").and_then(Value::as_u64) { | |
| 193 | shown["issue"] = json!(issue); | |
| 194 | } | |
| 195 | if event.kind == "pull.merged" { | |
| 196 | merged += 1; | |
| 197 | } | |
| 198 | } | |
| 199 | "issue.opened" | "issue.closed" => { | |
| 200 | let Some(number) = number else { continue }; | |
| 201 | shown["number"] = json!(number); | |
| 202 | if let Some(title) = text(data, "title") { | |
| 203 | shown["title"] = json!(title); | |
| 204 | } | |
| 205 | if let Some(reason) = text(data, "reason") { | |
| 206 | shown["reason"] = json!(reason); | |
| 207 | } | |
| 208 | if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) { | |
| 209 | shown["resolved_by"] = json!(pull); | |
| 210 | } | |
| 211 | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 212 | } | |
| 213 | "release.published" => { | |
| 214 | let tag = text(data, "tagName").unwrap_or_default(); | |
| 215 | shown["tag"] = json!(tag); | |
| 216 | if let Some(name) = data.get("release").and_then(|release| text(release, "name")) { | |
| 217 | shown["title"] = json!(name); | |
| 218 | } | |
| 219 | shown["url"] = json!(format!("{page}/releases/tag/{tag}")); | |
| 220 | releases += 1; | |
| 221 | } | |
| 222 | "deployment.succeeded" | "deployment.failed" => { | |
| 223 | let kind = text(data, "kind").unwrap_or("preview"); | |
| 224 | shown["environment"] = json!(kind); | |
| 225 | if let Some(project) = text(data, "project") { | |
| 226 | shown["project"] = json!(project); | |
| 227 | } | |
| 228 | if let Some(branch) = text(data, "branch") { | |
| 229 | shown["branch"] = json!(branch); | |
| 230 | } | |
| 231 | if let Some(commit) = text(data, "commit") { | |
| 232 | shown["commit"] = json!(commit.chars().take(12).collect::<String>()); | |
| 233 | } | |
| 234 | if let Some(error) = text(data, "error") { | |
| 235 | shown["error"] = json!(error.chars().take(300).collect::<String>()); | |
| 236 | } | |
| 237 | shown["url"] = json!(match text(data, "path") { | |
| 238 | Some(path) => format!("{site}{path}"), | |
| 239 | None => format!("{page}/deployments"), | |
| 240 | }); | |
| 241 | if event.kind == "deployment.succeeded" && kind == "production" { | |
| 242 | production += 1; | |
| 243 | } | |
| 244 | if event.kind == "deployment.failed" { | |
| 245 | failed += 1; | |
| 246 | } | |
| 247 | } | |
| 248 | "git.push" => { | |
| 249 | // Branches only: a tag's push is its release. | |
| 250 | let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) else { continue }; | |
| 251 | let default_branch = data.get("defaultBranch").and_then(Value::as_bool) == Some(true); | |
| 252 | shown["branch"] = json!(branch); | |
| 253 | shown["default_branch"] = json!(default_branch); | |
| 254 | if let Some(commits) = data.get("commits").and_then(Value::as_u64) { | |
| 255 | shown["commits"] = json!(commits); | |
| 256 | } | |
| 257 | if let Some(after) = text(data, "after") { | |
| 258 | shown["commit"] = json!(after.chars().take(12).collect::<String>()); | |
| 259 | shown["url"] = json!(format!("{page}/commit/{after}")); | |
| 260 | } | |
| 261 | if default_branch { | |
| 262 | landed += 1; | |
| 263 | } | |
| 264 | } | |
| 265 | "review.completed" | "comment.created" => { | |
| 266 | if let Some(number) = number.or_else(|| data.get("issue").and_then(Value::as_u64).map(|n| n as u32)) { | |
| 267 | shown["number"] = json!(number); | |
| 268 | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 269 | } | |
| 270 | if let Some(verdict) = text(data, "verdict") { | |
| 271 | shown["verdict"] = json!(verdict); | |
| 272 | } | |
| 273 | } | |
| 274 | "workflow.completed" => { | |
| 275 | let conclusion = text(data, "conclusion").unwrap_or_default(); | |
| 276 | if let Some(workflow) = text(data, "workflow") { | |
| 277 | shown["workflow"] = json!(workflow); | |
| 278 | } | |
| 279 | if let Some(run_number) = data.get("number").and_then(Value::as_u64) { | |
| 280 | shown["run_number"] = json!(run_number); | |
| 281 | } | |
| 282 | shown["conclusion"] = json!(conclusion); | |
| 283 | if let Some(branch) = text(data, "ref").map(|r| r.strip_prefix("refs/heads/").unwrap_or(r)) { | |
| 284 | shown["branch"] = json!(branch); | |
| 285 | } | |
| 286 | if let Some(sha) = text(data, "sha") { | |
| 287 | shown["commit"] = json!(sha.chars().take(12).collect::<String>()); | |
| 288 | } | |
| 289 | if let Some(pull) = data.get("pull").and_then(Value::as_u64) { | |
| 290 | shown["pull"] = json!(pull); | |
| 291 | } | |
| 292 | if let Some(run) = text(data, "runId") { | |
| 293 | shown["run_id"] = json!(run); | |
| 294 | shown["url"] = json!(format!("{page}/actions/runs/{run}")); | |
| 295 | } | |
| 296 | if conclusion == "failure" { | |
| 297 | runs_failed += 1; | |
| 298 | } | |
| 299 | } | |
| 300 | "package.published" => { | |
| 301 | for key in ["ecosystem", "name", "version"] { | |
| 302 | if let Some(value) = text(data, key) { | |
| 303 | shown[key] = json!(value); | |
| 304 | } | |
| 305 | } | |
| 306 | } | |
| 307 | _ => continue, | |
| 308 | } | |
| 309 | events.push(shown); | |
| 310 | } | |
| 311 | json!({ | |
| 312 | "workspace": found.workspace, | |
| 313 | "since": rfc3339(found.since), | |
| 314 | "until": rfc3339(found.until), | |
| 315 | "repositories": found.repos.len(), | |
| 316 | "summary": { | |
| 317 | "merged_pull_requests": merged, | |
| 318 | "releases": releases, | |
| 319 | "production_deployments": production, | |
| 320 | "failed_deployments": failed, | |
| 321 | "default_branch_pushes": landed, | |
| 322 | "failed_runs": runs_failed, | |
| 323 | }, | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 324 | // What landed on default branches in the span, one line a commit |
| 325 | // (`run` fills it in): what shipped where nothing was merged. | |
| 326 | "landed": [], | |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 327 | "events": events, |
| 328 | "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS, | |
| 329 | }) | |
| 330 | } | |
| 331 | ||
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 332 | /// One repository's default-branch commits made in the span, newest first, |
| 333 | /// each as one line: its short sha, the first line of its message, who | |
| 334 | /// wrote it, when, and its page. | |
| 335 | pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64) -> Vec<Value> { | |
| 336 | let site = site.trim_end_matches('/'); | |
| 337 | commits | |
| 338 | .iter() | |
| 339 | .filter(|commit| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until)) | |
| 340 | .map(|commit| { | |
| 341 | let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect(); | |
| 342 | json!({ | |
| 343 | "repo": repo, | |
| 344 | "commit": commit.hash.chars().take(12).collect::<String>(), | |
| 345 | "subject": subject, | |
| 346 | "author": commit.author.name, | |
| 347 | "time": commit.authored_at, | |
| 348 | "url": format!("{site}/{repo}/commit/{}", commit.hash), | |
| 349 | }) | |
| 350 | }) | |
| 351 | .collect() | |
| 352 | } | |
| 353 | ||
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 354 | pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 355 | let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase(); | |
| 356 | if workspace.is_empty() { | |
| 357 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug.")); | |
| 358 | } | |
| 359 | let (since, until) = match span(input, g1t_kit::now_ms()) { | |
| 360 | Ok(span) => span, | |
| 361 | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 362 | }; | |
| 363 | let types = match event_types(input) { | |
| 364 | Ok(types) => types, | |
| 365 | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 366 | }; | |
| 367 | let limit = limit(input); | |
| 368 | // The workspace's repositories as the caller may read them; a | |
| 369 | // workspace they cannot see has none. | |
| 370 | let repos: Vec<Repo> = g1t_kit::call( | |
| 371 | &services.repos, | |
| 372 | "list", | |
| 373 | &ListReposArgs { viewer: viewer.clone(), query: None, namespace: Some(workspace.clone()), member_only: false }, | |
| 374 | ) | |
| 375 | .await?; | |
| 376 | let repos: Vec<Repo> = repos.into_iter().filter(|repo| repo.namespace.eq_ignore_ascii_case(&workspace)).collect(); | |
| 377 | let site = services.addresses.site.clone(); | |
| 378 | let none = (HashMap::new(), HashMap::new()); | |
| 379 | if repos.is_empty() { | |
| 380 | return Ok(Outcome::Ok(shape(&Found { | |
| 381 | workspace: &workspace, | |
| 382 | site: &site, | |
| 383 | since, | |
| 384 | until, | |
| 385 | repos: &repos, | |
| 386 | events: &[], | |
| 387 | more: false, | |
| 388 | pull_titles: &none.0, | |
| 389 | usernames: &none.1, | |
| 390 | }))); | |
| 391 | } | |
| 392 | let events: Vec<Event> = g1t_kit::call( | |
| 393 | &services.events, | |
| 394 | "list", | |
| 395 | &ListEventsArgs { | |
| 396 | repo_ids: repos.iter().take(MAX_LIST_REPOS).map(|repo| repo.id.clone()).collect(), | |
| 397 | types: types.iter().map(|kind| (*kind).to_owned()).collect(), | |
| 398 | since: Some(rfc3339(since)), | |
| 399 | // One past the limit says whether there was more. | |
| 400 | limit: Some(limit + 1), | |
| 401 | ..ListEventsArgs::default() | |
| 402 | }, | |
| 403 | ) | |
| 404 | .await?; | |
| 405 | let more = events.len() > limit as usize; | |
| 406 | let events: Vec<Event> = events.into_iter().take(limit as usize).collect(); | |
| 407 | // Titles of the pull requests named, and the names of who did what. | |
| 408 | let pull_repos: Vec<String> = events | |
| 409 | .iter() | |
| 410 | .filter(|event| event.kind.starts_with("pull.")) | |
| 411 | .filter_map(|event| event.repo_id.clone()) | |
| 412 | .collect::<BTreeSet<_>>() | |
| 413 | .into_iter() | |
| 414 | .take(MAX_PULLS_FOR_REPOS) | |
| 415 | .collect(); | |
| 416 | let actors: Vec<String> = events.iter().filter_map(|event| event.actor.clone()).collect::<BTreeSet<_>>().into_iter().collect(); | |
| 417 | let pulls = async { | |
| 418 | if pull_repos.is_empty() { | |
| 419 | return Ok(Vec::new()); | |
| 420 | } | |
| 421 | g1t_kit::call::<_, Vec<RepoPulls>>( | |
| 422 | &services.work, | |
| 423 | "pulls_for_repos", | |
| 424 | &PullsForReposArgs { repo_ids: pull_repos, viewer: viewer.clone(), limit: 100 }, | |
| 425 | ) | |
| 426 | .await | |
| 427 | }; | |
| 428 | let names = async { | |
| 429 | if actors.is_empty() { | |
| 430 | return Ok(HashMap::new()); | |
| 431 | } | |
| 432 | g1t_kit::call::<_, HashMap<String, String>>(&services.identity, "usernames", &UsernamesArgs { ids: actors }).await | |
| 433 | }; | |
| 434 | let (pulls, names) = futures_util::future::join(pulls, names).await; | |
| 435 | // Without titles or names the events still say what happened. | |
| 436 | let mut pull_titles = HashMap::new(); | |
| 437 | for repo in pulls.unwrap_or_default() { | |
| 438 | for pull in repo.open.into_iter().chain(repo.closed) { | |
| 439 | pull_titles.insert((repo.repo_id.clone(), pull.number), pull.title); | |
| 440 | } | |
| 441 | } | |
| 442 | let usernames = names.unwrap_or_default(); | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 443 | let mut answer = shape(&Found { |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 444 | workspace: &workspace, |
| 445 | site: &site, | |
| 446 | since, | |
| 447 | until, | |
| 448 | repos: &repos, | |
| 449 | events: &events, | |
| 450 | more, | |
| 451 | pull_titles: &pull_titles, | |
| 452 | usernames: &usernames, | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 453 | }); |
| 454 | // The repositories whose default branch was pushed to, busiest first: | |
| 455 | // their commits say what landed. | |
| 456 | let mut pushed: Vec<(String, usize)> = Vec::new(); | |
| 457 | for event in events.iter().filter(|event| event.kind == "git.push" && event.data.get("defaultBranch").and_then(Value::as_bool) == Some(true)) { | |
| 458 | let Some(id) = event.repo_id.as_deref() else { continue }; | |
| 459 | match pushed.iter_mut().find(|(known, _)| known == id) { | |
| 460 | Some((_, count)) => *count += 1, | |
| 461 | None => pushed.push((id.to_owned(), 1)), | |
| 462 | } | |
| 463 | } | |
| 464 | pushed.sort_by(|a, b| b.1.cmp(&a.1)); | |
| 465 | let by_id: HashMap<&str, &Repo> = repos.iter().map(|repo| (repo.id.as_str(), repo)).collect(); | |
| 466 | let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move { | |
| 467 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; | |
| 468 | let log: Result<Outcome<Vec<Commit>>> = | |
| 469 | g1t_kit::call(&services.repos, "log", &LogArgs { path, viewer: viewer.clone(), git_ref: None, limit: LANDED_LOG }).await; | |
| 470 | (format!("{}/{}", repo.namespace, repo.name), log) | |
| 471 | }); | |
| 472 | let mut lines = Vec::new(); | |
| 473 | for (repo, log) in futures_util::future::join_all(logs).await { | |
| 474 | // A history that cannot be read leaves the events to say it. | |
| 475 | if let Ok(Outcome::Ok(commits)) = log { | |
| 476 | lines.extend(landed(&site, &repo, &commits, since, until)); | |
| 477 | } | |
| 478 | } | |
| 479 | lines.truncate(LANDED_MAX); | |
| 480 | answer["landed"] = json!(lines); | |
| 481 | Ok(Outcome::Ok(answer)) | |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 482 | } |
| 483 | ||
| 484 | #[cfg(test)] | |
| 485 | mod tests { | |
| 486 | use super::*; | |
| 487 | ||
| 488 | fn repo(id: &str, name: &str) -> Repo { | |
| 489 | serde_json::from_value(json!({ | |
| 490 | "id": id, "namespace": "acme", "name": name, "description": null, "isPrivate": false, | |
| 491 | "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z", | |
| 492 | })) | |
| 493 | .unwrap() | |
| 494 | } | |
| 495 | ||
| 496 | fn event(kind: &str, repo: &str, time: &str, actor: &str, data: Value) -> Event { | |
| 497 | Event { | |
| 498 | id: format!("evt_{time}"), | |
| 499 | kind: kind.into(), | |
| 500 | source: "test".into(), | |
| 501 | time: time.into(), | |
| 502 | repo_id: Some(repo.into()), | |
| 503 | actor: Some(actor.into()), | |
| 504 | data, | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | #[test] | |
| 509 | fn the_span_is_the_last_day_unless_given() { | |
| 510 | let now = parse_rfc3339("2026-10-12T18:00:00Z").unwrap(); | |
| 511 | let (since, until) = span(&json!({}), now).unwrap(); | |
| 512 | assert_eq!(until, now); | |
| 513 | assert_eq!(until - since, DEFAULT_SPAN_MS); | |
| 514 | let (since, _) = span(&json!({ "since": "2026-10-12T00:00:00Z" }), now).unwrap(); | |
| 515 | assert_eq!(since, parse_rfc3339("2026-10-12T00:00:00Z").unwrap()); | |
| 516 | assert!(span(&json!({ "since": "yesterday" }), now).unwrap_err().contains("RFC 3339")); | |
| 517 | assert!(span(&json!({ "since": "2026-10-13T00:00:00Z" }), now).is_err()); | |
| 518 | assert!(span(&json!({ "since": "2026-08-01T00:00:00Z" }), now).unwrap_err().contains("31 days")); | |
| 519 | } | |
| 520 | ||
| 521 | #[test] | |
| 522 | fn kinds_name_their_events() { | |
| 523 | assert_eq!(event_types(&json!({ "kinds": ["merged"] })).unwrap(), vec!["pull.merged"]); | |
| 524 | let all = event_types(&json!({})).unwrap(); | |
| 525 | assert!(all.contains(&"pull.merged") && all.contains(&"git.push") && all.contains(&"release.published")); | |
| 526 | assert!(!all.contains(&"comment.created")); | |
| 527 | assert!(event_types(&json!({ "kinds": ["shipped"] })).unwrap_err().contains("Kinds: merged")); | |
| 528 | // Over REST: one comma-separated text. | |
| 529 | assert_eq!(event_types(&json!({ "kinds": "merged, releases" })).unwrap(), vec!["pull.merged", "release.published"]); | |
| 530 | assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT); | |
| 531 | assert_eq!(limit(&json!({ "limit": 5 })), 5); | |
| 532 | assert_eq!(limit(&json!({})), DEFAULT_LIMIT); | |
| 533 | } | |
| 534 | ||
| 535 | #[test] | |
| Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated. | 536 | fn what_landed_is_one_line_a_commit_made_in_the_span() { |
| 537 | use g1t_contracts::repos::Signature; | |
| 538 | let commit = |hash: &str, message: &str, at: &str| Commit { | |
| 539 | hash: hash.to_owned(), | |
| 540 | tree_hash: String::new(), | |
| 541 | message: message.to_owned(), | |
| 542 | author: Signature { name: "Chase".into(), email: String::new() }, | |
| 543 | parents: Vec::new(), | |
| 544 | authored_at: at.to_owned(), | |
| 545 | }; | |
| 546 | let commits = [ | |
| 547 | commit("aaaaaaaaaaaaaaaa", "Ask reads g1t | |
| 548 | ||
| 549 | The long why.", "2026-10-13T05:00:00Z"), | |
| 550 | commit("bbbbbbbbbbbbbbbb", "Yesterday's change", "2026-10-12T05:00:00Z"), | |
| 551 | ]; | |
| 552 | let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap(); | |
| 553 | let until = parse_rfc3339("2026-10-13T06:00:00Z").unwrap(); | |
| 554 | let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until); | |
| 555 | assert_eq!(lines.len(), 1); | |
| 556 | assert_eq!(lines[0]["subject"], "Ask reads g1t"); | |
| 557 | assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa"); | |
| 558 | assert_eq!(lines[0]["author"], "Chase"); | |
| 559 | assert_eq!(lines[0]["url"], "https://g1t.sh/acme/web/commit/aaaaaaaaaaaaaaaa"); | |
| 560 | } | |
| 561 | ||
| 562 | #[test] | |
| g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other. | 563 | fn what_shipped_reads_as_one_list_with_links_and_names() { |
| 564 | let repos = [repo("rep_1", "web"), repo("rep_2", "api")]; | |
| 565 | let events = [ | |
| 566 | event("deployment.succeeded", "rep_1", "2026-10-12T15:00:00Z", "usr_1", json!({ "kind": "production", "project": "web", "commit": "abcdef1234567890", "path": "/acme/web/deployments/dpl_1", "triggeredBy": "ana" })), | |
| 567 | event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })), | |
| 568 | event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "issue": 3 })), | |
| 569 | event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })), | |
| 570 | event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })), | |
| 571 | event("workflow.completed", "rep_1", "2026-10-12T11:30:00Z", "usr_1", json!({ "runId": "run_257", "workflow": "Deploy", "number": 257, "conclusion": "failure", "ref": "refs/heads/main", "sha": "fedcba9876543210" })), | |
| 572 | event("issue.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 3, "title": "Login fails", "reason": "completed", "resolvedBy": 7 })), | |
| 573 | // Outside the span, and a repository the caller cannot read. | |
| 574 | event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })), | |
| 575 | event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })), | |
| 576 | ]; | |
| 577 | let titles = HashMap::from([(("rep_1".to_owned(), 7), "Fix login".to_owned())]); | |
| 578 | let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned()), ("usr_2".to_owned(), "bo".to_owned())]); | |
| 579 | let shown = shape(&Found { | |
| 580 | workspace: "acme", | |
| 581 | site: "https://g1t.sh/", | |
| 582 | since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(), | |
| 583 | until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(), | |
| 584 | repos: &repos, | |
| 585 | events: &events, | |
| 586 | more: false, | |
| 587 | pull_titles: &titles, | |
| 588 | usernames: &names, | |
| 589 | }); | |
| 590 | assert_eq!( | |
| 591 | shown["summary"], | |
| 592 | json!({ "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 0, "default_branch_pushes": 1, "failed_runs": 1 }) | |
| 593 | ); | |
| 594 | let listed = shown["events"].as_array().unwrap(); | |
| 595 | assert_eq!(listed.len(), 6, "{listed:#?}"); | |
| 596 | assert_eq!(listed[0]["url"], "https://g1t.sh/acme/web/deployments/dpl_1"); | |
| 597 | assert_eq!(listed[0]["commit"], "abcdef123456"); | |
| 598 | assert_eq!(listed[1]["title"], "Faster pushes"); | |
| 599 | assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0"); | |
| 600 | assert_eq!( | |
| 601 | listed[2], | |
| 602 | json!({ "kind": "pull_merged", "time": "2026-10-12T13:00:00Z", "repo": "acme/web", "actor": "ana", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7", "issue": 3 }) | |
| 603 | ); | |
| 604 | assert_eq!(listed[3]["default_branch"], true); | |
| 605 | assert_eq!(listed[3]["actor"], "bo"); | |
| 606 | assert_eq!( | |
| 607 | listed[4], | |
| 608 | json!({ "kind": "workflow_completed", "time": "2026-10-12T11:30:00Z", "repo": "acme/web", "actor": "ana", "workflow": "Deploy", "run_number": 257, "conclusion": "failure", "branch": "main", "commit": "fedcba987654", "run_id": "run_257", "url": "https://g1t.sh/acme/web/actions/runs/run_257" }) | |
| 609 | ); | |
| 610 | assert_eq!(listed[5]["resolved_by"], 7); | |
| 611 | assert_eq!(shown["complete"], true); | |
| 612 | } | |
| 613 | } |