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