| 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::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath}; |
| 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 | /// 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 | |
| 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 { |
| 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 | |
| 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 | }, |
| 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. |
| 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 | |
| 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(); |
| 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)] |
| 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] |
| 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] |
| 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 | } |