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.

Commit

Work: workspace_signals reads what a workspace's work says over the last weeks

Home's daily signal and the Workspace overview need more than Insights' totals: per repository and week, what merged and how much of it was an agent's, which of those a person sent back first, how long sessions took to open their pull requests and reviews took to come, and what sat open over a week; per day, what was opened, merged, reviewed and started; the pull requests waiting on a first review with the work they hold up; and the pull requests, owners, work items and milestones those pages draw from. It reads only repositories the viewer can read, as delivery_insights does. Work items come through the same visibility check as the rest of work items, and milestones only for a viewer who can read one of the workspace's repositories. An agent's pull request is told the way PULL_SESSIONS ties sessions to one, so the pages agree with the pull request's own. The SQL lives beside it so the tests run the very statements D1 does. Part of #157

G1t-Session:
ses_01m571rtyzeq8ax09mc3bm1zdq
syntaqxcommitted Parente15f8bcBrowse files
10 files+1053−10/10 viewed
+1−1
206206 }
207207 }
208208
209−fn valid_day(value: &str) -> bool {
209+pub(crate) fn valid_day(value: &str) -> bool {
210210 let bytes = value.as_bytes();
211211 if bytes.len() != 10
212212 || bytes[4] != b'-'
+2−0
3838 mod rulesets;
3939 mod runs;
4040 mod settings;
41+mod signals;
4142 mod statuses;
4243 mod team_reviews;
4344
26662667 "open_pull" => reply(&work.open_pull(args(body)?).await?),
26672668 "list_pulls" => reply(&work.list_pulls(args(body)?).await?),
26682669 "delivery_insights" => reply(&work.delivery_insights(args(body)?).await?),
2670+ "workspace_signals" => reply(&work.workspace_signals(args(body)?).await?),
26692671 "pulls_for_repos" => reply(&work.pulls_for_repos(args(body)?).await?),
26702672 "get_pull" => reply(&work.get_pull(args(body)?).await?),
26712673 "update_pull" => reply(&work.update_pull(args(body)?).await?),
+329−0
1+//! What a workspace's work says over the last weeks (`workspace_signals`):
2+//! the reads behind Home's daily signal and the Workspace overview, one
3+//! row per repository the viewer can read, as `delivery_insights` reads
4+//! them (insights.rs).
5+//!
6+//! The definitions, which the SQL beside this file (signals/) holds:
7+//!
8+//! - A pull request is an **agent's** when its `agent` is `agent` (opened
9+//! as an agent's), g1t's agent ([`AGENT_ID`]) opened it, or a harness
10+//! session is tied to it, tied as `PULL_SESSIONS` (coordination/links.rs)
11+//! ties them: a `pull` session link, the session's own `pull`, or a push
12+//! to its branch when it is not on a fork.
13+//! - Its **owner** is the person it was requested by, else its author.
14+//! - A **person's verdict** is an approval or request for changes by
15+//! someone other than the owner, not g1t's agent and not a workspace
16+//! agent (`agent_id` set). The first is its **first review**, as
17+//! insights.sql has it.
18+//! - **Rework**: an agent's pull request merged in the week that a person
19+//! asked for changes on before it merged.
20+//! - **Draft time**: for an agent's pull request whose first tied session
21+//! started before it opened, the seconds from that start to its opening,
22+//! counted in the week it opened.
23+//! - **Stalled** in a week: opened more than seven days before the week
24+//! ended (now, when that is earlier) and not finished by then: not
25+//! merged, and not closed (a closed one's `updated_at` stands in for when
26+//! it closed). Drafts count.
27+//! - A day's **reviews** are comments of any kind on a pull request by a
28+//! person other than its owner (not g1t's agent, a workspace agent or g1t
29+//! itself); **reworked**, the ones of them asking for changes.
30+//! - **Waiting**: open (not draft) pull requests with no first review.
31+//! - **Authors** are owners who are people: not g1t's agent, a workspace
32+//! agent (`agt_…`), a workspace (`wsp_…`) or g1t.
33+use std::collections::BTreeSet;
34+
35+use g1t_contracts::identity::AGENT_ID;
36+use g1t_contracts::repos::{ReadableArgs, Repo};
37+use g1t_contracts::time::{parse_rfc3339, rfc3339};
38+use g1t_contracts::work::{
39+ SignalAuthor, SignalDay, SignalItem, SignalMilestone, SignalPull, SignalWeek, State, WaitingPull, WorkspaceSignals,
40+ WorkspaceSignalsArgs,
41+};
42+use serde::{Deserialize, Serialize};
43+use worker::Result;
44+use worker::wasm_bindgen::JsValue;
45+
46+use crate::Work;
47+use crate::insights::valid_day;
48+use crate::items;
49+
50+const DAY_MS: u64 = 86_400_000;
51+/// How far `since` may reach back before `until`.
52+const MAX_SINCE_DAYS: u64 = 400;
53+/// `pulls` returns at most this many; pulls.sql asks for one more.
54+const PULLS_LIMIT: usize = 3000;
55+/// `recent` in authors: the days ending with `until`.
56+const RECENT_DAYS: u64 = 90;
57+
58+pub(crate) const WEEKS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/weeks.sql"));
59+pub(crate) const DAYS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/days.sql"));
60+pub(crate) const WAITING: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/waiting.sql"));
61+pub(crate) const PULLS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/pulls.sql"));
62+pub(crate) const AUTHORS: &str = include_str!("signals/authors.sql");
63+/// ?1 the workspace.
64+pub(crate) const MILESTONES: &str = "SELECT number, title, state, due_on, created_at, closed_at
65+ FROM milestones WHERE workspace = ?1 ORDER BY number";
66+
67+/// One week's bounds, as weeks.sql reads them from JSON.
68+#[derive(Debug, Serialize)]
69+struct WeekBounds {
70+ /// Its first UTC day, `YYYY-MM-DD`.
71+ start: String,
72+ from: String,
73+ to: String,
74+ /// When it is read as ended for stalled pull requests: `to`, or now
75+ /// when that is earlier.
76+ cut: String,
77+}
78+
79+/// What the queries are bound to, worked out from the arguments.
80+#[derive(Debug)]
81+struct Window {
82+ /// JSON array of [`WeekBounds`], oldest first.
83+ weeks: String,
84+ /// The first of `days`, and the day after `until`, as `YYYY-MM-DDT00:00:00`.
85+ days_from: String,
86+ end: String,
87+ /// The start of `since`.
88+ since: String,
89+ /// The first of the 90 days ending with `until`.
90+ recent_from: String,
91+}
92+
93+fn day_start(day: &str) -> Option<u64> {
94+ parse_rfc3339(&format!("{day}T00:00:00Z"))
95+}
96+
97+fn day_of(ms: u64) -> String {
98+ rfc3339(ms)[..10].to_owned()
99+}
100+
101+fn bound(ms: u64) -> String {
102+ format!("{}T00:00:00", day_of(ms))
103+}
104+
105+/// The bounds `a` asks for at `now`, or `None` when it asks for too many
106+/// repositories or an invalid range.
107+fn window(a: &WorkspaceSignalsArgs, now: u64) -> Option<Window> {
108+ if a.repo_ids.len() > 100
109+ || !(1..=13).contains(&a.weeks)
110+ || !(1..=28).contains(&a.days)
111+ || !valid_day(&a.until)
112+ || !valid_day(&a.since)
113+ || a.since > a.until
114+ {
115+ return None;
116+ }
117+ let until = day_start(&a.until)?;
118+ let since = day_start(&a.since)?;
119+ if until - since > MAX_SINCE_DAYS * DAY_MS {
120+ return None;
121+ }
122+ let end = until + DAY_MS;
123+ let first = end.checked_sub(u64::from(a.weeks) * 7 * DAY_MS)?;
124+ let weeks: Vec<WeekBounds> = (0..u64::from(a.weeks))
125+ .map(|i| {
126+ let from = first + i * 7 * DAY_MS;
127+ let to = from + 7 * DAY_MS;
128+ WeekBounds {
129+ start: day_of(from),
130+ from: rfc3339(from),
131+ to: rfc3339(to),
132+ cut: rfc3339(to.min(now)),
133+ }
134+ })
135+ .collect();
136+ Some(Window {
137+ weeks: serde_json::to_string(&weeks).ok()?,
138+ days_from: bound(end.checked_sub(u64::from(a.days) * DAY_MS)?),
139+ end: bound(end),
140+ since: bound(since),
141+ recent_from: bound(end.checked_sub(RECENT_DAYS * DAY_MS)?),
142+ })
143+}
144+
145+/// Whether an owner is a person: not g1t's agent, a workspace agent, a
146+/// workspace or g1t itself.
147+fn is_person(id: &str) -> bool {
148+ id != AGENT_ID && !id.starts_with("agt_") && !id.starts_with("wsp_") && !g1t_contracts::system::is_system_id(id)
149+}
150+
151+/// A row of waiting.sql.
152+#[derive(Deserialize)]
153+struct WaitingRow {
154+ repo_id: String,
155+ number: u32,
156+ title: String,
157+ created_at: String,
158+ agent: u32,
159+ draft_seconds: Option<f64>,
160+ item: Option<u32>,
161+ /// JSON array of numbers.
162+ blocks: String,
163+}
164+
165+impl From<WaitingRow> for WaitingPull {
166+ fn from(row: WaitingRow) -> Self {
167+ WaitingPull {
168+ repo_id: row.repo_id,
169+ number: row.number,
170+ title: row.title,
171+ created_at: row.created_at,
172+ agent: row.agent != 0,
173+ draft_seconds: row.draft_seconds,
174+ item: row.item,
175+ blocks: serde_json::from_str(&row.blocks).unwrap_or_default(),
176+ }
177+ }
178+}
179+
180+/// A row of pulls.sql.
181+#[derive(Deserialize)]
182+struct PullRow {
183+ repo_id: String,
184+ number: u32,
185+ title: String,
186+ status: String,
187+ agent: u32,
188+ owner_id: String,
189+ owner_name: String,
190+ created_at: String,
191+ merged_at: Option<String>,
192+ item: Option<u32>,
193+ milestone: Option<u32>,
194+}
195+
196+impl From<PullRow> for SignalPull {
197+ fn from(row: PullRow) -> Self {
198+ SignalPull {
199+ repo_id: row.repo_id,
200+ number: row.number,
201+ title: row.title,
202+ status: row.status,
203+ agent: row.agent != 0,
204+ owner_id: row.owner_id,
205+ owner_name: row.owner_name,
206+ created_at: row.created_at,
207+ merged_at: row.merged_at,
208+ item: row.item,
209+ milestone: row.milestone,
210+ }
211+ }
212+}
213+
214+/// The work item numbers `pulls` and `waiting` name, each once.
215+fn named_items(pulls: &[SignalPull], waiting: &[WaitingPull]) -> Vec<u32> {
216+ let mut numbers: BTreeSet<u32> = pulls.iter().filter_map(|pull| pull.item).collect();
217+ for pull in waiting {
218+ numbers.extend(pull.item);
219+ numbers.extend(pull.blocks.iter().copied());
220+ }
221+ numbers.into_iter().collect()
222+}
223+
224+impl Work {
225+ pub(crate) async fn workspace_signals(&self, a: WorkspaceSignalsArgs) -> Result<WorkspaceSignals> {
226+ // The web loader batches larger workspaces, as it does for insights.
227+ let Some(window) = window(&a, g1t_kit::now_ms()) else {
228+ return Err(worker::Error::RustError(
229+ "Invalid signals range or repository batch".into(),
230+ ));
231+ };
232+ let viewer = a.viewer.clone();
233+ let readable: Vec<Repo> = g1t_kit::call(
234+ &self.repos,
235+ "readable",
236+ &ReadableArgs {
237+ ids: a.repo_ids,
238+ viewer: a.viewer,
239+ },
240+ )
241+ .await?;
242+ let ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect();
243+ if ids.is_empty() {
244+ return Ok(WorkspaceSignals::default());
245+ }
246+ // The workspace's own milestones and work items, only for a viewer
247+ // who can read one of its repositories.
248+ let in_workspace = readable.iter().any(|repo| repo.namespace == a.workspace);
249+ let ids: JsValue = serde_json::to_string(&ids)?.into();
250+ let workspace: JsValue = a.workspace.as_str().into();
251+ let results = self
252+ .timing
253+ .db(
254+ 6,
255+ self.db.batch(vec![
256+ self.db.prepare(WEEKS).bind(&[ids.clone(), window.weeks.into()])?,
257+ self.db.prepare(DAYS).bind(&[
258+ ids.clone(),
259+ window.days_from.as_str().into(),
260+ window.end.as_str().into(),
261+ ])?,
262+ self.db.prepare(WAITING).bind(&[ids.clone(), workspace.clone()])?,
263+ self.db
264+ .prepare(PULLS)
265+ .bind(&[ids.clone(), workspace.clone(), window.since.into()])?,
266+ self.db
267+ .prepare(AUTHORS)
268+ .bind(&[ids, window.recent_from.into(), window.end.into()])?,
269+ self.db.prepare(MILESTONES).bind(&[workspace])?,
270+ ]),
271+ )
272+ .await?;
273+ let [weeks, days, waiting, pulls, authors, milestones] = results.as_slice() else {
274+ return Err(worker::Error::RustError("workspace_signals: missing results".into()));
275+ };
276+ let mut waiting: Vec<WaitingPull> = waiting
277+ .results::<WaitingRow>()?
278+ .into_iter()
279+ .map(WaitingPull::from)
280+ .collect();
281+ let mut pulls: Vec<SignalPull> = pulls.results::<PullRow>()?.into_iter().map(SignalPull::from).collect();
282+ let pulls_capped = pulls.len() > PULLS_LIMIT;
283+ pulls.truncate(PULLS_LIMIT);
284+ let authors: Vec<SignalAuthor> = authors
285+ .results::<SignalAuthor>()?
286+ .into_iter()
287+ .filter(|author| is_person(&author.owner_id))
288+ .collect();
289+ let (items, milestones) = if in_workspace {
290+ let numbers = named_items(&pulls, &waiting);
291+ let visible = items::visible_numbered(self, &a.workspace, &numbers, viewer.as_ref()).await?;
292+ let mut items: Vec<SignalItem> = visible
293+ .into_iter()
294+ .map(|held| SignalItem {
295+ number: held.item.number,
296+ title: held.item.title,
297+ state: match held.item.state {
298+ State::Open => "open",
299+ State::Closed => "closed",
300+ }
301+ .into(),
302+ milestone: held.item.milestone.map(|milestone| milestone.number),
303+ })
304+ .collect();
305+ items.sort_by_key(|item| item.number);
306+ (items, milestones.results::<SignalMilestone>()?)
307+ } else {
308+ (Vec::new(), Vec::new())
309+ };
310+ // A blocked work item the viewer may not see is not named.
311+ for pull in &mut waiting {
312+ pull.blocks
313+ .retain(|number| items.iter().any(|item| item.number == *number));
314+ }
315+ Ok(WorkspaceSignals {
316+ weeks: weeks.results::<SignalWeek>()?,
317+ days: days.results::<SignalDay>()?,
318+ waiting,
319+ pulls,
320+ pulls_capped,
321+ authors,
322+ items,
323+ milestones,
324+ })
325+ }
326+}
327+
328+#[cfg(test)]
329+mod tests;
+16−0
1+-- ?1 the repositories' ids as a JSON array; ?2 and ?3 the bounds of the 90
2+-- days ending with `until`. Each owner's merged pull requests in each
3+-- repository over its whole history, under the name on their latest.
4+-- Agents and g1t are left out by the caller (signals.rs `is_person`).
5+WITH m AS (
6+ SELECT repo_id, COALESCE(requested_by_id, author_id) AS owner_id,
7+ CASE WHEN requested_by_id IS NULL THEN author_name ELSE requested_by_name END AS owner_name, merged_at
8+ FROM pulls
9+ WHERE repo_id IN (SELECT value FROM json_each(?1)) AND status = 'merged' AND merged_at IS NOT NULL
10+)
11+SELECT repo_id, owner_id,
12+ (SELECT n.owner_name FROM m n WHERE n.repo_id = m.repo_id AND n.owner_id = m.owner_id
13+ ORDER BY n.merged_at DESC LIMIT 1) AS owner_name,
14+ MIN(merged_at) AS first_merged_at, MAX(merged_at) AS last_merged_at, COUNT(*) AS merged,
15+ COUNT(CASE WHEN merged_at >= ?2 AND merged_at < ?3 THEN 1 END) AS recent
16+FROM m GROUP BY repo_id, owner_id ORDER BY repo_id, first_merged_at
+38−0
1+-- ?1 the repositories' ids as a JSON array. Every read of workspace_signals
2+-- about pull requests starts with this, and goes on with its own CTEs.
3+--
4+-- tied: the harness sessions tied to each pull request, as PULL_SESSIONS
5+-- (coordination/links.rs) ties them: a link, the pull request the session
6+-- said when it started, or a push to its branch (not a fork's).
7+WITH tied(pull_id, session_id) AS (
8+ SELECT p.id, l.session_id FROM session_links l
9+ JOIN pulls p ON p.repo_id = l.repo_id AND p.number = l.number
10+ WHERE l.repo_id IN (SELECT value FROM json_each(?1)) AND l.kind = 'pull'
11+ UNION SELECT p.id, s.id FROM sessions s
12+ JOIN pulls p ON p.repo_id = s.repo_id AND p.number = s.pull
13+ WHERE s.repo_id IN (SELECT value FROM json_each(?1))
14+ UNION SELECT p.id, t.session_id FROM session_touches t
15+ JOIN pulls p ON p.repo_id = t.repo_id AND p.source_branch = t.value
16+ AND p.fork_repo_id IS NULL AND p.head_repo_id IS NULL
17+ WHERE t.repo_id IN (SELECT value FROM json_each(?1)) AND t.kind = 'branch'
18+),
19+-- When each tied pull request's first session started, as a Julian day.
20+first_session(pull_id, started) AS (
21+ SELECT tied.pull_id, MIN(julianday(s.started_at)) FROM tied JOIN sessions s ON s.id = tied.session_id
22+ GROUP BY tied.pull_id
23+),
24+-- The pull requests, with whose they are (the person who asked for it,
25+-- else its author), whether an agent's (g1t's agent opened it, it was
26+-- opened as an agent's, or a session is tied to it), and, when its first
27+-- session started before it opened, the seconds in between.
28+pr AS (
29+ SELECT p.id, p.repo_id, p.number, p.title, p.status, p.item_id, p.milestone,
30+ p.created_at, p.updated_at, p.merged_at,
31+ COALESCE(p.requested_by_id, p.author_id) AS owner_id,
32+ CASE WHEN p.requested_by_id IS NULL THEN p.author_name ELSE p.requested_by_name END AS owner_name,
33+ (p.agent = 'agent' OR p.author_id = 'usr_g1t_agent' OR f.pull_id IS NOT NULL) AS agent,
34+ CASE WHEN f.started < julianday(p.created_at)
35+ THEN MAX(0, (julianday(p.created_at) - f.started) * 86400) END AS draft_seconds
36+ FROM pulls p LEFT JOIN first_session f ON f.pull_id = p.id
37+ WHERE p.repo_id IN (SELECT value FROM json_each(?1))
38+)
+22−0
1+-- ?2 and ?3 the first day's start and the day after the last's, as
2+-- `YYYY-MM-DDT00:00:00`. One row per repository and UTC day that had
3+-- anything in it. Reviews are comments of any kind on a pull request by a
4+-- person other than its owner (not g1t's agent, a workspace agent or g1t
5+-- itself); reworked, the ones of them that ask for changes.
6+,
7+ev(repo_id, day, opened, merged, agent_merged, reworked, reviews, sessions) AS (
8+ SELECT repo_id, substr(created_at, 1, 10), 1, 0, 0, 0, 0, 0 FROM pr
9+ WHERE created_at >= ?2 AND created_at < ?3
10+ UNION ALL SELECT repo_id, substr(merged_at, 1, 10), 0, 1, agent, 0, 0, 0 FROM pr
11+ WHERE status = 'merged' AND merged_at >= ?2 AND merged_at < ?3
12+ UNION ALL SELECT c.repo_id, substr(c.created_at, 1, 10), 0, 0, 0, c.verdict IS 'request_changes', 1, 0
13+ FROM comments c JOIN pr ON pr.repo_id = c.repo_id AND pr.number = c.number
14+ WHERE c.repo_id IN (SELECT value FROM json_each(?1)) AND c.created_at >= ?2 AND c.created_at < ?3
15+ AND c.author_id != pr.owner_id AND c.agent_id IS NULL
16+ AND c.author_id NOT IN ('usr_g1t_agent', 'g1t', 'g1t_policy', 'svc_runner', 'g1t_runner')
17+ UNION ALL SELECT repo_id, substr(started_at, 1, 10), 0, 0, 0, 0, 0, 1 FROM sessions
18+ WHERE repo_id IN (SELECT value FROM json_each(?1)) AND started_at >= ?2 AND started_at < ?3
19+)
20+SELECT repo_id, day, SUM(opened) AS opened, SUM(merged) AS merged, SUM(agent_merged) AS agent_merged,
21+ SUM(reworked) AS reworked, SUM(reviews) AS reviews, SUM(sessions) AS sessions
22+FROM ev GROUP BY repo_id, day ORDER BY day, repo_id
+14−0
1+-- ?2 the workspace, ?3 the start of `since` as `YYYY-MM-DDT00:00:00`.
2+-- Pull requests opened or merged since then, newest first, one more than
3+-- are returned so the caller knows when there were more. A pull
4+-- request's milestone is its work item's, else its own when that is one
5+-- of the workspace's (until the move to work items finishes, a pull
6+-- request may still hold its repository's old number).
7+SELECT pr.repo_id, pr.number, pr.title, pr.status, pr.agent, pr.owner_id, pr.owner_name,
8+ pr.created_at, pr.merged_at, i.number AS item,
9+ COALESCE(i.milestone,
10+ (SELECT m.number FROM milestones m WHERE m.workspace = ?2 AND m.number = pr.milestone)) AS milestone
11+FROM pr LEFT JOIN work_items i ON i.id = pr.item_id AND i.workspace = ?2
12+WHERE pr.created_at >= ?3 OR (pr.status = 'merged' AND pr.merged_at >= ?3)
13+ORDER BY pr.created_at DESC, pr.repo_id, pr.number DESC
14+LIMIT 3001
+564−0
1+use g1t_contracts::work::{SignalAuthor, SignalDay, SignalPull, SignalWeek, WaitingPull, WorkspaceSignalsArgs};
2+use rusqlite::types::ValueRef;
3+use rusqlite::{Connection, ToSql, params};
4+use serde::de::DeserializeOwned;
5+
6+use super::{AUTHORS, DAYS, PULLS, PullRow, WAITING, WEEKS, WaitingRow, Window, is_person};
7+
8+const IDS: &str = r#"["a"]"#;
9+
10+fn schema() -> Connection {
11+ let db = Connection::open_in_memory().unwrap();
12+ db.execute_batch(
13+ "CREATE TABLE pulls (id TEXT, repo_id TEXT, number INTEGER, title TEXT DEFAULT 'A change', status TEXT,
14+ item_id TEXT, milestone INTEGER, agent TEXT DEFAULT 'person', author_id TEXT DEFAULT 'usr_ana',
15+ author_name TEXT DEFAULT 'ana', requested_by_id TEXT, requested_by_name TEXT,
16+ source_branch TEXT, fork_repo_id TEXT, head_repo_id TEXT,
17+ created_at TEXT, updated_at TEXT, merged_at TEXT);
18+ CREATE TABLE comments (repo_id TEXT, number INTEGER, author_id TEXT, agent_id TEXT, verdict TEXT, created_at TEXT);
19+ CREATE TABLE sessions (id TEXT, repo_id TEXT, pull INTEGER, started_at TEXT);
20+ CREATE TABLE session_links (session_id TEXT, repo_id TEXT, kind TEXT, number INTEGER);
21+ CREATE TABLE session_touches (session_id TEXT, repo_id TEXT, kind TEXT, value TEXT);
22+ CREATE TABLE work_items (id TEXT, workspace TEXT, number INTEGER, title TEXT, state TEXT,
23+ blocked_by TEXT DEFAULT '[]', milestone INTEGER);
24+ CREATE TABLE milestones (workspace TEXT, number INTEGER, title TEXT, state TEXT, due_on TEXT,
25+ created_at TEXT, closed_at TEXT);",
26+ )
27+ .unwrap();
28+ db
29+}
30+
31+/// A pull request of repository `a`, by `ana` unless changed afterwards.
32+fn pull(db: &Connection, number: u32, status: &str, created: &str, merged: Option<&str>, updated: &str) {
33+ db.execute(
34+ "INSERT INTO pulls (id, repo_id, number, status, created_at, merged_at, updated_at, source_branch)
35+ VALUES (?1, 'a', ?2, ?3, ?4, ?5, ?6, ?7)",
36+ params![
37+ format!("pr_{number}"),
38+ number,
39+ status,
40+ created,
41+ merged,
42+ updated,
43+ format!("branch-{number}")
44+ ],
45+ )
46+ .unwrap();
47+}
48+
49+fn comment(db: &Connection, number: u32, author: &str, agent: Option<&str>, verdict: Option<&str>, at: &str) {
50+ db.execute(
51+ "INSERT INTO comments VALUES ('a', ?1, ?2, ?3, ?4, ?5)",
52+ params![number, author, agent, verdict, at],
53+ )
54+ .unwrap();
55+}
56+
57+/// Every row `sql` returns, read as `T` the way D1's rows are.
58+fn rows<T: DeserializeOwned>(db: &Connection, sql: &str, binds: &[&dyn ToSql]) -> Vec<T> {
59+ let mut statement = db.prepare(sql).unwrap();
60+ let names: Vec<String> = statement.column_names().into_iter().map(str::to_owned).collect();
61+ let mut found = statement.query(binds).unwrap();
62+ let mut out = Vec::new();
63+ while let Some(row) = found.next().unwrap() {
64+ let mut object = serde_json::Map::new();
65+ for (i, name) in names.iter().enumerate() {
66+ let value = match row.get_ref(i).unwrap() {
67+ ValueRef::Null => serde_json::Value::Null,
68+ ValueRef::Integer(n) => n.into(),
69+ ValueRef::Real(n) => n.into(),
70+ ValueRef::Text(text) => String::from_utf8_lossy(text).into_owned().into(),
71+ ValueRef::Blob(_) => panic!("no blobs here"),
72+ };
73+ object.insert(name.clone(), value);
74+ }
75+ out.push(serde_json::from_value(object.into()).unwrap());
76+ }
77+ out
78+}
79+
80+fn args(until: &str, weeks: u32, days: u32, since: &str) -> WorkspaceSignalsArgs {
81+ WorkspaceSignalsArgs {
82+ workspace: "acme".into(),
83+ repo_ids: vec!["a".into()],
84+ viewer: None,
85+ until: until.into(),
86+ weeks,
87+ days,
88+ since: since.into(),
89+ }
90+}
91+
92+fn ms(at: &str) -> u64 {
93+ g1t_contracts::time::parse_rfc3339(at).unwrap()
94+}
95+
96+/// Two weeks ending with Sunday 2026-10-11 (Mondays 09-28 and 10-05),
97+/// read on `now`.
98+fn window(now: &str) -> Window {
99+ super::window(&args("2026-10-11", 2, 7, "2026-09-01"), ms(now)).unwrap()
100+}
101+
102+fn weeks(db: &Connection, now: &str) -> Vec<SignalWeek> {
103+ rows(db, WEEKS, &[&IDS, &window(now).weeks])
104+}
105+
106+fn in_week(found: &[SignalWeek], start: &str) -> SignalWeek {
107+ found.iter().find(|week| week.start == start).cloned().unwrap()
108+}
109+
110+fn pulls(db: &Connection) -> Vec<SignalPull> {
111+ rows::<PullRow>(db, PULLS, &[&IDS, &"acme", &"2026-01-01T00:00:00"])
112+ .into_iter()
113+ .map(Into::into)
114+ .collect()
115+}
116+
117+fn waiting(db: &Connection) -> Vec<WaitingPull> {
118+ rows::<WaitingRow>(db, WAITING, &[&IDS, &"acme"])
119+ .into_iter()
120+ .map(Into::into)
121+ .collect()
122+}
123+
124+#[test]
125+fn checks_the_arguments() {
126+ let now = ms("2026-10-18T00:00:00Z");
127+ let valid = |a: WorkspaceSignalsArgs| super::window(&a, now).is_some();
128+ assert!(valid(args("2026-10-11", 13, 28, "2025-09-06")), "400 days back");
129+ assert!(!valid(args("2026-10-11", 13, 28, "2025-09-05")), "401 days back");
130+ assert!(!valid(args("2026-10-11", 0, 7, "2026-10-01")));
131+ assert!(!valid(args("2026-10-11", 14, 7, "2026-10-01")));
132+ assert!(!valid(args("2026-10-11", 1, 0, "2026-10-01")));
133+ assert!(!valid(args("2026-10-11", 1, 29, "2026-10-01")));
134+ assert!(!valid(args("2026-10-11", 1, 7, "2026-10-12")), "since after until");
135+ assert!(valid(args("2026-10-11", 1, 7, "2026-10-11")));
136+ assert!(!valid(args("2026-02-30", 1, 7, "2026-02-01")));
137+ assert!(!valid(args("2026-10-11", 1, 7, "2026-10-1")));
138+ let mut many = args("2026-10-11", 1, 7, "2026-10-01");
139+ many.repo_ids = (0..101).map(|n| format!("r{n}")).collect();
140+ assert!(!valid(many));
141+
142+ let window = super::window(&args("2026-10-11", 2, 3, "2026-09-01"), now).unwrap();
143+ let weeks: serde_json::Value = serde_json::from_str(&window.weeks).unwrap();
144+ assert_eq!(weeks[0]["start"], "2026-09-28");
145+ assert_eq!(weeks[1]["start"], "2026-10-05");
146+ assert_eq!(weeks[1]["to"], "2026-10-12T00:00:00.000Z");
147+ assert_eq!(
148+ weeks[1]["cut"], "2026-10-12T00:00:00.000Z",
149+ "a week already over ends at its end"
150+ );
151+ assert_eq!(
152+ (
153+ window.days_from.as_str(),
154+ window.end.as_str(),
155+ window.since.as_str(),
156+ window.recent_from.as_str()
157+ ),
158+ (
159+ "2026-10-09T00:00:00",
160+ "2026-10-12T00:00:00",
161+ "2026-09-01T00:00:00",
162+ "2026-07-14T00:00:00"
163+ )
164+ );
165+}
166+
167+#[test]
168+fn an_agents_pull_request_is_one_a_session_is_tied_to() {
169+ let db = schema();
170+ for number in 1..=6 {
171+ pull(
172+ &db,
173+ number,
174+ "open",
175+ "2026-10-06T12:00:00Z",
176+ None,
177+ "2026-10-06T12:00:00Z",
178+ );
179+ }
180+ db.execute_batch(
181+ "UPDATE pulls SET agent = 'agent' WHERE number = 1;
182+ UPDATE pulls SET author_id = 'usr_g1t_agent', requested_by_id = 'usr_ana', requested_by_name = 'ana' WHERE number = 2;
183+ -- 3 by a link, 4 by the session saying so, 5 by a push to its branch.
184+ INSERT INTO sessions VALUES ('ses_3', 'a', NULL, '2026-10-06T11:00:00Z');
185+ INSERT INTO session_links VALUES ('ses_3', 'a', 'pull', 3);
186+ INSERT INTO sessions VALUES ('ses_4', 'a', 4, '2026-10-06T13:00:00Z');
187+ INSERT INTO sessions VALUES ('ses_5', 'a', NULL, '2026-10-06T11:30:00Z');
188+ INSERT INTO sessions VALUES ('ses_5b', 'a', NULL, '2026-10-06T10:00:00.500Z');
189+ INSERT INTO session_touches VALUES ('ses_5', 'a', 'branch', 'branch-5');
190+ INSERT INTO session_touches VALUES ('ses_5b', 'a', 'branch', 'branch-5');
191+ -- 6's branch is on a fork: a push to the same name is not its.
192+ UPDATE pulls SET fork_repo_id = 'fork' WHERE number = 6;
193+ INSERT INTO sessions VALUES ('ses_6', 'a', NULL, '2026-10-06T11:00:00Z');
194+ INSERT INTO session_touches VALUES ('ses_6', 'a', 'branch', 'branch-6');
195+ -- A session of another repository with the same number is not its.
196+ INSERT INTO sessions VALUES ('ses_x', 'b', 6, '2026-10-06T11:00:00Z');",
197+ )
198+ .unwrap();
199+ pull(&db, 7, "open", "2026-10-06T12:00:00Z", None, "2026-10-06T12:00:00Z");
200+ let mut agents: Vec<(u32, bool)> = pulls(&db).iter().map(|p| (p.number, p.agent)).collect();
201+ agents.sort();
202+ assert_eq!(
203+ agents,
204+ [
205+ (1, true),
206+ (2, true),
207+ (3, true),
208+ (4, true),
209+ (5, true),
210+ (6, false),
211+ (7, false)
212+ ]
213+ );
214+ assert_eq!(
215+ pulls(&db).iter().find(|p| p.number == 2).unwrap().owner_id,
216+ "usr_ana",
217+ "whose g1t's is"
218+ );
219+
220+ let mut drafts: Vec<(u32, Option<f64>)> = waiting(&db).iter().map(|p| (p.number, p.draft_seconds)).collect();
221+ drafts.sort_by_key(|(number, _)| *number);
222+ // 4's session started after it opened: no draft time. 5's first
223+ // session started 2 hours less half a second before.
224+ assert!(drafts[2].0 == 3 && (drafts[2].1.unwrap() - 3600.0).abs() < 0.01);
225+ assert_eq!(drafts[3], (4, None));
226+ assert!(drafts[4].0 == 5 && (drafts[4].1.unwrap() - 7199.5).abs() < 0.01);
227+ assert_eq!((drafts[0].1, drafts[5].1), (None, None));
228+
229+ let week = in_week(&weeks(&db, "2026-10-18T00:00:00Z"), "2026-10-05");
230+ assert_eq!(week.agent_drafted, 2);
231+ assert!((week.agent_draft_seconds - 10799.5).abs() < 0.01);
232+}
233+
234+#[test]
235+fn rework_is_a_persons_request_for_changes_before_it_merged() {
236+ let db = schema();
237+ pull(
238+ &db,
239+ 1,
240+ "merged",
241+ "2026-10-05T00:00:00Z",
242+ Some("2026-10-07T00:00:00Z"),
243+ "2026-10-07T00:00:00Z",
244+ );
245+ pull(
246+ &db,
247+ 2,
248+ "merged",
249+ "2026-10-05T00:00:00Z",
250+ Some("2026-10-07T00:00:00Z"),
251+ "2026-10-07T00:00:00Z",
252+ );
253+ db.execute_batch("UPDATE pulls SET agent = 'agent'").unwrap();
254+ // None of these is a person's, before it merged, on 1.
255+ comment(&db, 1, "usr_ana", None, Some("request_changes"), "2026-10-06T00:00:00Z");
256+ comment(
257+ &db,
258+ 1,
259+ "usr_g1t_agent",
260+ None,
261+ Some("request_changes"),
262+ "2026-10-06T00:00:00Z",
263+ );
264+ comment(
265+ &db,
266+ 1,
267+ "agt_margo",
268+ Some("agt_margo"),
269+ Some("request_changes"),
270+ "2026-10-06T00:00:00Z",
271+ );
272+ comment(&db, 1, "usr_bo", None, Some("approve"), "2026-10-06T00:00:00Z");
273+ comment(&db, 1, "usr_bo", None, Some("request_changes"), "2026-10-08T00:00:00Z");
274+ comment(&db, 2, "usr_bo", None, Some("request_changes"), "2026-10-06T00:00:00Z");
275+ let found = weeks(&db, "2026-10-18T00:00:00Z");
276+ assert_eq!(found.len(), 1);
277+ let week = &found[0];
278+ assert_eq!(
279+ (week.start.as_str(), week.merged, week.agent_merged, week.agent_reworked),
280+ ("2026-10-05", 2, 2, 1)
281+ );
282+ // A person's pull request asked for changes is not an agent's rework.
283+ db.execute("UPDATE pulls SET agent = 'person' WHERE number = 2", [])
284+ .unwrap();
285+ let week = in_week(&weeks(&db, "2026-10-18T00:00:00Z"), "2026-10-05");
286+ assert_eq!((week.merged, week.agent_merged, week.agent_reworked), (2, 1, 0));
287+}
288+
289+#[test]
290+fn stalled_is_open_over_a_week_when_the_week_ended() {
291+ let db = schema();
292+ // The second week ends at 2026-10-12T00:00:00Z: over seven days open
293+ // by then is opened before 10-05.
294+ pull(&db, 1, "open", "2026-10-05T00:00:00Z", None, "2026-10-05T00:00:00Z");
295+ pull(&db, 2, "open", "2026-10-04T23:59:59Z", None, "2026-10-04T23:59:59Z");
296+ // Merged after the week ended: still open when it did.
297+ pull(
298+ &db,
299+ 3,
300+ "merged",
301+ "2026-09-01T00:00:00Z",
302+ Some("2026-10-12T00:00:00Z"),
303+ "2026-10-12T00:00:00Z",
304+ );
305+ // Merged and closed before it ended.
306+ pull(
307+ &db,
308+ 4,
309+ "merged",
310+ "2026-09-01T00:00:00Z",
311+ Some("2026-10-11T23:00:00Z"),
312+ "2026-10-11T23:00:00Z",
313+ );
314+ pull(&db, 5, "closed", "2026-09-01T00:00:00Z", None, "2026-10-11T23:00:00Z");
315+ // Closed after it ended.
316+ pull(&db, 6, "closed", "2026-09-01T00:00:00Z", None, "2026-10-13T00:00:00Z");
317+ pull(&db, 7, "draft", "2026-09-27T00:00:00Z", None, "2026-09-27T00:00:00Z");
318+ let found = weeks(&db, "2026-10-18T00:00:00Z");
319+ let stalled: Vec<(&str, u32)> = found.iter().map(|w| (w.start.as_str(), w.stalled)).collect();
320+ // The first week ends on 10-05: 3 to 6 open since 09-01, and 7 opened
321+ // a second over seven days before. The second: 2, 3, 6 and 7.
322+ assert_eq!(stalled, [("2026-09-28", 5), ("2026-10-05", 4)]);
323+
324+ // Read mid-week, the current week ends now, 10-08T12:00: 3, 4, 5 and
325+ // 6 were not finished by then, 7 is old enough, 2 is not, and 1 at
326+ // exactly seven days is not either.
327+ db.execute(
328+ "UPDATE pulls SET created_at = '2026-10-01T12:00:00Z' WHERE number = 1",
329+ [],
330+ )
331+ .unwrap();
332+ assert_eq!(in_week(&weeks(&db, "2026-10-08T12:00:00Z"), "2026-10-05").stalled, 5);
333+ db.execute(
334+ "UPDATE pulls SET created_at = '2026-10-01T11:59:59Z' WHERE number = 1",
335+ [],
336+ )
337+ .unwrap();
338+ assert_eq!(in_week(&weeks(&db, "2026-10-08T12:00:00Z"), "2026-10-05").stalled, 6);
339+}
340+
341+#[test]
342+fn a_first_review_is_a_persons_other_than_the_owner() {
343+ let db = schema();
344+ pull(&db, 1, "open", "2026-10-06T00:00:00Z", None, "2026-10-06T00:00:00Z");
345+ db.execute_batch(
346+ "UPDATE pulls SET author_id = 'usr_g1t_agent', requested_by_id = 'owner', requested_by_name = 'owner'",
347+ )
348+ .unwrap();
349+ comment(&db, 1, "usr_g1t_agent", None, Some("approve"), "2026-10-06T01:00:00Z");
350+ comment(&db, 1, "owner", None, Some("approve"), "2026-10-06T02:00:00Z");
351+ comment(
352+ &db,
353+ 1,
354+ "agt_margo",
355+ Some("agt_margo"),
356+ Some("approve"),
357+ "2026-10-06T03:00:00Z",
358+ );
359+ comment(&db, 1, "usr_bo", None, None, "2026-10-06T03:30:00Z");
360+ assert!(weeks(&db, "2026-10-18T00:00:00Z").iter().all(|w| w.reviewed == 0));
361+ assert_eq!(
362+ waiting(&db).len(),
363+ 1,
364+ "g1t, an agent, the owner and a plain comment are no review"
365+ );
366+ comment(&db, 1, "usr_bo", None, Some("request_changes"), "2026-10-06T04:00:00Z");
367+ comment(&db, 1, "usr_cy", None, Some("approve"), "2026-10-06T05:00:00Z");
368+ let week = in_week(&weeks(&db, "2026-10-18T00:00:00Z"), "2026-10-05");
369+ assert_eq!(week.reviewed, 1);
370+ assert!((week.review_seconds - 4.0 * 3600.0).abs() < 1.0);
371+ assert!(waiting(&db).is_empty());
372+}
373+
374+#[test]
375+fn waiting_pull_requests_name_the_work_they_block() {
376+ let db = schema();
377+ pull(&db, 10, "open", "2026-10-02T00:00:00Z", None, "2026-10-02T00:00:00Z");
378+ pull(&db, 11, "open", "2026-10-01T00:00:00Z", None, "2026-10-01T00:00:00Z");
379+ pull(&db, 12, "draft", "2026-09-01T00:00:00Z", None, "2026-09-01T00:00:00Z");
380+ db.execute_batch(
381+ "UPDATE pulls SET item_id = 'wki_1' WHERE number = 10;
382+ INSERT INTO work_items VALUES ('wki_1', 'acme', 1, 'Login', 'open', '[]', 3);
383+ INSERT INTO work_items VALUES ('wki_2', 'acme', 2, 'Signup', 'open', '[\"wki_9\", \"wki_1\"]', NULL);
384+ INSERT INTO work_items VALUES ('wki_4', 'acme', 4, 'Billing', 'open', '[\"wki_1\"]', NULL);
385+ INSERT INTO work_items VALUES ('wki_5', 'acme', 5, 'Done', 'closed', '[\"wki_1\"]', NULL);
386+ INSERT INTO work_items VALUES ('wki_6', 'other', 6, 'Elsewhere', 'open', '[\"wki_1\"]', NULL);
387+ INSERT INTO work_items VALUES ('wki_7', 'acme', 7, 'Unrelated', 'open', '[\"wki_2\"]', NULL);",
388+ )
389+ .unwrap();
390+ let found = waiting(&db);
391+ let shown: Vec<(u32, Option<u32>, Vec<u32>)> = found.iter().map(|p| (p.number, p.item, p.blocks.clone())).collect();
392+ assert_eq!(
393+ shown,
394+ [(11, None, vec![]), (10, Some(1), vec![2, 4])],
395+ "oldest first; drafts wait on no one"
396+ );
397+ // Its milestone is its work item's.
398+ let pull = pulls(&db).into_iter().find(|p| p.number == 10).unwrap();
399+ assert_eq!((pull.item, pull.milestone), (Some(1), Some(3)));
400+}
401+
402+#[test]
403+fn a_pull_requests_own_milestone_counts_only_when_it_is_the_workspaces() {
404+ let db = schema();
405+ pull(&db, 1, "open", "2026-10-02T00:00:00Z", None, "2026-10-02T00:00:00Z");
406+ pull(&db, 2, "open", "2026-10-02T00:00:00Z", None, "2026-10-02T00:00:00Z");
407+ pull(
408+ &db,
409+ 3,
410+ "merged",
411+ "2025-01-01T00:00:00Z",
412+ Some("2026-10-03T00:00:00Z"),
413+ "2026-10-03T00:00:00Z",
414+ );
415+ pull(&db, 4, "closed", "2025-01-01T00:00:00Z", None, "2026-10-03T00:00:00Z");
416+ db.execute_batch(
417+ "UPDATE pulls SET milestone = 1 WHERE number = 1;
418+ UPDATE pulls SET milestone = 8 WHERE number = 2;
419+ INSERT INTO milestones VALUES ('acme', 1, 'Launch', 'open', NULL, '2026-01-01T00:00:00Z', NULL);
420+ INSERT INTO milestones VALUES ('other', 8, 'Not ours', 'open', NULL, '2026-01-01T00:00:00Z', NULL);",
421+ )
422+ .unwrap();
423+ let mut found: Vec<(u32, Option<u32>)> = pulls(&db).iter().map(|p| (p.number, p.milestone)).collect();
424+ found.sort();
425+ assert_eq!(
426+ found,
427+ [(1, Some(1)), (2, None), (3, None)],
428+ "merged since counts; closed long-opened does not"
429+ );
430+}
431+
432+#[test]
433+fn authors_are_people_with_merged_work() {
434+ let db = schema();
435+ pull(
436+ &db,
437+ 1,
438+ "merged",
439+ "2025-01-01T00:00:00Z",
440+ Some("2025-01-02T00:00:00Z"),
441+ "2025-01-02T00:00:00Z",
442+ );
443+ pull(
444+ &db,
445+ 2,
446+ "merged",
447+ "2026-10-01T00:00:00Z",
448+ Some("2026-10-02T00:00:00Z"),
449+ "2026-10-02T00:00:00Z",
450+ );
451+ pull(&db, 3, "open", "2026-10-01T00:00:00Z", None, "2026-10-01T00:00:00Z");
452+ for (number, author) in [
453+ (4, "usr_g1t_agent"),
454+ (5, "agt_margo"),
455+ (6, "wsp_acme"),
456+ (7, "g1t"),
457+ (8, "svc_runner"),
458+ ] {
459+ pull(
460+ &db,
461+ number,
462+ "merged",
463+ "2026-10-01T00:00:00Z",
464+ Some("2026-10-02T00:00:00Z"),
465+ "2026-10-02T00:00:00Z",
466+ );
467+ db.execute(
468+ "UPDATE pulls SET author_id = ?1 WHERE number = ?2",
469+ params![author, number],
470+ )
471+ .unwrap();
472+ }
473+ // g1t's agent's work for a person is theirs, under their name now.
474+ pull(
475+ &db,
476+ 9,
477+ "merged",
478+ "2026-10-01T00:00:00Z",
479+ Some("2026-10-03T00:00:00Z"),
480+ "2026-10-03T00:00:00Z",
481+ );
482+ db.execute_batch(
483+ "UPDATE pulls SET author_id = 'usr_g1t_agent', requested_by_id = 'usr_ana', requested_by_name = 'ana-renamed' WHERE number = 9",
484+ )
485+ .unwrap();
486+ let found: Vec<SignalAuthor> =
487+ rows::<SignalAuthor>(&db, AUTHORS, &[&IDS, &"2026-07-14T00:00:00", &"2026-10-12T00:00:00"])
488+ .into_iter()
489+ .filter(|author| is_person(&author.owner_id))
490+ .collect();
491+ assert_eq!(found.len(), 1);
492+ let ana = &found[0];
493+ assert_eq!(
494+ (
495+ ana.owner_id.as_str(),
496+ ana.owner_name.as_str(),
497+ ana.first_merged_at.as_str(),
498+ ana.last_merged_at.as_str()
499+ ),
500+ ("usr_ana", "ana-renamed", "2025-01-02T00:00:00Z", "2026-10-03T00:00:00Z")
501+ );
502+ assert_eq!((ana.merged, ana.recent), (3, 2));
503+}
504+
505+#[test]
506+fn days_count_what_happened_on_each() {
507+ let db = schema();
508+ pull(
509+ &db,
510+ 1,
511+ "merged",
512+ "2026-10-09T10:00:00Z",
513+ Some("2026-10-10T10:00:00Z"),
514+ "2026-10-10T10:00:00Z",
515+ );
516+ pull(&db, 2, "open", "2026-10-08T10:00:00Z", None, "2026-10-08T10:00:00Z");
517+ db.execute_batch(
518+ "UPDATE pulls SET agent = 'agent' WHERE number = 1;
519+ INSERT INTO sessions VALUES ('ses_1', 'a', NULL, '2026-10-10T09:00:00Z');
520+ INSERT INTO sessions VALUES ('ses_2', 'b', NULL, '2026-10-10T09:00:00Z');",
521+ )
522+ .unwrap();
523+ comment(&db, 1, "usr_bo", None, Some("request_changes"), "2026-10-10T01:00:00Z");
524+ comment(&db, 1, "usr_bo", None, None, "2026-10-10T02:00:00Z");
525+ comment(&db, 1, "usr_ana", None, None, "2026-10-10T03:00:00Z");
526+ comment(&db, 1, "g1t", None, None, "2026-10-10T03:00:00Z");
527+ comment(&db, 1, "agt_margo", Some("agt_margo"), None, "2026-10-10T03:00:00Z");
528+ let window = window("2026-10-18T00:00:00Z");
529+ let days: Vec<SignalDay> = rows(&db, DAYS, &[&IDS, &window.days_from, &window.end]);
530+ let shown: Vec<(&str, u32, u32, u32, u32, u32, u32)> = days
531+ .iter()
532+ .map(|d| {
533+ (
534+ d.day.as_str(),
535+ d.opened,
536+ d.merged,
537+ d.agent_merged,
538+ d.reworked,
539+ d.reviews,
540+ d.sessions,
541+ )
542+ })
543+ .collect();
544+ assert_eq!(
545+ shown,
546+ [
547+ ("2026-10-08", 1, 0, 0, 0, 0, 0),
548+ ("2026-10-09", 1, 0, 0, 0, 0, 0),
549+ ("2026-10-10", 0, 1, 1, 1, 2, 1)
550+ ],
551+ "the owner, g1t and an agent are not reviewers; another repository's session is not counted"
552+ );
553+}
554+
555+#[test]
556+fn days_leave_out_g1t_as_its_system_ids_say() {
557+ let mut ids = vec![g1t_contracts::system::ID];
558+ ids.extend(g1t_contracts::system::LEGACY_IDS);
559+ for id in ids {
560+ assert!(DAYS.contains(&format!("'{id}'")), "{id}");
561+ assert!(!is_person(id));
562+ }
563+ assert!(is_person("usr_ana"));
564+}
+18−0
1+-- ?2 the workspace. Open pull requests (not drafts) no person has given a
2+-- first review yet, oldest first: their work item's number, and the
3+-- numbers of the workspace's open work items blocked by it, as a JSON
4+-- array.
5+SELECT pr.repo_id, pr.number, pr.title, pr.created_at, pr.agent, pr.draft_seconds,
6+ i.number AS item,
7+ CASE WHEN i.id IS NULL THEN '[]' ELSE (SELECT json_group_array(number) FROM (
8+ SELECT b.number FROM work_items b
9+ WHERE b.workspace = ?2 AND b.state = 'open' AND b.number IS NOT NULL
10+ AND EXISTS (SELECT 1 FROM json_each(b.blocked_by) j WHERE j.value = i.id)
11+ ORDER BY b.number)) END AS blocks
12+FROM pr LEFT JOIN work_items i ON i.id = pr.item_id AND i.workspace = ?2
13+WHERE pr.status = 'open' AND NOT EXISTS (SELECT 1 FROM comments cm
14+ WHERE cm.repo_id = pr.repo_id AND cm.number = pr.number
15+ AND cm.verdict IN ('approve', 'request_changes')
16+ AND cm.author_id != pr.owner_id AND cm.author_id != 'usr_g1t_agent' AND cm.agent_id IS NULL)
17+ORDER BY pr.created_at, pr.repo_id, pr.number
18+LIMIT 50
+49−0
1+-- ?2 the weeks as a JSON array of { start, from, to, cut }: the first UTC
2+-- day, the bounds (from inclusive, to exclusive) and when the week is read
3+-- as ended for stalled pull requests (to, or now when that is earlier).
4+--
5+-- A person's verdict is an approval or request for changes by someone
6+-- other than the owner, not g1t's review agent, and not a workspace agent
7+-- (which keeps its verdict apart): the first one is the first review, as
8+-- insights.sql counts it.
9+,
10+w AS (
11+ SELECT json_extract(value, '$.start') AS start,
12+ julianday(json_extract(value, '$.from')) AS lo,
13+ julianday(json_extract(value, '$.to')) AS hi,
14+ julianday(json_extract(value, '$.cut')) AS cut
15+ FROM json_each(?2)
16+),
17+x AS MATERIALIZED (
18+ SELECT pr.repo_id, pr.status, pr.agent, pr.draft_seconds,
19+ julianday(pr.created_at) AS c, julianday(pr.merged_at) AS m, julianday(pr.updated_at) AS u,
20+ julianday((SELECT MIN(cm.created_at) FROM comments cm
21+ WHERE cm.repo_id = pr.repo_id AND cm.number = pr.number
22+ AND cm.verdict IN ('approve', 'request_changes')
23+ AND cm.author_id != pr.owner_id AND cm.author_id != 'usr_g1t_agent' AND cm.agent_id IS NULL)) AS r,
24+ -- An agent's merged pull request a person asked for changes on before it merged.
25+ (pr.agent AND pr.status = 'merged' AND EXISTS (SELECT 1 FROM comments cm
26+ WHERE cm.repo_id = pr.repo_id AND cm.number = pr.number AND cm.verdict = 'request_changes'
27+ AND cm.author_id != pr.owner_id AND cm.author_id != 'usr_g1t_agent' AND cm.agent_id IS NULL
28+ AND julianday(cm.created_at) < julianday(pr.merged_at))) AS reworked
29+ FROM pr
30+)
31+SELECT x.repo_id, w.start,
32+ COUNT(CASE WHEN x.status = 'merged' AND x.m >= w.lo AND x.m < w.hi THEN 1 END) AS merged,
33+ COUNT(CASE WHEN x.status = 'merged' AND x.m >= w.lo AND x.m < w.hi AND x.agent THEN 1 END) AS agent_merged,
34+ COUNT(CASE WHEN x.status = 'merged' AND x.m >= w.lo AND x.m < w.hi AND x.reworked THEN 1 END) AS agent_reworked,
35+ -- Opened in the week by an agent whose session started first.
36+ COUNT(CASE WHEN x.agent AND x.draft_seconds IS NOT NULL AND x.c >= w.lo AND x.c < w.hi THEN 1 END) AS agent_drafted,
37+ TOTAL(CASE WHEN x.agent AND x.c >= w.lo AND x.c < w.hi THEN x.draft_seconds END) AS agent_draft_seconds,
38+ COUNT(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN 1 END) AS reviewed,
39+ TOTAL(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN MAX(0, (x.r - x.c) * 86400) END) AS review_seconds,
40+ -- Open over seven days when the week ended, and not finished by then:
41+ -- not merged, and not closed (a closed one's updated_at stands in for
42+ -- when it closed, which pulls do not keep).
43+ COUNT(CASE WHEN x.c < w.cut - 7
44+ AND NOT COALESCE(x.m < w.cut, 0)
45+ AND NOT (x.status = 'closed' AND COALESCE(x.u < w.cut, 0)) THEN 1 END) AS stalled
46+FROM x CROSS JOIN w
47+GROUP BY x.repo_id, w.start
48+HAVING merged + agent_drafted + reviewed + stalled > 0
49+ORDER BY w.start, x.repo_id

Comments

Sign in to comment.