Skip to content

g1t/services/security/src/version_updates.rs

1,719 lines84,478 bytesCodeBlame

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.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! Version updates: pull requests that keep dependencies current, as the
2//! dependency update file (`dependabot.yml`, version 2) asks.
3//!
4//! 1. Reading the file (`deps::read_version_updates`, on every dependency
5//! scan) schedules each entry g1t can act on: a new entry runs at once,
6//! then on its `schedule`. A sweep every few minutes runs those due.
7//! 2. A run reads the entry's manifests and lockfiles on the default
8//! branch, asks each dependency's registry for its versions, and picks
9//! each one's target with `allow`, `ignore`, people's `@g1t ignore`
10//! comments, `cooldown` and `versioning-strategy` (`planning`). Updates
11//! are gathered by `groups`, and up to `open-pull-requests-limit` pull
12//! requests are asked of the runner's `bump` sandbox, the same one
13//! security updates use.
14//! 3. The sandbox's push opens the pull request as g1t (`User::system`),
15//! titled and described as `pull_text` says, assigned and with reviews
16//! asked as the file says. An older one for the same dependency or group
17//! is closed as superseded.
18//! 4. The sweep watches open ones: a failing required check closes it and
19//! puts g1t on an issue to make the code changes, as security updates
20//! do; one asked to merge (`@g1t merge`) merges once its checks pass;
21//! one in conflict is made again from its base (`rebase-strategy`).
22//! 5. `@g1t` comments on these pull requests are commands
23//! (`g1t_contracts::updates::update_command`).
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use g1t_contracts::access::{Capability, CollaboratorPermissionArgs, PermissionInfo};
28use g1t_contracts::actions::{ResolveSettingsArgs, ResolvedSettings};
29use g1t_contracts::events::Event;
30use g1t_contracts::repos::{BlobArgs, BlobView, BlobText, FileList, ListFilesArgs, RawFile, RawFileArgs, ReadBlobsArgs, RepoPath};
31use g1t_contracts::security::{BumpArgs, UpdateState};
32use g1t_contracts::time::{parse_rfc3339, rfc3339};
33use g1t_contracts::updates::{
34 BumpPackage, BumpRegistry, CheckUpdatesArgs, DEPENDABOT_CHECK, DEPENDABOT_PATHS, IgnoreCondition, UpdateCommand, UpdatedDependency,
35 VersionUpdatesState, update_command,
36};
37use g1t_contracts::work::{
38 Mergeable, OpenIssueArgs, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, RequiredState, Runtime, SetCommitStatusArgs,
39 UpdatePullArgs, ViewArgs,
40};
41use g1t_contracts::{FailureCode, Outcome, User};
42use g1t_kit::now_ms;
43use g1t_scan::lockfiles::Lockfile;
44use g1t_scan::version;
45use serde::Deserialize;
46use serde_json::{Value, json};
47use worker::{Fetch, Headers, Method, Request, RequestInit, Result};
48
49use crate::Security;
50use crate::config::{self, Config, Entry, Registry, glob};
51use crate::manifests::{self, Declared, DependencyType};
52use crate::planning::{self, Candidate, Planned, PullPlan, Skip};
53use crate::pull_text;
54use crate::ranges::{self, Bare};
55use crate::registries::{self, Package, Source};
56use crate::store::RepoRow;
57use crate::update_store::{NewPull, PullRow};
58use crate::updates;
59
60/// Entries run per sweep.
61const DUE_PER_SWEEP: u32 = 3;
62/// Open update pull requests looked at per sweep.
63const WATCHED_PER_SWEEP: u32 = 10;
64/// Dependencies one run asks registries about, at most.
65const MAX_PACKAGES: usize = 200;
66/// Registry requests in flight at once.
67const FETCH_AT_ONCE: usize = 8;
68/// Versions whose publish time a Go module's run asks the proxy for.
69const GO_TIMES: usize = 3;
70/// A sandbox that has not pushed after this long is taken to have failed.
71const STALLED_MS: u64 = 45 * 60 * 1000;
72const MAX_BLOB_BYTES: u32 = 5_000_000;
73const SKIPPED_DIRECTORIES: [&str; 6] = ["node_modules", ".git", "target", "dist", "build", ".venv"];
74
75/// OSV's name for a `package-ecosystem`, as lockfiles and the bump sandbox name it.
76pub fn osv_ecosystem(ecosystem: &str) -> Option<&'static str> {
77 Some(match ecosystem {
78 "npm" => "npm",
79 "cargo" => "crates.io",
80 "gomod" => "Go",
81 "pip" => "PyPI",
82 _ => return None,
83 })
84}
85
86/// The `package-ecosystem` for one of OSV's names.
87pub fn package_ecosystem(osv: &str) -> Option<&'static str> {
88 Some(match osv {
89 "npm" => "npm",
90 "crates.io" => "cargo",
91 "Go" => "gomod",
92 "PyPI" => "pip",
93 _ => return None,
94 })
95}
96
97/// A package name as its ecosystem compares names.
98fn normalize(ecosystem: &str, name: &str) -> String {
99 if ecosystem == "pip" { manifests::python_name(name) } else { name.to_owned() }
100}
101
102fn bare(ecosystem: &str) -> Bare {
103 if ecosystem == "cargo" { Bare::Caret } else { Bare::Exact }
104}
105
106const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
107
108pub fn base64_encode(bytes: &[u8]) -> String {
109 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
110 for chunk in bytes.chunks(3) {
111 let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
112 for (index, shift) in [18, 12, 6, 0].into_iter().enumerate() {
113 if index <= chunk.len() {
114 out.push(BASE64[(n >> shift & 63) as usize] as char);
115 } else {
116 out.push('=');
117 }
118 }
119 }
120 out
121}
122
123pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
124 let mut out = Vec::with_capacity(text.len() / 4 * 3);
125 let mut buffer = 0u32;
126 let mut bits = 0;
127 for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
128 let value = BASE64.iter().position(|known| *known == c)? as u32;
129 buffer = (buffer << 6) | value;
130 bits += 6;
131 if bits >= 8 {
132 bits -= 8;
133 out.push((buffer >> bits & 0xff) as u8);
134 }
135 }
136 Some(out)
137}
138
139/// `${{secrets.NAME}}` in `text` with each secret's value; the names of
140/// those that are not set.
141pub fn fill_secrets(text: &str, secrets: &serde_json::Map<String, Value>) -> (String, Vec<String>) {
142 let mut out = String::new();
143 let mut missing = Vec::new();
144 let mut rest = text;
145 while let Some(start) = rest.find("${{") {
146 out.push_str(&rest[..start]);
147 let after = &rest[start + 3..];
148 let Some(end) = after.find("}}") else {
149 out.push_str(&rest[start..]);
150 return (out, missing);
151 };
152 let inner = after[..end].trim();
153 match inner.strip_prefix("secrets.").map(str::trim) {
154 Some(name) => match secrets.get(name).and_then(Value::as_str) {
155 Some(value) => out.push_str(value),
156 None => missing.push(name.to_owned()),
157 },
158 None => out.push_str(&rest[start..start + 3 + end + 2]),
159 }
160 rest = &after[end + 2..];
161 }
162 out.push_str(rest);
163 (out, missing)
164}
165
166/// Whether a pull request's required checks passed (`Some(true)`), one
167/// failed (`Some(false)`), or they are still to come. Without required
168/// checks, what its workflows reported on its head.
169pub fn checks_verdict(detail: &PullDetail) -> Option<bool> {
170 if !detail.required_checks.is_empty() {
171 if detail.required_checks.iter().any(|check| check.state == RequiredState::Failure) {
172 return Some(false);
173 }
174 return detail.required_checks.iter().all(|check| check.state == RequiredState::Success).then_some(true);
175 }
176 if detail.statuses.iter().any(|status| matches!(status.state.as_str(), "failure" | "error")) {
177 return Some(false);
178 }
179 detail.statuses.iter().all(|status| status.state == "success").then_some(true)
180}
181
182/// The directories of `entry` that hold one of its manifests: each
183/// `directory`, and what each `directories` glob matches, without those
184/// `exclude-paths` covers. From the root, starting with `/`.
185pub fn entry_directories(entry: &Entry, files: &[String]) -> Vec<String> {
186 let names = manifests::manifest_names(&entry.ecosystem);
187 let mut found = BTreeSet::new();
188 for file in files {
189 let (directory, name) = file.rsplit_once('/').map_or(("", file.as_str()), |(directory, name)| (directory, name));
190 if !names.contains(&name) {
191 continue;
192 }
193 let directory = format!("/{directory}");
194 let directory = if directory == "/" { directory } else { directory.trim_end_matches('/').to_owned() };
195 for wanted in &entry.directories {
196 let hit = if wanted.contains(['*', '?']) { glob(wanted, &directory) } else { *wanted == directory };
197 if !hit {
198 continue;
199 }
200 let relative = file.strip_prefix(wanted.trim_start_matches('/')).unwrap_or(file).trim_start_matches('/');
201 let excluded = entry.exclude_paths.iter().any(|pattern| {
202 let pattern = pattern.trim_start_matches("./").trim_start_matches('/');
203 glob(pattern, relative) || relative.starts_with(&format!("{}/", pattern.trim_end_matches('/')))
204 });
205 if !excluded {
206 found.insert(directory.clone());
207 }
208 }
209 }
210 found.into_iter().collect()
211}
212
213/// The lockfiles that resolve `directory`'s dependencies in `ecosystem`:
214/// its own, or the nearest above it (a workspace's).
215pub fn lockfiles_for(ecosystem: &str, directory: &str, files: &[String]) -> Vec<String> {
216 let Some(osv) = osv_ecosystem(ecosystem) else { return Vec::new() };
217 let mut at = directory.trim_matches('/').to_owned();
218 loop {
219 let found: Vec<String> = files
220 .iter()
221 .filter(|file| {
222 let (dir, _) = file.rsplit_once('/').unwrap_or(("", file));
223 dir == at && Lockfile::for_path(file).is_some_and(|lockfile| lockfile.ecosystem().osv() == osv)
224 })
225 .cloned()
226 .collect();
227 if !found.is_empty() || at.is_empty() {
228 return found;
229 }
230 at = at.rsplit_once('/').map_or(String::new(), |(parent, _)| parent.to_owned());
231 }
232}
233
234/// What a run reads in one directory.
235pub struct Directory {
236 pub path: String,
237 pub declared: Vec<Declared>,
238 /// Each package the lockfiles resolve, by name, with every version.
239 pub locked: BTreeMap<String, Vec<String>>,
240 pub lockfiles: Vec<String>,
241}
242
243/// The dependencies a run looks at in one directory, with the version
244/// each is at now. A declared dependency with no version to be found is
245/// left out.
246pub fn directory_candidates(entry: &Entry, directory: &Directory) -> Vec<(String, DependencyType, String, Option<String>)> {
247 let ecosystem = entry.ecosystem.as_str();
248 let mut out = Vec::new();
249 let mut named = BTreeSet::new();
250 for declared in &directory.declared {
251 let name = normalize(ecosystem, &declared.name);
252 named.insert(name.clone());
253 let locked = directory.locked.get(&name).cloned().unwrap_or_default();
254 let fitting: Vec<&String> = locked
255 .iter()
256 .filter(|version| declared.requirement.as_deref().is_none_or(|req| ranges::satisfies(req, version, bare(ecosystem)) != Some(false)))
257 .collect();
258 let current = fitting
259 .into_iter()
260 .max_by(|a, b| version::compare(a, b))
261 .cloned()
262 .or_else(|| locked.iter().max_by(|a, b| version::compare(a, b)).cloned())
263 .or_else(|| match (ecosystem, declared.requirement.as_deref()) {
264 ("gomod", Some(version)) => Some(version.to_owned()),
265 ("pip", Some(requirement)) => requirement.strip_prefix("==").filter(|v| !v.contains([',', '*'])).map(str::to_owned),
266 _ => None,
267 });
268 if let Some(current) = current {
269 out.push((declared.name.clone(), declared.kind, current, declared.requirement.clone()));
270 }
271 }
272 let wants_indirect = entry.allow.iter().any(|rule| matches!(rule.dependency_type.as_deref(), Some("indirect" | "all")));
273 if wants_indirect && matches!(ecosystem, "cargo" | "gomod" | "pip") {
274 for (name, versions) in &directory.locked {
275 if named.contains(name) {
276 continue;
277 }
278 if let Some(current) = versions.iter().max_by(|a, b| version::compare(a, b)) {
279 out.push((name.clone(), DependencyType::Indirect, current.clone(), None));
280 }
281 }
282 }
283 out
284}
285
286/// What the run decided for the open-pull-requests limit: the plans to
287/// ask for now (replacing an older one when `Some`), and how many waited.
288pub fn admit<'a>(plans: &'a [PullPlan], existing: &'a [PullRow], limit: u32) -> (Vec<(&'a PullPlan, Option<&'a PullRow>)>, usize) {
289 let in_progress = |row: &&PullRow| matches!(row.state(), UpdateState::Requested | UpdateState::Open);
290 let mut open = existing.iter().filter(in_progress).count();
291 let mut admitted = Vec::new();
292 let mut held = 0;
293 let mut fresh = Vec::new();
294 for plan in plans {
295 let subject = plan.subject();
296 let signature = plan.signature();
297 let mine: Vec<&PullRow> = existing.iter().filter(|row| row.subject == subject).collect();
298 // Already open for these versions, or closed by a person for them.
299 if mine.iter().any(|row| row.signature == signature && (in_progress(row) || matches!(row.state(), UpdateState::Closed | UpdateState::NeedsCode))) {
300 continue;
301 }
302 match mine.into_iter().find(in_progress) {
303 Some(older) => admitted.push((plan, Some(older))),
304 None => fresh.push(plan),
305 }
306 }
307 for plan in fresh {
308 if open < limit as usize {
309 open += 1;
310 admitted.push((plan, None));
311 } else {
312 held += 1;
313 }
314 }
315 (admitted, held)
316}
317
318/// What the dependency update file says, as a run uses it.
319pub struct Loaded {
320 pub config: Config,
321 pub file: String,
322 pub default_branch: String,
323}
324
325#[derive(Deserialize)]
326struct CommentAuthor {
327 username: String,
328}
329
330impl Security {
331 fn repo_path(repo: &RepoRow) -> RepoPath {
332 RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }
333 }
334
335 /// The dependency update file on the default branch, if it is there
336 /// and has no problems.
337 pub(crate) async fn load_config(&self, repo: &RepoRow) -> Result<Option<Loaded>> {
338 let path = Self::repo_path(repo);
339 let mut found = Vec::new();
340 let mut default_branch = None;
341 for file in DEPENDABOT_PATHS {
342 let read: Outcome<BlobView> = g1t_kit::call(
343 &self.repos,
344 "blob",
345 &BlobArgs { path: path.clone(), viewer: Some(User::system(&repo.namespace)), git_ref: String::new(), file_path: file.to_owned() },
346 )
347 .await?;
348 if let Outcome::Ok(blob) = read {
349 default_branch = Some(blob.repo.default_branch.clone());
350 found.push((file.to_owned(), blob.text));
351 }
352 }
353 let Some(((file, Some(text)), _)) = updates::choose(found) else { return Ok(None) };
354 let read = config::read(&text);
355 if !read.problems.is_empty() {
356 return Ok(None);
357 }
358 Ok(Some(Loaded { config: read.config, file, default_branch: default_branch.unwrap_or_default() }))
359 }
360
361 /// Schedules each entry g1t can act on, after the file is read: a new
362 /// one runs now, the rest when their schedule next says since they last
363 /// ran. A file with problems runs nothing.
364 pub(crate) async fn schedule_entries(&self, repo: &RepoRow, config: Option<&Config>, default_branch: Option<&str>) -> Result<()> {
365 let known: BTreeMap<String, Option<String>> =
366 self.store.runs(&repo.repo_id).await?.into_iter().map(|run| (run.entry, run.last_checked_at)).collect();
367 let now = now_ms();
368 let mut rows = Vec::new();
369 for entry in config.map(|config| config.updates.as_slice()).unwrap_or_default() {
370 if !runnable(entry, default_branch) {
371 continue;
372 }
373 let Some(schedule) = &entry.schedule else { continue };
374 let next = match known.get(&entry.id()) {
375 Some(Some(last)) => schedule.next_run(parse_rfc3339(last).unwrap_or(now), &updates::seed(&repo.repo_id, entry)),
376 _ => Some(now),
377 };
378 rows.push((entry.id(), next.map(rfc3339)));
379 }
380 self.store.set_runs(&repo.repo_id, &rows).await
381 }
382
383 /// The sweep's part for version updates: due entries, open update
384 /// pull requests, and sandboxes that never pushed.
385 pub async fn version_update_sweep(&self) -> Result<()> {
386 for run in self.store.due_runs(&rfc3339(now_ms()), DUE_PER_SWEEP).await? {
387 let Some(repo) = self.store.repo(&run.repo_id).await? else { continue };
388 if let Err(error) = self.run_claimed(&repo, &run.entry).await {
389 worker::console_error!("security: version updates for {} {} failed: {error}", run.repo_id, run.entry);
390 }
391 }
392 for row in self.store.open_update_pulls(WATCHED_PER_SWEEP).await? {
393 if let Err(error) = self.watch_update_pull(&row).await {
394 worker::console_error!("security: update pull request {} not looked at: {error}", row.id);
395 }
396 }
397 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
398 for row in self.store.stalled_update_pulls(&before, 10).await? {
399 let error = "The update could not be made in a sandbox: its packages may need code changes to move, or a registry could not be reached.";
400 self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(error)).await?;
401 }
402 Ok(())
403 }
404
405 /// Runs one entry if no one else is, and schedules its next run.
406 async fn run_claimed(&self, repo: &RepoRow, entry_id: &str) -> Result<()> {
407 if !self.store.claim_run(&repo.repo_id, entry_id).await? {
408 return Ok(());
409 }
410 let loaded = self.load_config(repo).await;
411 let (result, error, next) = match loaded {
412 Ok(Some(loaded)) => match loaded.config.updates.iter().find(|entry| entry.id() == entry_id && runnable(entry, Some(&loaded.default_branch))) {
413 Some(entry) => {
414 let next = entry
415 .schedule
416 .as_ref()
417 .and_then(|schedule| schedule.next_run(now_ms(), &updates::seed(&repo.repo_id, entry)))
418 .map(rfc3339);
419 match self.run_entry(repo, &loaded, entry).await {
420 Ok(Ok(summary)) => (Some(summary), None, next),
421 Ok(Err(problem)) => (None, Some(problem), next),
422 Err(error) => (None, Some(format!("The check could not finish: {error}")), next),
423 }
424 }
425 None => (None, None, None),
426 },
427 Ok(None) => (None, Some("The dependency update file is missing or has problems.".to_owned()), None),
428 Err(error) => (None, Some(format!("The dependency update file could not be read: {error}")), None),
429 };
430 self.store.finish_run(&repo.repo_id, entry_id, next.as_deref(), result.as_deref(), error.as_deref()).await
431 }
432
433 /// `check_updates`: one entry, now.
434 pub async fn check_updates(&self, a: CheckUpdatesArgs) -> Result<Outcome<VersionUpdatesState>> {
435 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::Push).await? {
436 Outcome::Ok(repo) => repo,
437 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
438 };
439 if !a.actor.verified {
440 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
441 }
442 let state = repo.version_updates();
443 let Some(entry) = state.updates.iter().find(|entry| entry.id == a.entry) else {
444 return Ok(Outcome::fail(FailureCode::NotFound, "The dependency update file has no such entry."));
445 };
446 if !entry.supported {
447 return Ok(Outcome::fail(FailureCode::Invalid, format!("g1t does not open version updates for {} yet.", entry.ecosystem)));
448 }
449 if !self.store.claim_run(&repo.repo_id, &a.entry).await? {
450 return Ok(Outcome::fail(FailureCode::Conflict, "This entry is being checked now."));
451 }
452 // Claimed: run_claimed would claim again, so run it here.
453 let loaded = self.load_config(&repo).await?;
454 let (result, error) = match loaded.as_ref().and_then(|loaded| loaded.config.updates.iter().find(|entry| entry.id() == a.entry).map(|entry| (loaded, entry))) {
455 Some((loaded, entry)) => match self.run_entry(&repo, loaded, entry).await {
456 Ok(Ok(summary)) => (Some(summary), None),
457 Ok(Err(problem)) => (None, Some(problem)),
458 Err(error) => (None, Some(format!("The check could not finish: {error}"))),
459 },
460 None => (None, Some("The dependency update file is missing or has problems.".to_owned())),
461 };
462 let next = self
463 .store
464 .runs(&repo.repo_id)
465 .await?
466 .into_iter()
467 .find(|run| run.entry == a.entry)
468 .and_then(|run| run.next_run_at);
469 self.store.finish_run(&repo.repo_id, &a.entry, next.as_deref(), result.as_deref(), error.as_deref()).await?;
470 Ok(Outcome::Ok(self.version_updates_view(&repo).await?))
471 }
472
473 /// The registries an entry may use, ready for the sandbox and for
474 /// asking about versions, and what kept any from being used.
475 async fn entry_registries(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<(Vec<(Registry, BumpRegistry, Source)>, Vec<String>)> {
476 let kind_for = |ecosystem: &str| match ecosystem {
477 "npm" => "npm-registry",
478 "cargo" => "cargo-registry",
479 "pip" => "python-index",
480 "gomod" => "goproxy-server",
481 _ => "",
482 };
483 let wanted: Vec<&Registry> = entry
484 .registries
485 .iter()
486 .filter_map(|name| loaded.config.registries.iter().find(|registry| &registry.name == name))
487 .filter(|registry| registry.kind == kind_for(&entry.ecosystem))
488 .collect();
489 if wanted.is_empty() {
490 return Ok((Vec::new(), Vec::new()));
491 }
492 let settings: ResolvedSettings = g1t_kit::call(
493 &self.actions,
494 "resolve_settings",
495 &ResolveSettingsArgs {
496 repo_id: repo.repo_id.clone(),
497 repo: Self::repo_path(repo),
498 project_id: None,
499 project_slug: None,
500 consumer: "workflows".to_owned(),
501 environment: None,
502 trusted: true,
503 },
504 )
505 .await
506 .unwrap_or_default();
507 let mut ready = Vec::new();
508 let mut notes = Vec::new();
509 for registry in wanted {
510 if registry.oidc {
511 notes.push(format!("Registry {} signs in with OIDC, which g1t cannot do, so it was not used.", registry.name));
512 continue;
513 }
514 let mut missing = Vec::new();
515 let mut fill = |text: &Option<String>| {
516 text.as_deref().map(|text| {
517 let (filled, absent) = fill_secrets(text, &settings.secrets);
518 missing.extend(absent);
519 filled
520 })
521 };
522 let username = fill(&registry.username);
523 let password = fill(&registry.password);
524 let token = fill(&registry.token).or_else(|| fill(&registry.key));
525 if !missing.is_empty() {
526 notes.push(format!(
527 "Registry {} names secrets that are not set for this repository's workflows: {}.",
528 registry.name,
529 missing.join(", ")
530 ));
531 continue;
532 }
533 let url = registry.url.trim_end_matches('/').to_owned();
534 let authorization = match (&token, &username, &password) {
535 (Some(token), _, _) if registry.kind == "cargo-registry" => Some(token.clone()),
536 (Some(token), _, _) if registry.kind == "npm-registry" => Some(format!("Bearer {token}")),
537 (Some(token), _, _) => Some(format!("Basic {}", base64_encode(format!("__token__:{token}").as_bytes()))),
538 (None, Some(user), Some(password)) => Some(format!("Basic {}", base64_encode(format!("{user}:{password}").as_bytes()))),
539 _ => None,
540 };
541 let bump = BumpRegistry {
542 kind: registry.kind.clone(),
543 url: url.clone(),
544 username,
545 password,
546 token,
547 replaces_base: registry.replaces_base,
548 scopes: registry.scopes.clone(),
549 };
550 ready.push((registry.clone(), bump, Source { url, authorization }));
551 }
552 Ok((ready, notes))
553 }
554
555 async fn get(&self, url: &str, source: Option<&Source>, accept: &str) -> Result<Option<String>> {
556 let headers = Headers::new();
557 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
558 headers.set("accept", accept)?;
559 if let Some(authorization) = source.and_then(|source| source.authorization.as_deref()) {
560 headers.set("authorization", authorization)?;
561 }
562 let mut init = RequestInit::new();
563 init.with_method(Method::Get).with_headers(headers);
564 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
565 match response.status_code() {
566 200..=299 => Ok(Some(response.text().await?)),
567 404 | 410 => Ok(None),
568 status => Err(worker::Error::RustError(format!("{} answered {status}", url.split('/').take(3).collect::<Vec<_>>().join("/")))),
569 }
570 }
571
572 /// What `name`'s registry says, from the private one that serves it if
573 /// the entry names one.
574 async fn package(&self, ecosystem: &str, name: &str, current: &str, registries: &[(Registry, BumpRegistry, Source)]) -> Result<Option<Package>> {
575 let private = registries.iter().find(|(registry, _, _)| {
576 registry.replaces_base || registry.scopes.iter().any(|scope| name.starts_with(&format!("{scope}/")))
577 });
578 let source = private.map(|(_, _, source)| source);
579 let Some(url) = registries::package_url(ecosystem, name, source) else { return Ok(None) };
580 let accept = if ecosystem == "pip" && source.is_some() { "application/vnd.pypi.simple.v1+json" } else { "application/json, text/plain" };
581 let Some(body) = self.get(&url, source, accept).await? else { return Ok(None) };
582 let mut package = registries::read(ecosystem, name, source.is_some(), &body);
583 if ecosystem == "gomod" {
584 // When the newest few were published, for the cooldown.
585 let mut newer: Vec<usize> = (0..package.releases.len())
586 .filter(|at| version::compare(&package.releases[*at].version, current).is_gt())
587 .collect();
588 newer.sort_by(|a, b| version::compare(&package.releases[*b].version, &package.releases[*a].version));
589 for at in newer.into_iter().take(GO_TIMES) {
590 let info = url.replace("/@v/list", &format!("/@v/{}.info", package.releases[at].version));
591 if let Some(text) = self.get(&info, source, "application/json").await? {
592 package.releases[at].published_ms = serde_json::from_str(&text).ok().and_then(|value: Value| registries::go_info_time(&value));
593 }
594 }
595 }
596 Ok(Some(package))
597 }
598
599 /// One run of one entry: what it found, as a sentence, or why it could
600 /// not run.
601 async fn run_entry(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<std::result::Result<String, String>> {
602 if !self.active(&repo.repo_id).await? {
603 return Ok(Err("Version updates wait while the repository is archived or deleted.".to_owned()));
604 }
605 if entry.open_pull_requests_limit == 0 {
606 return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned()));
607 }
608 let listed: FileList = g1t_kit::call(
609 &self.repos,
610 "list_files",
611 &ListFilesArgs {
612 repo_id: repo.repo_id.clone(),
613 // The branch its updates are for: `target-branch`, or the default.
614 git_ref: target_of(entry, &loaded.default_branch),
615 skip_dirs: SKIPPED_DIRECTORIES.iter().map(|dir| (*dir).to_owned()).collect(),
616 limit: g1t_contracts::repos::MAX_LISTED_FILES,
617 },
618 )
619 .await?;
620 let paths: Vec<String> = listed.files.iter().filter(|file| file.hash.is_some()).map(|file| file.path.clone()).collect();
621 let hash_of = |path: &str| listed.files.iter().find(|file| file.path == path).and_then(|file| file.hash.clone());
622 let directories = entry_directories(entry, &paths);
623 if directories.is_empty() {
624 return Ok(Err(format!("No {} manifest was found in {}.", entry.ecosystem, entry.directories.join(", "))));
625 }
626 // Each directory's manifests and lockfiles, read at once.
627 let mut wanted: BTreeMap<String, String> = BTreeMap::new();
628 let mut plan: Vec<(String, Vec<String>, Vec<String>)> = Vec::new();
629 for directory in &directories {
630 let base = directory.trim_start_matches('/');
631 let manifests: Vec<String> = manifests::manifest_names(&entry.ecosystem)
632 .iter()
633 .map(|name| if base.is_empty() { (*name).to_owned() } else { format!("{base}/{name}") })
634 .filter(|path| paths.contains(path))
635 .collect();
636 let lockfiles = lockfiles_for(&entry.ecosystem, directory, &paths);
637 for path in manifests.iter().chain(&lockfiles) {
638 if let Some(hash) = hash_of(path) {
639 wanted.insert(path.clone(), hash);
640 }
641 }
642 plan.push((directory.clone(), manifests, lockfiles));
643 }
644 let mut texts: BTreeMap<String, String> = BTreeMap::new();
645 let entries: Vec<(String, String)> = wanted.into_iter().collect();
646 for chunk in entries.chunks(g1t_contracts::repos::MAX_READ_BLOBS) {
647 let blobs: Vec<BlobText> = g1t_kit::call(
648 &self.repos,
649 "read_blobs",
650 &ReadBlobsArgs { repo_id: repo.repo_id.clone(), hashes: chunk.iter().map(|(_, hash)| hash.clone()).collect(), max_bytes: MAX_BLOB_BYTES },
651 )
652 .await?;
653 for ((path, _), blob) in chunk.iter().zip(blobs) {
654 if let Some(text) = blob.text {
655 texts.insert(path.clone(), text);
656 }
657 }
658 }
659 let mut read: Vec<Directory> = Vec::new();
660 for (path, manifest_paths, lockfiles) in plan {
661 let mut declared = Vec::new();
662 for manifest in &manifest_paths {
663 let name = manifest.rsplit('/').next().unwrap_or(manifest);
664 // A pinned requirements.txt is also read as a lockfile.
665 declared.extend(texts.get(manifest).map(|text| manifests::declared(name, text)).unwrap_or_default());
666 }
667 let mut locked: BTreeMap<String, Vec<String>> = BTreeMap::new();
668 for lockfile in &lockfiles {
669 let Some(kind) = Lockfile::for_path(lockfile) else { continue };
670 for package in texts.get(lockfile).map(|text| kind.parse(text)).unwrap_or_default() {
671 locked.entry(normalize(&entry.ecosystem, &package.name)).or_default().push(package.version);
672 }
673 }
674 read.push(Directory { path, declared, locked, lockfiles });
675 }
676 let (registries, mut notes) = self.entry_registries(repo, loaded, entry).await?;
677 let comments = self.store.ignores(&repo.repo_id).await?;
678 // Every dependency to ask about, once.
679 let mut candidates: Vec<(String, String, DependencyType, String, Option<String>)> = Vec::new();
680 for directory in &read {
681 for (name, kind, current, requirement) in directory_candidates(entry, directory) {
682 if planning::allowed(entry, &name, kind) {
683 candidates.push((directory.path.clone(), name, kind, current, requirement));
684 }
685 }
686 }
687 let names: Vec<(String, String)> = {
688 let mut seen = BTreeMap::new();
689 for (_, name, _, current, _) in &candidates {
690 let lowest = seen.entry(name.clone()).or_insert_with(|| current.clone());
691 if version::compare(current, lowest).is_lt() {
692 *lowest = current.clone();
693 }
694 }
695 seen.into_iter().collect()
696 };
697 if names.len() > MAX_PACKAGES {
698 notes.push(format!("Only the first {MAX_PACKAGES} of {} dependencies were checked this time.", names.len()));
699 }
700 let mut packages: BTreeMap<String, Package> = BTreeMap::new();
701 let mut failures = Vec::new();
702 for chunk in names.iter().take(MAX_PACKAGES).collect::<Vec<_>>().chunks(FETCH_AT_ONCE) {
703 let asks = chunk.iter().map(|(name, current)| self.package(&entry.ecosystem, name, current, &registries));
704 for ((name, _), found) in chunk.iter().zip(futures_util::future::join_all(asks).await) {
705 match found {
706 Ok(Some(package)) => {
707 packages.insert(name.clone(), package);
708 }
709 Ok(None) => {}
710 Err(error) => failures.push(format!("{name} ({error})")),
711 }
712 }
713 }
714 let now = now_ms();
715 let mut planned = Vec::new();
716 let mut up_to_date = 0;
717 let mut ignored = 0;
718 for (directory, name, kind, current, requirement) in candidates {
719 let Some(package) = packages.get(&name) else { continue };
720 let candidate = Candidate { name, directory, kind, current, requirement, package: package.clone() };
721 match planning::target(entry, &comments, &candidate, now) {
722 Ok(update) => planned.push(update),
723 Err(Skip::UpToDate) => up_to_date += 1,
724 Err(Skip::Ignored) => ignored += 1,
725 Err(Skip::NotAllowed) => {}
726 }
727 }
728 let plans = planning::gather(&entry.groups, "version-updates", planned);
729 let existing: Vec<PullRow> = self
730 .store
731 .update_pulls(&repo.repo_id)
732 .await?
733 .into_iter()
734 .filter(|row| row.kind == "version" && row.entry == entry.id())
735 .collect();
736 let (admitted, held) = admit(&plans, &existing, entry.open_pull_requests_limit);
737 let lockfiles_of = |plan: &PullPlan| -> Vec<String> {
738 let mut all: Vec<String> = read
739 .iter()
740 .filter(|directory| plan.directories().contains(&directory.path))
741 .flat_map(|directory| directory.lockfiles.clone())
742 .collect();
743 all.sort();
744 all.dedup();
745 all
746 };
747 let mut asked = 0;
748 for (plan, _) in &admitted {
749 let lockfiles = lockfiles_of(plan);
750 if lockfiles.is_empty() {
751 notes.push(format!("{} has no lockfile, so it was not updated.", plan.directories().join(", ")));
752 continue;
753 }
754 // The same branch as one in progress is replaced in place.
755 let branch = pull_text::branch(entry, plan);
756 let force = existing.iter().any(|row| row.branch == branch && row.state().in_progress());
757 self.ask_version_update(repo, loaded, entry, plan, lockfiles, &registries, force).await?;
758 asked += 1;
759 }
760 let mut summary = format!(
761 "Checked {} {}: {} up to date{}, {} {} asked for",
762 names.len().min(MAX_PACKAGES),
763 if names.len() == 1 { "dependency" } else { "dependencies" },
764 up_to_date,
765 if ignored > 0 { format!(", {ignored} ignored") } else { String::new() },
766 asked,
767 if asked == 1 { "pull request" } else { "pull requests" },
768 );
769 if held > 0 {
770 summary.push_str(&format!(", {held} waiting for open-pull-requests-limit"));
771 }
772 summary.push('.');
773 if !failures.is_empty() {
774 notes.push(format!("These could not be looked up: {}.", failures.join(", ")));
775 }
776 for note in notes {
777 summary.push(' ');
778 summary.push_str(&note);
779 }
780 Ok(Ok(summary))
781 }
782
783 /// Asks the runner for one version update pull request.
784 #[allow(clippy::too_many_arguments)]
785 async fn ask_version_update(
786 &self,
787 repo: &RepoRow,
788 loaded: &Loaded,
789 entry: &Entry,
790 plan: &PullPlan,
791 lockfiles: Vec<String>,
792 registries: &[(Registry, BumpRegistry, Source)],
793 force: bool,
794 ) -> Result<()> {
795 let Some(osv) = osv_ecosystem(&entry.ecosystem) else { return Ok(()) };
796 let branch = pull_text::branch(entry, plan);
797 let title = pull_text::title(entry, plan);
798 let body = pull_text::body(entry, plan, &loaded.file);
799 // One version per package: the highest any directory needs.
800 let mut highest: BTreeMap<String, String> = BTreeMap::new();
801 for update in &plan.updates {
802 let known = highest.entry(update.name.clone()).or_insert_with(|| update.to.clone());
803 if version::compare(&update.to, known).is_gt() {
804 *known = update.to.clone();
805 }
806 }
807 let packages: Vec<BumpPackage> = highest.into_iter().map(|(package, version)| BumpPackage { package, version }).collect();
808 let strategy = match entry.versioning_strategy.as_deref() {
809 Some("lockfile-only") => "lockfile-only",
810 Some("increase-if-necessary") => "increase-if-necessary",
811 Some("widen") => "widen",
812 _ => "increase",
813 };
814 let mut bump = BumpArgs {
815 repo: Self::repo_path(repo),
816 ecosystem: osv.to_owned(),
817 package: packages[0].package.clone(),
818 version: packages[0].version.clone(),
819 lockfiles,
820 branch: branch.clone(),
821 message: pull_text::commit_message(entry, plan),
822 kind: Some("version".to_owned()),
823 packages,
824 strategy: Some(strategy.to_owned()),
825 force,
826 registries: Vec::new(),
827 base: target_of(entry, &loaded.default_branch),
828 };
829 let dependencies: Vec<UpdatedDependency> = plan.updates.iter().map(updated).collect();
830 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
831 let id = self
832 .store
833 .add_update_pull(&NewPull {
834 repo_id: &repo.repo_id,
835 kind: "version",
836 entry: &entry.id(),
837 ecosystem: &entry.ecosystem,
838 subject: &plan.subject(),
839 signature: &plan.signature(),
840 group: plan.group.as_deref(),
841 branch: &branch,
842 title: &title,
843 body: &body,
844 dependencies: &dependencies,
845 bump: &bump,
846 assignees: &people(&entry.assignees),
847 reviewers: &people(&entry.reviewers),
848 })
849 .await?;
850 bump.registries = registries.iter().map(|(_, ready, _)| ready.clone()).collect();
851 if let Err(reason) = self.start_bump(&bump).await {
852 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the update: {reason}"))).await?;
853 }
854 Ok(())
855 }
856
857 async fn start_bump(&self, bump: &BumpArgs) -> std::result::Result<(), String> {
858 match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", bump).await {
859 Ok(Outcome::Ok(_)) => Ok(()),
860 Ok(Outcome::Fail(refused)) => Err(refused.message),
861 Err(error) => Err(format!("the runner could not be reached: {error}")),
862 }
863 }
864
865 /// Makes an open update pull request again from its base, on the same
866 /// branch: a rebase, a recreate, or a reopen.
867 async fn remake(&self, repo: &RepoRow, row: &PullRow) -> std::result::Result<(), String> {
868 let Some(mut bump) = row.bump() else { return Err("it was made before g1t kept how".to_owned()) };
869 bump.force = true;
870 bump.registries.clear();
871 // Credentials are never stored, and g1t's own push is awaited: see
872 // `update_pull_pushed`.
873 self.store.remake_update_pull(&row.id, &bump).await.map_err(|error| error.to_string())?;
874 if row.kind == "version"
875 && let Ok(Some(loaded)) = self.load_config(repo).await
876 && let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == row.entry)
877 && let Ok((registries, _)) = self.entry_registries(repo, &loaded, entry).await
878 {
879 bump.registries = registries.into_iter().map(|(_, ready, _)| ready).collect();
880 }
881 self.start_bump(&bump).await
882 }
883
884 /// An update branch was pushed: its pull request opens, or a remade one
885 /// carries on. Returns whether the branch was one of these.
886 pub async fn update_pull_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<bool> {
887 let Some(row) = self.store.update_pull_by_branch(repo_id, branch).await? else { return Ok(false) };
888 match row.state() {
889 UpdateState::Open => {
890 // g1t's own push after a rebase leaves `head` empty; anyone
891 // else's leaves it, so a rebase knows not to overwrite them.
892 if row.head.is_none() {
893 self.store.set_update_pull_head(&row.id, after).await?;
894 }
895 Ok(true)
896 }
897 UpdateState::Requested => {
898 let Some(repo) = self.store.repo(repo_id).await? else { return Ok(true) };
899 self.open_update_pull(&repo, &row, after).await?;
900 Ok(true)
901 }
902 _ => Ok(true),
903 }
904 }
905
906 async fn open_update_pull(&self, repo: &RepoRow, row: &PullRow, after: &str) -> Result<()> {
907 let system = User::system(&repo.namespace);
908 let opened: Outcome<Pull> = g1t_kit::call(
909 &self.work,
910 "open_pull",
911 &OpenPullArgs {
912 actor: system.clone(),
913 repo: Self::repo_path(repo),
914 issue: None,
915 title: row.title.clone(),
916 body: row.body.clone(),
917 branch: Some(row.branch.clone()),
918 agent: String::new(),
919 runtime: Runtime::External,
920 // Into `target-branch`, which it was made from.
921 base: row.bump().and_then(|bump| bump.base),
922 },
923 )
924 .await?;
925 let pull = match opened {
926 Outcome::Ok(pull) => pull,
927 Outcome::Fail(refused) => {
928 let error = format!("The pull request could not be opened: {}", refused.message);
929 return self.store.set_update_pull(&row.id, UpdateState::Failed, None, None, Some(&error)).await;
930 }
931 };
932 self.store.set_update_pull(&row.id, UpdateState::Open, Some(pull.number), None, None).await?;
933 self.store.set_update_pull_head(&row.id, after).await?;
934 let (assignees, reviewers) = (row.names("assignees"), row.names("reviewers"));
935 // Its labels and milestone, as the entry says now.
936 let entry = match self.load_config(repo).await {
937 Ok(Some(loaded)) => loaded.config.updates.into_iter().find(|entry| entry.id() == row.entry),
938 _ => None,
939 };
940 let labels = config::update_labels(entry.as_ref().and_then(|entry| entry.labels.as_deref()), &row.ecosystem);
941 let milestone = entry.as_ref().and_then(|entry| entry.milestone);
942 let _: Outcome<Value> = g1t_kit::call(
943 &self.work,
944 "update_pull",
945 &UpdatePullArgs {
946 actor: system.clone(),
947 repo: Self::repo_path(repo),
948 number: pull.number,
949 assignees: (!assignees.is_empty()).then_some(assignees),
950 reviewers: (!reviewers.is_empty()).then_some(reviewers),
951 labels: (!labels.is_empty()).then_some(labels),
952 milestone: None,
953 base: None,
954 },
955 )
956 .await?;
957 // Apart, so that a milestone the repository lacks leaves the rest.
958 if milestone.is_some() {
959 let _: Outcome<Value> = g1t_kit::call(
960 &self.work,
961 "update_pull",
962 &UpdatePullArgs {
963 actor: system.clone(),
964 repo: Self::repo_path(repo),
965 number: pull.number,
966 assignees: None,
967 reviewers: None,
968 labels: None,
969 milestone,
970 base: None,
971 },
972 )
973 .await?;
974 }
975 // Older ones for the same dependency or group are replaced.
976 for older in self.store.update_pulls(&repo.repo_id).await? {
977 if older.id == row.id || older.subject != row.subject || older.entry != row.entry || older.state() != UpdateState::Open {
978 continue;
979 }
980 self.store.set_update_pull(&older.id, UpdateState::Superseded, older.pull(), None, None).await?;
981 if let Some(number) = older.pull() {
982 self.close_with(repo, number, format!("Superseded by #{}.", pull.number)).await?;
983 }
984 }
985 // A grouped security update stands for each package's own.
986 if row.kind == "security" {
987 for dependency in row.dependencies() {
988 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
989 if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? {
990 self.store.set_update(&update, UpdateState::Open, Some(pull.number), None, None).await?;
991 }
992 }
993 }
994 Ok(())
995 }
996
997 /// A pull request merged or closed: if it is an update's, where it
998 /// stands now. Returns whether it was one.
999 pub async fn update_pull_closed(&self, kind: &str, repo_id: &str, number: u32) -> Result<bool> {
1000 let Some(row) = self.store.update_pull_by_number(repo_id, number).await? else { return Ok(false) };
1001 if row.state() != UpdateState::Open {
1002 return Ok(true);
1003 }
1004 let state = if kind == "pull.merged" { UpdateState::Merged } else { UpdateState::Closed };
1005 self.store.set_update_pull(&row.id, state, Some(number), None, None).await?;
1006 if row.kind == "security" {
1007 for dependency in row.dependencies() {
1008 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1009 if let Some(update) = self.store.update(repo_id, osv, &dependency.name).await? {
1010 self.store.set_update(&update, state, Some(number), None, None).await?;
1011 }
1012 }
1013 }
1014 Ok(true)
1015 }
1016
1017 async fn pull_detail(&self, repo: &RepoRow, number: u32) -> Result<Option<PullDetail>> {
1018 let found: Outcome<PullDetail> = g1t_kit::call(
1019 &self.work,
1020 "get_pull",
1021 &ViewArgs { repo: Self::repo_path(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
1022 )
1023 .await?;
1024 Ok(found.into_result().ok())
1025 }
1026
1027 /// Looks at one open update pull request: merged or closed meanwhile,
1028 /// its checks failing or passing, or in conflict with its base.
1029 async fn watch_update_pull(&self, row: &PullRow) -> Result<()> {
1030 self.store.touch_update_pull(&row.id).await?;
1031 let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.pull()) else { return Ok(()) };
1032 let Some(detail) = self.pull_detail(&repo, number).await? else { return Ok(()) };
1033 match detail.pull.status {
1034 PullStatus::Merged => return self.update_pull_closed("pull.merged", &row.repo_id, number).await.map(|_| ()),
1035 PullStatus::Closed => return self.update_pull_closed("pull.closed", &row.repo_id, number).await.map(|_| ()),
1036 _ => {}
1037 }
1038 let ours = row.head.is_some() && row.head == detail.pull.head_commit;
1039 match checks_verdict(&detail) {
1040 Some(false) if ours => return self.update_needs_code(&repo, row, &detail).await,
1041 Some(true) if row.merge_by.is_some() => {
1042 let merged: Outcome<Pull> = g1t_kit::call(
1043 &self.work,
1044 "merge_pull",
1045 &PullActionArgs {
1046 actor: User::system(&repo.namespace),
1047 repo: Self::repo_path(&repo),
1048 number,
1049 summary: String::new(),
1050 keep_issue_open: false,
1051 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1052 bypass_rules: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1053 },
1054 )
1055 .await?;
1056 if let Outcome::Fail(refused) = merged {
1057 self.store.set_merge_by(&row.id, None).await?;
1058 self.comment(&User::system(&repo.namespace), &Self::repo_path(&repo), number, format!("I could not merge this: {}", refused.message))
1059 .await?;
1060 }
1061 return Ok(());
1062 }
1063 _ => {}
1064 }
1065 let rebases = row.kind == "security" || self.rebase_strategy(&repo, &row.entry) != "disabled";
1066 if detail.mergeable == Mergeable::Conflicting
1067 && ours
1068 && rebases
1069 && let Err(reason) = self.remake(&repo, row).await
1070 {
1071 worker::console_error!("security: update {} not rebased: {reason}", row.id);
1072 }
1073 Ok(())
1074 }
1075
1076 /// An entry's `rebase-strategy`, as last read.
1077 fn rebase_strategy(&self, repo: &RepoRow, entry: &str) -> String {
1078 repo.version_updates()
1079 .updates
1080 .iter()
1081 .find(|found| found.id == entry)
1082 .and_then(|found| found.options.get("rebase-strategy").and_then(Value::as_str).map(str::to_owned))
1083 .unwrap_or_else(|| "auto".to_owned())
1084 }
1085
1086 /// An update that breaks the branch's required checks: closed, and an
1087 /// issue opened for g1t to make the code changes it needs.
1088 async fn update_needs_code(&self, repo: &RepoRow, row: &PullRow, detail: &PullDetail) -> Result<()> {
1089 let system = User::system(&repo.namespace);
1090 let path = Self::repo_path(repo);
1091 let failing: Vec<String> = if detail.required_checks.is_empty() {
1092 detail.statuses.iter().filter(|status| matches!(status.state.as_str(), "failure" | "error")).map(|status| status.context.clone()).collect()
1093 } else {
1094 detail.required_checks.iter().filter(|check| check.state == RequiredState::Failure).map(|check| check.name.clone()).collect()
1095 };
1096 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
1097 &self.work,
1098 "open_issue",
1099 &OpenIssueArgs {
1100 actor: system.clone(),
1101 repo: path.clone(),
1102 title: format!("{}: needs code changes", row.title).chars().take(200).collect(),
1103 body: needs_code_text(row, &failing, detail.pull.number),
1104 labels: vec!["dependencies".to_owned()],
1105 checks: Vec::new(),
1106 milestone: None,
1107 },
1108 )
1109 .await?;
1110 let issue = match issue {
1111 Outcome::Ok(issue) => issue,
1112 Outcome::Fail(refused) => {
1113 let error = format!("Its checks fail, and the issue for it could not be opened: {}", refused.message);
1114 return self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(&error)).await;
1115 }
1116 };
1117 self.store.set_update_pull(&row.id, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some("Raising the versions fails this branch's required checks.")).await?;
1118 self.close_with(repo, detail.pull.number, format!("Raising the versions alone fails this branch's required checks, so code has to change too. g1t is making the change in #{}.", issue.number))
1119 .await?;
1120 let started: Outcome<Value> = g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
1121 if let Outcome::Fail(refused) = started {
1122 self.comment(&system, &path, issue.number, format!(
1123 "g1t could not put an agent on this update: {}\n\nAssign it to g1t once agents can run here, or make the change by hand.",
1124 refused.message
1125 ))
1126 .await?;
1127 }
1128 Ok(())
1129 }
1130
1131 /// A comment on an update pull request: a command, if it is one.
1132 pub async fn update_comment(&self, event: &Event) -> Result<()> {
1133 #[derive(Deserialize)]
1134 #[serde(rename_all = "camelCase")]
1135 struct Commented {
1136 comment_id: String,
1137 repo_id: String,
1138 number: u32,
1139 #[serde(default)]
1140 pull_id: Option<String>,
1141 }
1142 let Ok(commented) = serde_json::from_value::<Commented>(event.data.clone()) else { return Ok(()) };
1143 if commented.pull_id.is_none() || event.actor.as_deref().is_some_and(g1t_contracts::system::is_system_id) {
1144 return Ok(());
1145 }
1146 let row = self.store.update_pull_by_number(&commented.repo_id, commented.number).await?;
1147 let single = self.store.update_by_pull(&commented.repo_id, commented.number).await?;
1148 if row.is_none() && single.is_none() {
1149 return Ok(());
1150 }
1151 let Some(repo) = self.store.repo(&commented.repo_id).await? else { return Ok(()) };
1152 let Some(detail) = self.pull_detail(&repo, commented.number).await? else { return Ok(()) };
1153 let Some(comment) = detail.comments.iter().find(|comment| comment.id == commented.comment_id) else { return Ok(()) };
1154 let Some(command) = update_command(&comment.body) else { return Ok(()) };
1155 let author = serde_json::from_value::<CommentAuthor>(serde_json::to_value(&comment.author)?).map(|a| a.username).unwrap_or_default();
1156 let system = User::system(&repo.namespace);
1157 let path = Self::repo_path(&repo);
1158 let needed = if matches!(command, UpdateCommand::Merge | UpdateCommand::SquashAndMerge | UpdateCommand::CancelMerge) {
1159 Capability::Merge
1160 } else {
1161 Capability::Push
1162 };
1163 let permission: Outcome<PermissionInfo> = g1t_kit::call(
1164 &self.identity,
1165 "collaborator_permission",
1166 &CollaboratorPermissionArgs { viewer: Some(system.clone()), path: path.clone(), username: author.clone() },
1167 )
1168 .await?;
1169 let allowed = matches!(&permission, Outcome::Ok(info) if info.capabilities.contains(&needed));
1170 if !allowed {
1171 let text = format!("@{author}, that takes the {} role on this repository.", if needed == Capability::Merge { "Maintain or Write" } else { "Write" });
1172 return self.comment(&system, &path, commented.number, text).await;
1173 }
1174 let Some(row) = row else {
1175 // A security update for one package: the commands that make sense for it.
1176 return self.single_update_command(&repo, single.as_ref(), &command, &author, commented.number, &detail).await;
1177 };
1178 let reply = self.command(&repo, &row, &command, &author, &detail).await?;
1179 if let Some(reply) = reply {
1180 self.comment(&system, &path, commented.number, reply).await?;
1181 }
1182 Ok(())
1183 }
1184
1185 /// Acts on a command on an update pull request; what to say back.
1186 async fn command(&self, repo: &RepoRow, row: &PullRow, command: &UpdateCommand, author: &str, detail: &PullDetail) -> Result<Option<String>> {
1187 let number = detail.pull.number;
1188 let open = detail.pull.status.is_active();
1189 let ignore = |dependency: &str, versions: Option<String>, update_type: Option<String>| IgnoreCondition {
1190 ecosystem: row.ecosystem.clone(),
1191 dependency: dependency.to_owned(),
1192 versions,
1193 update_type,
1194 by: author.to_owned(),
1195 pull: Some(number),
1196 at: String::new(),
1197 };
1198 let dependencies = row.dependencies();
1199 Ok(Some(match command {
1200 UpdateCommand::Rebase | UpdateCommand::Recreate => {
1201 if !open {
1202 return Ok(Some(format!("@{author}, this pull request is {}; `@g1t reopen` opens it again.", detail.pull.status.as_str())));
1203 }
1204 let pushed_by_others = row.head.is_some() && row.head != detail.pull.head_commit;
1205 if *command == UpdateCommand::Rebase && pushed_by_others {
1206 return Ok(Some(format!("@{author}, someone else has pushed to this branch, so a rebase would drop their commits. `@g1t recreate` makes it again from scratch.")));
1207 }
1208 match self.remake(repo, row).await {
1209 Ok(()) => format!("@{author}, {} this pull request from its base branch now.", if *command == UpdateCommand::Rebase { "rebasing" } else { "recreating" }),
1210 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1211 }
1212 }
1213 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => {
1214 if !open {
1215 return Ok(Some(format!("@{author}, this pull request is already {}.", detail.pull.status.as_str())));
1216 }
1217 self.store.set_merge_by(&row.id, Some(author)).await?;
1218 match checks_verdict(detail) {
1219 Some(false) => format!("@{author}, its required checks are failing; it merges once they pass."),
1220 Some(true) => {
1221 self.watch_update_pull(row).await?;
1222 return Ok(None);
1223 }
1224 None => format!("@{author}, it merges once its required checks pass."),
1225 }
1226 }
1227 UpdateCommand::CancelMerge => {
1228 self.store.set_merge_by(&row.id, None).await?;
1229 format!("@{author}, it will not merge on its own now.")
1230 }
1231 UpdateCommand::Close => {
1232 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1233 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a pull request for these versions again, but will when a newer one is out.")).await?;
1234 return Ok(None);
1235 }
1236 UpdateCommand::Reopen => {
1237 if open {
1238 return Ok(Some(format!("@{author}, this pull request is already open.")));
1239 }
1240 self.store.set_update_pull(&row.id, UpdateState::Requested, None, None, None).await?;
1241 match self.remake(repo, row).await {
1242 Ok(()) => format!("@{author}, making it again; it opens as a new pull request on the same branch."),
1243 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1244 }
1245 }
1246 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1247 if row.group_name.is_some() && dependencies.len() > 1 {
1248 return Ok(Some(format!(
1249 "@{author}, this pull request updates several dependencies. Name one: `@g1t ignore <dependency>`, or `@g1t ignore <dependency> major version`."
1250 )));
1251 }
1252 for dependency in &dependencies {
1253 let condition = match command {
1254 UpdateCommand::IgnoreVersion { level } => ignore(&dependency.name, Some(ranges::ignore_level(&dependency.to, level)), None),
1255 _ => ignore(&dependency.name, None, None),
1256 };
1257 self.store.add_ignore(&repo.repo_id, &condition).await?;
1258 }
1259 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1260 let what = match command {
1261 UpdateCommand::IgnoreVersion { level } => format!("this {level} version"),
1262 _ => "this dependency".to_owned(),
1263 };
1264 self.close_with(repo, number, format!("@{author}, g1t will skip {what} from now on. `@g1t unignore {}` undoes it.", dependencies.first().map(|d| d.name.as_str()).unwrap_or("*")))
1265 .await?;
1266 return Ok(None);
1267 }
1268 UpdateCommand::IgnoreNamed { dependency, level } => {
1269 let Some(found) = dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency)) else {
1270 return Ok(Some(format!("@{author}, this pull request does not update {dependency}.")));
1271 };
1272 let condition = match level {
1273 Some(level) => ignore(&found.name, Some(ranges::ignore_level(&found.to, level)), None),
1274 None => ignore(&found.name, None, None),
1275 };
1276 self.store.add_ignore(&repo.repo_id, &condition).await?;
1277 format!("@{author}, g1t will skip {}{}; the next check leaves it out of this group.", found.name, level.as_deref().map(|level| format!(" {level} versions like {}", found.to)).unwrap_or_default())
1278 }
1279 UpdateCommand::Unignore { dependency, level } => {
1280 let condition = match (level, dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency))) {
1281 (Some(level), Some(found)) => Some(ranges::ignore_level(&found.to, level)),
1282 _ => None,
1283 };
1284 let removed = self.store.remove_ignores(&repo.repo_id, &row.ecosystem, dependency, condition.as_deref()).await?;
1285 if removed == 0 {
1286 format!("@{author}, nothing was being ignored for {dependency}.")
1287 } else {
1288 format!("@{author}, removed {removed} ignore {} for {dependency}.", if removed == 1 { "condition" } else { "conditions" })
1289 }
1290 }
1291 UpdateCommand::ShowIgnores { dependency } => {
1292 let names: Vec<String> = match dependency {
1293 Some(name) => vec![name.clone()],
1294 None => dependencies.iter().map(|d| d.name.clone()).collect(),
1295 };
1296 self.ignore_report(repo, &row.ecosystem, &row.entry, &names).await?
1297 }
1298 }))
1299 }
1300
1301 /// The ignore conditions on some dependencies, from the file and from
1302 /// comments, as a reply.
1303 async fn ignore_report(&self, repo: &RepoRow, ecosystem: &str, entry: &str, names: &[String]) -> Result<String> {
1304 let state = repo.version_updates();
1305 let rules = state.updates.iter().find(|found| found.id == entry).map(|found| found.ignore.clone()).unwrap_or_default();
1306 let comments = self.store.ignores(&repo.repo_id).await?;
1307 let mut lines = Vec::new();
1308 for name in names {
1309 for rule in rules.iter().filter(|rule| config::matches(&rule.dependency, name)) {
1310 let what = if !rule.versions.is_empty() {
1311 format!("versions {}", rule.versions.join(", "))
1312 } else if !rule.update_types.is_empty() {
1313 rule.update_types.join(", ")
1314 } else {
1315 "every version".to_owned()
1316 };
1317 lines.push(format!("- `{name}`: {what} (in the dependency update file, `{}`)", rule.dependency));
1318 }
1319 for condition in comments.iter().filter(|c| c.ecosystem == ecosystem && c.dependency.eq_ignore_ascii_case(name)) {
1320 let what = condition
1321 .versions
1322 .clone()
1323 .map(|versions| format!("versions `{versions}`"))
1324 .or_else(|| condition.update_type.clone())
1325 .unwrap_or_else(|| "every version".to_owned());
1326 lines.push(format!(
1327 "- `{name}`: {what} (asked by @{}{})",
1328 condition.by,
1329 condition.pull.map(|pull| format!(" in #{pull}")).unwrap_or_default()
1330 ));
1331 }
1332 }
1333 Ok(if lines.is_empty() {
1334 format!("Nothing is ignored for {}.", names.join(", "))
1335 } else {
1336 format!("Ignore conditions:\n\n{}", lines.join("\n"))
1337 })
1338 }
1339
1340 /// Commands on a security update for one package.
1341 async fn single_update_command(
1342 &self,
1343 repo: &RepoRow,
1344 update: Option<&crate::store::UpdateRow>,
1345 command: &UpdateCommand,
1346 author: &str,
1347 number: u32,
1348 detail: &PullDetail,
1349 ) -> Result<()> {
1350 let Some(update) = update else { return Ok(()) };
1351 let system = User::system(&repo.namespace);
1352 let path = Self::repo_path(repo);
1353 let Some(ecosystem) = package_ecosystem(&update.ecosystem) else { return Ok(()) };
1354 let ignore = |versions: Option<String>| IgnoreCondition {
1355 ecosystem: ecosystem.to_owned(),
1356 dependency: update.package.clone(),
1357 versions,
1358 update_type: None,
1359 by: author.to_owned(),
1360 pull: Some(number),
1361 at: String::new(),
1362 };
1363 let reply = match command {
1364 UpdateCommand::Close => {
1365 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1366 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a security update for {} {} again.", update.package, update.target)).await?;
1367 return Ok(());
1368 }
1369 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1370 let versions = match command {
1371 UpdateCommand::IgnoreVersion { level } => Some(ranges::ignore_level(&update.target, level)),
1372 _ => None,
1373 };
1374 self.store.add_ignore(&repo.repo_id, &ignore(versions)).await?;
1375 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1376 self.close_with(repo, number, format!("@{author}, g1t will skip that for {} from now on. `@g1t unignore {}` undoes it.", update.package, update.package)).await?;
1377 return Ok(());
1378 }
1379 UpdateCommand::Unignore { dependency, .. } => {
1380 let removed = self.store.remove_ignores(&repo.repo_id, ecosystem, dependency, None).await?;
1381 format!("@{author}, removed {removed} ignore conditions for {dependency}.")
1382 }
1383 UpdateCommand::ShowIgnores { dependency } => {
1384 let name = dependency.clone().unwrap_or_else(|| update.package.clone());
1385 self.ignore_report(repo, ecosystem, "", &[name]).await?
1386 }
1387 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => match checks_verdict(detail) {
1388 Some(true) => {
1389 let merged: Outcome<Pull> = g1t_kit::call(
1390 &self.work,
1391 "merge_pull",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1392 &PullActionArgs { actor: system.clone(), repo: path.clone(), number, summary: String::new(), keep_issue_open: false, ignore_checks: false, bypass_rules: false },
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1393 )
1394 .await?;
1395 match merged {
1396 Outcome::Ok(_) => return Ok(()),
1397 Outcome::Fail(refused) => format!("@{author}, I could not merge this: {}", refused.message),
1398 }
1399 }
1400 _ => format!("@{author}, its required checks have not passed yet; merge it once they do."),
1401 },
1402 _ => format!("@{author}, a security update is made again by `Re-scan now` on the Security page; this command is for version updates."),
1403 };
1404 self.comment(&system, &path, number, reply).await
1405 }
1406
1407 /// A pull request that changes the dependency update file gets a
1408 /// status saying whether the file is valid.
1409 pub async fn check_dependabot_file(&self, event: &Event) -> Result<()> {
1410 #[derive(Deserialize)]
1411 #[serde(rename_all = "camelCase")]
1412 struct Changed {
1413 repo_id: String,
1414 number: u32,
1415 }
1416 let Ok(changed) = serde_json::from_value::<Changed>(event.data.clone()) else { return Ok(()) };
1417 let Some(repo) = self.store.repo(&changed.repo_id).await? else { return Ok(()) };
1418 let Some(detail) = self.pull_detail(&repo, changed.number).await? else { return Ok(()) };
1419 let pull = &detail.pull;
1420 let Some(file) = pull.files.iter().find(|file| DEPENDABOT_PATHS.contains(&file.path.as_str())) else { return Ok(()) };
1421 let Some(head) = pull.head_commit.clone() else { return Ok(()) };
1422 let found: Option<RawFile> = g1t_kit::call(
1423 &self.repos,
1424 "raw_file",
1425 &RawFileArgs {
1426 repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.repo_id.clone()),
1427 git_ref: head.clone(),
1428 path: file.path.clone(),
1429 max_bytes: 1_000_000,
1430 },
1431 )
1432 .await?;
1433 let (state, description) = match found.and_then(|raw| base64_decode(&raw.data)).map(String::from_utf8) {
1434 None => ("success".to_owned(), format!("{} is removed.", file.path)),
1435 Some(Err(_)) => ("failure".to_owned(), format!("{} is not text.", file.path)),
1436 Some(Ok(text)) => {
1437 let read = config::read(&text);
1438 match read.problems.first() {
1439 None => ("success".to_owned(), format!("{} is valid: {} entries.", file.path, read.config.updates.len())),
1440 Some(problem) => {
1441 let more = if read.problems.len() > 1 { format!(" (and {} more)", read.problems.len() - 1) } else { String::new() };
1442 ("failure".to_owned(), format!("{}{more}", problem.sentence()))
1443 }
1444 }
1445 }
1446 };
1447 let site = format!("https://g1t.sh/{}/{}/blob/{head}/{}", repo.namespace, repo.name, file.path);
1448 let _: Outcome<bool> = g1t_kit::call(
1449 &self.work,
1450 "set_commit_status",
1451 &SetCommitStatusArgs {
1452 repo_id: repo.repo_id.clone(),
1453 sha: head,
1454 context: DEPENDABOT_CHECK.to_owned(),
1455 state,
1456 description: Some(description.chars().take(400).collect()),
1457 target_url: Some(site),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1458 source: Some("security".to_owned()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1459 },
1460 )
1461 .await?;
1462 Ok(())
1463 }
1464
1465 /// What the Security page shows: the file as last read, with each
1466 /// entry's runs, the update pull requests, and comment ignores.
1467 pub async fn version_updates_view(&self, repo: &RepoRow) -> Result<VersionUpdatesState> {
1468 let mut state = repo.version_updates();
1469 let runs = self.store.runs(&repo.repo_id).await?;
1470 for entry in &mut state.updates {
1471 if let Some(run) = runs.iter().find(|run| run.entry == entry.id) {
1472 entry.next_run_at = run.next_run_at.clone();
1473 entry.last_checked_at = run.last_checked_at.clone();
1474 entry.last_result = run.last_result.clone();
1475 entry.last_error = run.last_error.clone();
1476 }
1477 }
1478 let rows = self.store.update_pulls(&repo.repo_id).await?;
1479 let (live, done): (Vec<&PullRow>, Vec<&PullRow>) = rows.iter().partition(|row| row.state().in_progress());
1480 state.pulls = live.into_iter().chain(done.into_iter().take(20)).map(PullRow::to_contract).collect();
1481 state.ignores = self.store.ignores(&repo.repo_id).await?;
1482 Ok(state)
1483 }
1484}
1485
1486/// Whether g1t runs an entry: an ecosystem it updates, and pull requests
1487/// it can open (`open-pull-requests-limit` above 0), into the default
1488/// branch or the entry's `target-branch`.
1489pub fn runnable(entry: &Entry, _default_branch: Option<&str>) -> bool {
1490 entry.supported() && entry.open_pull_requests_limit > 0 && entry.schedule.is_some()
1491}
1492
1493/// The branch an entry's updates start from and merge into, when it is not
1494/// the default branch.
1495pub fn target_of(entry: &Entry, default_branch: &str) -> Option<String> {
1496 entry.target_branch.clone().filter(|branch| !branch.is_empty() && branch != default_branch)
1497}
1498
1499fn updated(update: &Planned) -> UpdatedDependency {
1500 UpdatedDependency {
1501 name: update.name.clone(),
1502 from: update.from.clone(),
1503 to: update.to.clone(),
1504 directory: update.directory.clone(),
1505 dependency_type: update.kind.label().to_owned(),
1506 update_type: format!("version-update:semver-{}", update.level),
1507 }
1508}
1509
1510/// The issue for an update that breaks the build, for the agent that takes
1511/// it as much as for a person.
1512pub fn needs_code_text(row: &PullRow, failing: &[String], pull: u32) -> String {
1513 let mut body = format!(
1514 "#{pull} raises these dependencies, and this branch's required checks fail with only the versions changed{}:\n\n| Package | Directory | From | To |\n| --- | --- | --- | --- |\n",
1515 if failing.is_empty() { String::new() } else { format!(" ({})", failing.join(", ")) }
1516 );
1517 for dependency in row.dependencies() {
1518 body.push_str(&format!("| `{}` | `{}` | {} | {} |\n", dependency.name, dependency.directory, dependency.from, dependency.to));
1519 }
1520 body.push_str(
1521 "\nUpgrade them as #{pull} does and change the code that depends on them until the checks pass, keeping other changes to what the upgrade needs. Read each package's release notes for what changed.",
1522 );
1523 let body = body.replace("#{pull}", &format!("#{pull}"));
1524 let mut body = g1t_contracts::work::with_definition_of_done(&body, &["Every dependency above is at its new version, and the required checks pass.".to_owned()]);
1525 body.push_str("\n\n---\n_Opened by g1t's version updates._");
1526 body
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 use super::*;
1532 use crate::config::read;
1533 use g1t_contracts::work::{CommitStatus, RequiredCheck};
1534
1535 fn entry(extra: &str) -> Entry {
1536 let found = read(&format!("version: 2\nupdates:\n - package-ecosystem: npm\n{extra} schedule: {{interval: daily}}\n"));
1537 assert!(found.problems.is_empty(), "{:?}", found.problems);
1538 found.config.updates.into_iter().next().unwrap()
1539 }
1540
1541 #[test]
1542 fn base64_round_trips() {
1543 for text in ["", "a", "ab", "abc", "user:pa$$word", "version: 2\n"] {
1544 assert_eq!(base64_decode(&base64_encode(text.as_bytes())).unwrap(), text.as_bytes());
1545 }
1546 assert_eq!(base64_encode(b"ci:secret"), "Y2k6c2VjcmV0");
1547 }
1548
1549 #[test]
1550 fn secrets_are_filled_in() {
1551 let mut secrets = serde_json::Map::new();
1552 secrets.insert("TOKEN".into(), json!("t0k"));
1553 assert_eq!(fill_secrets("${{secrets.TOKEN}}", &secrets), ("t0k".to_owned(), vec![]));
1554 assert_eq!(fill_secrets("Bearer ${{ secrets.TOKEN }}!", &secrets).0, "Bearer t0k!");
1555 assert_eq!(fill_secrets("${{secrets.NOPE}}", &secrets).1, ["NOPE"]);
1556 assert_eq!(fill_secrets("plain", &secrets).0, "plain");
1557 }
1558
1559 #[test]
1560 fn directories_from_globs_and_exclusions() {
1561 let files: Vec<String> = ["package.json", "apps/web/package.json", "apps/admin/package.json", "apps/web/vendor/x/package.json", "docs/readme.md"]
1562 .map(str::to_owned)
1563 .to_vec();
1564 assert_eq!(entry_directories(&entry(" directory: /\n"), &files), ["/"]);
1565 assert_eq!(entry_directories(&entry(" directories: [\"/apps/*\"]\n"), &files), ["/apps/admin", "/apps/web"]);
1566 assert_eq!(
1567 entry_directories(&entry(" directories: [\"/apps/**\"]\n exclude-paths: [\"**/vendor/**\"]\n"), &files),
1568 ["/apps/admin", "/apps/web"]
1569 );
1570 assert!(entry_directories(&entry(" directory: /missing\n"), &files).is_empty());
1571 let locks: Vec<String> = ["package-lock.json", "apps/web/package.json", "Cargo.lock"].map(str::to_owned).to_vec();
1572 assert_eq!(lockfiles_for("npm", "/apps/web", &locks), ["package-lock.json"]);
1573 assert!(lockfiles_for("pip", "/", &locks).is_empty());
1574 }
1575
1576 #[test]
1577 fn current_versions_come_from_the_lockfile() {
1578 let directory = Directory {
1579 path: "/".into(),
1580 declared: manifests::package_json(r#"{"dependencies":{"lodash":"^4.17.0","left-pad":"1.0.0"},"devDependencies":{"vitest":"^1"}}"#),
1581 locked: BTreeMap::from([
1582 ("lodash".to_owned(), vec!["4.17.20".to_owned(), "3.10.1".to_owned()]),
1583 ("minimist".to_owned(), vec!["1.2.0".to_owned()]),
1584 ]),
1585 lockfiles: vec!["package-lock.json".into()],
1586 };
1587 let found = directory_candidates(&entry(" directory: /\n"), &directory);
1588 assert_eq!(found, [("lodash".to_owned(), DependencyType::Production, "4.17.20".to_owned(), Some("^4.17.0".to_owned()))]);
1589 let go = Directory {
1590 path: "/".into(),
1591 declared: manifests::go_mod("require golang.org/x/net v0.7.0\n"),
1592 locked: BTreeMap::new(),
1593 lockfiles: vec!["go.mod".into()],
1594 };
1595 let mut go_entry = entry(" directory: /\n");
1596 go_entry.ecosystem = "gomod".into();
1597 let found = directory_candidates(&go_entry, &go);
1598 assert_eq!(found[0].2, "v0.7.0");
1599 }
1600
1601 fn row(subject: &str, signature: &str, state: &str) -> PullRow {
1602 PullRow {
1603 id: format!("upd_{subject}_{state}"),
1604 repo_id: "rep_1".into(),
1605 kind: "version".into(),
1606 entry: "npm:/".into(),
1607 ecosystem: "npm".into(),
1608 subject: subject.into(),
1609 signature: signature.into(),
1610 group_name: None,
1611 branch: "g1t/npm_and_yarn/x".into(),
1612 title: String::new(),
1613 body: String::new(),
1614 dependencies: "[]".into(),
1615 bump: "{}".into(),
1616 assignees: "[]".into(),
1617 reviewers: "[]".into(),
1618 state: state.into(),
1619 pull: Some(3),
1620 head: None,
1621 merge_by: None,
1622 error: None,
1623 updated_at: String::new(),
1624 }
1625 }
1626
1627 fn plan(name: &str, to: &str) -> PullPlan {
1628 PullPlan {
1629 group: None,
1630 by_name: false,
1631 updates: vec![Planned {
1632 name: name.into(),
1633 directory: "/".into(),
1634 kind: DependencyType::Production,
1635 from: "1.0.0".into(),
1636 to: to.into(),
1637 level: "minor",
1638 source: None,
1639 changelog: None,
1640 page: None,
1641 }],
1642 }
1643 }
1644
1645 #[test]
1646 fn the_limit_counts_open_pull_requests_and_replacements_do_not() {
1647 let plans = vec![plan("a", "1.1.0"), plan("b", "1.1.0"), plan("c", "1.1.0"), plan("d", "1.1.0")];
1648 let existing = vec![
1649 row("dependency:/:a", "/a@1.0.5", "open"),
1650 row("dependency:/:b", "/b@1.1.0", "open"),
1651 row("dependency:/:c", "/c@1.1.0", "closed"),
1652 ];
1653 let (admitted, held) = admit(&plans, &existing, 3);
1654 let names: Vec<(&str, bool)> = admitted.iter().map(|(plan, older)| (plan.updates[0].name.as_str(), older.is_some())).collect();
1655 // a replaces its older pull request; b is open already; c was closed by a person for this version; d is new, and fits.
1656 assert_eq!(names, [("a", true), ("d", false)]);
1657 assert_eq!(held, 0);
1658 let (admitted, held) = admit(&plans, &existing, 2);
1659 assert_eq!(admitted.len(), 1);
1660 assert_eq!(held, 1);
1661 }
1662
1663 #[test]
1664 fn checks_decide_failure_and_success() {
1665 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
Merge checks: statuses and check runs on every commit1666 let status = |state: &str| CommitStatus { context: "CI / push".into(), state: state.into(), description: None, target_url: None, updated_at: String::new(), source: None, check_run_id: None };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1667 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
1668 let mut detail: PullDetail = serde_json::from_value(json!({
1669 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
1670 "status": "open", "fork": null, "forkRepoId": null, "branch": "b", "headCommit": "c", "mergeBase": null, "mergedBy": null,
1671 "mergedAt": null, "supersededBy": null, "checkStatus": null,
1672 "author": {"id": "g1t", "username": "g1t", "kind": "system"}, "createdAt": "", "updatedAt": ""},
1673 "issue": null, "comments": [], "checks": null
1674 }))
1675 .unwrap();
1676 detail.required_checks = required;
1677 detail.statuses = statuses;
1678 detail
1679 };
1680 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success)], vec![])), Some(true));
1681 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success), check(RequiredState::Failure)], vec![])), Some(false));
1682 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Pending)], vec![])), None);
1683 assert_eq!(checks_verdict(&detail(vec![], vec![status("error")])), Some(false));
1684 assert_eq!(checks_verdict(&detail(vec![], vec![status("pending")])), None);
1685 assert_eq!(checks_verdict(&detail(vec![], vec![])), Some(true));
1686 }
1687
1688 #[test]
1689 fn which_entries_run() {
1690 assert!(runnable(&entry(" directory: /\n"), Some("main")));
1691 assert!(!runnable(&entry(" directory: /\n open-pull-requests-limit: 0\n"), Some("main")));
1692 assert!(runnable(&entry(" directory: /\n target-branch: main\n"), Some("main")));
1693 assert!(runnable(&entry(" directory: /\n target-branch: develop\n"), Some("main")));
1694 assert_eq!(target_of(&entry(" directory: /\n target-branch: develop\n"), "main").as_deref(), Some("develop"));
1695 assert_eq!(target_of(&entry(" directory: /\n target-branch: main\n"), "main"), None);
1696 assert_eq!(target_of(&entry(" directory: /\n"), "main"), None);
1697 assert_eq!(osv_ecosystem("gomod"), Some("Go"));
1698 assert_eq!(package_ecosystem("crates.io"), Some("cargo"));
1699 }
1700
1701 #[test]
1702 fn the_issue_names_what_broke() {
1703 let mut found = row("group:lint", "", "open");
1704 found.dependencies = serde_json::to_string(&[UpdatedDependency {
1705 name: "eslint".into(),
1706 from: "8.0.0".into(),
1707 to: "9.0.0".into(),
1708 directory: "/".into(),
1709 dependency_type: "direct:development".into(),
1710 update_type: "version-update:semver-major".into(),
1711 }])
1712 .unwrap();
1713 let text = needs_code_text(&found, &["CI".into()], 12);
1714 assert!(text.starts_with("#12 raises these dependencies, and this branch's required checks fail with only the versions changed (CI):"));
1715 assert!(text.contains("| `eslint` | `/` | 8.0.0 | 9.0.0 |"));
1716 assert!(text.contains("Upgrade them as #12 does"));
1717 assert!(text.contains("## Definition of done"));
1718 }
1719}

This file's history is long; its oldest lines are credited to the oldest commit read.