Skip to content
515 linesCodeBlameRaw

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.

g1t closes its own update pull requests once they are no longer needed, and deletes their branches1//! 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
26use std::collections::{BTreeMap, BTreeSet};
27
28use g1t_contracts::repos::{Branch, BranchesArgs, DeleteBranchArgs};
29use g1t_contracts::security::UpdateState;
30use g1t_contracts::updates::UpdatedDependency;
31use g1t_contracts::work::{Pull, PullStatus};
32use g1t_contracts::{Outcome, User};
33use g1t_scan::version;
34use worker::Result;
35
36use crate::Security;
37use crate::deps::Located;
38use crate::store::{Activity, RepoRow, VulnRow};
39use crate::version_updates::{lockfiles_for, normalize, osv_ecosystem};
40
41/// Branches of closed update pull requests removed per scan, at most.
42const 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.
46pub 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.
54pub fn still_needed(targeted: &[&VulnRow]) -> bool {
55 targeted.iter().any(|alert| alert.status == "open")
56}
57
58fn 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.
69pub 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)]
113pub 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.
123pub 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.
139pub 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.
173type 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)]
177pub 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
186impl 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)]
409mod 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();
486 Located { package: Package { name: name.into(), version: version.into(), ecosystem: lockfile.ecosystem() }, lockfile, path: path.into() }
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}