| 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: at its cut (when the week ended, or now when |
| 24 | //! that is earlier), open (not merged, and not closed, before it: a |
| 25 | //! closed one's `updated_at` stands in for when it closed), opened at |
| 26 | //! least three days before, and with no activity in the three days |
| 27 | //! before it. Drafts count. Activity is a comment of any kind on it |
| 28 | //! (timeline events among them: labels, assignees, review requests, |
| 29 | //! milestones, ready, draft, closed, reopened, base changes, the merge |
| 30 | //! queue, auto-merge, branches; a push is no timeline event), its |
| 31 | //! latest push (the only one `head_pushed_at` keeps), its acceptance |
| 32 | //! checks (`check_runs`, from created to finished), statuses and check |
| 33 | //! runs on its head commit (the only commit a pull keeps, so a past |
| 34 | //! week reads the current head's), entries recorded on it, and a |
| 35 | //! session tied to it or a subagent of one running then (from its start |
| 36 | //! to its last heartbeat: an approximation, busy or not). |
| 37 | //! - A week's **session cost**: what the sessions started in it cost, |
| 38 | //! subagents and all (each reports only its own transcript's). |
| 39 | //! - A pull request's **session cost**: each session's own cost (unknown |
| 40 | //! as 0), split evenly among the pull requests it counts with: the ones |
| 41 | //! it is tied to, as above, or, for a subagent tied to none, the ones its |
| 42 | //! root session is tied to. Only the repositories read count. |
| 43 | //! - A day's **reviews** are comments of any kind on a pull request by a |
| 44 | //! person other than its owner (not g1t's agent, a workspace agent or g1t |
| 45 | //! itself); **reworked**, the ones of them asking for changes. |
| 46 | //! - **Waiting**: open (not draft) pull requests with no first review. |
| 47 | //! - **Authors** are owners who are people: not g1t's agent, a workspace |
| 48 | //! agent (`agt_…`), a workspace (`wsp_…`) or g1t. |
| 49 | //! - **Harnesses**: people's root sessions (`parent_id` unset) by |
| 50 | //! repository and harness over all history, under the name on their |
| 51 | //! latest; robots are left out as for authors. |
| 52 | use std::collections::BTreeSet; |
| 53 | |
| 54 | use g1t_contracts::identity::AGENT_ID; |
| 55 | use g1t_contracts::repos::{ReadableArgs, Repo}; |
| 56 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; |
| 57 | use g1t_contracts::work::{ |
| 58 | SignalAuthor, SignalDay, SignalHarness, SignalItem, SignalMilestone, SignalPull, SignalWeek, State, WaitingPull, |
| 59 | WorkspaceSignals, WorkspaceSignalsArgs, |
| 60 | }; |
| 61 | use serde::{Deserialize, Serialize}; |
| 62 | use worker::Result; |
| 63 | use worker::wasm_bindgen::JsValue; |
| 64 | |
| 65 | use crate::Work; |
| 66 | use crate::insights::valid_day; |
| 67 | use crate::items; |
| 68 | |
| 69 | const DAY_MS: u64 = 86_400_000; |
| 70 | /// How far `since` may reach back before `until`. |
| 71 | const MAX_SINCE_DAYS: u64 = 400; |
| 72 | /// `pulls` returns at most this many; pulls.sql asks for one more. |
| 73 | const PULLS_LIMIT: usize = 3000; |
| 74 | /// `recent` in authors: the days ending with `until`. |
| 75 | const RECENT_DAYS: u64 = 90; |
| 76 | |
| 77 | pub(crate) const WEEKS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/weeks.sql")); |
| 78 | pub(crate) const DAYS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/days.sql")); |
| 79 | pub(crate) const WAITING: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/waiting.sql")); |
| 80 | pub(crate) const PULLS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/pulls.sql")); |
| 81 | pub(crate) const AUTHORS: &str = include_str!("signals/authors.sql"); |
| 82 | pub(crate) const HARNESSES: &str = include_str!("signals/harnesses.sql"); |
| 83 | /// ?1 the workspace. |
| 84 | pub(crate) const MILESTONES: &str = "SELECT number, title, state, due_on, created_at, closed_at |
| 85 | FROM milestones WHERE workspace = ?1 ORDER BY number"; |
| 86 | |
| 87 | /// One week's bounds, as weeks.sql reads them from JSON. |
| 88 | #[derive(Debug, Serialize)] |
| 89 | struct WeekBounds { |
| 90 | /// Its first UTC day, `YYYY-MM-DD`. |
| 91 | start: String, |
| 92 | from: String, |
| 93 | to: String, |
| 94 | /// When it is read as ended for stalled pull requests: `to`, or now |
| 95 | /// when that is earlier. |
| 96 | cut: String, |
| 97 | } |
| 98 | |
| 99 | /// What the queries are bound to, worked out from the arguments. |
| 100 | #[derive(Debug)] |
| 101 | struct Window { |
| 102 | /// JSON array of [`WeekBounds`], oldest first. |
| 103 | weeks: String, |
| 104 | /// The first of `days`, and the day after `until`, as `YYYY-MM-DDT00:00:00`. |
| 105 | days_from: String, |
| 106 | end: String, |
| 107 | /// The start of `since`. |
| 108 | since: String, |
| 109 | /// The first of the 90 days ending with `until`. |
| 110 | recent_from: String, |
| 111 | } |
| 112 | |
| 113 | fn day_start(day: &str) -> Option<u64> { |
| 114 | parse_rfc3339(&format!("{day}T00:00:00Z")) |
| 115 | } |
| 116 | |
| 117 | fn day_of(ms: u64) -> String { |
| 118 | rfc3339(ms)[..10].to_owned() |
| 119 | } |
| 120 | |
| 121 | fn bound(ms: u64) -> String { |
| 122 | format!("{}T00:00:00", day_of(ms)) |
| 123 | } |
| 124 | |
| 125 | /// The bounds `a` asks for at `now`, or `None` when it asks for too many |
| 126 | /// repositories or an invalid range. |
| 127 | fn window(a: &WorkspaceSignalsArgs, now: u64) -> Option<Window> { |
| 128 | if a.repo_ids.len() > 100 |
| 129 | || !(1..=13).contains(&a.weeks) |
| 130 | || !(1..=28).contains(&a.days) |
| 131 | || !valid_day(&a.until) |
| 132 | || !valid_day(&a.since) |
| 133 | || a.since > a.until |
| 134 | { |
| 135 | return None; |
| 136 | } |
| 137 | let until = day_start(&a.until)?; |
| 138 | let since = day_start(&a.since)?; |
| 139 | if until - since > MAX_SINCE_DAYS * DAY_MS { |
| 140 | return None; |
| 141 | } |
| 142 | let end = until + DAY_MS; |
| 143 | let first = end.checked_sub(u64::from(a.weeks) * 7 * DAY_MS)?; |
| 144 | let weeks: Vec<WeekBounds> = (0..u64::from(a.weeks)) |
| 145 | .map(|i| { |
| 146 | let from = first + i * 7 * DAY_MS; |
| 147 | let to = from + 7 * DAY_MS; |
| 148 | WeekBounds { |
| 149 | start: day_of(from), |
| 150 | from: rfc3339(from), |
| 151 | to: rfc3339(to), |
| 152 | cut: rfc3339(to.min(now)), |
| 153 | } |
| 154 | }) |
| 155 | .collect(); |
| 156 | Some(Window { |
| 157 | weeks: serde_json::to_string(&weeks).ok()?, |
| 158 | days_from: bound(end.checked_sub(u64::from(a.days) * DAY_MS)?), |
| 159 | end: bound(end), |
| 160 | since: bound(since), |
| 161 | recent_from: bound(end.checked_sub(RECENT_DAYS * DAY_MS)?), |
| 162 | }) |
| 163 | } |
| 164 | |
| 165 | /// Whether an owner is a person: not g1t's agent, a workspace agent, a |
| 166 | /// workspace or g1t itself. |
| 167 | fn is_person(id: &str) -> bool { |
| 168 | id != AGENT_ID && !id.starts_with("agt_") && !id.starts_with("wsp_") && !g1t_contracts::system::is_system_id(id) |
| 169 | } |
| 170 | |
| 171 | /// A row of waiting.sql. |
| 172 | #[derive(Deserialize)] |
| 173 | struct WaitingRow { |
| 174 | repo_id: String, |
| 175 | number: u32, |
| 176 | title: String, |
| 177 | created_at: String, |
| 178 | agent: u32, |
| 179 | draft_seconds: Option<f64>, |
| 180 | item: Option<u32>, |
| 181 | /// JSON array of numbers. |
| 182 | blocks: String, |
| 183 | } |
| 184 | |
| 185 | impl From<WaitingRow> for WaitingPull { |
| 186 | fn from(row: WaitingRow) -> Self { |
| 187 | WaitingPull { |
| 188 | repo_id: row.repo_id, |
| 189 | number: row.number, |
| 190 | title: row.title, |
| 191 | created_at: row.created_at, |
| 192 | agent: row.agent != 0, |
| 193 | draft_seconds: row.draft_seconds, |
| 194 | item: row.item, |
| 195 | blocks: serde_json::from_str(&row.blocks).unwrap_or_default(), |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /// A row of pulls.sql. |
| 201 | #[derive(Deserialize)] |
| 202 | struct PullRow { |
| 203 | repo_id: String, |
| 204 | number: u32, |
| 205 | title: String, |
| 206 | status: String, |
| 207 | agent: u32, |
| 208 | owner_id: String, |
| 209 | owner_name: String, |
| 210 | created_at: String, |
| 211 | merged_at: Option<String>, |
| 212 | item: Option<u32>, |
| 213 | milestone: Option<u32>, |
| 214 | session_cost_usd: f64, |
| 215 | } |
| 216 | |
| 217 | impl From<PullRow> for SignalPull { |
| 218 | fn from(row: PullRow) -> Self { |
| 219 | SignalPull { |
| 220 | repo_id: row.repo_id, |
| 221 | number: row.number, |
| 222 | title: row.title, |
| 223 | status: row.status, |
| 224 | agent: row.agent != 0, |
| 225 | owner_id: row.owner_id, |
| 226 | owner_name: row.owner_name, |
| 227 | created_at: row.created_at, |
| 228 | merged_at: row.merged_at, |
| 229 | item: row.item, |
| 230 | milestone: row.milestone, |
| 231 | session_cost_usd: row.session_cost_usd, |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// The work item numbers `pulls` and `waiting` name, each once. |
| 237 | fn named_items(pulls: &[SignalPull], waiting: &[WaitingPull]) -> Vec<u32> { |
| 238 | let mut numbers: BTreeSet<u32> = pulls.iter().filter_map(|pull| pull.item).collect(); |
| 239 | for pull in waiting { |
| 240 | numbers.extend(pull.item); |
| 241 | numbers.extend(pull.blocks.iter().copied()); |
| 242 | } |
| 243 | numbers.into_iter().collect() |
| 244 | } |
| 245 | |
| 246 | impl Work { |
| 247 | pub(crate) async fn workspace_signals(&self, a: WorkspaceSignalsArgs) -> Result<WorkspaceSignals> { |
| 248 | // The web loader batches larger workspaces, as it does for insights. |
| 249 | let Some(window) = window(&a, g1t_kit::now_ms()) else { |
| 250 | return Err(worker::Error::RustError( |
| 251 | "Invalid signals range or repository batch".into(), |
| 252 | )); |
| 253 | }; |
| 254 | let viewer = a.viewer.clone(); |
| 255 | let readable: Vec<Repo> = g1t_kit::call( |
| 256 | &self.repos, |
| 257 | "readable", |
| 258 | &ReadableArgs { |
| 259 | ids: a.repo_ids, |
| 260 | viewer: a.viewer, |
| 261 | }, |
| 262 | ) |
| 263 | .await?; |
| 264 | let ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect(); |
| 265 | if ids.is_empty() { |
| 266 | return Ok(WorkspaceSignals::default()); |
| 267 | } |
| 268 | // The workspace's own milestones and work items, only for a viewer |
| 269 | // who can read one of its repositories. |
| 270 | let in_workspace = readable.iter().any(|repo| repo.namespace == a.workspace); |
| 271 | let ids: JsValue = serde_json::to_string(&ids)?.into(); |
| 272 | let workspace: JsValue = a.workspace.as_str().into(); |
| 273 | let results = self |
| 274 | .timing |
| 275 | .db( |
| 276 | 7, |
| 277 | self.db.batch(vec![ |
| 278 | self.db.prepare(WEEKS).bind(&[ids.clone(), window.weeks.into()])?, |
| 279 | self.db.prepare(DAYS).bind(&[ |
| 280 | ids.clone(), |
| 281 | window.days_from.as_str().into(), |
| 282 | window.end.as_str().into(), |
| 283 | ])?, |
| 284 | self.db.prepare(WAITING).bind(&[ids.clone(), workspace.clone()])?, |
| 285 | self.db |
| 286 | .prepare(PULLS) |
| 287 | .bind(&[ids.clone(), workspace.clone(), window.since.into()])?, |
| 288 | self.db |
| 289 | .prepare(AUTHORS) |
| 290 | .bind(&[ids.clone(), window.recent_from.into(), window.end.into()])?, |
| 291 | self.db.prepare(HARNESSES).bind(&[ids])?, |
| 292 | self.db.prepare(MILESTONES).bind(&[workspace])?, |
| 293 | ]), |
| 294 | ) |
| 295 | .await?; |
| 296 | let [weeks, days, waiting, pulls, authors, harnesses, milestones] = results.as_slice() else { |
| 297 | return Err(worker::Error::RustError("workspace_signals: missing results".into())); |
| 298 | }; |
| 299 | let mut waiting: Vec<WaitingPull> = waiting |
| 300 | .results::<WaitingRow>()? |
| 301 | .into_iter() |
| 302 | .map(WaitingPull::from) |
| 303 | .collect(); |
| 304 | let mut pulls: Vec<SignalPull> = pulls.results::<PullRow>()?.into_iter().map(SignalPull::from).collect(); |
| 305 | let pulls_capped = pulls.len() > PULLS_LIMIT; |
| 306 | pulls.truncate(PULLS_LIMIT); |
| 307 | let authors: Vec<SignalAuthor> = authors |
| 308 | .results::<SignalAuthor>()? |
| 309 | .into_iter() |
| 310 | .filter(|author| is_person(&author.owner_id)) |
| 311 | .collect(); |
| 312 | let harnesses: Vec<SignalHarness> = harnesses |
| 313 | .results::<SignalHarness>()? |
| 314 | .into_iter() |
| 315 | .filter(|harness| is_person(&harness.owner_id)) |
| 316 | .collect(); |
| 317 | let (items, milestones) = if in_workspace { |
| 318 | let numbers = named_items(&pulls, &waiting); |
| 319 | let visible = items::visible_numbered(self, &a.workspace, &numbers, viewer.as_ref()).await?; |
| 320 | let mut items: Vec<SignalItem> = visible |
| 321 | .into_iter() |
| 322 | .map(|held| SignalItem { |
| 323 | number: held.item.number, |
| 324 | title: held.item.title, |
| 325 | state: match held.item.state { |
| 326 | State::Open => "open", |
| 327 | State::Closed => "closed", |
| 328 | } |
| 329 | .into(), |
| 330 | milestone: held.item.milestone.map(|milestone| milestone.number), |
| 331 | }) |
| 332 | .collect(); |
| 333 | items.sort_by_key(|item| item.number); |
| 334 | (items, milestones.results::<SignalMilestone>()?) |
| 335 | } else { |
| 336 | (Vec::new(), Vec::new()) |
| 337 | }; |
| 338 | // A blocked work item the viewer may not see is not named. |
| 339 | for pull in &mut waiting { |
| 340 | pull.blocks |
| 341 | .retain(|number| items.iter().any(|item| item.number == *number)); |
| 342 | } |
| 343 | Ok(WorkspaceSignals { |
| 344 | weeks: weeks.results::<SignalWeek>()?, |
| 345 | days: days.results::<SignalDay>()?, |
| 346 | waiting, |
| 347 | pulls, |
| 348 | pulls_capped, |
| 349 | authors, |
| 350 | harnesses, |
| 351 | items, |
| 352 | milestones, |
| 353 | }) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | #[cfg(test)] |
| 358 | mod tests; |