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
613 linesCodeBlameRaw
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::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath};
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/// The most repositories whose default-branch commits are read, and the
50/// most commits that `landed` lists across them.
51const LANDED_REPOS: usize = 8;
52const LANDED_MAX: usize = 40;
53/// How far back each of those repositories' history is read.
54const LANDED_LOG: u32 = 60;
55
56/// What is read when no kinds are given: everything but comments, which
57/// would crowd out the rest on a busy day.
58const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
59
60pub fn description() -> &'static str {
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."
62}
63
64pub 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.
83pub 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.
102pub 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.
126pub 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
135fn 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.
140pub 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
156pub 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 },
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": [],
327 "events": events,
328 "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS,
329 })
330}
331
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.
335pub 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
354pub 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();
443 let mut answer = shape(&Found {
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,
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))
482}
483
484#[cfg(test)]
485mod 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]
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
549The 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]
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}