Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,669 linesCodeBlameRaw
1//! 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, 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;
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.
98pub(crate) fn 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 // Left from when g1t opened an issue for an update whose checks
403 // failed: the issue closes, and the next run opens the update again
404 // as a pull request that stays open with its failing checks.
405 for row in self.store.needs_code_update_pulls(10).await? {
406 if let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.issue()) {
407 let why = "g1t no longer opens issues for updates whose checks fail. The entry's next run opens this update again \
408 as a pull request, which stays open with its checks for you to decide on.";
409 self.close_issue_with(&repo, number, why.to_owned()).await?;
410 }
411 let error = "Raising the versions failed this branch's required checks.";
412 self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(error)).await?;
413 }
414 Ok(())
415 }
416
417 /// Runs one entry if no one else is, and schedules its next run.
418 async fn run_claimed(&self, repo: &RepoRow, entry_id: &str) -> Result<()> {
419 if !self.store.claim_run(&repo.repo_id, entry_id).await? {
420 return Ok(());
421 }
422 let loaded = self.load_config(repo).await;
423 let (result, error, next) = match loaded {
424 Ok(Some(loaded)) => match loaded.config.updates.iter().find(|entry| entry.id() == entry_id && runnable(entry, Some(&loaded.default_branch))) {
425 Some(entry) => {
426 let next = entry
427 .schedule
428 .as_ref()
429 .and_then(|schedule| schedule.next_run(now_ms(), &updates::seed(&repo.repo_id, entry)))
430 .map(rfc3339);
431 match self.run_entry(repo, &loaded, entry).await {
432 Ok(Ok(summary)) => (Some(summary), None, next),
433 Ok(Err(problem)) => (None, Some(problem), next),
434 Err(error) => (None, Some(format!("The check could not finish: {error}")), next),
435 }
436 }
437 None => (None, None, None),
438 },
439 Ok(None) => (None, Some("The dependency update file is missing or has problems.".to_owned()), None),
440 Err(error) => (None, Some(format!("The dependency update file could not be read: {error}")), None),
441 };
442 self.store.finish_run(&repo.repo_id, entry_id, next.as_deref(), result.as_deref(), error.as_deref()).await
443 }
444
445 /// `check_updates`: one entry, now.
446 pub async fn check_updates(&self, a: CheckUpdatesArgs) -> Result<Outcome<VersionUpdatesState>> {
447 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::Push).await? {
448 Outcome::Ok(repo) => repo,
449 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
450 };
451 if !a.actor.verified {
452 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
453 }
454 let state = repo.version_updates();
455 let Some(entry) = state.updates.iter().find(|entry| entry.id == a.entry) else {
456 return Ok(Outcome::fail(FailureCode::NotFound, "The dependency update file has no such entry."));
457 };
458 if !entry.supported {
459 return Ok(Outcome::fail(FailureCode::Invalid, format!("g1t does not open version updates for {} yet.", entry.ecosystem)));
460 }
461 if !self.store.claim_run(&repo.repo_id, &a.entry).await? {
462 return Ok(Outcome::fail(FailureCode::Conflict, "This entry is being checked now."));
463 }
464 // Claimed: run_claimed would claim again, so run it here.
465 let loaded = self.load_config(&repo).await?;
466 let (result, error) = match loaded.as_ref().and_then(|loaded| loaded.config.updates.iter().find(|entry| entry.id() == a.entry).map(|entry| (loaded, entry))) {
467 Some((loaded, entry)) => match self.run_entry(&repo, loaded, entry).await {
468 Ok(Ok(summary)) => (Some(summary), None),
469 Ok(Err(problem)) => (None, Some(problem)),
470 Err(error) => (None, Some(format!("The check could not finish: {error}"))),
471 },
472 None => (None, Some("The dependency update file is missing or has problems.".to_owned())),
473 };
474 let next = self
475 .store
476 .runs(&repo.repo_id)
477 .await?
478 .into_iter()
479 .find(|run| run.entry == a.entry)
480 .and_then(|run| run.next_run_at);
481 self.store.finish_run(&repo.repo_id, &a.entry, next.as_deref(), result.as_deref(), error.as_deref()).await?;
482 Ok(Outcome::Ok(self.version_updates_view(&repo).await?))
483 }
484
485 /// The registries an entry may use, ready for the sandbox and for
486 /// asking about versions, and what kept any from being used.
487 async fn entry_registries(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<(Vec<(Registry, BumpRegistry, Source)>, Vec<String>)> {
488 let kind_for = |ecosystem: &str| match ecosystem {
489 "npm" => "npm-registry",
490 "cargo" => "cargo-registry",
491 "pip" => "python-index",
492 "gomod" => "goproxy-server",
493 _ => "",
494 };
495 let wanted: Vec<&Registry> = entry
496 .registries
497 .iter()
498 .filter_map(|name| loaded.config.registries.iter().find(|registry| &registry.name == name))
499 .filter(|registry| registry.kind == kind_for(&entry.ecosystem))
500 .collect();
501 if wanted.is_empty() {
502 return Ok((Vec::new(), Vec::new()));
503 }
504 let settings: ResolvedSettings = g1t_kit::call(
505 &self.actions,
506 "resolve_settings",
507 &ResolveSettingsArgs {
508 repo_id: repo.repo_id.clone(),
509 repo: Self::repo_path(repo),
510 project_id: None,
511 project_slug: None,
512 consumer: "workflows".to_owned(),
513 environment: None,
514 trusted: true,
515 },
516 )
517 .await
518 .unwrap_or_default();
519 let mut ready = Vec::new();
520 let mut notes = Vec::new();
521 for registry in wanted {
522 if registry.oidc {
523 notes.push(format!("Registry {} signs in with OIDC, which g1t cannot do, so it was not used.", registry.name));
524 continue;
525 }
526 let mut missing = Vec::new();
527 let mut fill = |text: &Option<String>| {
528 text.as_deref().map(|text| {
529 let (filled, absent) = fill_secrets(text, &settings.secrets);
530 missing.extend(absent);
531 filled
532 })
533 };
534 let username = fill(&registry.username);
535 let password = fill(&registry.password);
536 let token = fill(&registry.token).or_else(|| fill(&registry.key));
537 if !missing.is_empty() {
538 notes.push(format!(
539 "Registry {} names secrets that are not set for this repository's workflows: {}.",
540 registry.name,
541 missing.join(", ")
542 ));
543 continue;
544 }
545 let url = registry.url.trim_end_matches('/').to_owned();
546 let authorization = match (&token, &username, &password) {
547 (Some(token), _, _) if registry.kind == "cargo-registry" => Some(token.clone()),
548 (Some(token), _, _) if registry.kind == "npm-registry" => Some(format!("Bearer {token}")),
549 (Some(token), _, _) => Some(format!("Basic {}", base64_encode(format!("__token__:{token}").as_bytes()))),
550 (None, Some(user), Some(password)) => Some(format!("Basic {}", base64_encode(format!("{user}:{password}").as_bytes()))),
551 _ => None,
552 };
553 let bump = BumpRegistry {
554 kind: registry.kind.clone(),
555 url: url.clone(),
556 username,
557 password,
558 token,
559 replaces_base: registry.replaces_base,
560 scopes: registry.scopes.clone(),
561 };
562 ready.push((registry.clone(), bump, Source { url, authorization }));
563 }
564 Ok((ready, notes))
565 }
566
567 async fn get(&self, url: &str, source: Option<&Source>, accept: &str) -> Result<Option<String>> {
568 let headers = Headers::new();
569 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
570 headers.set("accept", accept)?;
571 if let Some(authorization) = source.and_then(|source| source.authorization.as_deref()) {
572 headers.set("authorization", authorization)?;
573 }
574 let mut init = RequestInit::new();
575 init.with_method(Method::Get).with_headers(headers);
576 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
577 match response.status_code() {
578 200..=299 => Ok(Some(response.text().await?)),
579 404 | 410 => Ok(None),
580 status => Err(worker::Error::RustError(format!("{} answered {status}", url.split('/').take(3).collect::<Vec<_>>().join("/")))),
581 }
582 }
583
584 /// What `name`'s registry says, from the private one that serves it if
585 /// the entry names one.
586 async fn package(&self, ecosystem: &str, name: &str, current: &str, registries: &[(Registry, BumpRegistry, Source)]) -> Result<Option<Package>> {
587 let private = registries.iter().find(|(registry, _, _)| {
588 registry.replaces_base || registry.scopes.iter().any(|scope| name.starts_with(&format!("{scope}/")))
589 });
590 let source = private.map(|(_, _, source)| source);
591 let Some(url) = registries::package_url(ecosystem, name, source) else { return Ok(None) };
592 let accept = if ecosystem == "pip" && source.is_some() { "application/vnd.pypi.simple.v1+json" } else { "application/json, text/plain" };
593 let Some(body) = self.get(&url, source, accept).await? else { return Ok(None) };
594 let mut package = registries::read(ecosystem, name, source.is_some(), &body);
595 if ecosystem == "gomod" {
596 // When the newest few were published, for the cooldown.
597 let mut newer: Vec<usize> = (0..package.releases.len())
598 .filter(|at| version::compare(&package.releases[*at].version, current).is_gt())
599 .collect();
600 newer.sort_by(|a, b| version::compare(&package.releases[*b].version, &package.releases[*a].version));
601 for at in newer.into_iter().take(GO_TIMES) {
602 let info = url.replace("/@v/list", &format!("/@v/{}.info", package.releases[at].version));
603 if let Some(text) = self.get(&info, source, "application/json").await? {
604 package.releases[at].published_ms = serde_json::from_str(&text).ok().and_then(|value: Value| registries::go_info_time(&value));
605 }
606 }
607 }
608 Ok(Some(package))
609 }
610
611 /// One run of one entry: what it found, as a sentence, or why it could
612 /// not run.
613 async fn run_entry(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<std::result::Result<String, String>> {
614 // Archived or deleted: nothing opens here on its own.
615 if let Err(why) = self.takes_updates(repo).await? {
616 return Ok(Err(why));
617 }
618 if entry.open_pull_requests_limit == 0 {
619 return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned()));
620 }
621 let listed: FileList = g1t_kit::call(
622 &self.repos,
623 "list_files",
624 &ListFilesArgs {
625 repo_id: repo.repo_id.clone(),
626 // The branch its updates are for: `target-branch`, or the default.
627 git_ref: target_of(entry, &loaded.default_branch),
628 skip_dirs: SKIPPED_DIRECTORIES.iter().map(|dir| (*dir).to_owned()).collect(),
629 limit: g1t_contracts::repos::MAX_LISTED_FILES,
630 links: false,
631 },
632 )
633 .await?;
634 let paths: Vec<String> = listed.files.iter().filter(|file| file.hash.is_some()).map(|file| file.path.clone()).collect();
635 let hash_of = |path: &str| listed.files.iter().find(|file| file.path == path).and_then(|file| file.hash.clone());
636 let directories = entry_directories(entry, &paths);
637 if directories.is_empty() {
638 return Ok(Err(format!("No {} manifest was found in {}.", entry.ecosystem, entry.directories.join(", "))));
639 }
640 // Each directory's manifests and lockfiles, read at once.
641 let mut wanted: BTreeMap<String, String> = BTreeMap::new();
642 let mut plan: Vec<(String, Vec<String>, Vec<String>)> = Vec::new();
643 for directory in &directories {
644 let base = directory.trim_start_matches('/');
645 let manifests: Vec<String> = manifests::manifest_names(&entry.ecosystem)
646 .iter()
647 .map(|name| if base.is_empty() { (*name).to_owned() } else { format!("{base}/{name}") })
648 .filter(|path| paths.contains(path))
649 .collect();
650 let lockfiles = lockfiles_for(&entry.ecosystem, directory, &paths);
651 for path in manifests.iter().chain(&lockfiles) {
652 if let Some(hash) = hash_of(path) {
653 wanted.insert(path.clone(), hash);
654 }
655 }
656 plan.push((directory.clone(), manifests, lockfiles));
657 }
658 let mut texts: BTreeMap<String, String> = BTreeMap::new();
659 let entries: Vec<(String, String)> = wanted.into_iter().collect();
660 for chunk in entries.chunks(g1t_contracts::repos::MAX_READ_BLOBS) {
661 let blobs: Vec<BlobText> = g1t_kit::call(
662 &self.repos,
663 "read_blobs",
664 &ReadBlobsArgs { repo_id: repo.repo_id.clone(), hashes: chunk.iter().map(|(_, hash)| hash.clone()).collect(), max_bytes: MAX_BLOB_BYTES },
665 )
666 .await?;
667 for ((path, _), blob) in chunk.iter().zip(blobs) {
668 if let Some(text) = blob.text {
669 texts.insert(path.clone(), text);
670 }
671 }
672 }
673 let mut read: Vec<Directory> = Vec::new();
674 for (path, manifest_paths, lockfiles) in plan {
675 let mut declared = Vec::new();
676 for manifest in &manifest_paths {
677 let name = manifest.rsplit('/').next().unwrap_or(manifest);
678 // A pinned requirements.txt is also read as a lockfile.
679 declared.extend(texts.get(manifest).map(|text| manifests::declared(name, text)).unwrap_or_default());
680 }
681 let mut locked: BTreeMap<String, Vec<String>> = BTreeMap::new();
682 for lockfile in &lockfiles {
683 let Some(kind) = Lockfile::for_path(lockfile) else { continue };
684 for package in texts.get(lockfile).map(|text| kind.parse(text)).unwrap_or_default() {
685 locked.entry(normalize(&entry.ecosystem, &package.name)).or_default().push(package.version);
686 }
687 }
688 read.push(Directory { path, declared, locked, lockfiles });
689 }
690 let (registries, mut notes) = self.entry_registries(repo, loaded, entry).await?;
691 let comments = self.store.ignores(&repo.repo_id).await?;
692 // Every dependency to ask about, once.
693 let mut candidates: Vec<(String, String, DependencyType, String, Option<String>)> = Vec::new();
694 for directory in &read {
695 for (name, kind, current, requirement) in directory_candidates(entry, directory) {
696 if planning::allowed(entry, &name, kind) {
697 candidates.push((directory.path.clone(), name, kind, current, requirement));
698 }
699 }
700 }
701 let names: Vec<(String, String)> = {
702 let mut seen = BTreeMap::new();
703 for (_, name, _, current, _) in &candidates {
704 let lowest = seen.entry(name.clone()).or_insert_with(|| current.clone());
705 if version::compare(current, lowest).is_lt() {
706 *lowest = current.clone();
707 }
708 }
709 seen.into_iter().collect()
710 };
711 if names.len() > MAX_PACKAGES {
712 notes.push(format!("Only the first {MAX_PACKAGES} of {} dependencies were checked this time.", names.len()));
713 }
714 let mut packages: BTreeMap<String, Package> = BTreeMap::new();
715 let mut failures = Vec::new();
716 for chunk in names.iter().take(MAX_PACKAGES).collect::<Vec<_>>().chunks(FETCH_AT_ONCE) {
717 let asks = chunk.iter().map(|(name, current)| self.package(&entry.ecosystem, name, current, &registries));
718 for ((name, _), found) in chunk.iter().zip(futures_util::future::join_all(asks).await) {
719 match found {
720 Ok(Some(package)) => {
721 packages.insert(name.clone(), package);
722 }
723 Ok(None) => {}
724 Err(error) => failures.push(format!("{name} ({error})")),
725 }
726 }
727 }
728 let now = now_ms();
729 let mut planned = Vec::new();
730 let mut up_to_date = 0;
731 let mut ignored = 0;
732 for (directory, name, kind, current, requirement) in candidates {
733 let Some(package) = packages.get(&name) else { continue };
734 let candidate = Candidate { name, directory, kind, current, requirement, package: package.clone() };
735 match planning::target(entry, &comments, &candidate, now) {
736 Ok(update) => planned.push(update),
737 Err(Skip::UpToDate) => up_to_date += 1,
738 Err(Skip::Ignored) => ignored += 1,
739 Err(Skip::NotAllowed) => {}
740 }
741 }
742 let plans = planning::gather(&entry.groups, "version-updates", planned);
743 let existing: Vec<PullRow> = self
744 .store
745 .update_pulls(&repo.repo_id)
746 .await?
747 .into_iter()
748 .filter(|row| row.kind == "version" && row.entry == entry.id())
749 .collect();
750 let (admitted, held) = admit(&plans, &existing, entry.open_pull_requests_limit);
751 let lockfiles_of = |plan: &PullPlan| -> Vec<String> {
752 let mut all: Vec<String> = read
753 .iter()
754 .filter(|directory| plan.directories().contains(&directory.path))
755 .flat_map(|directory| directory.lockfiles.clone())
756 .collect();
757 all.sort();
758 all.dedup();
759 all
760 };
761 let mut asked = 0;
762 for (plan, _) in &admitted {
763 let lockfiles = lockfiles_of(plan);
764 if lockfiles.is_empty() {
765 notes.push(format!("{} has no lockfile, so it was not updated.", plan.directories().join(", ")));
766 continue;
767 }
768 // The same branch as one in progress is replaced in place.
769 let branch = pull_text::branch(entry, plan);
770 let force = existing.iter().any(|row| row.branch == branch && row.state().in_progress());
771 self.ask_version_update(repo, loaded, entry, plan, lockfiles, &registries, force).await?;
772 asked += 1;
773 }
774 let mut summary = format!(
775 "Checked {} {}: {} up to date{}, {} {} asked for",
776 names.len().min(MAX_PACKAGES),
777 if names.len() == 1 { "dependency" } else { "dependencies" },
778 up_to_date,
779 if ignored > 0 { format!(", {ignored} ignored") } else { String::new() },
780 asked,
781 if asked == 1 { "pull request" } else { "pull requests" },
782 );
783 if held > 0 {
784 summary.push_str(&format!(", {held} waiting for open-pull-requests-limit"));
785 }
786 summary.push('.');
787 if !failures.is_empty() {
788 notes.push(format!("These could not be looked up: {}.", failures.join(", ")));
789 }
790 for note in notes {
791 summary.push(' ');
792 summary.push_str(&note);
793 }
794 Ok(Ok(summary))
795 }
796
797 /// Asks the runner for one version update pull request.
798 #[allow(clippy::too_many_arguments)]
799 async fn ask_version_update(
800 &self,
801 repo: &RepoRow,
802 loaded: &Loaded,
803 entry: &Entry,
804 plan: &PullPlan,
805 lockfiles: Vec<String>,
806 registries: &[(Registry, BumpRegistry, Source)],
807 force: bool,
808 ) -> Result<()> {
809 let Some(osv) = osv_ecosystem(&entry.ecosystem) else { return Ok(()) };
810 let branch = pull_text::branch(entry, plan);
811 let title = pull_text::title(entry, plan);
812 let body = pull_text::body(entry, plan, &loaded.file);
813 // One version per package: the highest any directory needs.
814 let mut highest: BTreeMap<String, String> = BTreeMap::new();
815 for update in &plan.updates {
816 let known = highest.entry(update.name.clone()).or_insert_with(|| update.to.clone());
817 if version::compare(&update.to, known).is_gt() {
818 *known = update.to.clone();
819 }
820 }
821 let packages: Vec<BumpPackage> = highest.into_iter().map(|(package, version)| BumpPackage { package, version }).collect();
822 let strategy = match entry.versioning_strategy.as_deref() {
823 Some("lockfile-only") => "lockfile-only",
824 Some("increase-if-necessary") => "increase-if-necessary",
825 Some("widen") => "widen",
826 _ => "increase",
827 };
828 let mut bump = BumpArgs {
829 repo: Self::repo_path(repo),
830 ecosystem: osv.to_owned(),
831 package: packages[0].package.clone(),
832 version: packages[0].version.clone(),
833 lockfiles,
834 branch: branch.clone(),
835 message: pull_text::commit_message(entry, plan),
836 kind: Some("version".to_owned()),
837 packages,
838 strategy: Some(strategy.to_owned()),
839 force,
840 registries: Vec::new(),
841 base: target_of(entry, &loaded.default_branch),
842 };
843 let dependencies: Vec<UpdatedDependency> = plan.updates.iter().map(updated).collect();
844 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
845 let id = self
846 .store
847 .add_update_pull(&NewPull {
848 repo_id: &repo.repo_id,
849 kind: "version",
850 entry: &entry.id(),
851 ecosystem: &entry.ecosystem,
852 subject: &plan.subject(),
853 signature: &plan.signature(),
854 group: plan.group.as_deref(),
855 branch: &branch,
856 title: &title,
857 body: &body,
858 dependencies: &dependencies,
859 bump: &bump,
860 assignees: &people(&entry.assignees),
861 reviewers: &people(&entry.reviewers),
862 })
863 .await?;
864 bump.registries = registries.iter().map(|(_, ready, _)| ready.clone()).collect();
865 if let Err(reason) = self.start_bump(&bump).await {
866 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the update: {reason}"))).await?;
867 }
868 Ok(())
869 }
870
871 async fn start_bump(&self, bump: &BumpArgs) -> std::result::Result<(), String> {
872 match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", bump).await {
873 Ok(Outcome::Ok(_)) => Ok(()),
874 Ok(Outcome::Fail(refused)) => Err(refused.message),
875 Err(error) => Err(format!("the runner could not be reached: {error}")),
876 }
877 }
878
879 /// Makes an open update pull request again from its base, on the same
880 /// branch: a rebase, a recreate, or a reopen.
881 async fn remake(&self, repo: &RepoRow, row: &PullRow) -> std::result::Result<(), String> {
882 let Some(mut bump) = row.bump() else { return Err("it was made before g1t kept how".to_owned()) };
883 bump.force = true;
884 bump.registries.clear();
885 // Credentials are never stored, and g1t's own push is awaited: see
886 // `update_pull_pushed`.
887 self.store.remake_update_pull(&row.id, &bump).await.map_err(|error| error.to_string())?;
888 if row.kind == "version"
889 && let Ok(Some(loaded)) = self.load_config(repo).await
890 && let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == row.entry)
891 && let Ok((registries, _)) = self.entry_registries(repo, &loaded, entry).await
892 {
893 bump.registries = registries.into_iter().map(|(_, ready, _)| ready).collect();
894 }
895 self.start_bump(&bump).await
896 }
897
898 /// An update branch was pushed: its pull request opens, or a remade one
899 /// carries on. Returns whether the branch was one of these.
900 pub async fn update_pull_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<bool> {
901 let Some(row) = self.store.update_pull_by_branch(repo_id, branch).await? else { return Ok(false) };
902 match row.state() {
903 UpdateState::Open => {
904 // g1t's own push after a rebase leaves `head` empty; anyone
905 // else's leaves it, so a rebase knows not to overwrite them.
906 if row.head.is_none() {
907 self.store.set_update_pull_head(&row.id, after).await?;
908 }
909 Ok(true)
910 }
911 UpdateState::Requested => {
912 let Some(repo) = self.store.repo(repo_id).await? else { return Ok(true) };
913 self.open_update_pull(&repo, &row, after).await?;
914 Ok(true)
915 }
916 // No longer needed by the time its sandbox pushed: the branch goes.
917 UpdateState::Superseded if row.pull().is_none() => {
918 self.drop_pushed(repo_id, branch, after).await?;
919 Ok(true)
920 }
921 _ => Ok(true),
922 }
923 }
924
925 async fn open_update_pull(&self, repo: &RepoRow, row: &PullRow, after: &str) -> Result<()> {
926 let system = User::system(&repo.namespace);
927 let opened: Outcome<Pull> = g1t_kit::call(
928 &self.work,
929 "open_pull",
930 &OpenPullArgs {
931 actor: system.clone(),
932 repo: Self::repo_path(repo),
933 issue: None,
934 title: row.title.clone(),
935 body: row.body.clone(),
936 branch: Some(row.branch.clone()),
937 head_repo: None,
938 agent: String::new(),
939 runtime: Runtime::External,
940 // Into `target-branch`, which it was made from.
941 base: row.bump().and_then(|bump| bump.base),
942 draft: false,
943 },
944 )
945 .await?;
946 let pull = match opened {
947 Outcome::Ok(pull) => pull,
948 Outcome::Fail(refused) => {
949 let error = format!("The pull request could not be opened: {}", refused.message);
950 return self.store.set_update_pull(&row.id, UpdateState::Failed, None, None, Some(&error)).await;
951 }
952 };
953 self.store.set_update_pull(&row.id, UpdateState::Open, Some(pull.number), None, None).await?;
954 self.store.set_update_pull_head(&row.id, after).await?;
955 let (assignees, reviewers) = (row.names("assignees"), row.names("reviewers"));
956 // Its labels and milestone, as the entry says now.
957 let entry = match self.load_config(repo).await {
958 Ok(Some(loaded)) => loaded.config.updates.into_iter().find(|entry| entry.id() == row.entry),
959 _ => None,
960 };
961 let labels = config::update_labels(entry.as_ref().and_then(|entry| entry.labels.as_deref()), &row.ecosystem);
962 let milestone = entry.as_ref().and_then(|entry| entry.milestone);
963 let _: Outcome<Value> = g1t_kit::call(
964 &self.work,
965 "update_pull",
966 &UpdatePullArgs {
967 actor: system.clone(),
968 repo: Self::repo_path(repo),
969 number: pull.number,
970 assignees: (!assignees.is_empty()).then_some(assignees),
971 reviewers: (!reviewers.is_empty()).then_some(reviewers),
972 labels: (!labels.is_empty()).then_some(labels),
973 milestone: None,
974 base: None,
975 },
976 )
977 .await?;
978 // Apart, so that a milestone the repository lacks leaves the rest.
979 if milestone.is_some() {
980 let _: Outcome<Value> = g1t_kit::call(
981 &self.work,
982 "update_pull",
983 &UpdatePullArgs {
984 actor: system.clone(),
985 repo: Self::repo_path(repo),
986 number: pull.number,
987 assignees: None,
988 reviewers: None,
989 labels: None,
990 milestone,
991 base: None,
992 },
993 )
994 .await?;
995 }
996 // Older ones for the same dependency or group are replaced.
997 for older in self.store.update_pulls(&repo.repo_id).await? {
998 if older.id == row.id || older.subject != row.subject || older.entry != row.entry || older.state() != UpdateState::Open {
999 continue;
1000 }
1001 self.store.set_update_pull(&older.id, UpdateState::Superseded, older.pull(), None, None).await?;
1002 if let Some(number) = older.pull()
1003 && let Some(found) = self.get_pull(repo, number).await?
1004 && matches!(found.status, PullStatus::Open | PullStatus::Draft)
1005 && self.close_with(repo, number, format!("Closed: superseded by #{}.", pull.number)).await?
1006 && older.branch != row.branch
1007 {
1008 self.delete_pull_branch(repo, &Pull { status: PullStatus::Closed, ..found }).await;
1009 }
1010 }
1011 // A grouped security update stands for each package's own.
1012 if row.kind == "security" {
1013 for dependency in row.dependencies() {
1014 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1015 if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? {
1016 self.store.set_update(&update, UpdateState::Open, Some(pull.number), None, None).await?;
1017 }
1018 }
1019 }
1020 Ok(())
1021 }
1022
1023 /// A pull request merged or closed: if it is an update's, where it
1024 /// stands now. Returns whether it was one.
1025 pub async fn update_pull_closed(&self, kind: &str, repo_id: &str, number: u32) -> Result<bool> {
1026 let Some(row) = self.store.update_pull_by_number(repo_id, number).await? else { return Ok(false) };
1027 if row.state() != UpdateState::Open {
1028 return Ok(true);
1029 }
1030 let state = if kind == "pull.merged" { UpdateState::Merged } else { UpdateState::Closed };
1031 self.store.set_update_pull(&row.id, state, Some(number), None, None).await?;
1032 if row.kind == "security" {
1033 for dependency in row.dependencies() {
1034 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1035 if let Some(update) = self.store.update(repo_id, osv, &dependency.name).await? {
1036 self.store.set_update(&update, state, Some(number), None, None).await?;
1037 }
1038 }
1039 }
1040 Ok(true)
1041 }
1042
1043 pub(crate) async fn pull_detail(&self, repo: &RepoRow, number: u32) -> Result<Option<PullDetail>> {
1044 let found: Outcome<PullDetail> = g1t_kit::call(
1045 &self.work,
1046 "get_pull",
1047 &ViewArgs { repo: Self::repo_path(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
1048 )
1049 .await?;
1050 Ok(found.into_result().ok())
1051 }
1052
1053 /// Looks at one open update pull request: merged or closed meanwhile,
1054 /// its checks failing or passing, or in conflict with its base.
1055 async fn watch_update_pull(&self, row: &PullRow) -> Result<()> {
1056 self.store.touch_update_pull(&row.id).await?;
1057 let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.pull()) else { return Ok(()) };
1058 let Some(detail) = self.pull_detail(&repo, number).await? else { return Ok(()) };
1059 match detail.pull.status {
1060 PullStatus::Merged => return self.update_pull_closed("pull.merged", &row.repo_id, number).await.map(|_| ()),
1061 PullStatus::Closed => return self.update_pull_closed("pull.closed", &row.repo_id, number).await.map(|_| ()),
1062 _ => {}
1063 }
1064 let ours = row.head.is_some() && row.head == detail.pull.head_commit;
1065 match checks_verdict(&detail) {
1066 Some(true) if row.merge_by.is_some() => {
1067 let merged: Outcome<Pull> = g1t_kit::call(
1068 &self.work,
1069 "merge_pull",
1070 &PullActionArgs {
1071 actor: User::system(&repo.namespace),
1072 repo: Self::repo_path(&repo),
1073 number,
1074 summary: String::new(),
1075 keep_issue_open: false,
1076 ignore_checks: false,
1077 bypass_rules: false,
1078 merge_method: None,
1079 commit_title: None,
1080 commit_message: None,
1081 expected_head: None,
1082 delete_branch: None,
1083 update_method: None,
1084 },
1085 )
1086 .await?;
1087 if let Outcome::Fail(refused) = merged {
1088 self.store.set_merge_by(&row.id, None).await?;
1089 self.comment(&User::system(&repo.namespace), &Self::repo_path(&repo), number, format!("I could not merge this: {}", refused.message))
1090 .await?;
1091 }
1092 return Ok(());
1093 }
1094 _ => {}
1095 }
1096 let rebases = row.kind == "security" || self.rebase_strategy(&repo, &row.entry) != "disabled";
1097 if detail.mergeable == Mergeable::Conflicting
1098 && ours
1099 && rebases
1100 && let Err(reason) = self.remake(&repo, row).await
1101 {
1102 worker::console_error!("security: update {} not rebased: {reason}", row.id);
1103 }
1104 Ok(())
1105 }
1106
1107 /// An entry's `rebase-strategy`, as last read.
1108 fn rebase_strategy(&self, repo: &RepoRow, entry: &str) -> String {
1109 repo.version_updates()
1110 .updates
1111 .iter()
1112 .find(|found| found.id == entry)
1113 .and_then(|found| found.options.get("rebase-strategy").and_then(Value::as_str).map(str::to_owned))
1114 .unwrap_or_else(|| "auto".to_owned())
1115 }
1116
1117 /// A comment on an update pull request: a command, if it is one.
1118 pub async fn update_comment(&self, event: &Event) -> Result<()> {
1119 #[derive(Deserialize)]
1120 #[serde(rename_all = "camelCase")]
1121 struct Commented {
1122 comment_id: String,
1123 repo_id: String,
1124 number: u32,
1125 #[serde(default)]
1126 pull_id: Option<String>,
1127 }
1128 let Ok(commented) = serde_json::from_value::<Commented>(event.data.clone()) else { return Ok(()) };
1129 if commented.pull_id.is_none() || event.actor.as_deref().is_some_and(g1t_contracts::system::is_system_id) {
1130 return Ok(());
1131 }
1132 let row = self.store.update_pull_by_number(&commented.repo_id, commented.number).await?;
1133 let single = self.store.update_by_pull(&commented.repo_id, commented.number).await?;
1134 if row.is_none() && single.is_none() {
1135 return Ok(());
1136 }
1137 let Some(repo) = self.store.repo(&commented.repo_id).await? else { return Ok(()) };
1138 let Some(detail) = self.pull_detail(&repo, commented.number).await? else { return Ok(()) };
1139 let Some(comment) = detail.comments.iter().find(|comment| comment.id == commented.comment_id) else { return Ok(()) };
1140 let Some(command) = update_command(&comment.body) else { return Ok(()) };
1141 let author = serde_json::from_value::<CommentAuthor>(serde_json::to_value(&comment.author)?).map(|a| a.username).unwrap_or_default();
1142 let system = User::system(&repo.namespace);
1143 let path = Self::repo_path(&repo);
1144 let needed = if matches!(command, UpdateCommand::Merge | UpdateCommand::SquashAndMerge | UpdateCommand::CancelMerge) {
1145 Capability::Merge
1146 } else {
1147 Capability::Push
1148 };
1149 let permission: Outcome<PermissionInfo> = g1t_kit::call(
1150 &self.identity,
1151 "collaborator_permission",
1152 &CollaboratorPermissionArgs { viewer: Some(system.clone()), path: path.clone(), username: author.clone() },
1153 )
1154 .await?;
1155 let allowed = matches!(&permission, Outcome::Ok(info) if info.capabilities.contains(&needed));
1156 if !allowed {
1157 let text = format!("@{author}, that takes the {} role on this repository.", if needed == Capability::Merge { "Maintain or Write" } else { "Write" });
1158 return self.comment(&system, &path, commented.number, text).await;
1159 }
1160 let Some(row) = row else {
1161 // A security update for one package: the commands that make sense for it.
1162 return self.single_update_command(&repo, single.as_ref(), &command, &author, commented.number, &detail).await;
1163 };
1164 let reply = self.command(&repo, &row, &command, &author, &detail).await?;
1165 if let Some(reply) = reply {
1166 self.comment(&system, &path, commented.number, reply).await?;
1167 }
1168 Ok(())
1169 }
1170
1171 /// Acts on a command on an update pull request; what to say back.
1172 async fn command(&self, repo: &RepoRow, row: &PullRow, command: &UpdateCommand, author: &str, detail: &PullDetail) -> Result<Option<String>> {
1173 let number = detail.pull.number;
1174 let open = detail.pull.status.is_active();
1175 let ignore = |dependency: &str, versions: Option<String>, update_type: Option<String>| IgnoreCondition {
1176 ecosystem: row.ecosystem.clone(),
1177 dependency: dependency.to_owned(),
1178 versions,
1179 update_type,
1180 by: author.to_owned(),
1181 pull: Some(number),
1182 at: String::new(),
1183 };
1184 let dependencies = row.dependencies();
1185 Ok(Some(match command {
1186 UpdateCommand::Rebase | UpdateCommand::Recreate => {
1187 if !open {
1188 return Ok(Some(format!("@{author}, this pull request is {}; `@g1t reopen` opens it again.", detail.pull.status.as_str())));
1189 }
1190 let pushed_by_others = row.head.is_some() && row.head != detail.pull.head_commit;
1191 if *command == UpdateCommand::Rebase && pushed_by_others {
1192 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.")));
1193 }
1194 match self.remake(repo, row).await {
1195 Ok(()) => format!("@{author}, {} this pull request from its base branch now.", if *command == UpdateCommand::Rebase { "rebasing" } else { "recreating" }),
1196 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1197 }
1198 }
1199 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => {
1200 if !open {
1201 return Ok(Some(format!("@{author}, this pull request is already {}.", detail.pull.status.as_str())));
1202 }
1203 self.store.set_merge_by(&row.id, Some(author)).await?;
1204 match checks_verdict(detail) {
1205 Some(false) => format!("@{author}, its required checks are failing; it merges once they pass."),
1206 Some(true) => {
1207 self.watch_update_pull(row).await?;
1208 return Ok(None);
1209 }
1210 None => format!("@{author}, it merges once its required checks pass."),
1211 }
1212 }
1213 UpdateCommand::CancelMerge => {
1214 self.store.set_merge_by(&row.id, None).await?;
1215 format!("@{author}, it will not merge on its own now.")
1216 }
1217 UpdateCommand::Close => {
1218 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1219 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?;
1220 return Ok(None);
1221 }
1222 UpdateCommand::Reopen => {
1223 if open {
1224 return Ok(Some(format!("@{author}, this pull request is already open.")));
1225 }
1226 self.store.set_update_pull(&row.id, UpdateState::Requested, None, None, None).await?;
1227 match self.remake(repo, row).await {
1228 Ok(()) => format!("@{author}, making it again; it opens as a new pull request on the same branch."),
1229 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1230 }
1231 }
1232 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1233 if row.group_name.is_some() && dependencies.len() > 1 {
1234 return Ok(Some(format!(
1235 "@{author}, this pull request updates several dependencies. Name one: `@g1t ignore <dependency>`, or `@g1t ignore <dependency> major version`."
1236 )));
1237 }
1238 for dependency in &dependencies {
1239 let condition = match command {
1240 UpdateCommand::IgnoreVersion { level } => ignore(&dependency.name, Some(ranges::ignore_level(&dependency.to, level)), None),
1241 _ => ignore(&dependency.name, None, None),
1242 };
1243 self.store.add_ignore(&repo.repo_id, &condition).await?;
1244 }
1245 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1246 let what = match command {
1247 UpdateCommand::IgnoreVersion { level } => format!("this {level} version"),
1248 _ => "this dependency".to_owned(),
1249 };
1250 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("*")))
1251 .await?;
1252 return Ok(None);
1253 }
1254 UpdateCommand::IgnoreNamed { dependency, level } => {
1255 let Some(found) = dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency)) else {
1256 return Ok(Some(format!("@{author}, this pull request does not update {dependency}.")));
1257 };
1258 let condition = match level {
1259 Some(level) => ignore(&found.name, Some(ranges::ignore_level(&found.to, level)), None),
1260 None => ignore(&found.name, None, None),
1261 };
1262 self.store.add_ignore(&repo.repo_id, &condition).await?;
1263 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())
1264 }
1265 UpdateCommand::Unignore { dependency, level } => {
1266 let condition = match (level, dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency))) {
1267 (Some(level), Some(found)) => Some(ranges::ignore_level(&found.to, level)),
1268 _ => None,
1269 };
1270 let removed = self.store.remove_ignores(&repo.repo_id, &row.ecosystem, dependency, condition.as_deref()).await?;
1271 if removed == 0 {
1272 format!("@{author}, nothing was being ignored for {dependency}.")
1273 } else {
1274 format!("@{author}, removed {removed} ignore {} for {dependency}.", if removed == 1 { "condition" } else { "conditions" })
1275 }
1276 }
1277 UpdateCommand::ShowIgnores { dependency } => {
1278 let names: Vec<String> = match dependency {
1279 Some(name) => vec![name.clone()],
1280 None => dependencies.iter().map(|d| d.name.clone()).collect(),
1281 };
1282 self.ignore_report(repo, &row.ecosystem, &row.entry, &names).await?
1283 }
1284 }))
1285 }
1286
1287 /// The ignore conditions on some dependencies, from the file and from
1288 /// comments, as a reply.
1289 async fn ignore_report(&self, repo: &RepoRow, ecosystem: &str, entry: &str, names: &[String]) -> Result<String> {
1290 let state = repo.version_updates();
1291 let rules = state.updates.iter().find(|found| found.id == entry).map(|found| found.ignore.clone()).unwrap_or_default();
1292 let comments = self.store.ignores(&repo.repo_id).await?;
1293 let mut lines = Vec::new();
1294 for name in names {
1295 for rule in rules.iter().filter(|rule| config::matches(&rule.dependency, name)) {
1296 let what = if !rule.versions.is_empty() {
1297 format!("versions {}", rule.versions.join(", "))
1298 } else if !rule.update_types.is_empty() {
1299 rule.update_types.join(", ")
1300 } else {
1301 "every version".to_owned()
1302 };
1303 lines.push(format!("- `{name}`: {what} (in the dependency update file, `{}`)", rule.dependency));
1304 }
1305 for condition in comments.iter().filter(|c| c.ecosystem == ecosystem && c.dependency.eq_ignore_ascii_case(name)) {
1306 let what = condition
1307 .versions
1308 .clone()
1309 .map(|versions| format!("versions `{versions}`"))
1310 .or_else(|| condition.update_type.clone())
1311 .unwrap_or_else(|| "every version".to_owned());
1312 lines.push(format!(
1313 "- `{name}`: {what} (asked by @{}{})",
1314 condition.by,
1315 condition.pull.map(|pull| format!(" in #{pull}")).unwrap_or_default()
1316 ));
1317 }
1318 }
1319 Ok(if lines.is_empty() {
1320 format!("Nothing is ignored for {}.", names.join(", "))
1321 } else {
1322 format!("Ignore conditions:\n\n{}", lines.join("\n"))
1323 })
1324 }
1325
1326 /// Commands on a security update for one package.
1327 async fn single_update_command(
1328 &self,
1329 repo: &RepoRow,
1330 update: Option<&crate::store::UpdateRow>,
1331 command: &UpdateCommand,
1332 author: &str,
1333 number: u32,
1334 detail: &PullDetail,
1335 ) -> Result<()> {
1336 let Some(update) = update else { return Ok(()) };
1337 let system = User::system(&repo.namespace);
1338 let path = Self::repo_path(repo);
1339 let Some(ecosystem) = package_ecosystem(&update.ecosystem) else { return Ok(()) };
1340 let ignore = |versions: Option<String>| IgnoreCondition {
1341 ecosystem: ecosystem.to_owned(),
1342 dependency: update.package.clone(),
1343 versions,
1344 update_type: None,
1345 by: author.to_owned(),
1346 pull: Some(number),
1347 at: String::new(),
1348 };
1349 let reply = match command {
1350 UpdateCommand::Close => {
1351 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1352 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a security update for {} {} again.", update.package, update.target)).await?;
1353 return Ok(());
1354 }
1355 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1356 let versions = match command {
1357 UpdateCommand::IgnoreVersion { level } => Some(ranges::ignore_level(&update.target, level)),
1358 _ => None,
1359 };
1360 self.store.add_ignore(&repo.repo_id, &ignore(versions)).await?;
1361 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1362 self.close_with(repo, number, format!("@{author}, g1t will skip that for {} from now on. `@g1t unignore {}` undoes it.", update.package, update.package)).await?;
1363 return Ok(());
1364 }
1365 UpdateCommand::Unignore { dependency, .. } => {
1366 let removed = self.store.remove_ignores(&repo.repo_id, ecosystem, dependency, None).await?;
1367 format!("@{author}, removed {removed} ignore conditions for {dependency}.")
1368 }
1369 UpdateCommand::ShowIgnores { dependency } => {
1370 let name = dependency.clone().unwrap_or_else(|| update.package.clone());
1371 self.ignore_report(repo, ecosystem, "", &[name]).await?
1372 }
1373 command @ (UpdateCommand::Merge | UpdateCommand::SquashAndMerge) => match checks_verdict(detail) {
1374 Some(true) => {
1375 let merged: Outcome<Pull> = g1t_kit::call(
1376 &self.work,
1377 "merge_pull",
1378 &PullActionArgs { actor: system.clone(), repo: path.clone(), number, summary: String::new(), keep_issue_open: false, ignore_checks: false, bypass_rules: false, merge_method: matches!(command, UpdateCommand::SquashAndMerge).then_some(g1t_contracts::rules::MergeMethod::Squash), commit_title: None, commit_message: None, expected_head: None, delete_branch: None, update_method: None },
1379 )
1380 .await?;
1381 match merged {
1382 Outcome::Ok(_) => return Ok(()),
1383 Outcome::Fail(refused) => format!("@{author}, I could not merge this: {}", refused.message),
1384 }
1385 }
1386 _ => format!("@{author}, its required checks have not passed yet; merge it once they do."),
1387 },
1388 _ => format!("@{author}, a security update is made again by `Re-scan now` on the Security page; this command is for version updates."),
1389 };
1390 self.comment(&system, &path, number, reply).await
1391 }
1392
1393 /// A pull request that changes the dependency update file gets a
1394 /// status saying whether the file is valid.
1395 pub async fn check_dependabot_file(&self, event: &Event) -> Result<()> {
1396 #[derive(Deserialize)]
1397 #[serde(rename_all = "camelCase")]
1398 struct Changed {
1399 repo_id: String,
1400 number: u32,
1401 }
1402 let Ok(changed) = serde_json::from_value::<Changed>(event.data.clone()) else { return Ok(()) };
1403 let Some(repo) = self.store.repo(&changed.repo_id).await? else { return Ok(()) };
1404 let Some(detail) = self.pull_detail(&repo, changed.number).await? else { return Ok(()) };
1405 let pull = &detail.pull;
1406 let Some(file) = pull.files.iter().find(|file| DEPENDABOT_PATHS.contains(&file.path.as_str())) else { return Ok(()) };
1407 let Some(head) = pull.head_commit.clone() else { return Ok(()) };
1408 let found: Option<RawFile> = g1t_kit::call(
1409 &self.repos,
1410 "raw_file",
1411 &RawFileArgs {
1412 repo_id: pull.source_id().to_owned(),
1413 git_ref: head.clone(),
1414 path: file.path.clone(),
1415 max_bytes: 1_000_000,
1416 },
1417 )
1418 .await?;
1419 let (state, description) = match found.and_then(|raw| base64_decode(&raw.data)).map(String::from_utf8) {
1420 None => ("success".to_owned(), format!("{} is removed.", file.path)),
1421 Some(Err(_)) => ("failure".to_owned(), format!("{} is not text.", file.path)),
1422 Some(Ok(text)) => {
1423 let read = config::read(&text);
1424 match read.problems.first() {
1425 None => ("success".to_owned(), format!("{} is valid: {} entries.", file.path, read.config.updates.len())),
1426 Some(problem) => {
1427 let more = if read.problems.len() > 1 { format!(" (and {} more)", read.problems.len() - 1) } else { String::new() };
1428 ("failure".to_owned(), format!("{}{more}", problem.sentence()))
1429 }
1430 }
1431 }
1432 };
1433 let site = format!("https://g1t.sh/{}/{}/blob/{head}/{}", repo.namespace, repo.name, file.path);
1434 let _: Outcome<bool> = g1t_kit::call(
1435 &self.work,
1436 "set_commit_status",
1437 &SetCommitStatusArgs {
1438 repo_id: repo.repo_id.clone(),
1439 sha: head,
1440 context: DEPENDABOT_CHECK.to_owned(),
1441 state,
1442 description: Some(description.chars().take(400).collect()),
1443 target_url: Some(site),
1444 source: Some("security".to_owned()),
1445 },
1446 )
1447 .await?;
1448 Ok(())
1449 }
1450
1451 /// What the Security page shows: the file as last read, with each
1452 /// entry's runs, the update pull requests, and comment ignores.
1453 pub async fn version_updates_view(&self, repo: &RepoRow) -> Result<VersionUpdatesState> {
1454 let mut state = repo.version_updates();
1455 let runs = self.store.runs(&repo.repo_id).await?;
1456 for entry in &mut state.updates {
1457 if let Some(run) = runs.iter().find(|run| run.entry == entry.id) {
1458 entry.next_run_at = run.next_run_at.clone();
1459 entry.last_checked_at = run.last_checked_at.clone();
1460 entry.last_result = run.last_result.clone();
1461 entry.last_error = run.last_error.clone();
1462 }
1463 }
1464 let rows = self.store.update_pulls(&repo.repo_id).await?;
1465 let (live, done): (Vec<&PullRow>, Vec<&PullRow>) = rows.iter().partition(|row| row.state().in_progress());
1466 state.pulls = live.into_iter().chain(done.into_iter().take(20)).map(PullRow::to_contract).collect();
1467 state.ignores = self.store.ignores(&repo.repo_id).await?;
1468 Ok(state)
1469 }
1470}
1471
1472/// Whether g1t runs an entry: an ecosystem it updates, and pull requests
1473/// it can open (`open-pull-requests-limit` above 0), into the default
1474/// branch or the entry's `target-branch`.
1475pub fn runnable(entry: &Entry, _default_branch: Option<&str>) -> bool {
1476 entry.supported() && entry.open_pull_requests_limit > 0 && entry.schedule.is_some()
1477}
1478
1479/// The branch an entry's updates start from and merge into, when it is not
1480/// the default branch.
1481pub fn target_of(entry: &Entry, default_branch: &str) -> Option<String> {
1482 entry.target_branch.clone().filter(|branch| !branch.is_empty() && branch != default_branch)
1483}
1484
1485fn updated(update: &Planned) -> UpdatedDependency {
1486 UpdatedDependency {
1487 name: update.name.clone(),
1488 from: update.from.clone(),
1489 to: update.to.clone(),
1490 directory: update.directory.clone(),
1491 dependency_type: update.kind.label().to_owned(),
1492 update_type: format!("version-update:semver-{}", update.level),
1493 }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498 use super::*;
1499 use crate::config::read;
1500 use g1t_contracts::work::{CommitStatus, RequiredCheck};
1501 use serde_json::json;
1502
1503 fn entry(extra: &str) -> Entry {
1504 let found = read(&format!("version: 2\nupdates:\n - package-ecosystem: npm\n{extra} schedule: {{interval: daily}}\n"));
1505 assert!(found.problems.is_empty(), "{:?}", found.problems);
1506 found.config.updates.into_iter().next().unwrap()
1507 }
1508
1509 #[test]
1510 fn base64_round_trips() {
1511 for text in ["", "a", "ab", "abc", "user:pa$$word", "version: 2\n"] {
1512 assert_eq!(base64_decode(&base64_encode(text.as_bytes())).unwrap(), text.as_bytes());
1513 }
1514 assert_eq!(base64_encode(b"ci:secret"), "Y2k6c2VjcmV0");
1515 }
1516
1517 #[test]
1518 fn secrets_are_filled_in() {
1519 let mut secrets = serde_json::Map::new();
1520 secrets.insert("TOKEN".into(), json!("t0k"));
1521 assert_eq!(fill_secrets("${{secrets.TOKEN}}", &secrets), ("t0k".to_owned(), vec![]));
1522 assert_eq!(fill_secrets("Bearer ${{ secrets.TOKEN }}!", &secrets).0, "Bearer t0k!");
1523 assert_eq!(fill_secrets("${{secrets.NOPE}}", &secrets).1, ["NOPE"]);
1524 assert_eq!(fill_secrets("plain", &secrets).0, "plain");
1525 }
1526
1527 #[test]
1528 fn directories_from_globs_and_exclusions() {
1529 let files: Vec<String> = ["package.json", "apps/web/package.json", "apps/admin/package.json", "apps/web/vendor/x/package.json", "docs/readme.md"]
1530 .map(str::to_owned)
1531 .to_vec();
1532 assert_eq!(entry_directories(&entry(" directory: /\n"), &files), ["/"]);
1533 assert_eq!(entry_directories(&entry(" directories: [\"/apps/*\"]\n"), &files), ["/apps/admin", "/apps/web"]);
1534 assert_eq!(
1535 entry_directories(&entry(" directories: [\"/apps/**\"]\n exclude-paths: [\"**/vendor/**\"]\n"), &files),
1536 ["/apps/admin", "/apps/web"]
1537 );
1538 assert!(entry_directories(&entry(" directory: /missing\n"), &files).is_empty());
1539 let locks: Vec<String> = ["package-lock.json", "apps/web/package.json", "Cargo.lock"].map(str::to_owned).to_vec();
1540 assert_eq!(lockfiles_for("npm", "/apps/web", &locks), ["package-lock.json"]);
1541 assert!(lockfiles_for("pip", "/", &locks).is_empty());
1542 }
1543
1544 #[test]
1545 fn current_versions_come_from_the_lockfile() {
1546 let directory = Directory {
1547 path: "/".into(),
1548 declared: manifests::package_json(r#"{"dependencies":{"lodash":"^4.17.0","left-pad":"1.0.0"},"devDependencies":{"vitest":"^1"}}"#),
1549 locked: BTreeMap::from([
1550 ("lodash".to_owned(), vec!["4.17.20".to_owned(), "3.10.1".to_owned()]),
1551 ("minimist".to_owned(), vec!["1.2.0".to_owned()]),
1552 ]),
1553 lockfiles: vec!["package-lock.json".into()],
1554 };
1555 let found = directory_candidates(&entry(" directory: /\n"), &directory);
1556 assert_eq!(found, [("lodash".to_owned(), DependencyType::Production, "4.17.20".to_owned(), Some("^4.17.0".to_owned()))]);
1557 let go = Directory {
1558 path: "/".into(),
1559 declared: manifests::go_mod("require golang.org/x/net v0.7.0\n"),
1560 locked: BTreeMap::new(),
1561 lockfiles: vec!["go.mod".into()],
1562 };
1563 let mut go_entry = entry(" directory: /\n");
1564 go_entry.ecosystem = "gomod".into();
1565 let found = directory_candidates(&go_entry, &go);
1566 assert_eq!(found[0].2, "v0.7.0");
1567 }
1568
1569 fn row(subject: &str, signature: &str, state: &str) -> PullRow {
1570 PullRow {
1571 id: format!("upd_{subject}_{state}"),
1572 repo_id: "rep_1".into(),
1573 kind: "version".into(),
1574 entry: "npm:/".into(),
1575 ecosystem: "npm".into(),
1576 subject: subject.into(),
1577 signature: signature.into(),
1578 group_name: None,
1579 branch: "g1t/npm_and_yarn/x".into(),
1580 title: String::new(),
1581 body: String::new(),
1582 dependencies: "[]".into(),
1583 bump: "{}".into(),
1584 assignees: "[]".into(),
1585 reviewers: "[]".into(),
1586 state: state.into(),
1587 pull: Some(3),
1588 issue: None,
1589 head: None,
1590 merge_by: None,
1591 error: None,
1592 updated_at: String::new(),
1593 }
1594 }
1595
1596 fn plan(name: &str, to: &str) -> PullPlan {
1597 PullPlan {
1598 group: None,
1599 by_name: false,
1600 updates: vec![Planned {
1601 name: name.into(),
1602 directory: "/".into(),
1603 kind: DependencyType::Production,
1604 from: "1.0.0".into(),
1605 to: to.into(),
1606 level: "minor",
1607 source: None,
1608 changelog: None,
1609 page: None,
1610 }],
1611 }
1612 }
1613
1614 #[test]
1615 fn the_limit_counts_open_pull_requests_and_replacements_do_not() {
1616 let plans = vec![plan("a", "1.1.0"), plan("b", "1.1.0"), plan("c", "1.1.0"), plan("d", "1.1.0")];
1617 let existing = vec![
1618 row("dependency:/:a", "/a@1.0.5", "open"),
1619 row("dependency:/:b", "/b@1.1.0", "open"),
1620 row("dependency:/:c", "/c@1.1.0", "closed"),
1621 ];
1622 let (admitted, held) = admit(&plans, &existing, 3);
1623 let names: Vec<(&str, bool)> = admitted.iter().map(|(plan, older)| (plan.updates[0].name.as_str(), older.is_some())).collect();
1624 // a replaces its older pull request; b is open already; c was closed by a person for this version; d is new, and fits.
1625 assert_eq!(names, [("a", true), ("d", false)]);
1626 assert_eq!(held, 0);
1627 let (admitted, held) = admit(&plans, &existing, 2);
1628 assert_eq!(admitted.len(), 1);
1629 assert_eq!(held, 1);
1630 }
1631
1632 #[test]
1633 fn checks_decide_failure_and_success() {
1634 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
1635 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 };
1636 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
1637 let mut detail: PullDetail = serde_json::from_value(json!({
1638 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
1639 "status": "open", "fork": null, "forkRepoId": null, "branch": "b", "headCommit": "c", "mergeBase": null, "mergedBy": null,
1640 "mergedAt": null, "supersededBy": null, "checkStatus": null,
1641 "author": {"id": "g1t", "username": "g1t", "kind": "system"}, "createdAt": "", "updatedAt": ""},
1642 "issue": null, "comments": [], "checks": null
1643 }))
1644 .unwrap();
1645 detail.required_checks = required;
1646 detail.statuses = statuses;
1647 detail
1648 };
1649 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success)], vec![])), Some(true));
1650 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success), check(RequiredState::Failure)], vec![])), Some(false));
1651 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Pending)], vec![])), None);
1652 assert_eq!(checks_verdict(&detail(vec![], vec![status("error")])), Some(false));
1653 assert_eq!(checks_verdict(&detail(vec![], vec![status("pending")])), None);
1654 assert_eq!(checks_verdict(&detail(vec![], vec![])), Some(true));
1655 }
1656
1657 #[test]
1658 fn which_entries_run() {
1659 assert!(runnable(&entry(" directory: /\n"), Some("main")));
1660 assert!(!runnable(&entry(" directory: /\n open-pull-requests-limit: 0\n"), Some("main")));
1661 assert!(runnable(&entry(" directory: /\n target-branch: main\n"), Some("main")));
1662 assert!(runnable(&entry(" directory: /\n target-branch: develop\n"), Some("main")));
1663 assert_eq!(target_of(&entry(" directory: /\n target-branch: develop\n"), "main").as_deref(), Some("develop"));
1664 assert_eq!(target_of(&entry(" directory: /\n target-branch: main\n"), "main"), None);
1665 assert_eq!(target_of(&entry(" directory: /\n"), "main"), None);
1666 assert_eq!(osv_ecosystem("gomod"), Some("Go"));
1667 assert_eq!(package_ecosystem("crates.io"), Some("cargo"));
1668 }
1669}