| 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; |
| 19 | use g1t_contracts::repos::{ListArgs as ListReposArgs, Repo}; |
| 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 | |
| 49 | /// What is read when no kinds are given: everything but comments, which |
| 50 | /// would crowd out the rest on a busy day. |
| 51 | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; |
| 52 | |
| 53 | pub 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 | |
| 57 | pub 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. |
| 76 | pub 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. |
| 95 | pub 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. |
| 119 | pub 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 | |
| 128 | fn 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. |
| 133 | pub 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 | |
| 149 | pub 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 | |
| 322 | pub 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)] |
| 425 | mod 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 | } |