| 17 | 17 | | //! are, by the `merge_group` workflows, and never landed). What is left is |
| 18 | 18 | | //! reported as `suspects`. |
| 19 | 19 | | //! |
| 20 | + | //! **A default branch that moved.** An entry that fails with nothing ahead |
| 21 | + | //! of it, though its own checks pass on its head, fails on the default |
| 22 | + | //! branch as it is now: something landed there since it branched. g1t looks |
| 23 | + | //! up the pull requests merged into the default branch whose commits the |
| 24 | + | //! entry's history does not hold, newest first and at most [`MAX_LANDED`], |
| 25 | + | //! and reports them as `landed`. The entry still answers for the failure |
| 26 | + | //! (it is the one that must adapt); one landed pull request is named as the |
| 27 | + | //! likely cause. |
| 28 | + | //! |
| 20 | 29 | | //! **Telling the work.** The answer goes on the pull request's and its |
| 21 | 30 | | //! issue's timelines, into the pull request's session, as a decision |
| 22 | 31 | | //! record (`integration_failure`, tested) and as `integration.failed`. A |
| ⋯ |
| 28 | 37 | | |
| 29 | 38 | | use g1t_contracts::decisions::{DecisionConfidence, DecisionKind, FailedCheck, IntegrationFailed, IntegrationPassed}; |
| 30 | 39 | | use g1t_contracts::events::SessionAppended; |
| 31 | | − | use g1t_contracts::repos::{GetByIdArgs, PathByIdArgs, Repo, RepoPath}; |
| 40 | + | use g1t_contracts::repos::{Commit, GetByIdArgs, LogArgs, PathByIdArgs, Repo, RepoPath}; |
| 32 | 41 | | use g1t_contracts::time::rfc3339; |
| 33 | 42 | | use g1t_contracts::work::*; |
| 34 | 43 | | use g1t_contracts::{FailureCode, Outcome, new_id}; |
| ⋯ |
| 47 | 56 | | /// The most extra combinations built to narrow down one failure. |
| 48 | 57 | | pub(crate) const PROBE_BUDGET: u32 = 3; |
| 49 | 58 | | |
| 59 | + | /// The most pull requests reported as landed since an entry branched. |
| 60 | + | pub(crate) const MAX_LANDED: usize = 10; |
| 61 | + | |
| 62 | + | /// How far back the entry's own history is read to tell what it holds. |
| 63 | + | const LANDED_HISTORY: u32 = 1000; |
| 64 | + | |
| 65 | + | /// How many of the latest merged pull requests are looked at. |
| 66 | + | const LANDED_CANDIDATES: u32 = 50; |
| 67 | + | |
| 50 | 68 | | /// What kind of failure a combined state had. |
| 51 | 69 | | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 52 | 70 | | #[serde(rename_all = "snake_case")] |
| ⋯ |
| 174 | 192 | | Blame(Vec<u32>), |
| 175 | 193 | | /// It fails with some of the entries ahead: narrow them down. |
| 176 | 194 | | Bisect, |
| 195 | + | /// Nothing is ahead and it passes its checks on its own, yet it fails |
| 196 | + | /// on the default branch as it is now: it answers for it, and what |
| 197 | + | /// landed there since it branched is looked up. |
| 198 | + | BaseMoved, |
| 177 | 199 | | } |
| 178 | 200 | | |
| 179 | 201 | | /// Decides who answers for a failure of the entry tested with `ahead` |
| 180 | 202 | | /// merged in first. `passed_alone`: its own checks pass on its head. |
| 181 | 203 | | pub(crate) fn plan(ahead: &[u32], failure: &Failure, passed_alone: bool) -> Plan { |
| 182 | 204 | | if ahead.is_empty() { |
| 183 | | − | return Plan::Blame(Vec::new()); |
| 205 | + | return if failure.kind == FailureKind::Checks && passed_alone { |
| 206 | + | Plan::BaseMoved |
| 207 | + | } else { |
| 208 | + | Plan::Blame(Vec::new()) |
| 209 | + | }; |
| 184 | 210 | | } |
| 185 | 211 | | match failure.kind { |
| 186 | 212 | | // The sandbox names the entry ahead that changed the same files. |
| ⋯ |
| 193 | 219 | | } |
| 194 | 220 | | } |
| 195 | 221 | | |
| 222 | + | /// A pull request merged into the default branch, as looked at for what |
| 223 | + | /// landed since an entry branched. |
| 224 | + | #[derive(Clone, Debug, Deserialize)] |
| 225 | + | pub(crate) struct MergedRow { |
| 226 | + | pub(crate) number: u32, |
| 227 | + | /// The commit it landed as. |
| 228 | + | pub(crate) head_commit: Option<String>, |
| 229 | + | pub(crate) merged_at: Option<String>, |
| 230 | + | } |
| 231 | + | |
| 232 | + | /// The pull requests among `merged` (newest first) that landed since the |
| 233 | + | /// entry branched: those whose commit `history`, the entry's own, does not |
| 234 | + | /// hold. When that history was cut short, `oldest` is the time of its |
| 235 | + | /// oldest commit, and a pull request merged before it is not counted, as |
| 236 | + | /// its commit may be further back. Newest first, at most [`MAX_LANDED`]. |
| 237 | + | pub(crate) fn landed_since(merged: &[MergedRow], history: &[Commit], oldest: Option<&str>) -> Vec<u32> { |
| 238 | + | merged |
| 239 | + | .iter() |
| 240 | + | .filter(|pull| { |
| 241 | + | pull.head_commit |
| 242 | + | .as_deref() |
| 243 | + | .is_some_and(|commit| !history.iter().any(|known| known.hash == commit)) |
| 244 | + | }) |
| 245 | + | .filter(|pull| match (oldest, pull.merged_at.as_deref()) { |
| 246 | + | (Some(oldest), Some(at)) => at >= oldest, |
| 247 | + | (Some(_), None) => false, |
| 248 | + | (None, _) => true, |
| 249 | + | }) |
| 250 | + | .map(|pull| pull.number) |
| 251 | + | .take(MAX_LANDED) |
| 252 | + | .collect() |
| 253 | + | } |
| 254 | + | |
| 196 | 255 | | /// The suspects to build with the entry next, the first half of them, or |
| 197 | 256 | | /// none when one is left or the budget is spent. |
| 198 | 257 | | pub(crate) fn next_probe(suspects: &[u32], builds: u32) -> Option<Vec<u32>> { |
| ⋯ |
| 234 | 293 | | |
| 235 | 294 | | /// The note on the pull request, as the rest of a sentence starting with |
| 236 | 295 | | /// `g1t`. |
| 237 | | − | pub(crate) fn pull_note(ahead: &[u32], suspects: &[u32], bisected: bool, check: &FailedCheck) -> String { |
| 296 | + | pub(crate) fn pull_note(ahead: &[u32], suspects: &[u32], bisected: bool, landed: &[u32], check: &FailedCheck) -> String { |
| 238 | 297 | | let what = what_failed(check); |
| 239 | 298 | | let rejoin = "Push a fix: once its checks pass on the new commit, it rejoins the merge queue by itself."; |
| 299 | + | if suspects.is_empty() && ahead.is_empty() && !landed.is_empty() { |
| 300 | + | return format!( |
| 301 | + | "traced its merge queue failure to the default branch as it is now: {what} with this change merged into it, though this passes its checks on its own. Since this branched, {}. Bring it up to date with the default branch, fix what breaks, and push. {rejoin}", |
| 302 | + | landed_clause(landed), |
| 303 | + | ); |
| 304 | + | } |
| 240 | 305 | | if suspects.is_empty() { |
| 241 | 306 | | let on = if ahead.is_empty() { |
| 242 | 307 | | "on the default branch as it is now".to_owned() |
| ⋯ |
| 258 | 323 | | ) |
| 259 | 324 | | } |
| 260 | 325 | | |
| 326 | + | /// What landed on the default branch since the entry branched, as a |
| 327 | + | /// clause: one is named as the likely cause. |
| 328 | + | fn landed_clause(landed: &[u32]) -> String { |
| 329 | + | match landed { |
| 330 | + | [one] => format!("#{one} landed and is the likely cause"), |
| 331 | + | _ => format!("{} landed and may be what it conflicts with", numbers(landed)), |
| 332 | + | } |
| 333 | + | } |
| 334 | + | |
| 261 | 335 | | /// The note on its issue, and the one-line summary of the decision. |
| 262 | | − | pub(crate) fn summary(number: u32, suspects: &[u32], check: &FailedCheck) -> String { |
| 336 | + | pub(crate) fn summary(number: u32, suspects: &[u32], landed: &[u32], check: &FailedCheck) -> String { |
| 263 | 337 | | let what = what_failed(check); |
| 264 | | − | if suspects.is_empty() { |
| 338 | + | if suspects.is_empty() && !landed.is_empty() { |
| 339 | + | format!( |
| 340 | + | "#{number} failed in the merge queue on the default branch as it is now: {what}, though #{number} passes its checks on its own; since it branched, {}.", |
| 341 | + | landed_clause(landed), |
| 342 | + | ) |
| 343 | + | } else if suspects.is_empty() { |
| 265 | 344 | | format!("#{number} failed in the merge queue: {what}, and #{number} itself is the cause.") |
| 266 | 345 | | } else { |
| 267 | 346 | | format!("#{number} failed in the merge queue together with {}: {what} when they were combined.", numbers(suspects)) |
| ⋯ |
| 376 | 455 | | return Ok(()); |
| 377 | 456 | | }; |
| 378 | 457 | | let ahead = row.ahead(); |
| 379 | | − | // Only a check failure with entries ahead asks whether it fails alone. |
| 380 | | − | let passed_alone = failure.kind != FailureKind::Checks || ahead.is_empty() || self.passed_alone(row).await?; |
| 458 | + | // Only a check failure asks whether it fails alone. |
| 459 | + | let passed_alone = failure.kind != FailureKind::Checks || self.passed_alone(row).await?; |
| 381 | 460 | | match plan(&ahead, &failure, passed_alone) { |
| 382 | | − | Plan::Blame(suspects) => self.integration_failed(row, &pull, &failure, false, &suspects).await, |
| 461 | + | Plan::Blame(suspects) => self.integration_failed(row, &pull, &failure, false, &suspects, &[]).await, |
| 462 | + | Plan::BaseMoved => { |
| 463 | + | // What landed is context: without it, the failure is still told. |
| 464 | + | let landed = self.landed_since_branched(row, &pull).await.unwrap_or_default(); |
| 465 | + | self.integration_failed(row, &pull, &failure, false, &[], &landed).await |
| 466 | + | } |
| 383 | 467 | | Plan::Bisect => { |
| 384 | 468 | | let Some(stack) = self.tested_stack(row, &pull, &ahead).await? else { |
| 385 | 469 | | // What was tested can no longer be found: say what is known. |
| 386 | | − | return self.integration_failed(row, &pull, &failure, false, &ahead).await; |
| 470 | + | return self.integration_failed(row, &pull, &failure, false, &ahead, &[]).await; |
| 387 | 471 | | }; |
| 388 | 472 | | let diagnosis = Diagnosis { |
| 389 | 473 | | base_commit: row.base_commit.clone().unwrap_or_default(), |
| ⋯ |
| 398 | 482 | | } |
| 399 | 483 | | } |
| 400 | 484 | | |
| 485 | + | /// The pull requests merged into the default branch since `pull`'s |
| 486 | + | /// entry branched from it and before it was tested, newest first: the |
| 487 | + | /// latest merged ones whose commits the entry's history does not hold. |
| 488 | + | async fn landed_since_branched(&self, row: &EntryRow, pull: &Pull) -> Result<Vec<u32>> { |
| 489 | + | let (Some(head), Some(tested_at)) = (row.head_commit.clone(), row.tested_at.clone()) else { |
| 490 | + | return Ok(Vec::new()); |
| 491 | + | }; |
| 492 | + | let viewer = Some(pull.owner().clone()); |
| 493 | + | let repo: Outcome<Repo> = g1t_kit::call( |
| 494 | + | &self.repos, |
| 495 | + | "get_by_id", |
| 496 | + | &GetByIdArgs { id: row.repo_id.clone(), viewer: viewer.clone() }, |
| 497 | + | ) |
| 498 | + | .await?; |
| 499 | + | let Outcome::Ok(repo) = repo else { return Ok(Vec::new()) }; |
| 500 | + | let path = pull.fork.clone().unwrap_or_else(|| RepoPath { |
| 501 | + | namespace: repo.namespace.clone(), |
| 502 | + | name: repo.name.clone(), |
| 503 | + | }); |
| 504 | + | let merged = async { |
| 505 | + | self.db |
| 506 | + | .prepare( |
| 507 | + | "SELECT number, head_commit, merged_at FROM pulls |
| 508 | + | WHERE repo_id = ? AND status = 'merged' AND id != ? AND merged_at <= ? |
| 509 | + | AND (base_branch IS NULL OR base_branch = '' OR base_branch = ?) |
| 510 | + | ORDER BY merged_at DESC LIMIT ?", |
| 511 | + | ) |
| 512 | + | .bind(&[ |
| 513 | + | row.repo_id.as_str().into(), |
| 514 | + | pull.id.as_str().into(), |
| 515 | + | tested_at.as_str().into(), |
| 516 | + | repo.default_branch.as_str().into(), |
| 517 | + | LANDED_CANDIDATES.into(), |
| 518 | + | ])? |
| 519 | + | .all() |
| 520 | + | .await? |
| 521 | + | .results::<MergedRow>() |
| 522 | + | }; |
| 523 | + | let log = LogArgs { path, viewer, git_ref: Some(head), limit: LANDED_HISTORY }; |
| 524 | + | let history = g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &log); |
| 525 | + | let (merged, history) = futures_util::future::try_join(merged, history).await?; |
| 526 | + | let Outcome::Ok(history) = history else { return Ok(Vec::new()) }; |
| 527 | + | // A history cut short holds only what is newer than its oldest commit. |
| 528 | + | let oldest = (history.len() >= LANDED_HISTORY as usize) |
| 529 | + | .then(|| history.iter().map(|commit| commit.authored_at.as_str()).min()) |
| 530 | + | .flatten(); |
| 531 | + | Ok(landed_since(&merged, &history, oldest)) |
| 532 | + | } |
| 533 | + | |
| 401 | 534 | | /// Each entry ahead and then `row`'s, as they were tested together. |
| 402 | 535 | | async fn tested_stack(&self, row: &EntryRow, pull: &Pull, ahead: &[u32]) -> Result<Option<Vec<QueueStackItem>>> { |
| 403 | 536 | | let path: Option<RepoPath> = g1t_kit::call( |
| ⋯ |
| 450 | 583 | | return Ok(()); |
| 451 | 584 | | }; |
| 452 | 585 | | return self |
| 453 | | − | .integration_failed(row, &pull, &diagnosis.failure, narrowed(&diagnosis), &diagnosis.suspects) |
| 586 | + | .integration_failed(row, &pull, &diagnosis.failure, narrowed(&diagnosis), &diagnosis.suspects, &[]) |
| 454 | 587 | | .await; |
| 455 | 588 | | }; |
| 456 | 589 | | let now = now_ms(); |
| ⋯ |
| 721 | 854 | | let Some(pull) = self.pull_by_id(&row.pull_id).await? else { |
| 722 | 855 | | return Ok(()); |
| 723 | 856 | | }; |
| 724 | | − | self.integration_failed(&row, &pull, &diagnosis.failure, narrowed(&diagnosis), &diagnosis.suspects) |
| 857 | + | self.integration_failed(&row, &pull, &diagnosis.failure, narrowed(&diagnosis), &diagnosis.suspects, &[]) |
| 725 | 858 | | .await |
| 726 | 859 | | } |
| 727 | 860 | | ProbeResult::Broke | ProbeResult::Passed => { |
| ⋯ |
| 735 | 868 | | // --- Telling the work ------------------------------------------------------- |
| 736 | 869 | | |
| 737 | 870 | | /// Tells the pull request, its issue, its session and anyone following |
| 738 | | − | /// that its combined state failed, and who it failed with. |
| 871 | + | /// that its combined state failed, and who it failed with. `landed`: |
| 872 | + | /// with nothing ahead, the pull requests merged into the default branch |
| 873 | + | /// since it branched. |
| 739 | 874 | | pub(crate) async fn integration_failed( |
| 740 | 875 | | &self, |
| 741 | 876 | | row: &EntryRow, |
| ⋯ |
| 743 | 878 | | failure: &Failure, |
| 744 | 879 | | bisected: bool, |
| 745 | 880 | | suspects: &[u32], |
| 881 | + | landed: &[u32], |
| 746 | 882 | | ) -> Result<()> { |
| 747 | 883 | | let ahead = row.ahead(); |
| 748 | 884 | | let mut combination = ahead.clone(); |
| 749 | 885 | | combination.push(pull.number); |
| 750 | 886 | | let g1t = ("g1t", "g1t"); |
| 751 | | − | let note = pull_note(&ahead, suspects, bisected, &failure.check); |
| 887 | + | let note = pull_note(&ahead, suspects, bisected, landed, &failure.check); |
| 752 | 888 | | self.note(&row.repo_id, pull.number, g1t, ¬e).await?; |
| 753 | | − | let summary = summary(pull.number, suspects, &failure.check); |
| 889 | + | let summary = summary(pull.number, suspects, landed, &failure.check); |
| 754 | 890 | | if let Some(issue) = pull.issue { |
| 755 | 891 | | self.note(&row.repo_id, issue, g1t, &format!("noted: {summary}")).await?; |
| 756 | 892 | | } |
| ⋯ |
| 772 | 908 | | "other_pulls": ahead, |
| 773 | 909 | | "suspects": suspects, |
| 774 | 910 | | "bisected": bisected, |
| 911 | + | "landed": landed, |
| 775 | 912 | | }), |
| 776 | 913 | | }, |
| 777 | 914 | | ) |
| ⋯ |
| 791 | 928 | | bisected, |
| 792 | 929 | | suspects: suspects.to_vec(), |
| 793 | 930 | | other_pulls: ahead, |
| 931 | + | landed: landed.to_vec(), |
| 794 | 932 | | commit: failure.combined_commit.clone(), |
| 795 | 933 | | }, |
| 796 | 934 | | ) |
| ⋯ |
| 981 | 1119 | | #[test] |
| 982 | 1120 | | fn who_answers_for_a_failure() { |
| 983 | 1121 | | let checks = failure(FailureKind::Checks); |
| 984 | | − | // Tested alone on the default branch: it is the cause. |
| 985 | | − | assert_eq!(plan(&[], &checks, true), Plan::Blame(vec![])); |
| 1122 | + | // Tested alone on the default branch, failing there though it |
| 1123 | + | // passes on its own: the default branch moved under it. |
| 1124 | + | assert_eq!(plan(&[], &checks, true), Plan::BaseMoved); |
| 1125 | + | // Alone, and failing on its own too: it is the cause. |
| 1126 | + | assert_eq!(plan(&[], &checks, false), Plan::Blame(vec![])); |
| 1127 | + | // Alone, but not a check failure: nothing to look up. |
| 1128 | + | for kind in [FailureKind::Conflict, FailureKind::Missing, FailureKind::Build] { |
| 1129 | + | assert_eq!(plan(&[], &failure(kind), true), Plan::Blame(vec![]), "{kind:?}"); |
| 1130 | + | } |
| 986 | 1131 | | // Its own checks fail too: still the cause. |
| 987 | 1132 | | assert_eq!(plan(&[11, 12], &checks, false), Plan::Blame(vec![])); |
| 988 | 1133 | | // One entry ahead: that one, without building anything. |
| ⋯ |
| 1035 | 1180 | | #[test] |
| 1036 | 1181 | | fn the_notes_name_the_other_changes_and_the_check() { |
| 1037 | 1182 | | let check = failure(FailureKind::Checks).check; |
| 1038 | | − | let note = pull_note(&[11, 12, 13], &[12], true, &check); |
| 1183 | + | let note = pull_note(&[11, 12, 13], &[12], true, &[], &check); |
| 1039 | 1184 | | assert!(note.starts_with("traced its merge queue failure to how it combines with #12 (narrowed down from #11, #12 and #13"), "{note}"); |
| 1040 | 1185 | | assert!(note.contains("`CI / merge_group` failed")); |
| 1041 | 1186 | | assert!(note.contains("once #12 has landed")); |
| 1042 | | − | let own = pull_note(&[], &[], false, &check); |
| 1187 | + | let own = pull_note(&[], &[], false, &[], &check); |
| 1043 | 1188 | | assert!(own.contains("on the default branch as it is now, and the change is the cause"), "{own}"); |
| 1044 | | − | let conflict = pull_note(&[11], &[11], false, &FailedCheck { name: "merge".into(), conclusion: "conflict".into(), url: None }); |
| 1189 | + | let conflict = pull_note(&[11], &[11], false, &[], &FailedCheck { name: "merge".into(), conclusion: "conflict".into(), url: None }); |
| 1045 | 1190 | | assert!(conflict.contains("it did not merge cleanly"), "{conflict}"); |
| 1046 | 1191 | | assert!(!conflict.contains("narrowed")); |
| 1047 | | − | assert_eq!(summary(14, &[11, 12], &check), "#14 failed in the merge queue together with #11 and #12: `CI / merge_group` failed when they were combined."); |
| 1192 | + | assert_eq!(summary(14, &[11, 12], &[], &check), "#14 failed in the merge queue together with #11 and #12: `CI / merge_group` failed when they were combined."); |
| 1048 | 1193 | | assert_eq!(numbers(&[]), ""); |
| 1049 | 1194 | | } |
| 1050 | 1195 | | |
| 1051 | 1196 | | #[test] |
| 1197 | + | fn the_notes_name_what_landed_since_it_branched() { |
| 1198 | + | let check = failure(FailureKind::Checks).check; |
| 1199 | + | // One landed: named as the likely cause. |
| 1200 | + | let one = pull_note(&[], &[], false, &[1], &check); |
| 1201 | + | assert_eq!( |
| 1202 | + | one, |
| 1203 | + | "traced its merge queue failure to the default branch as it is now: `CI / merge_group` failed with this change merged into it, though this passes its checks on its own. Since this branched, #1 landed and is the likely cause. Bring it up to date with the default branch, fix what breaks, and push. Push a fix: once its checks pass on the new commit, it rejoins the merge queue by itself." |
| 1204 | + | ); |
| 1205 | + | assert_eq!( |
| 1206 | + | summary(14, &[], &[1], &check), |
| 1207 | + | "#14 failed in the merge queue on the default branch as it is now: `CI / merge_group` failed, though #14 passes its checks on its own; since it branched, #1 landed and is the likely cause." |
| 1208 | + | ); |
| 1209 | + | // Several: listed, newest first, as possible causes. |
| 1210 | + | let several = pull_note(&[], &[], false, &[7, 5, 1], &check); |
| 1211 | + | assert!(several.contains("Since this branched, #7, #5 and #1 landed and may be what it conflicts with."), "{several}"); |
| 1212 | + | assert!(!several.contains("the change is the cause")); |
| 1213 | + | assert!(summary(14, &[], &[7, 5], &check).ends_with("since it branched, #7 and #5 landed and may be what it conflicts with.")); |
| 1214 | + | // None found: the change is the cause, as before. |
| 1215 | + | assert_eq!(summary(14, &[], &[], &check), "#14 failed in the merge queue: `CI / merge_group` failed, and #14 itself is the cause."); |
| 1216 | + | } |
| 1217 | + | |
| 1218 | + | fn commit(hash: &str, at: &str) -> Commit { |
| 1219 | + | Commit { |
| 1220 | + | hash: hash.into(), |
| 1221 | + | tree_hash: format!("t{hash}"), |
| 1222 | + | message: String::new(), |
| 1223 | + | author: g1t_contracts::repos::Signature { name: "a".into(), email: "a@example.com".into() }, |
| 1224 | + | parents: Vec::new(), |
| 1225 | + | authored_at: at.into(), |
| 1226 | + | } |
| 1227 | + | } |
| 1228 | + | |
| 1229 | + | fn merged(number: u32, commit: &str, at: &str) -> MergedRow { |
| 1230 | + | MergedRow { number, head_commit: Some(commit.into()), merged_at: Some(at.into()) } |
| 1231 | + | } |
| 1232 | + | |
| 1233 | + | #[test] |
| 1234 | + | fn what_landed_is_what_its_history_does_not_hold() { |
| 1235 | + | // #3 and #2 landed after it branched from #1's commit. |
| 1236 | + | let pulls = [ |
| 1237 | + | merged(3, "c3", "2026-10-12T12:00:00.000Z"), |
| 1238 | + | merged(2, "c2", "2026-10-12T11:00:00.000Z"), |
| 1239 | + | merged(1, "c1", "2026-10-12T10:00:00.000Z"), |
| 1240 | + | ]; |
| 1241 | + | let history = [commit("mine", "2026-10-12T10:30:00Z"), commit("c1", "2026-10-12T10:00:00Z"), commit("c0", "2026-10-11T00:00:00Z")]; |
| 1242 | + | assert_eq!(landed_since(&pulls, &history, None), vec![3, 2]); |
| 1243 | + | // Brought up to date with #2 since: only #3 is new to it. |
| 1244 | + | let updated = [commit("merge", "2026-10-12T11:30:00Z"), commit("c2", "2026-10-12T11:00:00Z"), commit("c1", "2026-10-12T10:00:00Z")]; |
| 1245 | + | assert_eq!(landed_since(&pulls, &updated, None), vec![3]); |
| 1246 | + | // A history cut short does not count what is older than it. |
| 1247 | + | let short = [commit("mine", "2026-10-12T11:30:00Z"), commit("x", "2026-10-12T10:30:00Z")]; |
| 1248 | + | assert_eq!(landed_since(&pulls, &short, Some("2026-10-12T10:30:00Z")), vec![3, 2]); |
| 1249 | + | // A merged pull request with no recorded commit is not counted. |
| 1250 | + | let unknown = [MergedRow { number: 9, head_commit: None, merged_at: None }]; |
| 1251 | + | assert!(landed_since(&unknown, &history, None).is_empty()); |
| 1252 | + | // At most MAX_LANDED, newest first. |
| 1253 | + | let many: Vec<MergedRow> = (1..=15).rev().map(|n| merged(n, &format!("m{n}"), "2026-10-12T12:00:00.000Z")).collect(); |
| 1254 | + | let found = landed_since(&many, &history, None); |
| 1255 | + | assert_eq!(found.len(), MAX_LANDED); |
| 1256 | + | assert_eq!(found.first(), Some(&15)); |
| 1257 | + | } |
| 1258 | + | |
| 1259 | + | #[test] |
| 1052 | 1260 | | fn failures_are_read_from_what_was_reported() { |
| 1053 | 1261 | | let conflict = ReportQueueArgs { |
| 1054 | 1262 | | entry_id: "qen_1".into(), |