Skip to content

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

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