Skip to content
1,730 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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.
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches98pub(crate) fn normalize(ecosystem: &str, name: &str) -> String {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar99 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 }
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches902 // No longer needed by the time its sandbox pushed: the branch goes.
903 UpdateState::Superseded if row.pull().is_none() => {
904 self.drop_pushed(repo_id, branch, after).await?;
905 Ok(true)
906 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar907 _ => Ok(true),
908 }
909 }
910
911 async fn open_update_pull(&self, repo: &RepoRow, row: &PullRow, after: &str) -> Result<()> {
912 let system = User::system(&repo.namespace);
913 let opened: Outcome<Pull> = g1t_kit::call(
914 &self.work,
915 "open_pull",
916 &OpenPullArgs {
917 actor: system.clone(),
918 repo: Self::repo_path(repo),
919 issue: None,
920 title: row.title.clone(),
921 body: row.body.clone(),
922 branch: Some(row.branch.clone()),
923 agent: String::new(),
924 runtime: Runtime::External,
925 // Into `target-branch`, which it was made from.
926 base: row.bump().and_then(|bump| bump.base),
The API opens a pull request as a draft with "draft": true927 draft: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar928 },
929 )
930 .await?;
931 let pull = match opened {
932 Outcome::Ok(pull) => pull,
933 Outcome::Fail(refused) => {
934 let error = format!("The pull request could not be opened: {}", refused.message);
935 return self.store.set_update_pull(&row.id, UpdateState::Failed, None, None, Some(&error)).await;
936 }
937 };
938 self.store.set_update_pull(&row.id, UpdateState::Open, Some(pull.number), None, None).await?;
939 self.store.set_update_pull_head(&row.id, after).await?;
940 let (assignees, reviewers) = (row.names("assignees"), row.names("reviewers"));
941 // Its labels and milestone, as the entry says now.
942 let entry = match self.load_config(repo).await {
943 Ok(Some(loaded)) => loaded.config.updates.into_iter().find(|entry| entry.id() == row.entry),
944 _ => None,
945 };
946 let labels = config::update_labels(entry.as_ref().and_then(|entry| entry.labels.as_deref()), &row.ecosystem);
947 let milestone = entry.as_ref().and_then(|entry| entry.milestone);
948 let _: Outcome<Value> = g1t_kit::call(
949 &self.work,
950 "update_pull",
951 &UpdatePullArgs {
952 actor: system.clone(),
953 repo: Self::repo_path(repo),
954 number: pull.number,
955 assignees: (!assignees.is_empty()).then_some(assignees),
956 reviewers: (!reviewers.is_empty()).then_some(reviewers),
957 labels: (!labels.is_empty()).then_some(labels),
958 milestone: None,
959 base: None,
960 },
961 )
962 .await?;
963 // Apart, so that a milestone the repository lacks leaves the rest.
964 if milestone.is_some() {
965 let _: Outcome<Value> = g1t_kit::call(
966 &self.work,
967 "update_pull",
968 &UpdatePullArgs {
969 actor: system.clone(),
970 repo: Self::repo_path(repo),
971 number: pull.number,
972 assignees: None,
973 reviewers: None,
974 labels: None,
975 milestone,
976 base: None,
977 },
978 )
979 .await?;
980 }
981 // Older ones for the same dependency or group are replaced.
982 for older in self.store.update_pulls(&repo.repo_id).await? {
983 if older.id == row.id || older.subject != row.subject || older.entry != row.entry || older.state() != UpdateState::Open {
984 continue;
985 }
986 self.store.set_update_pull(&older.id, UpdateState::Superseded, older.pull(), None, None).await?;
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches987 if let Some(number) = older.pull()
988 && let Some(found) = self.get_pull(repo, number).await?
989 && matches!(found.status, PullStatus::Open | PullStatus::Draft)
990 && self.close_with(repo, number, format!("Closed: superseded by #{}.", pull.number)).await?
991 && older.branch != row.branch
992 {
993 self.delete_pull_branch(repo, &Pull { status: PullStatus::Closed, ..found }).await;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar994 }
995 }
996 // A grouped security update stands for each package's own.
997 if row.kind == "security" {
998 for dependency in row.dependencies() {
999 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1000 if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? {
1001 self.store.set_update(&update, UpdateState::Open, Some(pull.number), None, None).await?;
1002 }
1003 }
1004 }
1005 Ok(())
1006 }
1007
1008 /// A pull request merged or closed: if it is an update's, where it
1009 /// stands now. Returns whether it was one.
1010 pub async fn update_pull_closed(&self, kind: &str, repo_id: &str, number: u32) -> Result<bool> {
1011 let Some(row) = self.store.update_pull_by_number(repo_id, number).await? else { return Ok(false) };
1012 if row.state() != UpdateState::Open {
1013 return Ok(true);
1014 }
1015 let state = if kind == "pull.merged" { UpdateState::Merged } else { UpdateState::Closed };
1016 self.store.set_update_pull(&row.id, state, Some(number), None, None).await?;
1017 if row.kind == "security" {
1018 for dependency in row.dependencies() {
1019 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1020 if let Some(update) = self.store.update(repo_id, osv, &dependency.name).await? {
1021 self.store.set_update(&update, state, Some(number), None, None).await?;
1022 }
1023 }
1024 }
1025 Ok(true)
1026 }
1027
1028 async fn pull_detail(&self, repo: &RepoRow, number: u32) -> Result<Option<PullDetail>> {
1029 let found: Outcome<PullDetail> = g1t_kit::call(
1030 &self.work,
1031 "get_pull",
1032 &ViewArgs { repo: Self::repo_path(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
1033 )
1034 .await?;
1035 Ok(found.into_result().ok())
1036 }
1037
1038 /// Looks at one open update pull request: merged or closed meanwhile,
1039 /// its checks failing or passing, or in conflict with its base.
1040 async fn watch_update_pull(&self, row: &PullRow) -> Result<()> {
1041 self.store.touch_update_pull(&row.id).await?;
1042 let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.pull()) else { return Ok(()) };
1043 let Some(detail) = self.pull_detail(&repo, number).await? else { return Ok(()) };
1044 match detail.pull.status {
1045 PullStatus::Merged => return self.update_pull_closed("pull.merged", &row.repo_id, number).await.map(|_| ()),
1046 PullStatus::Closed => return self.update_pull_closed("pull.closed", &row.repo_id, number).await.map(|_| ()),
1047 _ => {}
1048 }
1049 let ours = row.head.is_some() && row.head == detail.pull.head_commit;
1050 match checks_verdict(&detail) {
1051 Some(false) if ours => return self.update_needs_code(&repo, row, &detail).await,
1052 Some(true) if row.merge_by.is_some() => {
1053 let merged: Outcome<Pull> = g1t_kit::call(
1054 &self.work,
1055 "merge_pull",
1056 &PullActionArgs {
1057 actor: User::system(&repo.namespace),
1058 repo: Self::repo_path(&repo),
1059 number,
1060 summary: String::new(),
1061 keep_issue_open: false,
1062 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1063 bypass_rules: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1064 },
1065 )
1066 .await?;
1067 if let Outcome::Fail(refused) = merged {
1068 self.store.set_merge_by(&row.id, None).await?;
1069 self.comment(&User::system(&repo.namespace), &Self::repo_path(&repo), number, format!("I could not merge this: {}", refused.message))
1070 .await?;
1071 }
1072 return Ok(());
1073 }
1074 _ => {}
1075 }
1076 let rebases = row.kind == "security" || self.rebase_strategy(&repo, &row.entry) != "disabled";
1077 if detail.mergeable == Mergeable::Conflicting
1078 && ours
1079 && rebases
1080 && let Err(reason) = self.remake(&repo, row).await
1081 {
1082 worker::console_error!("security: update {} not rebased: {reason}", row.id);
1083 }
1084 Ok(())
1085 }
1086
1087 /// An entry's `rebase-strategy`, as last read.
1088 fn rebase_strategy(&self, repo: &RepoRow, entry: &str) -> String {
1089 repo.version_updates()
1090 .updates
1091 .iter()
1092 .find(|found| found.id == entry)
1093 .and_then(|found| found.options.get("rebase-strategy").and_then(Value::as_str).map(str::to_owned))
1094 .unwrap_or_else(|| "auto".to_owned())
1095 }
1096
1097 /// An update that breaks the branch's required checks: closed, and an
1098 /// issue opened for g1t to make the code changes it needs.
1099 async fn update_needs_code(&self, repo: &RepoRow, row: &PullRow, detail: &PullDetail) -> Result<()> {
1100 let system = User::system(&repo.namespace);
1101 let path = Self::repo_path(repo);
1102 let failing: Vec<String> = if detail.required_checks.is_empty() {
1103 detail.statuses.iter().filter(|status| matches!(status.state.as_str(), "failure" | "error")).map(|status| status.context.clone()).collect()
1104 } else {
1105 detail.required_checks.iter().filter(|check| check.state == RequiredState::Failure).map(|check| check.name.clone()).collect()
1106 };
1107 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
1108 &self.work,
1109 "open_issue",
1110 &OpenIssueArgs {
1111 actor: system.clone(),
1112 repo: path.clone(),
1113 title: format!("{}: needs code changes", row.title).chars().take(200).collect(),
1114 body: needs_code_text(row, &failing, detail.pull.number),
1115 labels: vec!["dependencies".to_owned()],
1116 checks: Vec::new(),
1117 milestone: None,
1118 },
1119 )
1120 .await?;
1121 let issue = match issue {
1122 Outcome::Ok(issue) => issue,
1123 Outcome::Fail(refused) => {
1124 let error = format!("Its checks fail, and the issue for it could not be opened: {}", refused.message);
1125 return self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(&error)).await;
1126 }
1127 };
1128 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?;
1129 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))
1130 .await?;
1131 let started: Outcome<Value> = g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
1132 if let Outcome::Fail(refused) = started {
1133 self.comment(&system, &path, issue.number, format!(
1134 "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.",
1135 refused.message
1136 ))
1137 .await?;
1138 }
1139 Ok(())
1140 }
1141
1142 /// A comment on an update pull request: a command, if it is one.
1143 pub async fn update_comment(&self, event: &Event) -> Result<()> {
1144 #[derive(Deserialize)]
1145 #[serde(rename_all = "camelCase")]
1146 struct Commented {
1147 comment_id: String,
1148 repo_id: String,
1149 number: u32,
1150 #[serde(default)]
1151 pull_id: Option<String>,
1152 }
1153 let Ok(commented) = serde_json::from_value::<Commented>(event.data.clone()) else { return Ok(()) };
1154 if commented.pull_id.is_none() || event.actor.as_deref().is_some_and(g1t_contracts::system::is_system_id) {
1155 return Ok(());
1156 }
1157 let row = self.store.update_pull_by_number(&commented.repo_id, commented.number).await?;
1158 let single = self.store.update_by_pull(&commented.repo_id, commented.number).await?;
1159 if row.is_none() && single.is_none() {
1160 return Ok(());
1161 }
1162 let Some(repo) = self.store.repo(&commented.repo_id).await? else { return Ok(()) };
1163 let Some(detail) = self.pull_detail(&repo, commented.number).await? else { return Ok(()) };
1164 let Some(comment) = detail.comments.iter().find(|comment| comment.id == commented.comment_id) else { return Ok(()) };
1165 let Some(command) = update_command(&comment.body) else { return Ok(()) };
1166 let author = serde_json::from_value::<CommentAuthor>(serde_json::to_value(&comment.author)?).map(|a| a.username).unwrap_or_default();
1167 let system = User::system(&repo.namespace);
1168 let path = Self::repo_path(&repo);
1169 let needed = if matches!(command, UpdateCommand::Merge | UpdateCommand::SquashAndMerge | UpdateCommand::CancelMerge) {
1170 Capability::Merge
1171 } else {
1172 Capability::Push
1173 };
1174 let permission: Outcome<PermissionInfo> = g1t_kit::call(
1175 &self.identity,
1176 "collaborator_permission",
1177 &CollaboratorPermissionArgs { viewer: Some(system.clone()), path: path.clone(), username: author.clone() },
1178 )
1179 .await?;
1180 let allowed = matches!(&permission, Outcome::Ok(info) if info.capabilities.contains(&needed));
1181 if !allowed {
1182 let text = format!("@{author}, that takes the {} role on this repository.", if needed == Capability::Merge { "Maintain or Write" } else { "Write" });
1183 return self.comment(&system, &path, commented.number, text).await;
1184 }
1185 let Some(row) = row else {
1186 // A security update for one package: the commands that make sense for it.
1187 return self.single_update_command(&repo, single.as_ref(), &command, &author, commented.number, &detail).await;
1188 };
1189 let reply = self.command(&repo, &row, &command, &author, &detail).await?;
1190 if let Some(reply) = reply {
1191 self.comment(&system, &path, commented.number, reply).await?;
1192 }
1193 Ok(())
1194 }
1195
1196 /// Acts on a command on an update pull request; what to say back.
1197 async fn command(&self, repo: &RepoRow, row: &PullRow, command: &UpdateCommand, author: &str, detail: &PullDetail) -> Result<Option<String>> {
1198 let number = detail.pull.number;
1199 let open = detail.pull.status.is_active();
1200 let ignore = |dependency: &str, versions: Option<String>, update_type: Option<String>| IgnoreCondition {
1201 ecosystem: row.ecosystem.clone(),
1202 dependency: dependency.to_owned(),
1203 versions,
1204 update_type,
1205 by: author.to_owned(),
1206 pull: Some(number),
1207 at: String::new(),
1208 };
1209 let dependencies = row.dependencies();
1210 Ok(Some(match command {
1211 UpdateCommand::Rebase | UpdateCommand::Recreate => {
1212 if !open {
1213 return Ok(Some(format!("@{author}, this pull request is {}; `@g1t reopen` opens it again.", detail.pull.status.as_str())));
1214 }
1215 let pushed_by_others = row.head.is_some() && row.head != detail.pull.head_commit;
1216 if *command == UpdateCommand::Rebase && pushed_by_others {
1217 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.")));
1218 }
1219 match self.remake(repo, row).await {
1220 Ok(()) => format!("@{author}, {} this pull request from its base branch now.", if *command == UpdateCommand::Rebase { "rebasing" } else { "recreating" }),
1221 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1222 }
1223 }
1224 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => {
1225 if !open {
1226 return Ok(Some(format!("@{author}, this pull request is already {}.", detail.pull.status.as_str())));
1227 }
1228 self.store.set_merge_by(&row.id, Some(author)).await?;
1229 match checks_verdict(detail) {
1230 Some(false) => format!("@{author}, its required checks are failing; it merges once they pass."),
1231 Some(true) => {
1232 self.watch_update_pull(row).await?;
1233 return Ok(None);
1234 }
1235 None => format!("@{author}, it merges once its required checks pass."),
1236 }
1237 }
1238 UpdateCommand::CancelMerge => {
1239 self.store.set_merge_by(&row.id, None).await?;
1240 format!("@{author}, it will not merge on its own now.")
1241 }
1242 UpdateCommand::Close => {
1243 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1244 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?;
1245 return Ok(None);
1246 }
1247 UpdateCommand::Reopen => {
1248 if open {
1249 return Ok(Some(format!("@{author}, this pull request is already open.")));
1250 }
1251 self.store.set_update_pull(&row.id, UpdateState::Requested, None, None, None).await?;
1252 match self.remake(repo, row).await {
1253 Ok(()) => format!("@{author}, making it again; it opens as a new pull request on the same branch."),
1254 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1255 }
1256 }
1257 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1258 if row.group_name.is_some() && dependencies.len() > 1 {
1259 return Ok(Some(format!(
1260 "@{author}, this pull request updates several dependencies. Name one: `@g1t ignore <dependency>`, or `@g1t ignore <dependency> major version`."
1261 )));
1262 }
1263 for dependency in &dependencies {
1264 let condition = match command {
1265 UpdateCommand::IgnoreVersion { level } => ignore(&dependency.name, Some(ranges::ignore_level(&dependency.to, level)), None),
1266 _ => ignore(&dependency.name, None, None),
1267 };
1268 self.store.add_ignore(&repo.repo_id, &condition).await?;
1269 }
1270 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1271 let what = match command {
1272 UpdateCommand::IgnoreVersion { level } => format!("this {level} version"),
1273 _ => "this dependency".to_owned(),
1274 };
1275 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("*")))
1276 .await?;
1277 return Ok(None);
1278 }
1279 UpdateCommand::IgnoreNamed { dependency, level } => {
1280 let Some(found) = dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency)) else {
1281 return Ok(Some(format!("@{author}, this pull request does not update {dependency}.")));
1282 };
1283 let condition = match level {
1284 Some(level) => ignore(&found.name, Some(ranges::ignore_level(&found.to, level)), None),
1285 None => ignore(&found.name, None, None),
1286 };
1287 self.store.add_ignore(&repo.repo_id, &condition).await?;
1288 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())
1289 }
1290 UpdateCommand::Unignore { dependency, level } => {
1291 let condition = match (level, dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency))) {
1292 (Some(level), Some(found)) => Some(ranges::ignore_level(&found.to, level)),
1293 _ => None,
1294 };
1295 let removed = self.store.remove_ignores(&repo.repo_id, &row.ecosystem, dependency, condition.as_deref()).await?;
1296 if removed == 0 {
1297 format!("@{author}, nothing was being ignored for {dependency}.")
1298 } else {
1299 format!("@{author}, removed {removed} ignore {} for {dependency}.", if removed == 1 { "condition" } else { "conditions" })
1300 }
1301 }
1302 UpdateCommand::ShowIgnores { dependency } => {
1303 let names: Vec<String> = match dependency {
1304 Some(name) => vec![name.clone()],
1305 None => dependencies.iter().map(|d| d.name.clone()).collect(),
1306 };
1307 self.ignore_report(repo, &row.ecosystem, &row.entry, &names).await?
1308 }
1309 }))
1310 }
1311
1312 /// The ignore conditions on some dependencies, from the file and from
1313 /// comments, as a reply.
1314 async fn ignore_report(&self, repo: &RepoRow, ecosystem: &str, entry: &str, names: &[String]) -> Result<String> {
1315 let state = repo.version_updates();
1316 let rules = state.updates.iter().find(|found| found.id == entry).map(|found| found.ignore.clone()).unwrap_or_default();
1317 let comments = self.store.ignores(&repo.repo_id).await?;
1318 let mut lines = Vec::new();
1319 for name in names {
1320 for rule in rules.iter().filter(|rule| config::matches(&rule.dependency, name)) {
1321 let what = if !rule.versions.is_empty() {
1322 format!("versions {}", rule.versions.join(", "))
1323 } else if !rule.update_types.is_empty() {
1324 rule.update_types.join(", ")
1325 } else {
1326 "every version".to_owned()
1327 };
1328 lines.push(format!("- `{name}`: {what} (in the dependency update file, `{}`)", rule.dependency));
1329 }
1330 for condition in comments.iter().filter(|c| c.ecosystem == ecosystem && c.dependency.eq_ignore_ascii_case(name)) {
1331 let what = condition
1332 .versions
1333 .clone()
1334 .map(|versions| format!("versions `{versions}`"))
1335 .or_else(|| condition.update_type.clone())
1336 .unwrap_or_else(|| "every version".to_owned());
1337 lines.push(format!(
1338 "- `{name}`: {what} (asked by @{}{})",
1339 condition.by,
1340 condition.pull.map(|pull| format!(" in #{pull}")).unwrap_or_default()
1341 ));
1342 }
1343 }
1344 Ok(if lines.is_empty() {
1345 format!("Nothing is ignored for {}.", names.join(", "))
1346 } else {
1347 format!("Ignore conditions:\n\n{}", lines.join("\n"))
1348 })
1349 }
1350
1351 /// Commands on a security update for one package.
1352 async fn single_update_command(
1353 &self,
1354 repo: &RepoRow,
1355 update: Option<&crate::store::UpdateRow>,
1356 command: &UpdateCommand,
1357 author: &str,
1358 number: u32,
1359 detail: &PullDetail,
1360 ) -> Result<()> {
1361 let Some(update) = update else { return Ok(()) };
1362 let system = User::system(&repo.namespace);
1363 let path = Self::repo_path(repo);
1364 let Some(ecosystem) = package_ecosystem(&update.ecosystem) else { return Ok(()) };
1365 let ignore = |versions: Option<String>| IgnoreCondition {
1366 ecosystem: ecosystem.to_owned(),
1367 dependency: update.package.clone(),
1368 versions,
1369 update_type: None,
1370 by: author.to_owned(),
1371 pull: Some(number),
1372 at: String::new(),
1373 };
1374 let reply = match command {
1375 UpdateCommand::Close => {
1376 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1377 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a security update for {} {} again.", update.package, update.target)).await?;
1378 return Ok(());
1379 }
1380 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1381 let versions = match command {
1382 UpdateCommand::IgnoreVersion { level } => Some(ranges::ignore_level(&update.target, level)),
1383 _ => None,
1384 };
1385 self.store.add_ignore(&repo.repo_id, &ignore(versions)).await?;
1386 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1387 self.close_with(repo, number, format!("@{author}, g1t will skip that for {} from now on. `@g1t unignore {}` undoes it.", update.package, update.package)).await?;
1388 return Ok(());
1389 }
1390 UpdateCommand::Unignore { dependency, .. } => {
1391 let removed = self.store.remove_ignores(&repo.repo_id, ecosystem, dependency, None).await?;
1392 format!("@{author}, removed {removed} ignore conditions for {dependency}.")
1393 }
1394 UpdateCommand::ShowIgnores { dependency } => {
1395 let name = dependency.clone().unwrap_or_else(|| update.package.clone());
1396 self.ignore_report(repo, ecosystem, "", &[name]).await?
1397 }
1398 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => match checks_verdict(detail) {
1399 Some(true) => {
1400 let merged: Outcome<Pull> = g1t_kit::call(
1401 &self.work,
1402 "merge_pull",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1403 &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 bar1404 )
1405 .await?;
1406 match merged {
1407 Outcome::Ok(_) => return Ok(()),
1408 Outcome::Fail(refused) => format!("@{author}, I could not merge this: {}", refused.message),
1409 }
1410 }
1411 _ => format!("@{author}, its required checks have not passed yet; merge it once they do."),
1412 },
1413 _ => format!("@{author}, a security update is made again by `Re-scan now` on the Security page; this command is for version updates."),
1414 };
1415 self.comment(&system, &path, number, reply).await
1416 }
1417
1418 /// A pull request that changes the dependency update file gets a
1419 /// status saying whether the file is valid.
1420 pub async fn check_dependabot_file(&self, event: &Event) -> Result<()> {
1421 #[derive(Deserialize)]
1422 #[serde(rename_all = "camelCase")]
1423 struct Changed {
1424 repo_id: String,
1425 number: u32,
1426 }
1427 let Ok(changed) = serde_json::from_value::<Changed>(event.data.clone()) else { return Ok(()) };
1428 let Some(repo) = self.store.repo(&changed.repo_id).await? else { return Ok(()) };
1429 let Some(detail) = self.pull_detail(&repo, changed.number).await? else { return Ok(()) };
1430 let pull = &detail.pull;
1431 let Some(file) = pull.files.iter().find(|file| DEPENDABOT_PATHS.contains(&file.path.as_str())) else { return Ok(()) };
1432 let Some(head) = pull.head_commit.clone() else { return Ok(()) };
1433 let found: Option<RawFile> = g1t_kit::call(
1434 &self.repos,
1435 "raw_file",
1436 &RawFileArgs {
1437 repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.repo_id.clone()),
1438 git_ref: head.clone(),
1439 path: file.path.clone(),
1440 max_bytes: 1_000_000,
1441 },
1442 )
1443 .await?;
1444 let (state, description) = match found.and_then(|raw| base64_decode(&raw.data)).map(String::from_utf8) {
1445 None => ("success".to_owned(), format!("{} is removed.", file.path)),
1446 Some(Err(_)) => ("failure".to_owned(), format!("{} is not text.", file.path)),
1447 Some(Ok(text)) => {
1448 let read = config::read(&text);
1449 match read.problems.first() {
1450 None => ("success".to_owned(), format!("{} is valid: {} entries.", file.path, read.config.updates.len())),
1451 Some(problem) => {
1452 let more = if read.problems.len() > 1 { format!(" (and {} more)", read.problems.len() - 1) } else { String::new() };
1453 ("failure".to_owned(), format!("{}{more}", problem.sentence()))
1454 }
1455 }
1456 }
1457 };
1458 let site = format!("https://g1t.sh/{}/{}/blob/{head}/{}", repo.namespace, repo.name, file.path);
1459 let _: Outcome<bool> = g1t_kit::call(
1460 &self.work,
1461 "set_commit_status",
1462 &SetCommitStatusArgs {
1463 repo_id: repo.repo_id.clone(),
1464 sha: head,
1465 context: DEPENDABOT_CHECK.to_owned(),
1466 state,
1467 description: Some(description.chars().take(400).collect()),
1468 target_url: Some(site),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1469 source: Some("security".to_owned()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1470 },
1471 )
1472 .await?;
1473 Ok(())
1474 }
1475
1476 /// What the Security page shows: the file as last read, with each
1477 /// entry's runs, the update pull requests, and comment ignores.
1478 pub async fn version_updates_view(&self, repo: &RepoRow) -> Result<VersionUpdatesState> {
1479 let mut state = repo.version_updates();
1480 let runs = self.store.runs(&repo.repo_id).await?;
1481 for entry in &mut state.updates {
1482 if let Some(run) = runs.iter().find(|run| run.entry == entry.id) {
1483 entry.next_run_at = run.next_run_at.clone();
1484 entry.last_checked_at = run.last_checked_at.clone();
1485 entry.last_result = run.last_result.clone();
1486 entry.last_error = run.last_error.clone();
1487 }
1488 }
1489 let rows = self.store.update_pulls(&repo.repo_id).await?;
1490 let (live, done): (Vec<&PullRow>, Vec<&PullRow>) = rows.iter().partition(|row| row.state().in_progress());
1491 state.pulls = live.into_iter().chain(done.into_iter().take(20)).map(PullRow::to_contract).collect();
1492 state.ignores = self.store.ignores(&repo.repo_id).await?;
1493 Ok(state)
1494 }
1495}
1496
1497/// Whether g1t runs an entry: an ecosystem it updates, and pull requests
1498/// it can open (`open-pull-requests-limit` above 0), into the default
1499/// branch or the entry's `target-branch`.
1500pub fn runnable(entry: &Entry, _default_branch: Option<&str>) -> bool {
1501 entry.supported() && entry.open_pull_requests_limit > 0 && entry.schedule.is_some()
1502}
1503
1504/// The branch an entry's updates start from and merge into, when it is not
1505/// the default branch.
1506pub fn target_of(entry: &Entry, default_branch: &str) -> Option<String> {
1507 entry.target_branch.clone().filter(|branch| !branch.is_empty() && branch != default_branch)
1508}
1509
1510fn updated(update: &Planned) -> UpdatedDependency {
1511 UpdatedDependency {
1512 name: update.name.clone(),
1513 from: update.from.clone(),
1514 to: update.to.clone(),
1515 directory: update.directory.clone(),
1516 dependency_type: update.kind.label().to_owned(),
1517 update_type: format!("version-update:semver-{}", update.level),
1518 }
1519}
1520
1521/// The issue for an update that breaks the build, for the agent that takes
1522/// it as much as for a person.
1523pub fn needs_code_text(row: &PullRow, failing: &[String], pull: u32) -> String {
1524 let mut body = format!(
1525 "#{pull} raises these dependencies, and this branch's required checks fail with only the versions changed{}:\n\n| Package | Directory | From | To |\n| --- | --- | --- | --- |\n",
1526 if failing.is_empty() { String::new() } else { format!(" ({})", failing.join(", ")) }
1527 );
1528 for dependency in row.dependencies() {
1529 body.push_str(&format!("| `{}` | `{}` | {} | {} |\n", dependency.name, dependency.directory, dependency.from, dependency.to));
1530 }
1531 body.push_str(
1532 "\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.",
1533 );
1534 let body = body.replace("#{pull}", &format!("#{pull}"));
1535 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()]);
1536 body.push_str("\n\n---\n_Opened by g1t's version updates._");
1537 body
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542 use super::*;
1543 use crate::config::read;
1544 use g1t_contracts::work::{CommitStatus, RequiredCheck};
1545
1546 fn entry(extra: &str) -> Entry {
1547 let found = read(&format!("version: 2\nupdates:\n - package-ecosystem: npm\n{extra} schedule: {{interval: daily}}\n"));
1548 assert!(found.problems.is_empty(), "{:?}", found.problems);
1549 found.config.updates.into_iter().next().unwrap()
1550 }
1551
1552 #[test]
1553 fn base64_round_trips() {
1554 for text in ["", "a", "ab", "abc", "user:pa$$word", "version: 2\n"] {
1555 assert_eq!(base64_decode(&base64_encode(text.as_bytes())).unwrap(), text.as_bytes());
1556 }
1557 assert_eq!(base64_encode(b"ci:secret"), "Y2k6c2VjcmV0");
1558 }
1559
1560 #[test]
1561 fn secrets_are_filled_in() {
1562 let mut secrets = serde_json::Map::new();
1563 secrets.insert("TOKEN".into(), json!("t0k"));
1564 assert_eq!(fill_secrets("${{secrets.TOKEN}}", &secrets), ("t0k".to_owned(), vec![]));
1565 assert_eq!(fill_secrets("Bearer ${{ secrets.TOKEN }}!", &secrets).0, "Bearer t0k!");
1566 assert_eq!(fill_secrets("${{secrets.NOPE}}", &secrets).1, ["NOPE"]);
1567 assert_eq!(fill_secrets("plain", &secrets).0, "plain");
1568 }
1569
1570 #[test]
1571 fn directories_from_globs_and_exclusions() {
1572 let files: Vec<String> = ["package.json", "apps/web/package.json", "apps/admin/package.json", "apps/web/vendor/x/package.json", "docs/readme.md"]
1573 .map(str::to_owned)
1574 .to_vec();
1575 assert_eq!(entry_directories(&entry(" directory: /\n"), &files), ["/"]);
1576 assert_eq!(entry_directories(&entry(" directories: [\"/apps/*\"]\n"), &files), ["/apps/admin", "/apps/web"]);
1577 assert_eq!(
1578 entry_directories(&entry(" directories: [\"/apps/**\"]\n exclude-paths: [\"**/vendor/**\"]\n"), &files),
1579 ["/apps/admin", "/apps/web"]
1580 );
1581 assert!(entry_directories(&entry(" directory: /missing\n"), &files).is_empty());
1582 let locks: Vec<String> = ["package-lock.json", "apps/web/package.json", "Cargo.lock"].map(str::to_owned).to_vec();
1583 assert_eq!(lockfiles_for("npm", "/apps/web", &locks), ["package-lock.json"]);
1584 assert!(lockfiles_for("pip", "/", &locks).is_empty());
1585 }
1586
1587 #[test]
1588 fn current_versions_come_from_the_lockfile() {
1589 let directory = Directory {
1590 path: "/".into(),
1591 declared: manifests::package_json(r#"{"dependencies":{"lodash":"^4.17.0","left-pad":"1.0.0"},"devDependencies":{"vitest":"^1"}}"#),
1592 locked: BTreeMap::from([
1593 ("lodash".to_owned(), vec!["4.17.20".to_owned(), "3.10.1".to_owned()]),
1594 ("minimist".to_owned(), vec!["1.2.0".to_owned()]),
1595 ]),
1596 lockfiles: vec!["package-lock.json".into()],
1597 };
1598 let found = directory_candidates(&entry(" directory: /\n"), &directory);
1599 assert_eq!(found, [("lodash".to_owned(), DependencyType::Production, "4.17.20".to_owned(), Some("^4.17.0".to_owned()))]);
1600 let go = Directory {
1601 path: "/".into(),
1602 declared: manifests::go_mod("require golang.org/x/net v0.7.0\n"),
1603 locked: BTreeMap::new(),
1604 lockfiles: vec!["go.mod".into()],
1605 };
1606 let mut go_entry = entry(" directory: /\n");
1607 go_entry.ecosystem = "gomod".into();
1608 let found = directory_candidates(&go_entry, &go);
1609 assert_eq!(found[0].2, "v0.7.0");
1610 }
1611
1612 fn row(subject: &str, signature: &str, state: &str) -> PullRow {
1613 PullRow {
1614 id: format!("upd_{subject}_{state}"),
1615 repo_id: "rep_1".into(),
1616 kind: "version".into(),
1617 entry: "npm:/".into(),
1618 ecosystem: "npm".into(),
1619 subject: subject.into(),
1620 signature: signature.into(),
1621 group_name: None,
1622 branch: "g1t/npm_and_yarn/x".into(),
1623 title: String::new(),
1624 body: String::new(),
1625 dependencies: "[]".into(),
1626 bump: "{}".into(),
1627 assignees: "[]".into(),
1628 reviewers: "[]".into(),
1629 state: state.into(),
1630 pull: Some(3),
1631 head: None,
1632 merge_by: None,
1633 error: None,
1634 updated_at: String::new(),
1635 }
1636 }
1637
1638 fn plan(name: &str, to: &str) -> PullPlan {
1639 PullPlan {
1640 group: None,
1641 by_name: false,
1642 updates: vec![Planned {
1643 name: name.into(),
1644 directory: "/".into(),
1645 kind: DependencyType::Production,
1646 from: "1.0.0".into(),
1647 to: to.into(),
1648 level: "minor",
1649 source: None,
1650 changelog: None,
1651 page: None,
1652 }],
1653 }
1654 }
1655
1656 #[test]
1657 fn the_limit_counts_open_pull_requests_and_replacements_do_not() {
1658 let plans = vec![plan("a", "1.1.0"), plan("b", "1.1.0"), plan("c", "1.1.0"), plan("d", "1.1.0")];
1659 let existing = vec![
1660 row("dependency:/:a", "/a@1.0.5", "open"),
1661 row("dependency:/:b", "/b@1.1.0", "open"),
1662 row("dependency:/:c", "/c@1.1.0", "closed"),
1663 ];
1664 let (admitted, held) = admit(&plans, &existing, 3);
1665 let names: Vec<(&str, bool)> = admitted.iter().map(|(plan, older)| (plan.updates[0].name.as_str(), older.is_some())).collect();
1666 // a replaces its older pull request; b is open already; c was closed by a person for this version; d is new, and fits.
1667 assert_eq!(names, [("a", true), ("d", false)]);
1668 assert_eq!(held, 0);
1669 let (admitted, held) = admit(&plans, &existing, 2);
1670 assert_eq!(admitted.len(), 1);
1671 assert_eq!(held, 1);
1672 }
1673
1674 #[test]
1675 fn checks_decide_failure_and_success() {
1676 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
Merge checks: statuses and check runs on every commit1677 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 bar1678 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
1679 let mut detail: PullDetail = serde_json::from_value(json!({
1680 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
1681 "status": "open", "fork": null, "forkRepoId": null, "branch": "b", "headCommit": "c", "mergeBase": null, "mergedBy": null,
1682 "mergedAt": null, "supersededBy": null, "checkStatus": null,
1683 "author": {"id": "g1t", "username": "g1t", "kind": "system"}, "createdAt": "", "updatedAt": ""},
1684 "issue": null, "comments": [], "checks": null
1685 }))
1686 .unwrap();
1687 detail.required_checks = required;
1688 detail.statuses = statuses;
1689 detail
1690 };
1691 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success)], vec![])), Some(true));
1692 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success), check(RequiredState::Failure)], vec![])), Some(false));
1693 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Pending)], vec![])), None);
1694 assert_eq!(checks_verdict(&detail(vec![], vec![status("error")])), Some(false));
1695 assert_eq!(checks_verdict(&detail(vec![], vec![status("pending")])), None);
1696 assert_eq!(checks_verdict(&detail(vec![], vec![])), Some(true));
1697 }
1698
1699 #[test]
1700 fn which_entries_run() {
1701 assert!(runnable(&entry(" directory: /\n"), Some("main")));
1702 assert!(!runnable(&entry(" directory: /\n open-pull-requests-limit: 0\n"), Some("main")));
1703 assert!(runnable(&entry(" directory: /\n target-branch: main\n"), Some("main")));
1704 assert!(runnable(&entry(" directory: /\n target-branch: develop\n"), Some("main")));
1705 assert_eq!(target_of(&entry(" directory: /\n target-branch: develop\n"), "main").as_deref(), Some("develop"));
1706 assert_eq!(target_of(&entry(" directory: /\n target-branch: main\n"), "main"), None);
1707 assert_eq!(target_of(&entry(" directory: /\n"), "main"), None);
1708 assert_eq!(osv_ecosystem("gomod"), Some("Go"));
1709 assert_eq!(package_ecosystem("crates.io"), Some("cargo"));
1710 }
1711
1712 #[test]
1713 fn the_issue_names_what_broke() {
1714 let mut found = row("group:lint", "", "open");
1715 found.dependencies = serde_json::to_string(&[UpdatedDependency {
1716 name: "eslint".into(),
1717 from: "8.0.0".into(),
1718 to: "9.0.0".into(),
1719 directory: "/".into(),
1720 dependency_type: "direct:development".into(),
1721 update_type: "version-update:semver-major".into(),
1722 }])
1723 .unwrap();
1724 let text = needs_code_text(&found, &["CI".into()], 12);
1725 assert!(text.starts_with("#12 raises these dependencies, and this branch's required checks fail with only the versions changed (CI):"));
1726 assert!(text.contains("| `eslint` | `/` | 8.0.0 | 9.0.0 |"));
1727 assert!(text.contains("Upgrade them as #12 does"));
1728 assert!(text.contains("## Definition of done"));
1729 }
1730}

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