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.

358 linesCodeBlameRaw
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.
52use std::collections::BTreeSet;
53
54use g1t_contracts::identity::AGENT_ID;
55use g1t_contracts::repos::{ReadableArgs, Repo};
56use g1t_contracts::time::{parse_rfc3339, rfc3339};
57use g1t_contracts::work::{
58 SignalAuthor, SignalDay, SignalHarness, SignalItem, SignalMilestone, SignalPull, SignalWeek, State, WaitingPull,
59 WorkspaceSignals, WorkspaceSignalsArgs,
60};
61use serde::{Deserialize, Serialize};
62use worker::Result;
63use worker::wasm_bindgen::JsValue;
64
65use crate::Work;
66use crate::insights::valid_day;
67use crate::items;
68
69const DAY_MS: u64 = 86_400_000;
70/// How far `since` may reach back before `until`.
71const MAX_SINCE_DAYS: u64 = 400;
72/// `pulls` returns at most this many; pulls.sql asks for one more.
73const PULLS_LIMIT: usize = 3000;
74/// `recent` in authors: the days ending with `until`.
75const RECENT_DAYS: u64 = 90;
76
77pub(crate) const WEEKS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/weeks.sql"));
78pub(crate) const DAYS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/days.sql"));
79pub(crate) const WAITING: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/waiting.sql"));
80pub(crate) const PULLS: &str = concat!(include_str!("signals/base.sql"), include_str!("signals/pulls.sql"));
81pub(crate) const AUTHORS: &str = include_str!("signals/authors.sql");
82pub(crate) const HARNESSES: &str = include_str!("signals/harnesses.sql");
83/// ?1 the workspace.
84pub(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)]
89struct 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)]
101struct 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
113fn day_start(day: &str) -> Option<u64> {
114 parse_rfc3339(&format!("{day}T00:00:00Z"))
115}
116
117fn day_of(ms: u64) -> String {
118 rfc3339(ms)[..10].to_owned()
119}
120
121fn 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.
127fn 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.
167fn 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)]
173struct 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
185impl 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)]
202struct 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
217impl 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.
237fn 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
246impl 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)]
358mod tests;