| 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; |