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.

978 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, work items
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//! Shipping on g1t is reaching the default branch, then production. For the
16//! repositories whose default branch moved, `landed` lists the commits, each
17//! with its author's g1t username where their address is one's
18//! (commits.rs), and whether it is in production ([`production`]): carried
19//! by a successful deploy (a run of a deploy workflow on the default branch,
20//! for a repository that deploys itself, or a project's production
21//! deployment on g1t.page), still deploying, or failed to.
22
23use std::collections::{BTreeSet, HashMap};
24
25use g1t_contracts::actions::{RunsArgs, WorkflowRun};
26use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS};
27use g1t_contracts::identity::UsernamesArgs;
28use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath};
29use g1t_contracts::time::{parse_rfc3339, rfc3339};
30use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls};
31use g1t_contracts::{FailureCode, Outcome, Viewer};
32use serde_json::{Value, json};
33use worker::Result;
34
35use crate::commits::{self, Authors};
36use crate::operations::Services;
37
38/// The span when none is given: the last day.
39const DEFAULT_SPAN_MS: u64 = 24 * 60 * 60 * 1000;
40/// The longest span one call reads.
41const MAX_SPAN_MS: u64 = 31 * 24 * 60 * 60 * 1000;
42pub const DEFAULT_LIMIT: u32 = 100;
43pub const MAX_LIMIT: u32 = 200;
44
45/// The kinds a caller may ask for, and the events each is.
46pub const KINDS: &[(&str, &[&str])] = &[
47 ("merged", &["pull.merged"]),
48 ("pulls", &["pull.opened", "pull.merged", "pull.closed"]),
49 ("work_items", &["work_item.opened", "work_item.closed"]),
50 ("releases", &["release.published"]),
51 ("deployments", &["deployment.succeeded", "deployment.failed"]),
52 ("pushes", &["git.push"]),
53 ("reviews", &["review.completed"]),
54 ("comments", &["comment.created"]),
55 ("packages", &["package.published"]),
56 ("runs", &["workflow.completed"]),
57];
58
59/// The most repositories whose default-branch commits are read, and the
60/// most commits that `landed` lists across them.
61const LANDED_REPOS: usize = 8;
62const LANDED_MAX: usize = 40;
63/// How far back each of those repositories' history is read.
64const LANDED_LOG: u32 = 60;
65/// How many of each of those repositories' default-branch runs in the span
66/// are read for its deploys.
67const LANDED_RUNS: u32 = 100;
68
69/// What is read when no kinds are given: everything but comments, which
70/// would crowd out the rest on a busy day.
71const DEFAULT_KINDS: &[&str] = &["pulls", "work_items", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
72
73pub fn description() -> &'static str {
74 "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, work items opened and closed (linked to the repositories, numbered in the workspace), 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, and `production` true for a run of a deploy workflow on the default branch: a repository that deploys itself). 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 (projects' production deployments and successful deploy runs on default branches), failed ones, default-branch pushes and failed runs, and in `shipped` the landed commits by where they are. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author` as git wrote it, `username` when the author's address is a g1t account's, `time`, `url`), and, where the repository deploys, `production`: `live` (a successful deploy carried it, named in `deploy` with its `url`), `deploying`, `failed` (its deploy failed and none since succeeded) or `not_deployed`. That is what shipped: what reached the default branch, and which of it is live. 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, work_items, 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."
75}
76
77pub fn input() -> Value {
78 json!({
79 "type": "object",
80 "properties": {
81 "workspace": { "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." },
82 "since": { "type": "string", "description": "RFC 3339: the start of the span, inclusive. The last 24 hours when left out; for \"today\", midnight UTC." },
83 "until": { "type": "string", "description": "RFC 3339: the end of the span, exclusive. Now when left out." },
84 "kinds": {
85 "type": "array",
86 "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() },
87 "description": "What to read: merged (merged pull requests only), pulls, work_items, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.",
88 },
89 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." },
90 },
91 "required": ["workspace"],
92 })
93}
94
95/// The span asked for, in milliseconds, or why it cannot be read.
96pub fn span(input: &Value, now: u64) -> std::result::Result<(u64, u64), String> {
97 let at = |key: &str| -> std::result::Result<Option<u64>, String> {
98 match input.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) {
99 None => Ok(None),
100 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.")),
101 }
102 };
103 let until = at("until")?.unwrap_or(now).min(now);
104 let since = at("since")?.unwrap_or(until.saturating_sub(DEFAULT_SPAN_MS));
105 if since >= until {
106 return Err("since must be before until.".to_owned());
107 }
108 if until - since > MAX_SPAN_MS {
109 return Err("A span can be at most 31 days. Narrow since and until.".to_owned());
110 }
111 Ok((since, until))
112}
113
114/// The event types the `kinds` asked for read, or the unknown kind.
115pub fn event_types(input: &Value) -> std::result::Result<Vec<&'static str>, String> {
116 // An array, or over REST a comma-separated list.
117 let asked: Vec<String> = match input.get("kinds") {
118 Some(Value::Array(kinds)) => kinds.iter().filter_map(Value::as_str).map(str::to_owned).collect(),
119 Some(Value::String(kinds)) => kinds.split(',').map(str::to_owned).collect(),
120 _ => Vec::new(),
121 }
122 .into_iter()
123 .map(|kind| kind.trim().to_lowercase())
124 .filter(|kind| !kind.is_empty())
125 .collect();
126 let names: Vec<&str> = if asked.is_empty() { DEFAULT_KINDS.to_vec() } else { asked.iter().map(String::as_str).collect() };
127 let mut types = BTreeSet::new();
128 for name in names {
129 let Some((_, kinds)) = KINDS.iter().find(|(kind, _)| *kind == name) else {
130 let known: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
131 return Err(format!("There is no kind {name}. Kinds: {}.", known.join(", ")));
132 };
133 types.extend(kinds.iter().copied());
134 }
135 Ok(types.into_iter().collect())
136}
137
138/// The most events asked for: a number, or over REST its text.
139pub fn limit(input: &Value) -> u32 {
140 let asked = match input.get("limit") {
141 Some(Value::String(text)) => text.trim().parse::<u64>().ok(),
142 Some(value) => value.as_u64(),
143 None => None,
144 };
145 asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32)
146}
147
148fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
149 data.get(key).and_then(Value::as_str).filter(|text| !text.is_empty())
150}
151
152/// What the reads found, shaped into the answer.
153pub struct Found<'a> {
154 pub workspace: &'a str,
155 pub site: &'a str,
156 pub since: u64,
157 pub until: u64,
158 pub repos: &'a [Repo],
159 /// Newest first, as the events service lists them.
160 pub events: &'a [Event],
161 /// Whether the events service had more than was read.
162 pub more: bool,
163 /// `(repo id, number)` to a pull request's title.
164 pub pull_titles: &'a HashMap<(String, u32), String>,
165 /// Account id to username.
166 pub usernames: &'a HashMap<String, String>,
167}
168
169pub fn shape(found: &Found) -> Value {
170 let site = found.site.trim_end_matches('/');
171 let by_id: HashMap<&str, &Repo> = found.repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
172 let mut events = Vec::new();
173 let (mut merged, mut releases, mut production, mut landed, mut failed) = (0u32, 0u32, 0u32, 0u32, 0u32);
174 let mut runs_failed = 0u32;
175 for event in found.events {
176 let Some(repo) = event.repo_id.as_deref().and_then(|id| by_id.get(id)) else {
177 continue;
178 };
179 let at = parse_rfc3339(&event.time).unwrap_or_default();
180 if at < found.since || at >= found.until {
181 continue;
182 }
183 let full = format!("{}/{}", repo.namespace, repo.name);
184 let page = format!("{site}/{full}");
185 let data = &event.data;
186 let number = data.get("number").and_then(Value::as_u64).map(|n| n as u32);
187 let actor = event
188 .actor
189 .as_deref()
190 .and_then(|id| found.usernames.get(id).cloned())
191 .or_else(|| data.get("author").and_then(|author| text(author, "username")).map(str::to_owned))
192 .or_else(|| text(data, "triggeredBy").map(str::to_owned));
193 let mut shown = json!({ "kind": event.kind.replace('.', "_"), "time": event.time, "repo": full });
194 if let Some(actor) = actor {
195 shown["actor"] = json!(actor);
196 }
197 match event.kind.as_str() {
198 "pull.opened" | "pull.merged" | "pull.closed" => {
199 let Some(number) = number else { continue };
200 shown["number"] = json!(number);
201 if let Some(title) = found.pull_titles.get(&(repo.id.clone(), number)) {
202 shown["title"] = json!(title);
203 }
204 shown["url"] = json!(format!("{page}/pull/{number}"));
205 if let Some(item) = data.get("item").and_then(Value::as_u64) {
206 shown["item"] = json!(item);
207 }
208 if event.kind == "pull.merged" {
209 merged += 1;
210 }
211 }
212 "work_item.opened" | "work_item.closed" => {
213 let Some(number) = number else { continue };
214 shown["number"] = json!(number);
215 if let Some(title) = text(data, "title") {
216 shown["title"] = json!(title);
217 }
218 if let Some(reason) = text(data, "reason") {
219 shown["reason"] = json!(reason);
220 }
221 // What landed it here: a merged pull request, or a commit
222 // pushed to the default branch.
223 if let Some(resolution) = data.get("resolution") {
224 if let Some(pull) = resolution.get("pull").and_then(Value::as_u64) {
225 shown["resolved_by"] = json!(pull);
226 }
227 if let Some(commit) = text(resolution, "commit") {
228 shown["resolved_commit"] = json!(commit);
229 }
230 }
231 let workspace = text(data, "workspace").unwrap_or(repo.namespace.as_str());
232 shown["url"] = json!(format!("{site}/{workspace}/-/work/{number}"));
233 }
234 "release.published" => {
235 let tag = text(data, "tagName").unwrap_or_default();
236 shown["tag"] = json!(tag);
237 if let Some(name) = data.get("release").and_then(|release| text(release, "name")) {
238 shown["title"] = json!(name);
239 }
240 shown["url"] = json!(format!("{page}/releases/tag/{tag}"));
241 releases += 1;
242 }
243 "deployment.succeeded" | "deployment.failed" => {
244 let kind = text(data, "kind").unwrap_or("preview");
245 shown["environment"] = json!(kind);
246 if let Some(project) = text(data, "project") {
247 shown["project"] = json!(project);
248 }
249 if let Some(branch) = text(data, "branch") {
250 shown["branch"] = json!(branch);
251 }
252 if let Some(commit) = text(data, "commit") {
253 shown["commit"] = json!(commit.chars().take(12).collect::<String>());
254 }
255 if let Some(error) = text(data, "error") {
256 shown["error"] = json!(error.chars().take(300).collect::<String>());
257 }
258 shown["url"] = json!(match text(data, "path") {
259 Some(path) => format!("{site}{path}"),
260 None => format!("{page}/deployments"),
261 });
262 if event.kind == "deployment.succeeded" && kind == "production" {
263 production += 1;
264 }
265 if event.kind == "deployment.failed" {
266 failed += 1;
267 }
268 }
269 "git.push" => {
270 // Branches only: a tag's push is its release.
271 let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) else { continue };
272 let default_branch = data.get("defaultBranch").and_then(Value::as_bool) == Some(true);
273 shown["branch"] = json!(branch);
274 shown["default_branch"] = json!(default_branch);
275 if let Some(commits) = data.get("commits").and_then(Value::as_u64) {
276 shown["commits"] = json!(commits);
277 }
278 if let Some(after) = text(data, "after") {
279 shown["commit"] = json!(after.chars().take(12).collect::<String>());
280 shown["url"] = json!(format!("{page}/commit/{after}"));
281 }
282 if default_branch {
283 landed += 1;
284 }
285 }
286 "review.completed" | "comment.created" => {
287 if let Some(number) = number {
288 shown["number"] = json!(number);
289 // On a work item, its page in the workspace; else the
290 // pull request's.
291 shown["url"] = match (text(data, "itemId"), text(data, "workspace")) {
292 (Some(_), Some(workspace)) => json!(format!("{site}/{workspace}/-/work/{number}")),
293 _ => json!(format!("{page}/pull/{number}")),
294 };
295 }
296 if let Some(verdict) = text(data, "verdict") {
297 shown["verdict"] = json!(verdict);
298 }
299 }
300 "workflow.completed" => {
301 let conclusion = text(data, "conclusion").unwrap_or_default();
302 if let Some(workflow) = text(data, "workflow") {
303 shown["workflow"] = json!(workflow);
304 }
305 if let Some(run_number) = data.get("number").and_then(Value::as_u64) {
306 shown["run_number"] = json!(run_number);
307 }
308 shown["conclusion"] = json!(conclusion);
309 if let Some(branch) = text(data, "ref").map(|r| r.strip_prefix("refs/heads/").unwrap_or(r)) {
310 shown["branch"] = json!(branch);
311 }
312 if let Some(sha) = text(data, "sha") {
313 shown["commit"] = json!(sha.chars().take(12).collect::<String>());
314 }
315 if let Some(pull) = data.get("pull").and_then(Value::as_u64) {
316 shown["pull"] = json!(pull);
317 }
318 if let Some(run) = text(data, "runId") {
319 shown["run_id"] = json!(run);
320 shown["url"] = json!(format!("{page}/actions/runs/{run}"));
321 }
322 if conclusion == "failure" {
323 runs_failed += 1;
324 }
325 // A repository that deploys itself: its deploy workflow's
326 // runs on the default branch are its production deployments.
327 let on_default = text(data, "ref") == Some(format!("refs/heads/{}", repo.default_branch).as_str());
328 if on_default
329 && text(data, "event") != Some("pull_request")
330 && deploy_workflow(text(data, "workflow").unwrap_or_default(), text(data, "path").unwrap_or_default())
331 {
332 shown["production"] = json!(true);
333 match conclusion {
334 "success" => production += 1,
335 "failure" | "timed_out" => failed += 1,
336 _ => {}
337 }
338 }
339 }
340 "package.published" => {
341 for key in ["ecosystem", "name", "version"] {
342 if let Some(value) = text(data, key) {
343 shown[key] = json!(value);
344 }
345 }
346 }
347 _ => continue,
348 }
349 events.push(shown);
350 }
351 json!({
352 "workspace": found.workspace,
353 "since": rfc3339(found.since),
354 "until": rfc3339(found.until),
355 "repositories": found.repos.len(),
356 "summary": {
357 "merged_pull_requests": merged,
358 "releases": releases,
359 "production_deployments": production,
360 "failed_deployments": failed,
361 "default_branch_pushes": landed,
362 "failed_runs": runs_failed,
363 // The landed commits by where they are in production (`run`
364 // fills it in).
365 "shipped": shipped(&[]),
366 },
367 // What landed on default branches in the span, one line a commit
368 // (`run` fills it in): what shipped where nothing was merged.
369 "landed": [],
370 "events": events,
371 "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS,
372 })
373}
374
375/// Whether a workflow deploys: its name or file says so (Deploy,
376/// deploy.yml, deploy-production.yml), and it is not for previews.
377pub fn deploy_workflow(name: &str, path: &str) -> bool {
378 let file = path.rsplit('/').next().unwrap_or_default();
379 let named = format!("{name} {file}").to_lowercase();
380 named.contains("deploy") && !named.contains("preview")
381}
382
383/// Where a deploy of a commit to production is.
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub enum DeployState {
386 Succeeded,
387 /// Queued, waiting or running.
388 Running,
389 Failed,
390}
391
392/// One deploy of a commit to production: a run of a repository's deploy
393/// workflow on its default branch, or a project's production deployment.
394#[derive(Clone, Debug, PartialEq)]
395pub struct Deploy {
396 pub sha: String,
397 pub state: DeployState,
398 /// What it was, for a person: "Deploy #311".
399 pub label: String,
400 pub url: String,
401}
402
403/// A run as a deploy to production, when it is one: its deploy workflow's,
404/// on the default branch and not for a pull request. Cancelled and skipped
405/// runs deployed nothing.
406pub fn run_deploy(site: &str, repo: &str, default_branch: &str, run: &WorkflowRun) -> Option<Deploy> {
407 if run.git_ref != format!("refs/heads/{default_branch}") || run.event == "pull_request" || !deploy_workflow(&run.name, &run.path) {
408 return None;
409 }
410 let state = match (run.status.as_str(), run.conclusion.as_deref()) {
411 ("completed", Some("success")) => DeployState::Succeeded,
412 ("completed", Some("failure" | "timed_out")) => DeployState::Failed,
413 ("completed", _) => return None,
414 _ => DeployState::Running,
415 };
416 Some(Deploy {
417 sha: run.sha.clone(),
418 state,
419 label: format!("{} #{}", run.name, run.number),
420 url: format!("{}/{repo}/actions/runs/{}", site.trim_end_matches('/'), run.id),
421 })
422}
423
424/// A repository's deploys to production that the events name: projects'
425/// production deployments, and its deploy workflow's finished runs.
426pub fn event_deploys(site: &str, repo: &Repo, events: &[Event]) -> Vec<Deploy> {
427 let site = site.trim_end_matches('/');
428 let full = format!("{}/{}", repo.namespace, repo.name);
429 let mut found = Vec::new();
430 for event in events.iter().filter(|event| event.repo_id.as_deref() == Some(repo.id.as_str())) {
431 let data = &event.data;
432 match event.kind.as_str() {
433 "deployment.succeeded" | "deployment.failed" if text(data, "kind") == Some("production") => {
434 let Some(sha) = text(data, "commit") else { continue };
435 found.push(Deploy {
436 sha: sha.to_owned(),
437 state: if event.kind == "deployment.succeeded" { DeployState::Succeeded } else { DeployState::Failed },
438 label: format!("{}'s production deployment", text(data, "project").unwrap_or(&repo.name)),
439 url: match text(data, "path") {
440 Some(path) => format!("{site}{path}"),
441 None => format!("{site}/{full}/deployments"),
442 },
443 });
444 }
445 "workflow.completed" => {
446 let run = WorkflowRun {
447 id: text(data, "runId").unwrap_or_default().to_owned(),
448 path: text(data, "path").unwrap_or_default().to_owned(),
449 name: text(data, "workflow").unwrap_or_default().to_owned(),
450 number: data.get("number").and_then(Value::as_u64).unwrap_or_default(),
451 event: text(data, "event").unwrap_or_default().to_owned(),
452 git_ref: text(data, "ref").unwrap_or_default().to_owned(),
453 sha: text(data, "sha").unwrap_or_default().to_owned(),
454 status: "completed".to_owned(),
455 conclusion: text(data, "conclusion").map(str::to_owned),
456 ..WorkflowRun::default()
457 };
458 if run.id.is_empty() || run.sha.is_empty() {
459 continue;
460 }
461 found.extend(run_deploy(site, &full, &repo.default_branch, &run));
462 }
463 _ => {}
464 }
465 }
466 found
467}
468
469/// Where each commit of a default branch's history (newest first) is in
470/// production, given the deploys seen: `live` when a successful deploy was
471/// of it or of a commit after it (which carries it), else `deploying` when
472/// one is under way, `failed` when one failed, or `not_deployed`; with the
473/// deploy that says so. None for every commit when the repository was not
474/// seen deploying at all.
475pub fn production<'a>(log: &[Commit], deploys: &'a [Deploy]) -> Vec<Option<(&'static str, Option<&'a Deploy>)>> {
476 if deploys.is_empty() {
477 return vec![None; log.len()];
478 }
479 let same = |sha: &str, hash: &str| !sha.is_empty() && (hash.starts_with(sha) || sha.starts_with(hash));
480 // Each deploy at its commit's place in the history.
481 let placed: Vec<(usize, &Deploy)> = deploys
482 .iter()
483 .filter_map(|deploy| log.iter().position(|commit| same(&deploy.sha, &commit.hash)).map(|at| (at, deploy)))
484 .collect();
485 // The nearest deploy at or after a commit in the given state: the one
486 // that first carried it.
487 let nearest = |index: usize, state: DeployState| {
488 placed.iter().filter(|(at, deploy)| *at <= index && deploy.state == state).max_by_key(|(at, _)| *at).map(|(_, deploy)| *deploy)
489 };
490 (0..log.len())
491 .map(|index| {
492 Some(if let Some(deploy) = nearest(index, DeployState::Succeeded) {
493 ("live", Some(deploy))
494 } else if let Some(deploy) = nearest(index, DeployState::Running) {
495 ("deploying", Some(deploy))
496 } else if let Some(deploy) = nearest(index, DeployState::Failed) {
497 ("failed", Some(deploy))
498 } else {
499 ("not_deployed", None)
500 })
501 })
502 .collect()
503}
504
505/// One repository's default-branch commits made in the span, newest first,
506/// each as one line: its short sha, the first line of its message, who
507/// wrote it (and their username, when known), when, its page, and where it
508/// is in production when the repository deploys.
509pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64, authors: &Authors, deploys: &[Deploy]) -> Vec<Value> {
510 let site = site.trim_end_matches('/');
511 let states = production(commits, deploys);
512 commits
513 .iter()
514 .zip(states)
515 .filter(|(commit, _)| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until))
516 .map(|(commit, state)| {
517 let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect();
518 let mut line = json!({
519 "repo": repo,
520 "commit": commit.hash.chars().take(12).collect::<String>(),
521 "subject": subject,
522 "author": commit.author.name,
523 "time": commit.authored_at,
524 "url": format!("{site}/{repo}/commit/{}", commit.hash),
525 });
526 if let Some(username) = commits::username(commit, authors) {
527 line["username"] = json!(username);
528 }
529 if let Some((state, deploy)) = state {
530 line["production"] = json!(state);
531 if let Some(deploy) = deploy {
532 line["deploy"] = json!({ "name": deploy.label, "url": deploy.url });
533 }
534 }
535 line
536 })
537 .collect()
538}
539
540/// How many landed commits are where, for the summary.
541pub fn shipped(lines: &[Value]) -> Value {
542 let count = |state: &str| lines.iter().filter(|line| line["production"] == state).count();
543 json!({
544 "live": count("live"),
545 "deploying": count("deploying"),
546 "failed": count("failed"),
547 "not_deployed": count("not_deployed"),
548 })
549}
550
551pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
552 let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase();
553 if workspace.is_empty() {
554 return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug."));
555 }
556 let (since, until) = match span(input, g1t_kit::now_ms()) {
557 Ok(span) => span,
558 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
559 };
560 let types = match event_types(input) {
561 Ok(types) => types,
562 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
563 };
564 let limit = limit(input);
565 // The workspace's repositories as the caller may read them; a
566 // workspace they cannot see has none.
567 let repos: Vec<Repo> = g1t_kit::call(
568 &services.repos,
569 "list",
570 &ListReposArgs { viewer: viewer.clone(), query: None, namespace: Some(workspace.clone()), member_only: false },
571 )
572 .await?;
573 let repos: Vec<Repo> = repos.into_iter().filter(|repo| repo.namespace.eq_ignore_ascii_case(&workspace)).collect();
574 let site = services.addresses.site.clone();
575 let none = (HashMap::new(), HashMap::new());
576 if repos.is_empty() {
577 return Ok(Outcome::Ok(shape(&Found {
578 workspace: &workspace,
579 site: &site,
580 since,
581 until,
582 repos: &repos,
583 events: &[],
584 more: false,
585 pull_titles: &none.0,
586 usernames: &none.1,
587 })));
588 }
589 let events: Vec<Event> = g1t_kit::call(
590 &services.events,
591 "list",
592 &ListEventsArgs {
593 repo_ids: repos.iter().take(MAX_LIST_REPOS).map(|repo| repo.id.clone()).collect(),
594 types: types.iter().map(|kind| (*kind).to_owned()).collect(),
595 since: Some(rfc3339(since)),
596 // One past the limit says whether there was more.
597 limit: Some(limit + 1),
598 ..ListEventsArgs::default()
599 },
600 )
601 .await?;
602 let more = events.len() > limit as usize;
603 let events: Vec<Event> = events.into_iter().take(limit as usize).collect();
604 // Titles of the pull requests named, and the names of who did what.
605 let pull_repos: Vec<String> = events
606 .iter()
607 .filter(|event| event.kind.starts_with("pull."))
608 .filter_map(|event| event.repo_id.clone())
609 .collect::<BTreeSet<_>>()
610 .into_iter()
611 .take(MAX_PULLS_FOR_REPOS)
612 .collect();
613 let actors: Vec<String> = events.iter().filter_map(|event| event.actor.clone()).collect::<BTreeSet<_>>().into_iter().collect();
614 let pulls = async {
615 if pull_repos.is_empty() {
616 return Ok(Vec::new());
617 }
618 g1t_kit::call::<_, Vec<RepoPulls>>(
619 &services.work,
620 "pulls_for_repos",
621 &PullsForReposArgs { repo_ids: pull_repos, viewer: viewer.clone(), limit: 100 },
622 )
623 .await
624 };
625 let names = async {
626 if actors.is_empty() {
627 return Ok(HashMap::new());
628 }
629 g1t_kit::call::<_, HashMap<String, String>>(&services.identity, "usernames", &UsernamesArgs { ids: actors }).await
630 };
631 let (pulls, names) = futures_util::future::join(pulls, names).await;
632 // Without titles or names the events still say what happened.
633 let mut pull_titles = HashMap::new();
634 for repo in pulls.unwrap_or_default() {
635 for pull in repo.open.into_iter().chain(repo.closed) {
636 pull_titles.insert((repo.repo_id.clone(), pull.number), pull.title);
637 }
638 }
639 let usernames = names.unwrap_or_default();
640 let mut answer = shape(&Found {
641 workspace: &workspace,
642 site: &site,
643 since,
644 until,
645 repos: &repos,
646 events: &events,
647 more,
648 pull_titles: &pull_titles,
649 usernames: &usernames,
650 });
651 // The repositories whose default branch was pushed to, busiest first:
652 // their commits say what landed.
653 let mut pushed: Vec<(String, usize)> = Vec::new();
654 for event in events.iter().filter(|event| event.kind == "git.push" && event.data.get("defaultBranch").and_then(Value::as_bool) == Some(true)) {
655 let Some(id) = event.repo_id.as_deref() else { continue };
656 match pushed.iter_mut().find(|(known, _)| known == id) {
657 Some((_, count)) => *count += 1,
658 None => pushed.push((id.to_owned(), 1)),
659 }
660 }
661 pushed.sort_by(|a, b| b.1.cmp(&a.1));
662 let by_id: HashMap<&str, &Repo> = repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
663 // Each one's history, and its default branch's runs in the span: the
664 // deploys among them, finished or not, say what reached production.
665 let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move {
666 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
667 let log_args = LogArgs { path: path.clone(), viewer: viewer.clone(), git_ref: None, limit: LANDED_LOG };
668 let runs_args = RunsArgs {
669 repo: path,
670 viewer: viewer.clone(),
671 workflow: None,
672 branch: Some(repo.default_branch.clone()),
673 event: None,
674 pull: None,
675 sha: None,
676 status: None,
677 since: Some(rfc3339(since)),
678 until: None,
679 limit: Some(LANDED_RUNS),
680 };
681 let (log, runs) = futures_util::future::join(
682 g1t_kit::call::<_, Outcome<Vec<Commit>>>(&services.repos, "log", &log_args),
683 g1t_kit::call::<_, Outcome<Vec<WorkflowRun>>>(&services.actions, "runs", &runs_args),
684 )
685 .await;
686 (repo, log, runs)
687 });
688 let mut histories = Vec::new();
689 for (repo, log, runs) in futures_util::future::join_all(logs).await {
690 // A history that cannot be read leaves the events to say it.
691 let Ok(Outcome::Ok(commits)) = log else { continue };
692 let full = format!("{}/{}", repo.namespace, repo.name);
693 let mut deploys = event_deploys(&site, repo, &events);
694 if let Ok(Outcome::Ok(runs)) = runs {
695 for deploy in runs.iter().filter_map(|run| run_deploy(&site, &full, &repo.default_branch, run)) {
696 // A run the events named too is the same deploy; the run says
697 // where it is now.
698 deploys.retain(|known| known.url != deploy.url);
699 deploys.push(deploy);
700 }
701 }
702 histories.push((full, commits, deploys));
703 }
704 let all: Vec<&Commit> = histories.iter().flat_map(|(_, commits, _)| commits.iter()).collect();
705 let authors = commits::read_authors(services, &all).await;
706 let mut lines = Vec::new();
707 for (repo, commits, deploys) in &histories {
708 lines.extend(landed(&site, repo, commits, since, until, &authors, deploys));
709 }
710 lines.truncate(LANDED_MAX);
711 answer["summary"]["shipped"] = shipped(&lines);
712 answer["landed"] = json!(lines);
713 Ok(Outcome::Ok(answer))
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 fn repo(id: &str, name: &str) -> Repo {
721 serde_json::from_value(json!({
722 "id": id, "namespace": "acme", "name": name, "isPrivate": false,
723 "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z",
724 }))
725 .unwrap()
726 }
727
728 fn event(kind: &str, repo: &str, time: &str, actor: &str, data: Value) -> Event {
729 Event {
730 id: format!("evt_{time}"),
731 kind: kind.into(),
732 source: "test".into(),
733 time: time.into(),
734 repo_id: Some(repo.into()),
735 actor: Some(actor.into()),
736 data,
737 }
738 }
739
740 #[test]
741 fn the_span_is_the_last_day_unless_given() {
742 let now = parse_rfc3339("2026-10-12T18:00:00Z").unwrap();
743 let (since, until) = span(&json!({}), now).unwrap();
744 assert_eq!(until, now);
745 assert_eq!(until - since, DEFAULT_SPAN_MS);
746 let (since, _) = span(&json!({ "since": "2026-10-12T00:00:00Z" }), now).unwrap();
747 assert_eq!(since, parse_rfc3339("2026-10-12T00:00:00Z").unwrap());
748 assert!(span(&json!({ "since": "yesterday" }), now).unwrap_err().contains("RFC 3339"));
749 assert!(span(&json!({ "since": "2026-10-13T00:00:00Z" }), now).is_err());
750 assert!(span(&json!({ "since": "2026-08-01T00:00:00Z" }), now).unwrap_err().contains("31 days"));
751 }
752
753 #[test]
754 fn kinds_name_their_events() {
755 assert_eq!(event_types(&json!({ "kinds": ["merged"] })).unwrap(), vec!["pull.merged"]);
756 let all = event_types(&json!({})).unwrap();
757 assert!(all.contains(&"pull.merged") && all.contains(&"git.push") && all.contains(&"release.published"));
758 assert!(!all.contains(&"comment.created"));
759 assert!(event_types(&json!({ "kinds": ["shipped"] })).unwrap_err().contains("Kinds: merged"));
760 // Over REST: one comma-separated text.
761 assert_eq!(event_types(&json!({ "kinds": "merged, releases" })).unwrap(), vec!["pull.merged", "release.published"]);
762 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
763 assert_eq!(limit(&json!({ "limit": 5 })), 5);
764 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
765 }
766
767 #[test]
768 fn what_landed_is_one_line_a_commit_made_in_the_span() {
769 use g1t_contracts::repos::Signature;
770 let commit = |hash: &str, message: &str, at: &str| Commit {
771 hash: hash.to_owned(),
772 tree_hash: String::new(),
773 message: message.to_owned(),
774 author: Signature { name: "Chase".into(), email: String::new() },
775 parents: Vec::new(),
776 authored_at: at.to_owned(),
777 };
778 let commits = [
779 commit("aaaaaaaaaaaaaaaa", "Ask reads g1t
780
781The long why.", "2026-10-13T05:00:00Z"),
782 commit("bbbbbbbbbbbbbbbb", "Yesterday's change", "2026-10-12T05:00:00Z"),
783 ];
784 let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap();
785 let until = parse_rfc3339("2026-10-13T06:00:00Z").unwrap();
786 let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until, &Authors::new(), &[]);
787 assert_eq!(lines.len(), 1);
788 assert_eq!(lines[0]["subject"], "Ask reads g1t");
789 assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa");
790 assert_eq!(lines[0]["author"], "Chase");
791 assert_eq!(lines[0]["url"], "https://g1t.sh/acme/web/commit/aaaaaaaaaaaaaaaa");
792 // Nobody known by the address, and no deploys seen: no username made
793 // up from the name, and nothing said about production.
794 assert!(lines[0].get("username").is_none());
795 assert!(lines[0].get("production").is_none());
796 }
797
798 fn by(hash: &str, email: &str, at: &str) -> Commit {
799 Commit {
800 hash: hash.to_owned(),
801 tree_hash: String::new(),
802 message: format!("Change {hash}"),
803 author: g1t_contracts::repos::Signature { name: "Chase Pierce".into(), email: email.into() },
804 parents: Vec::new(),
805 authored_at: at.to_owned(),
806 }
807 }
808
809 fn run(id: &str, name: &str, number: u64, sha: &str, status: &str, conclusion: Option<&str>) -> WorkflowRun {
810 WorkflowRun {
811 id: id.into(),
812 path: ".g1t/workflows/deploy.yml".into(),
813 name: name.into(),
814 number,
815 event: "push".into(),
816 git_ref: "refs/heads/main".into(),
817 sha: sha.into(),
818 status: status.into(),
819 conclusion: conclusion.map(str::to_owned),
820 ..WorkflowRun::default()
821 }
822 }
823
824 #[test]
825 fn what_shipped_is_what_reached_the_default_branch_and_which_of_it_is_live() {
826 // Newest first: e was pushed last and is still deploying, which
827 // carries d, whose own deploy failed; c went out with Deploy #311,
828 // which carried b and a too.
829 let log = [
830 by("eeee0000", "syntaqx@gmail.com", "2026-10-14T13:00:00Z"),
831 by("dddd0000", "syntaqx@gmail.com", "2026-10-14T11:00:00Z"),
832 by("cccc0000", "syntaqx@gmail.com", "2026-10-14T09:00:00Z"),
833 by("bbbb0000", "someone@example.com", "2026-10-14T08:00:00Z"),
834 by("aaaa0000", "syntaqx@gmail.com", "2026-10-13T08:00:00Z"),
835 ];
836 let site = "https://g1t.sh";
837 let runs = [
838 run("run_313", "Deploy", 313, "eeee0000", "in_progress", None),
839 run("run_312", "Deploy", 312, "dddd0000", "completed", Some("failure")),
840 run("run_311", "Deploy", 311, "cccc0000", "completed", Some("success")),
841 // CI is not a deploy; a cancelled deploy deployed nothing; a
842 // deploy of another branch is not production.
843 WorkflowRun { path: ".g1t/workflows/ci.yml".into(), ..run("run_9", "CI", 9, "eeee0000", "completed", Some("success")) },
844 run("run_310", "Deploy", 310, "dddd0000", "completed", Some("cancelled")),
845 WorkflowRun { git_ref: "refs/heads/next".into(), ..run("run_8", "Deploy", 8, "eeee0000", "completed", Some("success")) },
846 ];
847 let deploys: Vec<Deploy> = runs.iter().filter_map(|found| run_deploy(site, "flagon-io/g1t", "main", found)).collect();
848 assert_eq!(deploys.len(), 3);
849 assert_eq!(deploys[2], Deploy {
850 sha: "cccc0000".into(),
851 state: DeployState::Succeeded,
852 label: "Deploy #311".into(),
853 url: "https://g1t.sh/flagon-io/g1t/actions/runs/run_311".into(),
854 });
855 let owners = HashMap::from([(
856 "syntaqx@gmail.com".to_owned(),
857 g1t_contracts::accounts::EmailOwner { id: "usr_1".into(), username: "syntaqx".into(), avatar: None },
858 )]);
859 let authors = commits::authors(&log.iter().collect::<Vec<_>>(), &owners);
860 let since = parse_rfc3339("2026-10-14T06:00:00Z").unwrap();
861 let until = parse_rfc3339("2026-10-14T20:00:00Z").unwrap();
862 let lines = landed(site, "flagon-io/g1t", &log, since, until, &authors, &deploys);
863 let states: Vec<&str> = lines.iter().map(|line| line["production"].as_str().unwrap()).collect();
864 assert_eq!(states, ["deploying", "deploying", "live", "live"], "a is yesterday's");
865 assert_eq!(lines[2]["deploy"], json!({ "name": "Deploy #311", "url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_311" }));
866 assert_eq!(lines[1]["deploy"]["name"], "Deploy #313");
867 // The author by their username; someone unknown by their name alone.
868 assert_eq!(lines[0]["username"], "syntaqx");
869 assert_eq!(lines[0]["author"], "Chase Pierce");
870 assert!(lines[3].get("username").is_none());
871 assert_eq!(shipped(&lines), json!({ "live": 2, "deploying": 2, "failed": 0, "not_deployed": 0 }));
872 // Before #313 started: d failed to deploy, and e is not deployed.
873 let lines = landed(site, "flagon-io/g1t", &log, since, until, &authors, &deploys[1..]);
874 assert_eq!(shipped(&lines), json!({ "live": 2, "deploying": 0, "failed": 1, "not_deployed": 1 }));
875 assert_eq!(lines[1]["deploy"]["name"], "Deploy #312");
876 assert!(lines[0].get("deploy").is_none());
877 // Once #313 succeeds, everything is live; before any deploy of today's
878 // commits, they are not deployed.
879 let done = [Deploy { state: DeployState::Succeeded, ..deploys[0].clone() }];
880 assert!(production(&log, &done).iter().all(|state| state.map(|(state, _)| state) == Some("live")));
881 let old = [Deploy { sha: "ffff9999".into(), ..deploys[2].clone() }];
882 assert!(production(&log, &old).iter().all(|state| state.map(|(state, _)| state) == Some("not_deployed")));
883 assert!(production(&log, &[]).iter().all(Option::is_none));
884 }
885
886 #[test]
887 fn deploy_workflows_are_known_by_name_or_file() {
888 assert!(deploy_workflow("Deploy", ".g1t/workflows/deploy.yml"));
889 assert!(deploy_workflow("Ship it", ".g1t/workflows/deploy-production.yml"));
890 assert!(deploy_workflow("Deploy site", ".g1t/workflows/site.yml"));
891 assert!(!deploy_workflow("CI", ".g1t/workflows/ci.yml"));
892 assert!(!deploy_workflow("Deploy preview", ".g1t/workflows/preview.yml"));
893 }
894
895 #[test]
896 fn what_shipped_reads_as_one_list_with_links_and_names() {
897 let repos = [repo("rep_1", "web"), repo("rep_2", "api")];
898 let events = [
899 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" })),
900 event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })),
901 event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "item": 3 })),
902 event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })),
903 event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })),
904 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" })),
905 event("work_item.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "workspace": "acme", "number": 3, "title": "Login fails", "reason": "completed", "resolution": { "repo": "acme/web", "pull": 7 } })),
906 // Outside the span, and a repository the caller cannot read.
907 event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })),
908 event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })),
909 ];
910 let titles = HashMap::from([(("rep_1".to_owned(), 7), "Fix login".to_owned())]);
911 let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned()), ("usr_2".to_owned(), "bo".to_owned())]);
912 let shown = shape(&Found {
913 workspace: "acme",
914 site: "https://g1t.sh/",
915 since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(),
916 until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(),
917 repos: &repos,
918 events: &events,
919 more: false,
920 pull_titles: &titles,
921 usernames: &names,
922 });
923 assert_eq!(
924 shown["summary"],
925 json!({
926 "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 1, "default_branch_pushes": 1, "failed_runs": 1,
927 "shipped": { "live": 0, "deploying": 0, "failed": 0, "not_deployed": 0 },
928 })
929 );
930 let listed = shown["events"].as_array().unwrap();
931 assert_eq!(listed.len(), 6, "{listed:#?}");
932 assert_eq!(listed[0]["url"], "https://g1t.sh/acme/web/deployments/dpl_1");
933 assert_eq!(listed[0]["commit"], "abcdef123456");
934 assert_eq!(listed[1]["title"], "Faster pushes");
935 assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0");
936 assert_eq!(
937 listed[2],
938 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", "item": 3 })
939 );
940 assert_eq!(listed[3]["default_branch"], true);
941 assert_eq!(listed[3]["actor"], "bo");
942 assert_eq!(
943 listed[4],
944 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", "production": true })
945 );
946 assert_eq!(listed[5]["resolved_by"], 7);
947 assert_eq!(listed[5]["url"], "https://g1t.sh/acme/-/work/3");
948 assert_eq!(shown["complete"], true);
949 }
950
951 #[test]
952 fn a_work_item_a_commit_closed_names_the_commit() {
953 let repos = [repo("rep_1", "web")];
954 let events = [event(
955 "work_item.closed",
956 "rep_1",
957 "2026-10-12T10:00:00Z",
958 "usr_1",
959 json!({ "workspace": "acme", "number": 3, "title": "Login fails", "reason": "completed", "resolution": { "repo": "acme/web", "commit": "0123456789abcdef0123456789abcdef01234567" } }),
960 )];
961 let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned())]);
962 let shown = shape(&Found {
963 workspace: "acme",
964 site: "https://g1t.sh/",
965 since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(),
966 until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(),
967 repos: &repos,
968 events: &events,
969 more: false,
970 pull_titles: &HashMap::new(),
971 usernames: &names,
972 });
973 let closed = &shown["events"][0];
974 assert_eq!(closed["resolved_commit"], "0123456789abcdef0123456789abcdef01234567");
975 assert!(closed.get("resolved_by").is_none());
976 assert_eq!(closed["actor"], "ana");
977 }
978}