Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 1 | //! Update pull requests that are no longer needed, and the branches g1t |
| 2 | //! leaves behind. | |
| 3 | //! | |
| 4 | //! After every dependency scan of the default branch, and when someone | |
| 5 | //! dismisses an alert, g1t looks at its own open update pull requests: | |
| 6 | //! | |
| 7 | //! - A security update (one package, or a `security-updates` group) is | |
| 8 | //! closed once none of the alerts it fixes is open: each was fixed on the | |
| 9 | //! default branch (its vulnerable version left the lockfiles) or | |
| 10 | //! dismissed. An alert counts as one the update fixes when it is on the | |
| 11 | //! update's package at a version below the update's target. | |
| 12 | //! - A version update into the default branch is closed once every | |
| 13 | //! dependency it raises is already at its new version or later in the | |
| 14 | //! lockfiles, or is no longer a dependency. | |
| 15 | //! | |
| 16 | //! g1t comments why, closes the pull request and deletes its branch. The | |
| 17 | //! update is recorded as superseded, so a later scan asks for a new one | |
| 18 | //! only when one of its packages is vulnerable again. | |
| 19 | //! | |
| 20 | //! Branches: when one of these pull requests closes or merges, by g1t or | |
| 21 | //! by a person, its branch is deleted, and each scan removes those still | |
| 22 | //! left from update pull requests already closed. Only a branch g1t made | |
| 23 | //! for an update, with no update in progress on it, still at the commit its | |
| 24 | //! pull request last had, is deleted. | |
| 25 | ||
| 26 | use std::collections::{BTreeMap, BTreeSet}; | |
| 27 | ||
| 28 | use g1t_contracts::repos::{Branch, BranchesArgs, DeleteBranchArgs}; | |
| 29 | use g1t_contracts::security::UpdateState; | |
| 30 | use g1t_contracts::updates::UpdatedDependency; | |
| 31 | use g1t_contracts::work::{Pull, PullStatus}; | |
| 32 | use g1t_contracts::{Outcome, User}; | |
| 33 | use g1t_scan::version; | |
| 34 | use worker::Result; | |
| 35 | ||
| 36 | use crate::Security; | |
| 37 | use crate::deps::Located; | |
| 38 | use crate::store::{Activity, RepoRow, VulnRow}; | |
| 39 | use crate::version_updates::{lockfiles_for, normalize, osv_ecosystem}; | |
| 40 | ||
| 41 | /// Branches of closed update pull requests removed per scan, at most. | |
| 42 | const BRANCHES_PER_SCAN: usize = 10; | |
| 43 | ||
| 44 | /// The alerts an update to `target` fixes: those on `package` at a lower | |
| 45 | /// version, whatever their state now. | |
| 46 | pub fn targeted<'a>(alerts: &'a [VulnRow], ecosystem: &str, package: &str, target: &str) -> Vec<&'a VulnRow> { | |
| 47 | alerts | |
| 48 | .iter() | |
| 49 | .filter(|alert| alert.ecosystem == ecosystem && alert.package == package && version::compare(&alert.version, target).is_lt()) | |
| 50 | .collect() | |
| 51 | } | |
| 52 | ||
| 53 | /// Whether one of the alerts an update fixes is still open. | |
| 54 | pub fn still_needed(targeted: &[&VulnRow]) -> bool { | |
| 55 | targeted.iter().any(|alert| alert.status == "open") | |
| 56 | } | |
| 57 | ||
| 58 | fn join(items: &[String], last: &str) -> String { | |
| 59 | match items { | |
| 60 | [] => String::new(), | |
| 61 | [one] => one.clone(), | |
| 62 | [rest @ .., end] => format!("{} {last} {end}", rest.join(", ")), | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// The comment g1t closes a security update with once none of the alerts | |
| 67 | /// it fixes is open. `packages` names the update's packages, for when no | |
| 68 | /// alert is left to name. | |
| 69 | pub fn resolved_text(base: &str, packages: &[String], targeted: &[&VulnRow]) -> String { | |
| 70 | let fixed: Vec<&&VulnRow> = targeted.iter().filter(|alert| alert.status == "fixed").collect(); | |
| 71 | let dismissed = targeted.iter().any(|alert| alert.status == "dismissed"); | |
| 72 | let advisories: BTreeSet<&str> = targeted.iter().map(|alert| alert.osv_id.as_str()).collect(); | |
| 73 | let (one, plural) = (advisories.len() <= 1, advisories.len() > 1); | |
| 74 | let subject = if one { "the alert this fixed" } else { "the alerts this fixed" }; | |
| 75 | if fixed.is_empty() && !dismissed { | |
| 76 | let names: Vec<String> = packages.iter().map(|name| format!("`{name}`")).collect(); | |
| 77 | return format!( | |
| 78 | "Closed: {} no longer vulnerable on `{base}`, so this update is no longer needed.", | |
| 79 | if names.len() == 1 { format!("{} is", names[0]) } else { format!("{} are", join(&names, "and")) } | |
| 80 | ); | |
| 81 | } | |
| 82 | if fixed.is_empty() { | |
| 83 | return format!("Closed: {subject} {} dismissed, so this update is no longer needed.", if plural { "were" } else { "was" }); | |
| 84 | } | |
| 85 | // Each package with the vulnerable versions that left, and where from. | |
| 86 | let mut versions: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); | |
| 87 | let mut lockfiles: BTreeSet<&str> = BTreeSet::new(); | |
| 88 | for alert in &fixed { | |
| 89 | versions.entry(alert.package.as_str()).or_default().insert(alert.version.as_str()); | |
| 90 | lockfiles.insert(alert.manifest.as_str()); | |
| 91 | } | |
| 92 | let count: usize = versions.values().map(BTreeSet::len).sum(); | |
| 93 | let named: Vec<String> = versions | |
| 94 | .iter() | |
| 95 | .map(|(package, found)| { | |
| 96 | let found: Vec<String> = found.iter().map(|v| (*v).to_owned()).collect(); | |
| 97 | format!("`{package}` {}", join(&found, "and")) | |
| 98 | }) | |
| 99 | .collect(); | |
| 100 | let files: Vec<String> = lockfiles.iter().map(|path| format!("`{path}`")).collect(); | |
| 101 | format!( | |
| 102 | "Closed: {subject} {} resolved on `{base}`{} ({} {} no longer in {}).", | |
| 103 | if one { "is" } else { "are" }, | |
| 104 | if dismissed { " or dismissed" } else { "" }, | |
| 105 | join(&named, "and"), | |
| 106 | if count == 1 { "is" } else { "are" }, | |
| 107 | join(&files, "or"), | |
| 108 | ) | |
| 109 | } | |
| 110 | ||
| 111 | /// Where a dependency a version update raises stands on the default branch. | |
| 112 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 113 | pub enum Now { | |
| 114 | /// The highest version its lockfiles resolve. | |
| 115 | At(String), | |
| 116 | /// Its lockfiles no longer have it. | |
| 117 | Gone, | |
| 118 | /// No lockfile for its directory could be read: nothing is decided. | |
| 119 | Unknown, | |
| 120 | } | |
| 121 | ||
| 122 | /// Where `dependency` stands in the lockfiles a scan read. | |
| 123 | pub fn now_of(ecosystem: &str, dependency: &UpdatedDependency, located: &[Located], paths: &[String]) -> Now { | |
| 124 | let lockfiles = lockfiles_for(ecosystem, &dependency.directory, paths); | |
| 125 | let read: Vec<&Located> = located.iter().filter(|item| lockfiles.contains(&item.path)).collect(); | |
| 126 | if read.is_empty() { | |
| 127 | return Now::Unknown; | |
| 128 | } | |
| 129 | let name = normalize(ecosystem, &dependency.name); | |
| 130 | read.iter() | |
| 131 | .filter(|item| normalize(ecosystem, &item.package.name) == name) | |
| 132 | .map(|item| item.package.version.clone()) | |
| 133 | .max_by(|a, b| version::compare(a, b)) | |
| 134 | .map_or(Now::Gone, Now::At) | |
| 135 | } | |
| 136 | ||
| 137 | /// The comment g1t closes a version update with when every dependency it | |
| 138 | /// raises is already there, or `None` while one still needs it. | |
| 139 | pub fn version_resolved_text(base: &str, dependencies: &[(UpdatedDependency, Now)]) -> Option<String> { | |
| 140 | if dependencies.is_empty() { | |
| 141 | return None; | |
| 142 | } | |
| 143 | for (dependency, now) in dependencies { | |
| 144 | match now { | |
| 145 | Now::Unknown => return None, | |
| 146 | Now::At(current) if version::compare(current, &dependency.to).is_lt() => return None, | |
| 147 | _ => {} | |
| 148 | } | |
| 149 | } | |
| 150 | if let [(dependency, now)] = dependencies { | |
| 151 | return Some(match now { | |
| 152 | Now::At(current) => format!( | |
| 153 | "Closed: `{}` is already at {current} on `{base}`, so this update to {} is no longer needed.", | |
| 154 | dependency.name, dependency.to | |
| 155 | ), | |
| 156 | _ => format!("Closed: `{}` is no longer a dependency on `{base}`, so this update is no longer needed.", dependency.name), | |
| 157 | }); | |
| 158 | } | |
| 159 | let each: Vec<String> = dependencies | |
| 160 | .iter() | |
| 161 | .map(|(dependency, now)| match now { | |
| 162 | Now::At(current) => format!("`{}` is at {current}", dependency.name), | |
| 163 | _ => format!("`{}` is no longer a dependency", dependency.name), | |
| 164 | }) | |
| 165 | .collect(); | |
| 166 | Some(format!( | |
| 167 | "Closed: every dependency this updates is already at its new version or later on `{base}` ({}), so this update is no longer needed.", | |
| 168 | join(&each, "and") | |
| 169 | )) | |
| 170 | } | |
| 171 | ||
| 172 | /// The closing comment, given the pull request's base branch. | |
| 173 | type Why<'a> = Box<dyn FnOnce(&str) -> String + 'a>; | |
| 174 | ||
| 175 | /// What closing a no longer needed update pull request found. | |
| 176 | #[derive(Debug, PartialEq, Eq)] | |
| 177 | pub enum Retired { | |
| 178 | /// It was open: g1t commented, closed it and deleted its branch. | |
| 179 | Closed, | |
| 180 | /// It merged meanwhile. | |
| 181 | Merged, | |
| 182 | /// Already closed, or not found. | |
| 183 | Left, | |
| 184 | } | |
| 185 | ||
| 186 | impl Security { | |
| 187 | /// Closes one of g1t's update pull requests that is no longer needed, | |
| 188 | /// with the comment `why` gives for its base branch, and deletes its | |
| 189 | /// branch. | |
| 190 | pub(crate) async fn retire(&self, repo: &RepoRow, number: u32, why: impl FnOnce(&str) -> String) -> Result<Retired> { | |
| 191 | let Some(pull) = self.get_pull(repo, number).await? else { return Ok(Retired::Left) }; | |
| 192 | match pull.status { | |
| 193 | PullStatus::Merged => Ok(Retired::Merged), | |
| 194 | PullStatus::Closed => Ok(Retired::Left), | |
| 195 | PullStatus::Open | PullStatus::Draft => { | |
| 196 | let base = pull.base.clone().unwrap_or_else(|| "the default branch".to_owned()); | |
| 197 | if self.close_with(repo, number, why(&base)).await? { | |
| 198 | let closed = Pull { status: PullStatus::Closed, ..pull }; | |
| 199 | self.delete_pull_branch(repo, &closed).await; | |
| 200 | } | |
| 201 | Ok(Retired::Closed) | |
| 202 | } | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | /// Deletes a closed or merged update pull request's branch, if it is | |
| 207 | /// still at the pull request's last commit. Failures are logged: a | |
| 208 | /// branch left behind is removed by a later scan. | |
| 209 | pub(crate) async fn delete_pull_branch(&self, repo: &RepoRow, pull: &Pull) { | |
| 210 | if matches!(pull.status, PullStatus::Open | PullStatus::Draft) || pull.fork.is_some() || pull.fork_repo_id.is_some() { | |
| 211 | return; | |
| 212 | } | |
| 213 | let (Some(branch), Some(head)) = (pull.branch.clone(), pull.head_commit.clone()) else { return }; | |
| 214 | if pull.base.as_deref() == Some(branch.as_str()) { | |
| 215 | return; | |
| 216 | } | |
| 217 | let deleted: Result<Outcome<bool>> = | |
| 218 | g1t_kit::call(&self.repos, "delete_branch", &DeleteBranchArgs { repo_id: repo.repo_id.clone(), branch: branch.clone(), head: Some(head) }).await; | |
| 219 | match deleted { | |
| 220 | Ok(Outcome::Ok(_)) => {} | |
| 221 | Ok(Outcome::Fail(refused)) => worker::console_log!("security: branch {branch} of {} kept: {}", repo.repo_id, refused.message), | |
| 222 | Err(error) => worker::console_error!("security: branch {branch} of {} not deleted: {error}", repo.repo_id), | |
| 223 | } | |
| 224 | } | |
| 225 | ||
| 226 | /// An update branch pushed by its sandbox after the update stopped | |
| 227 | /// being needed: deleted, while still at what the sandbox pushed. | |
| 228 | pub(crate) async fn drop_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<()> { | |
| 229 | if after.is_empty() || after.bytes().all(|byte| byte == b'0') { | |
| 230 | return Ok(()); | |
| 231 | } | |
| 232 | let deleted: Outcome<bool> = g1t_kit::call( | |
| 233 | &self.repos, | |
| 234 | "delete_branch", | |
| 235 | &DeleteBranchArgs { repo_id: repo_id.to_owned(), branch: branch.to_owned(), head: Some(after.to_owned()) }, | |
| 236 | ) | |
| 237 | .await?; | |
| 238 | if let Outcome::Fail(refused) = deleted { | |
| 239 | worker::console_log!("security: branch {branch} of {repo_id} kept: {}", refused.message); | |
| 240 | } | |
| 241 | Ok(()) | |
| 242 | } | |
| 243 | ||
| 244 | /// A pull request closed or merged: if it is one of g1t's update pull | |
| 245 | /// requests, its branch goes. | |
| 246 | pub(crate) async fn pull_finished(&self, repo_id: &str, number: u32) -> Result<()> { | |
| 247 | let ours = self.store.update_by_pull(repo_id, number).await?.is_some() || self.store.update_pull_by_number(repo_id, number).await?.is_some(); | |
| 248 | if !ours { | |
| 249 | return Ok(()); | |
| 250 | } | |
| 251 | let Some(repo) = self.store.repo(repo_id).await? else { return Ok(()) }; | |
| 252 | if let Some(pull) = self.get_pull(&repo, number).await? | |
| 253 | && !self.branch_in_use(&repo.repo_id, pull.branch.as_deref().unwrap_or_default()).await? | |
| 254 | { | |
| 255 | self.delete_pull_branch(&repo, &pull).await; | |
| 256 | } | |
| 257 | Ok(()) | |
| 258 | } | |
| 259 | ||
| 260 | /// Whether an update still in progress is made on `branch`. | |
| 261 | async fn branch_in_use(&self, repo_id: &str, branch: &str) -> Result<bool> { | |
| 262 | let single = self.store.updates(repo_id).await?.into_iter().any(|row| row.state().in_progress() && row.branch.as_deref() == Some(branch)); | |
| 263 | let pulls = self.store.update_pulls(repo_id).await?.into_iter().any(|row| row.state().in_progress() && row.branch == branch); | |
| 264 | Ok(single || pulls) | |
| 265 | } | |
| 266 | ||
| 267 | /// Closes g1t's update pull requests that are no longer needed (see the | |
| 268 | /// module), then removes branches left by closed ones. `lockfiles` is | |
| 269 | /// what a scan of the default branch read, when one did: without it, | |
| 270 | /// version updates are left as they are. | |
| 271 | pub(crate) async fn resolve_updates(&self, repo: &RepoRow, lockfiles: Option<(&[Located], &[String])>) -> Result<()> { | |
| 272 | let alerts = self.store.all_vulnerabilities(&repo.repo_id).await?; | |
| 273 | let pulls = self.store.update_pulls(&repo.repo_id).await?; | |
| 274 | let grouped: BTreeSet<u32> = pulls.iter().filter(|row| row.kind == "security").filter_map(|row| row.pull()).collect(); | |
| 275 | let system = g1t_contracts::system::USERNAME; | |
| 276 | // One package's security update. | |
| 277 | for row in self.store.updates(&repo.repo_id).await? { | |
| 278 | if !row.state().in_progress() || row.pull().is_some_and(|number| grouped.contains(&number)) { | |
| 279 | continue; | |
| 280 | } | |
| 281 | let fixes = targeted(&alerts, &row.ecosystem, &row.package, &row.target); | |
| 282 | if still_needed(&fixes) { | |
| 283 | continue; | |
| 284 | } | |
| 285 | self.store.set_update(&row, UpdateState::Superseded, row.pull(), row.issue(), None).await?; | |
| 286 | let Some(number) = row.pull() else { continue }; | |
| 287 | let packages = [row.package.clone()]; | |
| 288 | match self.retire(repo, number, |base| resolved_text(base, &packages, &fixes)).await? { | |
| 289 | Retired::Merged => self.store.set_update(&row, UpdateState::Merged, Some(number), row.issue(), None).await?, | |
| 290 | Retired::Closed => { | |
| 291 | let activity: Vec<Activity> = | |
| 292 | fixes.iter().map(|alert| Activity { alert_id: &alert.id, action: "update_superseded", actor: Some(system), reason: None, comment: None, number: Some(number) }).collect(); | |
| 293 | self.store.record(&repo.repo_id, &activity).await?; | |
| 294 | } | |
| 295 | Retired::Left => {} | |
| 296 | } | |
| 297 | } | |
| 298 | // Grouped security updates, and version updates. | |
| 299 | for row in &pulls { | |
| 300 | if !matches!(row.state(), UpdateState::Open | UpdateState::Requested) { | |
| 301 | continue; | |
| 302 | } | |
| 303 | let dependencies = row.dependencies(); | |
| 304 | let why: Option<Why<'_>> = if row.kind == "security" { | |
| 305 | let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue }; | |
| 306 | let fixes: Vec<&VulnRow> = dependencies.iter().flat_map(|dependency| targeted(&alerts, osv, &dependency.name, &dependency.to)).collect(); | |
| 307 | if dependencies.is_empty() || still_needed(&fixes) { | |
| 308 | None | |
| 309 | } else { | |
| 310 | let packages: Vec<String> = dependencies.iter().map(|dependency| dependency.name.clone()).collect(); | |
| 311 | Some(Box::new(move |base: &str| resolved_text(base, &packages, &fixes))) | |
| 312 | } | |
| 313 | } else { | |
| 314 | // Only into the default branch, which the scan read, and | |
| 315 | // only once its pull request is open. | |
| 316 | let into_default = row.bump().is_none_or(|bump| bump.base.is_none()); | |
| 317 | match lockfiles { | |
| 318 | Some((located, paths)) if into_default && row.state() == UpdateState::Open => { | |
| 319 | let now: Vec<(UpdatedDependency, Now)> = | |
| 320 | dependencies.iter().map(|dependency| (dependency.clone(), now_of(&row.ecosystem, dependency, located, paths))).collect(); | |
| 321 | version_resolved_text("", &now).is_some().then(|| { | |
| 322 | Box::new(move |base: &str| version_resolved_text(base, &now).unwrap_or_default()) as Why<'_> | |
| 323 | }) | |
| 324 | } | |
| 325 | _ => None, | |
| 326 | } | |
| 327 | }; | |
| 328 | let Some(why) = why else { continue }; | |
| 329 | self.store.set_update_pull(&row.id, UpdateState::Superseded, row.pull(), None, None).await?; | |
| 330 | let retired = match row.pull() { | |
| 331 | Some(number) => self.retire(repo, number, why).await?, | |
| 332 | None => Retired::Left, | |
| 333 | }; | |
| 334 | if retired == Retired::Merged { | |
| 335 | self.store.set_update_pull(&row.id, UpdateState::Merged, row.pull(), None, None).await?; | |
| 336 | } | |
| 337 | // A grouped security update stands for each package's own. | |
| 338 | if row.kind == "security" | |
| 339 | && let Some(osv) = osv_ecosystem(&row.ecosystem) | |
| 340 | { | |
| 341 | let state = if retired == Retired::Merged { UpdateState::Merged } else { UpdateState::Superseded }; | |
| 342 | for dependency in &dependencies { | |
| 343 | if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? | |
| 344 | && (update.state().in_progress() && (update.pull() == row.pull() || update.branch.as_deref() == Some(row.branch.as_str()))) | |
| 345 | { | |
| 346 | self.store.set_update(&update, state, update.pull(), update.issue(), None).await?; | |
| 347 | } | |
| 348 | } | |
| 349 | } | |
| 350 | } | |
| 351 | if let Err(error) = self.clean_update_branches(repo).await { | |
| 352 | worker::console_error!("security: update branches of {} not cleaned: {error}", repo.repo_id); | |
| 353 | } | |
| 354 | Ok(()) | |
| 355 | } | |
| 356 | ||
| 357 | /// Removes branches left by g1t's update pull requests that are already | |
| 358 | /// closed or merged: a few per scan, only those no update in progress | |
| 359 | /// uses, and only while they are at the pull request's last commit. | |
| 360 | async fn clean_update_branches(&self, repo: &RepoRow) -> Result<()> { | |
| 361 | let updates = self.store.updates(&repo.repo_id).await?; | |
| 362 | let pulls = self.store.update_pulls(&repo.repo_id).await?; | |
| 363 | let finished = |state: UpdateState| matches!(state, UpdateState::Closed | UpdateState::Merged | UpdateState::Superseded); | |
| 364 | let in_use: BTreeSet<&str> = updates | |
| 365 | .iter() | |
| 366 | .filter(|row| row.state().in_progress()) | |
| 367 | .filter_map(|row| row.branch.as_deref()) | |
| 368 | .chain(pulls.iter().filter(|row| row.state().in_progress()).map(|row| row.branch.as_str())) | |
| 369 | .collect(); | |
| 370 | let mut candidates: BTreeMap<&str, u32> = BTreeMap::new(); | |
| 371 | for (branch, number) in updates | |
| 372 | .iter() | |
| 373 | .filter(|row| finished(row.state())) | |
| 374 | .filter_map(|row| Some((row.branch.as_deref()?, row.pull()?))) | |
| 375 | .chain(pulls.iter().filter(|row| finished(row.state())).filter_map(|row| Some((row.branch.as_str(), row.pull()?)))) | |
| 376 | { | |
| 377 | if !branch.is_empty() && !in_use.contains(branch) { | |
| 378 | candidates.entry(branch).or_insert(number); | |
| 379 | } | |
| 380 | } | |
| 381 | if candidates.is_empty() { | |
| 382 | return Ok(()); | |
| 383 | } | |
| 384 | let listed: Outcome<Vec<Branch>> = g1t_kit::call( | |
| 385 | &self.repos, | |
| 386 | "branches", | |
| 387 | &BranchesArgs { path: Self::path_of(repo), viewer: Some(User::system(&repo.namespace)) }, | |
| 388 | ) | |
| 389 | .await?; | |
| 390 | let Outcome::Ok(branches) = listed else { return Ok(()) }; | |
| 391 | let tips: BTreeMap<&str, &str> = branches.iter().map(|branch| (branch.name.as_str(), branch.hash.as_str())).collect(); | |
| 392 | let mut looked = 0; | |
| 393 | for (branch, number) in candidates { | |
| 394 | if looked >= BRANCHES_PER_SCAN { | |
| 395 | break; | |
| 396 | } | |
| 397 | let Some(tip) = tips.get(branch) else { continue }; | |
| 398 | looked += 1; | |
| 399 | let Some(pull) = self.get_pull(repo, number).await? else { continue }; | |
| 400 | if pull.branch.as_deref() == Some(branch) && pull.head_commit.as_deref() == Some(*tip) { | |
| 401 | self.delete_pull_branch(repo, &pull).await; | |
| 402 | } | |
| 403 | } | |
| 404 | Ok(()) | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | #[cfg(test)] | |
| 409 | mod tests { | |
| 410 | use super::*; | |
| 411 | use g1t_scan::lockfiles::{Lockfile, Package}; | |
| 412 | ||
| 413 | fn alert(package: &str, version: &str, manifest: &str, osv: &str, status: &str) -> VulnRow { | |
| 414 | VulnRow { | |
| 415 | id: format!("vul_{package}_{version}_{osv}"), | |
| 416 | repo_id: "rep_1".into(), | |
| 417 | ecosystem: "npm".into(), | |
| 418 | package: package.into(), | |
| 419 | version: version.into(), | |
| 420 | manifest: manifest.into(), | |
| 421 | osv_id: osv.into(), | |
| 422 | advisory: osv.into(), | |
| 423 | summary: String::new(), | |
| 424 | severity: "high".into(), | |
| 425 | fixed_version: Some("0.35.5".into()), | |
| 426 | status: status.into(), | |
| 427 | found_at: "2026-10-04T00:00:00Z".into(), | |
| 428 | fixed_at: None, | |
| 429 | number: None, | |
| 430 | dismiss_reason: None, | |
| 431 | dismiss_comment: None, | |
| 432 | dismissed_by: None, | |
| 433 | dismissed_at: None, | |
| 434 | } | |
| 435 | } | |
| 436 | ||
| 437 | #[test] | |
| 438 | fn an_update_is_needed_while_an_alert_below_its_target_is_open() { | |
| 439 | let alerts = vec![ | |
| 440 | alert("sharp", "0.34.1", "package-lock.json", "GHSA-1", "open"), | |
| 441 | // At or above the target: another update's business. | |
| 442 | alert("sharp", "0.35.5", "package-lock.json", "GHSA-2", "open"), | |
| 443 | alert("lodash", "4.17.20", "package-lock.json", "GHSA-3", "open"), | |
| 444 | ]; | |
| 445 | let fixes = targeted(&alerts, "npm", "sharp", "0.35.5"); | |
| 446 | assert_eq!(fixes.len(), 1); | |
| 447 | assert!(still_needed(&fixes)); | |
| 448 | let alerts = vec![alert("sharp", "0.34.1", "package-lock.json", "GHSA-1", "fixed"), alert("sharp", "0.35.5", "package-lock.json", "GHSA-2", "open")]; | |
| 449 | let fixes = targeted(&alerts, "npm", "sharp", "0.35.5"); | |
| 450 | assert!(!still_needed(&fixes), "an alert the update would not fix keeps nothing open"); | |
| 451 | assert!(targeted(&alerts, "crates.io", "sharp", "0.35.5").is_empty()); | |
| 452 | } | |
| 453 | ||
| 454 | #[test] | |
| 455 | fn the_closing_comment_says_why() { | |
| 456 | let packages = ["sharp".to_owned()]; | |
| 457 | let fixed = alert("sharp", "0.34.1", "package-lock.json", "GHSA-1", "fixed"); | |
| 458 | assert_eq!( | |
| 459 | resolved_text("main", &packages, &[&fixed]), | |
| 460 | "Closed: the alert this fixed is resolved on `main` (`sharp` 0.34.1 is no longer in `package-lock.json`)." | |
| 461 | ); | |
| 462 | let other = alert("sharp", "0.33.0", "web/package-lock.json", "GHSA-9", "fixed"); | |
| 463 | assert_eq!( | |
| 464 | resolved_text("main", &packages, &[&fixed, &other]), | |
| 465 | "Closed: the alerts this fixed are resolved on `main` (`sharp` 0.33.0 and 0.34.1 are no longer in `package-lock.json` or `web/package-lock.json`)." | |
| 466 | ); | |
| 467 | let dismissed = alert("sharp", "0.34.1", "package-lock.json", "GHSA-9", "dismissed"); | |
| 468 | assert_eq!(resolved_text("main", &packages, &[&dismissed]), "Closed: the alert this fixed was dismissed, so this update is no longer needed."); | |
| 469 | assert!(resolved_text("main", &packages, &[&fixed, &dismissed]).starts_with("Closed: the alerts this fixed are resolved on `main` or dismissed (")); | |
| 470 | assert_eq!(resolved_text("trunk", &packages, &[]), "Closed: `sharp` is no longer vulnerable on `trunk`, so this update is no longer needed."); | |
| 471 | } | |
| 472 | ||
| 473 | fn dependency(name: &str, to: &str, directory: &str) -> UpdatedDependency { | |
| 474 | UpdatedDependency { | |
| 475 | name: name.into(), | |
| 476 | from: "1.0.0".into(), | |
| 477 | to: to.into(), | |
| 478 | directory: directory.into(), | |
| 479 | dependency_type: "direct:production".into(), | |
| 480 | update_type: "version-update:semver-minor".into(), | |
| 481 | } | |
| 482 | } | |
| 483 | ||
| 484 | fn located(name: &str, version: &str, path: &str) -> Located { | |
| 485 | let lockfile = Lockfile::for_path(path).unwrap(); | |
| Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in. | 486 | Located { package: Package { name: name.into(), version: version.into(), ecosystem: lockfile.ecosystem() }, path: path.into() } |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 487 | } |
| 488 | ||
| 489 | #[test] | |
| 490 | fn a_version_update_is_done_once_the_lockfile_has_its_version() { | |
| 491 | let paths = vec!["package-lock.json".to_owned(), "apps/web/package.json".to_owned()]; | |
| 492 | let read = vec![located("lodash", "4.17.21", "package-lock.json"), located("react", "18.2.0", "package-lock.json")]; | |
| 493 | let lodash = dependency("lodash", "4.17.21", "/"); | |
| 494 | let react = dependency("react", "18.3.1", "/apps/web"); | |
| 495 | let gone = dependency("left-pad", "1.3.0", "/"); | |
| 496 | // A workspace directory is resolved by the lockfile above it. | |
| 497 | assert_eq!(now_of("npm", &react, &read, &paths), Now::At("18.2.0".into())); | |
| 498 | assert_eq!(now_of("npm", &gone, &read, &paths), Now::Gone); | |
| 499 | assert_eq!(now_of("cargo", &dependency("serde", "1.0.200", "/"), &read, &paths), Now::Unknown); | |
| 500 | let at = now_of("npm", &lodash, &read, &paths); | |
| 501 | assert_eq!( | |
| 502 | version_resolved_text("main", &[(lodash.clone(), at.clone())]).as_deref(), | |
| 503 | Some("Closed: `lodash` is already at 4.17.21 on `main`, so this update to 4.17.21 is no longer needed.") | |
| 504 | ); | |
| 505 | assert_eq!( | |
| 506 | version_resolved_text("main", &[(gone.clone(), Now::Gone)]).as_deref(), | |
| 507 | Some("Closed: `left-pad` is no longer a dependency on `main`, so this update is no longer needed.") | |
| 508 | ); | |
| 509 | // One dependency still behind keeps the whole pull request. | |
| 510 | assert_eq!(version_resolved_text("main", &[(lodash.clone(), at.clone()), (react.clone(), Now::At("18.2.0".into()))]), None); | |
| 511 | assert_eq!(version_resolved_text("main", &[(lodash.clone(), Now::Unknown)]), None); | |
| 512 | let both = version_resolved_text("main", &[(lodash, at), (gone, Now::Gone)]).unwrap(); | |
| 513 | assert!(both.contains("`lodash` is at 4.17.21 and `left-pad` is no longer a dependency"), "{both}"); | |
| 514 | } | |
| 515 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.