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
368 linesCodeBlameRaw
1//! Full-history aggregates, restricted to repositories the viewer can read.
2use std::collections::BTreeMap;
3
4use futures_util::StreamExt;
5use g1t_contracts::actions::RunDetail;
6use g1t_contracts::checks::{ActionsChecksArgs, job_statuses};
7use g1t_contracts::repos::{ReadableArgs, Repo};
8use g1t_contracts::work::{DeliveryDay, DeliveryInsights, DeliveryInsightsArgs, DeliveryMetrics};
9use serde::Deserialize;
10use worker::Result;
11use worker::wasm_bindgen::JsValue;
12
13use crate::Work;
14
15/// Heads asked of the actions service at once: well under its 100 commits
16/// and 200 runs a call, so no head's latest runs are cut off.
17const HEADS_PER_CALL: usize = 20;
18
19/// One open pull request's head and one status stored on it (none when it
20/// has none).
21#[derive(Deserialize)]
22struct HeadRow {
23 id: String,
24 repo_id: String,
25 sha: String,
26 state: Option<String>,
27}
28
29impl Work {
30 pub(crate) async fn delivery_insights(
31 &self,
32 a: DeliveryInsightsArgs,
33 ) -> Result<DeliveryInsights> {
34 // The web loader batches larger workspaces instead of silently truncating them.
35 if a.repo_ids.len() > 100 || !valid_day(&a.from) || !valid_day(&a.until) || a.from > a.until
36 {
37 return Err(worker::Error::RustError(
38 "Invalid insights range or repository batch".into(),
39 ));
40 }
41 let readable: Vec<Repo> = g1t_kit::call(
42 &self.repos,
43 "readable",
44 &ReadableArgs {
45 ids: a.repo_ids,
46 viewer: a.viewer,
47 },
48 )
49 .await?;
50 let ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect();
51 if ids.is_empty() {
52 return Ok(DeliveryInsights {
53 projects: Vec::new(),
54 days: Vec::new(),
55 checks_unavailable: false,
56 });
57 }
58 let binds: [JsValue; 3] = [
59 serde_json::to_string(&ids)?.into(),
60 format!("{}T00:00:00", a.from).into(),
61 format!("{}T24:00:00", a.until).into(),
62 ];
63 let projects = self
64 .db
65 .prepare(include_str!("insights.sql"))
66 .bind(&binds)?
67 .all()
68 .await?
69 .results::<DeliveryMetrics>()?;
70 let days = self
71 .db
72 .prepare(
73 "SELECT substr(merged_at, 1, 10) AS day, COUNT(*) AS merged FROM pulls
74 WHERE repo_id IN (SELECT value FROM json_each(?1)) AND status = 'merged'
75 AND merged_at >= ?2 AND merged_at < ?3 GROUP BY day ORDER BY day",
76 )
77 .bind(&binds)?
78 .all()
79 .await?
80 .results::<DeliveryDay>()?;
81 let (checks, checks_unavailable) = self.head_checks(&binds[0]).await?;
82 // Include readable repositories with no pulls, but never inaccessible ones.
83 let projects = ids
84 .into_iter()
85 .map(|id| {
86 let mut row = projects
87 .iter()
88 .find(|row| row.repo_id == id)
89 .cloned()
90 .unwrap_or_else(|| DeliveryMetrics {
91 repo_id: id.into(),
92 ..Default::default()
93 });
94 (row.checking, row.failed) = checks.get(id).copied().unwrap_or_default();
95 row
96 })
97 .collect();
98 Ok(DeliveryInsights {
99 projects,
100 days,
101 checks_unavailable,
102 })
103 }
104
105 /// Per repository, how many open pull requests have checks still going
106 /// on their head and how many have one that failed, each counted once,
107 /// as their pages add them up: the statuses and check runs stored here
108 /// with the latest g1t Actions jobs. Beside it, whether g1t Actions
109 /// could not be read for some of them.
110 async fn head_checks(&self, ids: &JsValue) -> Result<(BTreeMap<String, (u32, u32)>, bool)> {
111 let rows = self
112 .db
113 .prepare(
114 "SELECT p.id, p.repo_id, p.head_commit AS sha, s.state FROM pulls p
115 LEFT JOIN commit_statuses s ON s.repo_id = p.repo_id AND s.sha = p.head_commit
116 WHERE p.repo_id IN (SELECT value FROM json_each(?1))
117 AND p.status IN ('draft', 'open') AND p.head_commit IS NOT NULL",
118 )
119 .bind(std::slice::from_ref(ids))?
120 .all()
121 .await?
122 .results::<HeadRow>()?;
123 // Each pull request's repository, head and stored states.
124 let mut pulls: BTreeMap<String, (String, String, Vec<String>)> = BTreeMap::new();
125 for row in rows {
126 let pull = pulls
127 .entry(row.id)
128 .or_insert_with(|| (row.repo_id, row.sha, Vec::new()));
129 pull.2.extend(row.state);
130 }
131 let mut heads: BTreeMap<&str, Vec<String>> = BTreeMap::new();
132 for (repo_id, sha, _) in pulls.values() {
133 let shas = heads.entry(repo_id.as_str()).or_default();
134 if !shas.contains(sha) {
135 shas.push(sha.clone());
136 }
137 }
138 let calls: Vec<ActionsChecksArgs> = heads
139 .iter()
140 .flat_map(|(repo_id, shas)| {
141 shas.chunks(HEADS_PER_CALL).map(|chunk| ActionsChecksArgs {
142 repo_id: (*repo_id).to_owned(),
143 shas: chunk.to_vec(),
144 ..Default::default()
145 })
146 })
147 .collect();
148 let answers: Vec<(String, Result<Vec<RunDetail>>)> = futures_util::stream::iter(calls)
149 .map(|args| async move {
150 let found = g1t_kit::call(&self.actions, "check_runs", &args).await;
151 (args.repo_id, found)
152 })
153 .buffer_unordered(6)
154 .collect()
155 .await;
156 let mut unavailable = false;
157 // Each call's heads are its own, so its runs stay newest first per head.
158 let mut runs: BTreeMap<String, Vec<_>> = BTreeMap::new();
159 for (repo_id, found) in answers {
160 match found {
161 Ok(found) => runs
162 .entry(repo_id)
163 .or_default()
164 .extend(found.into_iter().map(|detail| (detail.run, detail.jobs))),
165 Err(_) => unavailable = true,
166 }
167 }
168 let mut counts: BTreeMap<String, (u32, u32)> = BTreeMap::new();
169 for (repo_id, sha, mut states) in pulls.into_values() {
170 if let Some(found) = runs.get(&repo_id) {
171 states.extend(
172 job_statuses(found, &sha)
173 .into_iter()
174 .map(|status| status.state),
175 );
176 }
177 let count = counts.entry(repo_id).or_default();
178 match head_state(&states) {
179 Some(HeadState::Failed) => count.1 += 1,
180 Some(HeadState::Pending) => count.0 += 1,
181 None => {}
182 }
183 }
184 Ok((counts, unavailable))
185 }
186}
187
188#[derive(Debug, PartialEq)]
189enum HeadState {
190 Pending,
191 Failed,
192}
193
194/// Where a head's checks stand: failed if any did, else pending while any
195/// is going; `None` when every one passed or none reported.
196fn head_state(states: &[String]) -> Option<HeadState> {
197 if states
198 .iter()
199 .any(|state| matches!(state.as_str(), "failure" | "error"))
200 {
201 Some(HeadState::Failed)
202 } else if states.iter().any(|state| state == "pending") {
203 Some(HeadState::Pending)
204 } else {
205 None
206 }
207}
208
209fn valid_day(value: &str) -> bool {
210 let bytes = value.as_bytes();
211 if bytes.len() != 10
212 || bytes[4] != b'-'
213 || bytes[7] != b'-'
214 || !bytes
215 .iter()
216 .enumerate()
217 .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
218 {
219 return false;
220 }
221 let year: u32 = value[..4].parse().unwrap();
222 let month: usize = value[5..7].parse().unwrap();
223 let day: u32 = value[8..].parse().unwrap();
224 let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
225 let lengths = [
226 31,
227 if leap { 29 } else { 28 },
228 31,
229 30,
230 31,
231 30,
232 31,
233 31,
234 30,
235 31,
236 30,
237 31,
238 ];
239 year > 0 && (1..=12).contains(&month) && day > 0 && day <= lengths[month - 1]
240}
241
242#[cfg(test)]
243mod tests {
244 use super::HeadState;
245 use rusqlite::{Connection, params};
246
247 #[test]
248 fn rejects_invalid_calendar_dates() {
249 for day in [
250 "",
251 "2026-02-29",
252 "2026-04-31",
253 "2026-13-01",
254 "2026-01-00",
255 "2026-10-01T00:00:00Z",
256 ] {
257 assert!(!super::valid_day(day));
258 }
259 assert!(super::valid_day("2024-02-29"));
260 }
261
262 #[test]
263 fn a_head_counts_once_and_failure_wins() {
264 let states = |all: &[&str]| all.iter().map(|s| s.to_string()).collect::<Vec<_>>();
265 assert_eq!(
266 super::head_state(&states(&["pending", "failure"])),
267 Some(HeadState::Failed)
268 );
269 assert_eq!(
270 super::head_state(&states(&["success", "error"])),
271 Some(HeadState::Failed)
272 );
273 assert_eq!(
274 super::head_state(&states(&["success", "pending"])),
275 Some(HeadState::Pending)
276 );
277 assert_eq!(super::head_state(&states(&["success"])), None);
278 assert_eq!(
279 super::head_state(&[]),
280 None,
281 "a head nothing reported on is neither"
282 );
283 }
284
285 fn schema(db: &Connection) {
286 db.execute_batch(
287 "CREATE TABLE pulls (id TEXT, repo_id TEXT, number INTEGER, author_id TEXT, requested_by_id TEXT, status TEXT, created_at TEXT, merged_at TEXT, head_commit TEXT);
288 CREATE TABLE comments (repo_id TEXT, number INTEGER, author_id TEXT, agent_id TEXT, verdict TEXT, created_at TEXT);
289 CREATE TABLE queue_entries (pull_id TEXT, state TEXT);",
290 )
291 .unwrap();
292 }
293
294 /// (opened, merged, active, unreviewed, queued, lead, review, reviewed)
295 fn metrics(
296 db: &Connection,
297 from: &str,
298 until: &str,
299 ) -> (u32, u32, u32, u32, u32, f64, f64, u32) {
300 db.query_row(
301 include_str!("insights.sql"),
302 params![r#"["a"]"#, from, until],
303 |row| {
304 Ok((
305 row.get("opened")?,
306 row.get("merged")?,
307 row.get("active")?,
308 row.get("unreviewed")?,
309 row.get("queued")?,
310 row.get("lead_seconds")?,
311 row.get("review_seconds")?,
312 row.get("reviewed")?,
313 ))
314 },
315 )
316 .unwrap()
317 }
318
319 #[test]
320 fn aggregates_full_history_with_period_boundaries_and_current_waits() {
321 let db = Connection::open_in_memory().unwrap();
322 schema(&db);
323 db.execute_batch("
324 INSERT INTO pulls VALUES ('old', 'a', 1, 'author', NULL, 'merged', '2026-09-30T00:00:00Z', '2026-10-01T00:00:00Z', NULL);
325 INSERT INTO pulls VALUES ('next', 'a', 2, 'author', NULL, 'merged', '2026-10-01T00:00:00Z', '2026-10-08T00:00:00Z', NULL);
326 INSERT INTO pulls VALUES ('open', 'a', 3, 'author', NULL, 'open', '2026-09-01T00:00:00Z', NULL, 'current');
327 INSERT INTO pulls VALUES ('secret', 'private', 1, 'author', NULL, 'merged', '2026-10-01T00:00:00Z', '2026-10-02T00:00:00Z', NULL);
328 INSERT INTO comments VALUES ('a', 3, 'author', NULL, 'approve', '2026-10-02T00:00:00Z');
329 INSERT INTO comments VALUES ('a', 1, 'reviewer', NULL, 'approve', '2026-10-01T00:00:00Z');
330 INSERT INTO comments VALUES ('a', 1, 'reviewer', NULL, 'approve', '2026-10-02T00:00:00Z');
331 INSERT INTO queue_entries VALUES ('open', 'waiting');
332 INSERT INTO queue_entries VALUES ('open', 'testing');").unwrap();
333 for n in 4..154 {
334 db.execute("INSERT INTO pulls VALUES (?1, 'a', ?2, 'author', NULL, 'merged', '2026-10-02T00:00:00Z', '2026-10-03T00:00:00.123Z', NULL)", params![format!("p{n}"), n]).unwrap();
335 }
336 let (opened, merged, active, unreviewed, queued, lead, _, reviewed) =
337 metrics(&db, "2026-10-01T00:00:00", "2026-10-07T24:00:00");
338 assert_eq!(
339 (opened, merged, active, unreviewed, queued, reviewed),
340 (151, 151, 1, 1, 1, 1)
341 );
342 // Timestamps with milliseconds, as rfc3339 writes them, count them.
343 assert!((lead - (151.0 * 86400.0 + 150.0 * 0.123)).abs() < 0.01);
344 }
345
346 #[test]
347 fn a_first_review_is_a_persons_other_than_the_owner() {
348 let db = Connection::open_in_memory().unwrap();
349 schema(&db);
350 db.execute_batch("
351 INSERT INTO pulls VALUES ('mine', 'a', 1, 'usr_g1t_agent', 'owner', 'open', '2026-10-01T00:00:00Z', NULL, NULL);
352 INSERT INTO comments VALUES ('a', 1, 'usr_g1t_agent', NULL, 'approve', '2026-10-01T01:00:00Z');
353 INSERT INTO comments VALUES ('a', 1, 'owner', NULL, 'approve', '2026-10-01T02:00:00Z');
354 INSERT INTO comments VALUES ('a', 1, 'agt_margo', 'agt_margo', 'approve', '2026-10-01T03:00:00Z');").unwrap();
355 let (.., unreviewed, _, _, _, reviewed) =
356 metrics(&db, "2026-10-01T00:00:00", "2026-10-01T24:00:00");
357 assert_eq!(
358 (unreviewed, reviewed),
359 (1, 0),
360 "g1t, an agent and the owner are not reviewers"
361 );
362 db.execute("INSERT INTO comments VALUES ('a', 1, 'reviewer', NULL, 'request_changes', '2026-10-01T04:00:00Z')", []).unwrap();
363 let (.., unreviewed, _, _, review, reviewed) =
364 metrics(&db, "2026-10-01T00:00:00", "2026-10-01T24:00:00");
365 assert_eq!((unreviewed, reviewed), (0, 1));
366 assert!((review - 4.0 * 3600.0).abs() < 1.0);
367 }
368}