Skip to content
515 linesCodeBlameRaw
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
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}