Skip to content

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

g1t.sh
526 linesCodeBlameRaw

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
15use std::collections::{BTreeSet, HashMap};
16
17use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS};
18use g1t_contracts::identity::UsernamesArgs;
19use g1t_contracts::repos::{ListArgs as ListReposArgs, Repo};
20use g1t_contracts::time::{parse_rfc3339, rfc3339};
21use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls};
22use g1t_contracts::{FailureCode, Outcome, Viewer};
23use serde_json::{Value, json};
24use worker::Result;
25
26use crate::operations::Services;
27
28/// The span when none is given: the last day.
29const DEFAULT_SPAN_MS: u64 = 24 * 60 * 60 * 1000;
30/// The longest span one call reads.
31const MAX_SPAN_MS: u64 = 31 * 24 * 60 * 60 * 1000;
32pub const DEFAULT_LIMIT: u32 = 100;
33pub const MAX_LIMIT: u32 = 200;
34
35/// The kinds a caller may ask for, and the events each is.
36pub 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
49/// What is read when no kinds are given: everything but comments, which
50/// would crowd out the rest on a busy day.
51const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
52
53pub fn description() -> &'static str {
54 "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. 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."
55}
56
57pub fn input() -> Value {
58 json!({
59 "type": "object",
60 "properties": {
61 "workspace": { "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." },
62 "since": { "type": "string", "description": "RFC 3339: the start of the span, inclusive. The last 24 hours when left out; for \"today\", midnight UTC." },
63 "until": { "type": "string", "description": "RFC 3339: the end of the span, exclusive. Now when left out." },
64 "kinds": {
65 "type": "array",
66 "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() },
67 "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.",
68 },
69 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." },
70 },
71 "required": ["workspace"],
72 })
73}
74
75/// The span asked for, in milliseconds, or why it cannot be read.
76pub fn span(input: &Value, now: u64) -> std::result::Result<(u64, u64), String> {
77 let at = |key: &str| -> std::result::Result<Option<u64>, String> {
78 match input.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) {
79 None => Ok(None),
80 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.")),
81 }
82 };
83 let until = at("until")?.unwrap_or(now).min(now);
84 let since = at("since")?.unwrap_or(until.saturating_sub(DEFAULT_SPAN_MS));
85 if since >= until {
86 return Err("since must be before until.".to_owned());
87 }
88 if until - since > MAX_SPAN_MS {
89 return Err("A span can be at most 31 days. Narrow since and until.".to_owned());
90 }
91 Ok((since, until))
92}
93
94/// The event types the `kinds` asked for read, or the unknown kind.
95pub fn event_types(input: &Value) -> std::result::Result<Vec<&'static str>, String> {
96 // An array, or over REST a comma-separated list.
97 let asked: Vec<String> = match input.get("kinds") {
98 Some(Value::Array(kinds)) => kinds.iter().filter_map(Value::as_str).map(str::to_owned).collect(),
99 Some(Value::String(kinds)) => kinds.split(',').map(str::to_owned).collect(),
100 _ => Vec::new(),
101 }
102 .into_iter()
103 .map(|kind| kind.trim().to_lowercase())
104 .filter(|kind| !kind.is_empty())
105 .collect();
106 let names: Vec<&str> = if asked.is_empty() { DEFAULT_KINDS.to_vec() } else { asked.iter().map(String::as_str).collect() };
107 let mut types = BTreeSet::new();
108 for name in names {
109 let Some((_, kinds)) = KINDS.iter().find(|(kind, _)| *kind == name) else {
110 let known: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
111 return Err(format!("There is no kind {name}. Kinds: {}.", known.join(", ")));
112 };
113 types.extend(kinds.iter().copied());
114 }
115 Ok(types.into_iter().collect())
116}
117
118/// The most events asked for: a number, or over REST its text.
119pub fn limit(input: &Value) -> u32 {
120 let asked = match input.get("limit") {
121 Some(Value::String(text)) => text.trim().parse::<u64>().ok(),
122 Some(value) => value.as_u64(),
123 None => None,
124 };
125 asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32)
126}
127
128fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
129 data.get(key).and_then(Value::as_str).filter(|text| !text.is_empty())
130}
131
132/// What the reads found, shaped into the answer.
133pub struct Found<'a> {
134 pub workspace: &'a str,
135 pub site: &'a str,
136 pub since: u64,
137 pub until: u64,
138 pub repos: &'a [Repo],
139 /// Newest first, as the events service lists them.
140 pub events: &'a [Event],
141 /// Whether the events service had more than was read.
142 pub more: bool,
143 /// `(repo id, number)` to a pull request's title.
144 pub pull_titles: &'a HashMap<(String, u32), String>,
145 /// Account id to username.
146 pub usernames: &'a HashMap<String, String>,
147}
148
149pub fn shape(found: &Found) -> Value {
150 let site = found.site.trim_end_matches('/');
151 let by_id: HashMap<&str, &Repo> = found.repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
152 let mut events = Vec::new();
153 let (mut merged, mut releases, mut production, mut landed, mut failed) = (0u32, 0u32, 0u32, 0u32, 0u32);
154 let mut runs_failed = 0u32;
155 for event in found.events {
156 let Some(repo) = event.repo_id.as_deref().and_then(|id| by_id.get(id)) else {
157 continue;
158 };
159 let at = parse_rfc3339(&event.time).unwrap_or_default();
160 if at < found.since || at >= found.until {
161 continue;
162 }
163 let full = format!("{}/{}", repo.namespace, repo.name);
164 let page = format!("{site}/{full}");
165 let data = &event.data;
166 let number = data.get("number").and_then(Value::as_u64).map(|n| n as u32);
167 let actor = event
168 .actor
169 .as_deref()
170 .and_then(|id| found.usernames.get(id).cloned())
171 .or_else(|| data.get("author").and_then(|author| text(author, "username")).map(str::to_owned))
172 .or_else(|| text(data, "triggeredBy").map(str::to_owned));
173 let mut shown = json!({ "kind": event.kind.replace('.', "_"), "time": event.time, "repo": full });
174 if let Some(actor) = actor {
175 shown["actor"] = json!(actor);
176 }
177 match event.kind.as_str() {
178 "pull.opened" | "pull.merged" | "pull.closed" => {
179 let Some(number) = number else { continue };
180 shown["number"] = json!(number);
181 if let Some(title) = found.pull_titles.get(&(repo.id.clone(), number)) {
182 shown["title"] = json!(title);
183 }
184 shown["url"] = json!(format!("{page}/pull/{number}"));
185 if let Some(issue) = data.get("issue").and_then(Value::as_u64) {
186 shown["issue"] = json!(issue);
187 }
188 if event.kind == "pull.merged" {
189 merged += 1;
190 }
191 }
192 "issue.opened" | "issue.closed" => {
193 let Some(number) = number else { continue };
194 shown["number"] = json!(number);
195 if let Some(title) = text(data, "title") {
196 shown["title"] = json!(title);
197 }
198 if let Some(reason) = text(data, "reason") {
199 shown["reason"] = json!(reason);
200 }
201 if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) {
202 shown["resolved_by"] = json!(pull);
203 }
204 shown["url"] = json!(format!("{page}/issues/{number}"));
205 }
206 "release.published" => {
207 let tag = text(data, "tagName").unwrap_or_default();
208 shown["tag"] = json!(tag);
209 if let Some(name) = data.get("release").and_then(|release| text(release, "name")) {
210 shown["title"] = json!(name);
211 }
212 shown["url"] = json!(format!("{page}/releases/tag/{tag}"));
213 releases += 1;
214 }
215 "deployment.succeeded" | "deployment.failed" => {
216 let kind = text(data, "kind").unwrap_or("preview");
217 shown["environment"] = json!(kind);
218 if let Some(project) = text(data, "project") {
219 shown["project"] = json!(project);
220 }
221 if let Some(branch) = text(data, "branch") {
222 shown["branch"] = json!(branch);
223 }
224 if let Some(commit) = text(data, "commit") {
225 shown["commit"] = json!(commit.chars().take(12).collect::<String>());
226 }
227 if let Some(error) = text(data, "error") {
228 shown["error"] = json!(error.chars().take(300).collect::<String>());
229 }
230 shown["url"] = json!(match text(data, "path") {
231 Some(path) => format!("{site}{path}"),
232 None => format!("{page}/deployments"),
233 });
234 if event.kind == "deployment.succeeded" && kind == "production" {
235 production += 1;
236 }
237 if event.kind == "deployment.failed" {
238 failed += 1;
239 }
240 }
241 "git.push" => {
242 // Branches only: a tag's push is its release.
243 let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) else { continue };
244 let default_branch = data.get("defaultBranch").and_then(Value::as_bool) == Some(true);
245 shown["branch"] = json!(branch);
246 shown["default_branch"] = json!(default_branch);
247 if let Some(commits) = data.get("commits").and_then(Value::as_u64) {
248 shown["commits"] = json!(commits);
249 }
250 if let Some(after) = text(data, "after") {
251 shown["commit"] = json!(after.chars().take(12).collect::<String>());
252 shown["url"] = json!(format!("{page}/commit/{after}"));
253 }
254 if default_branch {
255 landed += 1;
256 }
257 }
258 "review.completed" | "comment.created" => {
259 if let Some(number) = number.or_else(|| data.get("issue").and_then(Value::as_u64).map(|n| n as u32)) {
260 shown["number"] = json!(number);
261 shown["url"] = json!(format!("{page}/issues/{number}"));
262 }
263 if let Some(verdict) = text(data, "verdict") {
264 shown["verdict"] = json!(verdict);
265 }
266 }
267 "workflow.completed" => {
268 let conclusion = text(data, "conclusion").unwrap_or_default();
269 if let Some(workflow) = text(data, "workflow") {
270 shown["workflow"] = json!(workflow);
271 }
272 if let Some(run_number) = data.get("number").and_then(Value::as_u64) {
273 shown["run_number"] = json!(run_number);
274 }
275 shown["conclusion"] = json!(conclusion);
276 if let Some(branch) = text(data, "ref").map(|r| r.strip_prefix("refs/heads/").unwrap_or(r)) {
277 shown["branch"] = json!(branch);
278 }
279 if let Some(sha) = text(data, "sha") {
280 shown["commit"] = json!(sha.chars().take(12).collect::<String>());
281 }
282 if let Some(pull) = data.get("pull").and_then(Value::as_u64) {
283 shown["pull"] = json!(pull);
284 }
285 if let Some(run) = text(data, "runId") {
286 shown["run_id"] = json!(run);
287 shown["url"] = json!(format!("{page}/actions/runs/{run}"));
288 }
289 if conclusion == "failure" {
290 runs_failed += 1;
291 }
292 }
293 "package.published" => {
294 for key in ["ecosystem", "name", "version"] {
295 if let Some(value) = text(data, key) {
296 shown[key] = json!(value);
297 }
298 }
299 }
300 _ => continue,
301 }
302 events.push(shown);
303 }
304 json!({
305 "workspace": found.workspace,
306 "since": rfc3339(found.since),
307 "until": rfc3339(found.until),
308 "repositories": found.repos.len(),
309 "summary": {
310 "merged_pull_requests": merged,
311 "releases": releases,
312 "production_deployments": production,
313 "failed_deployments": failed,
314 "default_branch_pushes": landed,
315 "failed_runs": runs_failed,
316 },
317 "events": events,
318 "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS,
319 })
320}
321
322pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
323 let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase();
324 if workspace.is_empty() {
325 return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug."));
326 }
327 let (since, until) = match span(input, g1t_kit::now_ms()) {
328 Ok(span) => span,
329 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
330 };
331 let types = match event_types(input) {
332 Ok(types) => types,
333 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
334 };
335 let limit = limit(input);
336 // The workspace's repositories as the caller may read them; a
337 // workspace they cannot see has none.
338 let repos: Vec<Repo> = g1t_kit::call(
339 &services.repos,
340 "list",
341 &ListReposArgs { viewer: viewer.clone(), query: None, namespace: Some(workspace.clone()), member_only: false },
342 )
343 .await?;
344 let repos: Vec<Repo> = repos.into_iter().filter(|repo| repo.namespace.eq_ignore_ascii_case(&workspace)).collect();
345 let site = services.addresses.site.clone();
346 let none = (HashMap::new(), HashMap::new());
347 if repos.is_empty() {
348 return Ok(Outcome::Ok(shape(&Found {
349 workspace: &workspace,
350 site: &site,
351 since,
352 until,
353 repos: &repos,
354 events: &[],
355 more: false,
356 pull_titles: &none.0,
357 usernames: &none.1,
358 })));
359 }
360 let events: Vec<Event> = g1t_kit::call(
361 &services.events,
362 "list",
363 &ListEventsArgs {
364 repo_ids: repos.iter().take(MAX_LIST_REPOS).map(|repo| repo.id.clone()).collect(),
365 types: types.iter().map(|kind| (*kind).to_owned()).collect(),
366 since: Some(rfc3339(since)),
367 // One past the limit says whether there was more.
368 limit: Some(limit + 1),
369 ..ListEventsArgs::default()
370 },
371 )
372 .await?;
373 let more = events.len() > limit as usize;
374 let events: Vec<Event> = events.into_iter().take(limit as usize).collect();
375 // Titles of the pull requests named, and the names of who did what.
376 let pull_repos: Vec<String> = events
377 .iter()
378 .filter(|event| event.kind.starts_with("pull."))
379 .filter_map(|event| event.repo_id.clone())
380 .collect::<BTreeSet<_>>()
381 .into_iter()
382 .take(MAX_PULLS_FOR_REPOS)
383 .collect();
384 let actors: Vec<String> = events.iter().filter_map(|event| event.actor.clone()).collect::<BTreeSet<_>>().into_iter().collect();
385 let pulls = async {
386 if pull_repos.is_empty() {
387 return Ok(Vec::new());
388 }
389 g1t_kit::call::<_, Vec<RepoPulls>>(
390 &services.work,
391 "pulls_for_repos",
392 &PullsForReposArgs { repo_ids: pull_repos, viewer: viewer.clone(), limit: 100 },
393 )
394 .await
395 };
396 let names = async {
397 if actors.is_empty() {
398 return Ok(HashMap::new());
399 }
400 g1t_kit::call::<_, HashMap<String, String>>(&services.identity, "usernames", &UsernamesArgs { ids: actors }).await
401 };
402 let (pulls, names) = futures_util::future::join(pulls, names).await;
403 // Without titles or names the events still say what happened.
404 let mut pull_titles = HashMap::new();
405 for repo in pulls.unwrap_or_default() {
406 for pull in repo.open.into_iter().chain(repo.closed) {
407 pull_titles.insert((repo.repo_id.clone(), pull.number), pull.title);
408 }
409 }
410 let usernames = names.unwrap_or_default();
411 Ok(Outcome::Ok(shape(&Found {
412 workspace: &workspace,
413 site: &site,
414 since,
415 until,
416 repos: &repos,
417 events: &events,
418 more,
419 pull_titles: &pull_titles,
420 usernames: &usernames,
421 })))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn repo(id: &str, name: &str) -> Repo {
429 serde_json::from_value(json!({
430 "id": id, "namespace": "acme", "name": name, "description": null, "isPrivate": false,
431 "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z",
432 }))
433 .unwrap()
434 }
435
436 fn event(kind: &str, repo: &str, time: &str, actor: &str, data: Value) -> Event {
437 Event {
438 id: format!("evt_{time}"),
439 kind: kind.into(),
440 source: "test".into(),
441 time: time.into(),
442 repo_id: Some(repo.into()),
443 actor: Some(actor.into()),
444 data,
445 }
446 }
447
448 #[test]
449 fn the_span_is_the_last_day_unless_given() {
450 let now = parse_rfc3339("2026-10-12T18:00:00Z").unwrap();
451 let (since, until) = span(&json!({}), now).unwrap();
452 assert_eq!(until, now);
453 assert_eq!(until - since, DEFAULT_SPAN_MS);
454 let (since, _) = span(&json!({ "since": "2026-10-12T00:00:00Z" }), now).unwrap();
455 assert_eq!(since, parse_rfc3339("2026-10-12T00:00:00Z").unwrap());
456 assert!(span(&json!({ "since": "yesterday" }), now).unwrap_err().contains("RFC 3339"));
457 assert!(span(&json!({ "since": "2026-10-13T00:00:00Z" }), now).is_err());
458 assert!(span(&json!({ "since": "2026-08-01T00:00:00Z" }), now).unwrap_err().contains("31 days"));
459 }
460
461 #[test]
462 fn kinds_name_their_events() {
463 assert_eq!(event_types(&json!({ "kinds": ["merged"] })).unwrap(), vec!["pull.merged"]);
464 let all = event_types(&json!({})).unwrap();
465 assert!(all.contains(&"pull.merged") && all.contains(&"git.push") && all.contains(&"release.published"));
466 assert!(!all.contains(&"comment.created"));
467 assert!(event_types(&json!({ "kinds": ["shipped"] })).unwrap_err().contains("Kinds: merged"));
468 // Over REST: one comma-separated text.
469 assert_eq!(event_types(&json!({ "kinds": "merged, releases" })).unwrap(), vec!["pull.merged", "release.published"]);
470 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
471 assert_eq!(limit(&json!({ "limit": 5 })), 5);
472 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
473 }
474
475 #[test]
476 fn what_shipped_reads_as_one_list_with_links_and_names() {
477 let repos = [repo("rep_1", "web"), repo("rep_2", "api")];
478 let events = [
479 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" })),
480 event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })),
481 event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "issue": 3 })),
482 event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })),
483 event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })),
484 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" })),
485 event("issue.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 3, "title": "Login fails", "reason": "completed", "resolvedBy": 7 })),
486 // Outside the span, and a repository the caller cannot read.
487 event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })),
488 event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })),
489 ];
490 let titles = HashMap::from([(("rep_1".to_owned(), 7), "Fix login".to_owned())]);
491 let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned()), ("usr_2".to_owned(), "bo".to_owned())]);
492 let shown = shape(&Found {
493 workspace: "acme",
494 site: "https://g1t.sh/",
495 since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(),
496 until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(),
497 repos: &repos,
498 events: &events,
499 more: false,
500 pull_titles: &titles,
501 usernames: &names,
502 });
503 assert_eq!(
504 shown["summary"],
505 json!({ "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 0, "default_branch_pushes": 1, "failed_runs": 1 })
506 );
507 let listed = shown["events"].as_array().unwrap();
508 assert_eq!(listed.len(), 6, "{listed:#?}");
509 assert_eq!(listed[0]["url"], "https://g1t.sh/acme/web/deployments/dpl_1");
510 assert_eq!(listed[0]["commit"], "abcdef123456");
511 assert_eq!(listed[1]["title"], "Faster pushes");
512 assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0");
513 assert_eq!(
514 listed[2],
515 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 })
516 );
517 assert_eq!(listed[3]["default_branch"], true);
518 assert_eq!(listed[3]["actor"], "bo");
519 assert_eq!(
520 listed[4],
521 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" })
522 );
523 assert_eq!(listed[5]["resolved_by"], 7);
524 assert_eq!(shown["complete"], true);
525 }
526}