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
| 206 | 206 | } | |
| 207 | 207 | } | |
| 208 | 208 | ||
| 209 | − | fn valid_day(value: &str) -> bool { | |
| 209 | + | pub(crate) fn valid_day(value: &str) -> bool { | |
| 210 | 210 | let bytes = value.as_bytes(); | |
| 211 | 211 | if bytes.len() != 10 | |
| 212 | 212 | || bytes[4] != b'-' |
| 38 | 38 | mod rulesets; | |
| 39 | 39 | mod runs; | |
| 40 | 40 | mod settings; | |
| 41 | + | mod signals; | |
| 41 | 42 | mod statuses; | |
| 42 | 43 | mod team_reviews; | |
| 43 | 44 | ||
| ⋯ | |||
| 2666 | 2667 | "open_pull" => reply(&work.open_pull(args(body)?).await?), | |
| 2667 | 2668 | "list_pulls" => reply(&work.list_pulls(args(body)?).await?), | |
| 2668 | 2669 | "delivery_insights" => reply(&work.delivery_insights(args(body)?).await?), | |
| 2670 | + | "workspace_signals" => reply(&work.workspace_signals(args(body)?).await?), | |
| 2669 | 2671 | "pulls_for_repos" => reply(&work.pulls_for_repos(args(body)?).await?), | |
| 2670 | 2672 | "get_pull" => reply(&work.get_pull(args(body)?).await?), | |
| 2671 | 2673 | "update_pull" => reply(&work.update_pull(args(body)?).await?), | |
| 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; |
| 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 |
| 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 | + | ) |
| 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 |
| 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 |
| 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 | + | } |
| 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 |
| 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.