Skip to content
1,650 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::{
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.38 Mergeable, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, RequiredState, Runtime, SetCommitStatusArgs,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar39 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;
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.46use serde_json::Value;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar47use 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>> {
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.602 // Archived, deleted, or a mirror that is not taken over: nothing
603 // opens here on its own (`g1t_contracts::mirrors`).
604 if let Err(why) = self.takes_updates(repo).await? {
605 return Ok(Err(why.replace("Security updates wait", "Version updates wait")));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar606 }
607 if entry.open_pull_requests_limit == 0 {
608 return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned()));
609 }
610 let listed: FileList = g1t_kit::call(
611 &self.repos,
612 "list_files",
613 &ListFilesArgs {
614 repo_id: repo.repo_id.clone(),
615 // The branch its updates are for: `target-branch`, or the default.
616 git_ref: target_of(entry, &loaded.default_branch),
617 skip_dirs: SKIPPED_DIRECTORIES.iter().map(|dir| (*dir).to_owned()).collect(),
618 limit: g1t_contracts::repos::MAX_LISTED_FILES,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.619 links: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar620 },
621 )
622 .await?;
623 let paths: Vec<String> = listed.files.iter().filter(|file| file.hash.is_some()).map(|file| file.path.clone()).collect();
624 let hash_of = |path: &str| listed.files.iter().find(|file| file.path == path).and_then(|file| file.hash.clone());
625 let directories = entry_directories(entry, &paths);
626 if directories.is_empty() {
627 return Ok(Err(format!("No {} manifest was found in {}.", entry.ecosystem, entry.directories.join(", "))));
628 }
629 // Each directory's manifests and lockfiles, read at once.
630 let mut wanted: BTreeMap<String, String> = BTreeMap::new();
631 let mut plan: Vec<(String, Vec<String>, Vec<String>)> = Vec::new();
632 for directory in &directories {
633 let base = directory.trim_start_matches('/');
634 let manifests: Vec<String> = manifests::manifest_names(&entry.ecosystem)
635 .iter()
636 .map(|name| if base.is_empty() { (*name).to_owned() } else { format!("{base}/{name}") })
637 .filter(|path| paths.contains(path))
638 .collect();
639 let lockfiles = lockfiles_for(&entry.ecosystem, directory, &paths);
640 for path in manifests.iter().chain(&lockfiles) {
641 if let Some(hash) = hash_of(path) {
642 wanted.insert(path.clone(), hash);
643 }
644 }
645 plan.push((directory.clone(), manifests, lockfiles));
646 }
647 let mut texts: BTreeMap<String, String> = BTreeMap::new();
648 let entries: Vec<(String, String)> = wanted.into_iter().collect();
649 for chunk in entries.chunks(g1t_contracts::repos::MAX_READ_BLOBS) {
650 let blobs: Vec<BlobText> = g1t_kit::call(
651 &self.repos,
652 "read_blobs",
653 &ReadBlobsArgs { repo_id: repo.repo_id.clone(), hashes: chunk.iter().map(|(_, hash)| hash.clone()).collect(), max_bytes: MAX_BLOB_BYTES },
654 )
655 .await?;
656 for ((path, _), blob) in chunk.iter().zip(blobs) {
657 if let Some(text) = blob.text {
658 texts.insert(path.clone(), text);
659 }
660 }
661 }
662 let mut read: Vec<Directory> = Vec::new();
663 for (path, manifest_paths, lockfiles) in plan {
664 let mut declared = Vec::new();
665 for manifest in &manifest_paths {
666 let name = manifest.rsplit('/').next().unwrap_or(manifest);
667 // A pinned requirements.txt is also read as a lockfile.
668 declared.extend(texts.get(manifest).map(|text| manifests::declared(name, text)).unwrap_or_default());
669 }
670 let mut locked: BTreeMap<String, Vec<String>> = BTreeMap::new();
671 for lockfile in &lockfiles {
672 let Some(kind) = Lockfile::for_path(lockfile) else { continue };
673 for package in texts.get(lockfile).map(|text| kind.parse(text)).unwrap_or_default() {
674 locked.entry(normalize(&entry.ecosystem, &package.name)).or_default().push(package.version);
675 }
676 }
677 read.push(Directory { path, declared, locked, lockfiles });
678 }
679 let (registries, mut notes) = self.entry_registries(repo, loaded, entry).await?;
680 let comments = self.store.ignores(&repo.repo_id).await?;
681 // Every dependency to ask about, once.
682 let mut candidates: Vec<(String, String, DependencyType, String, Option<String>)> = Vec::new();
683 for directory in &read {
684 for (name, kind, current, requirement) in directory_candidates(entry, directory) {
685 if planning::allowed(entry, &name, kind) {
686 candidates.push((directory.path.clone(), name, kind, current, requirement));
687 }
688 }
689 }
690 let names: Vec<(String, String)> = {
691 let mut seen = BTreeMap::new();
692 for (_, name, _, current, _) in &candidates {
693 let lowest = seen.entry(name.clone()).or_insert_with(|| current.clone());
694 if version::compare(current, lowest).is_lt() {
695 *lowest = current.clone();
696 }
697 }
698 seen.into_iter().collect()
699 };
700 if names.len() > MAX_PACKAGES {
701 notes.push(format!("Only the first {MAX_PACKAGES} of {} dependencies were checked this time.", names.len()));
702 }
703 let mut packages: BTreeMap<String, Package> = BTreeMap::new();
704 let mut failures = Vec::new();
705 for chunk in names.iter().take(MAX_PACKAGES).collect::<Vec<_>>().chunks(FETCH_AT_ONCE) {
706 let asks = chunk.iter().map(|(name, current)| self.package(&entry.ecosystem, name, current, &registries));
707 for ((name, _), found) in chunk.iter().zip(futures_util::future::join_all(asks).await) {
708 match found {
709 Ok(Some(package)) => {
710 packages.insert(name.clone(), package);
711 }
712 Ok(None) => {}
713 Err(error) => failures.push(format!("{name} ({error})")),
714 }
715 }
716 }
717 let now = now_ms();
718 let mut planned = Vec::new();
719 let mut up_to_date = 0;
720 let mut ignored = 0;
721 for (directory, name, kind, current, requirement) in candidates {
722 let Some(package) = packages.get(&name) else { continue };
723 let candidate = Candidate { name, directory, kind, current, requirement, package: package.clone() };
724 match planning::target(entry, &comments, &candidate, now) {
725 Ok(update) => planned.push(update),
726 Err(Skip::UpToDate) => up_to_date += 1,
727 Err(Skip::Ignored) => ignored += 1,
728 Err(Skip::NotAllowed) => {}
729 }
730 }
731 let plans = planning::gather(&entry.groups, "version-updates", planned);
732 let existing: Vec<PullRow> = self
733 .store
734 .update_pulls(&repo.repo_id)
735 .await?
736 .into_iter()
737 .filter(|row| row.kind == "version" && row.entry == entry.id())
738 .collect();
739 let (admitted, held) = admit(&plans, &existing, entry.open_pull_requests_limit);
740 let lockfiles_of = |plan: &PullPlan| -> Vec<String> {
741 let mut all: Vec<String> = read
742 .iter()
743 .filter(|directory| plan.directories().contains(&directory.path))
744 .flat_map(|directory| directory.lockfiles.clone())
745 .collect();
746 all.sort();
747 all.dedup();
748 all
749 };
750 let mut asked = 0;
751 for (plan, _) in &admitted {
752 let lockfiles = lockfiles_of(plan);
753 if lockfiles.is_empty() {
754 notes.push(format!("{} has no lockfile, so it was not updated.", plan.directories().join(", ")));
755 continue;
756 }
757 // The same branch as one in progress is replaced in place.
758 let branch = pull_text::branch(entry, plan);
759 let force = existing.iter().any(|row| row.branch == branch && row.state().in_progress());
760 self.ask_version_update(repo, loaded, entry, plan, lockfiles, &registries, force).await?;
761 asked += 1;
762 }
763 let mut summary = format!(
764 "Checked {} {}: {} up to date{}, {} {} asked for",
765 names.len().min(MAX_PACKAGES),
766 if names.len() == 1 { "dependency" } else { "dependencies" },
767 up_to_date,
768 if ignored > 0 { format!(", {ignored} ignored") } else { String::new() },
769 asked,
770 if asked == 1 { "pull request" } else { "pull requests" },
771 );
772 if held > 0 {
773 summary.push_str(&format!(", {held} waiting for open-pull-requests-limit"));
774 }
775 summary.push('.');
776 if !failures.is_empty() {
777 notes.push(format!("These could not be looked up: {}.", failures.join(", ")));
778 }
779 for note in notes {
780 summary.push(' ');
781 summary.push_str(&note);
782 }
783 Ok(Ok(summary))
784 }
785
786 /// Asks the runner for one version update pull request.
787 #[allow(clippy::too_many_arguments)]
788 async fn ask_version_update(
789 &self,
790 repo: &RepoRow,
791 loaded: &Loaded,
792 entry: &Entry,
793 plan: &PullPlan,
794 lockfiles: Vec<String>,
795 registries: &[(Registry, BumpRegistry, Source)],
796 force: bool,
797 ) -> Result<()> {
798 let Some(osv) = osv_ecosystem(&entry.ecosystem) else { return Ok(()) };
799 let branch = pull_text::branch(entry, plan);
800 let title = pull_text::title(entry, plan);
801 let body = pull_text::body(entry, plan, &loaded.file);
802 // One version per package: the highest any directory needs.
803 let mut highest: BTreeMap<String, String> = BTreeMap::new();
804 for update in &plan.updates {
805 let known = highest.entry(update.name.clone()).or_insert_with(|| update.to.clone());
806 if version::compare(&update.to, known).is_gt() {
807 *known = update.to.clone();
808 }
809 }
810 let packages: Vec<BumpPackage> = highest.into_iter().map(|(package, version)| BumpPackage { package, version }).collect();
811 let strategy = match entry.versioning_strategy.as_deref() {
812 Some("lockfile-only") => "lockfile-only",
813 Some("increase-if-necessary") => "increase-if-necessary",
814 Some("widen") => "widen",
815 _ => "increase",
816 };
817 let mut bump = BumpArgs {
818 repo: Self::repo_path(repo),
819 ecosystem: osv.to_owned(),
820 package: packages[0].package.clone(),
821 version: packages[0].version.clone(),
822 lockfiles,
823 branch: branch.clone(),
824 message: pull_text::commit_message(entry, plan),
825 kind: Some("version".to_owned()),
826 packages,
827 strategy: Some(strategy.to_owned()),
828 force,
829 registries: Vec::new(),
830 base: target_of(entry, &loaded.default_branch),
831 };
832 let dependencies: Vec<UpdatedDependency> = plan.updates.iter().map(updated).collect();
833 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
834 let id = self
835 .store
836 .add_update_pull(&NewPull {
837 repo_id: &repo.repo_id,
838 kind: "version",
839 entry: &entry.id(),
840 ecosystem: &entry.ecosystem,
841 subject: &plan.subject(),
842 signature: &plan.signature(),
843 group: plan.group.as_deref(),
844 branch: &branch,
845 title: &title,
846 body: &body,
847 dependencies: &dependencies,
848 bump: &bump,
849 assignees: &people(&entry.assignees),
850 reviewers: &people(&entry.reviewers),
851 })
852 .await?;
853 bump.registries = registries.iter().map(|(_, ready, _)| ready.clone()).collect();
854 if let Err(reason) = self.start_bump(&bump).await {
855 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the update: {reason}"))).await?;
856 }
857 Ok(())
858 }
859
860 async fn start_bump(&self, bump: &BumpArgs) -> std::result::Result<(), String> {
861 match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", bump).await {
862 Ok(Outcome::Ok(_)) => Ok(()),
863 Ok(Outcome::Fail(refused)) => Err(refused.message),
864 Err(error) => Err(format!("the runner could not be reached: {error}")),
865 }
866 }
867
868 /// Makes an open update pull request again from its base, on the same
869 /// branch: a rebase, a recreate, or a reopen.
870 async fn remake(&self, repo: &RepoRow, row: &PullRow) -> std::result::Result<(), String> {
871 let Some(mut bump) = row.bump() else { return Err("it was made before g1t kept how".to_owned()) };
872 bump.force = true;
873 bump.registries.clear();
874 // Credentials are never stored, and g1t's own push is awaited: see
875 // `update_pull_pushed`.
876 self.store.remake_update_pull(&row.id, &bump).await.map_err(|error| error.to_string())?;
877 if row.kind == "version"
878 && let Ok(Some(loaded)) = self.load_config(repo).await
879 && let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == row.entry)
880 && let Ok((registries, _)) = self.entry_registries(repo, &loaded, entry).await
881 {
882 bump.registries = registries.into_iter().map(|(_, ready, _)| ready).collect();
883 }
884 self.start_bump(&bump).await
885 }
886
887 /// An update branch was pushed: its pull request opens, or a remade one
888 /// carries on. Returns whether the branch was one of these.
889 pub async fn update_pull_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<bool> {
890 let Some(row) = self.store.update_pull_by_branch(repo_id, branch).await? else { return Ok(false) };
891 match row.state() {
892 UpdateState::Open => {
893 // g1t's own push after a rebase leaves `head` empty; anyone
894 // else's leaves it, so a rebase knows not to overwrite them.
895 if row.head.is_none() {
896 self.store.set_update_pull_head(&row.id, after).await?;
897 }
898 Ok(true)
899 }
900 UpdateState::Requested => {
901 let Some(repo) = self.store.repo(repo_id).await? else { return Ok(true) };
902 self.open_update_pull(&repo, &row, after).await?;
903 Ok(true)
904 }
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches905 // No longer needed by the time its sandbox pushed: the branch goes.
906 UpdateState::Superseded if row.pull().is_none() => {
907 self.drop_pushed(repo_id, branch, after).await?;
908 Ok(true)
909 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar910 _ => Ok(true),
911 }
912 }
913
914 async fn open_update_pull(&self, repo: &RepoRow, row: &PullRow, after: &str) -> Result<()> {
915 let system = User::system(&repo.namespace);
916 let opened: Outcome<Pull> = g1t_kit::call(
917 &self.work,
918 "open_pull",
919 &OpenPullArgs {
920 actor: system.clone(),
921 repo: Self::repo_path(repo),
922 issue: None,
923 title: row.title.clone(),
924 body: row.body.clone(),
925 branch: Some(row.branch.clone()),
926 agent: String::new(),
927 runtime: Runtime::External,
928 // Into `target-branch`, which it was made from.
929 base: row.bump().and_then(|bump| bump.base),
The API opens a pull request as a draft with "draft": true930 draft: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar931 },
932 )
933 .await?;
934 let pull = match opened {
935 Outcome::Ok(pull) => pull,
936 Outcome::Fail(refused) => {
937 let error = format!("The pull request could not be opened: {}", refused.message);
938 return self.store.set_update_pull(&row.id, UpdateState::Failed, None, None, Some(&error)).await;
939 }
940 };
941 self.store.set_update_pull(&row.id, UpdateState::Open, Some(pull.number), None, None).await?;
942 self.store.set_update_pull_head(&row.id, after).await?;
943 let (assignees, reviewers) = (row.names("assignees"), row.names("reviewers"));
944 // Its labels and milestone, as the entry says now.
945 let entry = match self.load_config(repo).await {
946 Ok(Some(loaded)) => loaded.config.updates.into_iter().find(|entry| entry.id() == row.entry),
947 _ => None,
948 };
949 let labels = config::update_labels(entry.as_ref().and_then(|entry| entry.labels.as_deref()), &row.ecosystem);
950 let milestone = entry.as_ref().and_then(|entry| entry.milestone);
951 let _: Outcome<Value> = g1t_kit::call(
952 &self.work,
953 "update_pull",
954 &UpdatePullArgs {
955 actor: system.clone(),
956 repo: Self::repo_path(repo),
957 number: pull.number,
958 assignees: (!assignees.is_empty()).then_some(assignees),
959 reviewers: (!reviewers.is_empty()).then_some(reviewers),
960 labels: (!labels.is_empty()).then_some(labels),
961 milestone: None,
962 base: None,
963 },
964 )
965 .await?;
966 // Apart, so that a milestone the repository lacks leaves the rest.
967 if milestone.is_some() {
968 let _: Outcome<Value> = g1t_kit::call(
969 &self.work,
970 "update_pull",
971 &UpdatePullArgs {
972 actor: system.clone(),
973 repo: Self::repo_path(repo),
974 number: pull.number,
975 assignees: None,
976 reviewers: None,
977 labels: None,
978 milestone,
979 base: None,
980 },
981 )
982 .await?;
983 }
984 // Older ones for the same dependency or group are replaced.
985 for older in self.store.update_pulls(&repo.repo_id).await? {
986 if older.id == row.id || older.subject != row.subject || older.entry != row.entry || older.state() != UpdateState::Open {
987 continue;
988 }
989 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 branches990 if let Some(number) = older.pull()
991 && let Some(found) = self.get_pull(repo, number).await?
992 && matches!(found.status, PullStatus::Open | PullStatus::Draft)
993 && self.close_with(repo, number, format!("Closed: superseded by #{}.", pull.number)).await?
994 && older.branch != row.branch
995 {
996 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 bar997 }
998 }
999 // A grouped security update stands for each package's own.
1000 if row.kind == "security" {
1001 for dependency in row.dependencies() {
1002 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1003 if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? {
1004 self.store.set_update(&update, UpdateState::Open, Some(pull.number), None, None).await?;
1005 }
1006 }
1007 }
1008 Ok(())
1009 }
1010
1011 /// A pull request merged or closed: if it is an update's, where it
1012 /// stands now. Returns whether it was one.
1013 pub async fn update_pull_closed(&self, kind: &str, repo_id: &str, number: u32) -> Result<bool> {
1014 let Some(row) = self.store.update_pull_by_number(repo_id, number).await? else { return Ok(false) };
1015 if row.state() != UpdateState::Open {
1016 return Ok(true);
1017 }
1018 let state = if kind == "pull.merged" { UpdateState::Merged } else { UpdateState::Closed };
1019 self.store.set_update_pull(&row.id, state, Some(number), None, None).await?;
1020 if row.kind == "security" {
1021 for dependency in row.dependencies() {
1022 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1023 if let Some(update) = self.store.update(repo_id, osv, &dependency.name).await? {
1024 self.store.set_update(&update, state, Some(number), None, None).await?;
1025 }
1026 }
1027 }
1028 Ok(true)
1029 }
1030
1031 async fn pull_detail(&self, repo: &RepoRow, number: u32) -> Result<Option<PullDetail>> {
1032 let found: Outcome<PullDetail> = g1t_kit::call(
1033 &self.work,
1034 "get_pull",
1035 &ViewArgs { repo: Self::repo_path(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
1036 )
1037 .await?;
1038 Ok(found.into_result().ok())
1039 }
1040
1041 /// Looks at one open update pull request: merged or closed meanwhile,
1042 /// its checks failing or passing, or in conflict with its base.
1043 async fn watch_update_pull(&self, row: &PullRow) -> Result<()> {
1044 self.store.touch_update_pull(&row.id).await?;
1045 let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.pull()) else { return Ok(()) };
1046 let Some(detail) = self.pull_detail(&repo, number).await? else { return Ok(()) };
1047 match detail.pull.status {
1048 PullStatus::Merged => return self.update_pull_closed("pull.merged", &row.repo_id, number).await.map(|_| ()),
1049 PullStatus::Closed => return self.update_pull_closed("pull.closed", &row.repo_id, number).await.map(|_| ()),
1050 _ => {}
1051 }
1052 let ours = row.head.is_some() && row.head == detail.pull.head_commit;
1053 match checks_verdict(&detail) {
1054 Some(true) if row.merge_by.is_some() => {
1055 let merged: Outcome<Pull> = g1t_kit::call(
1056 &self.work,
1057 "merge_pull",
1058 &PullActionArgs {
1059 actor: User::system(&repo.namespace),
1060 repo: Self::repo_path(&repo),
1061 number,
1062 summary: String::new(),
1063 keep_issue_open: false,
1064 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1065 bypass_rules: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1066 },
1067 )
1068 .await?;
1069 if let Outcome::Fail(refused) = merged {
1070 self.store.set_merge_by(&row.id, None).await?;
1071 self.comment(&User::system(&repo.namespace), &Self::repo_path(&repo), number, format!("I could not merge this: {}", refused.message))
1072 .await?;
1073 }
1074 return Ok(());
1075 }
1076 _ => {}
1077 }
1078 let rebases = row.kind == "security" || self.rebase_strategy(&repo, &row.entry) != "disabled";
1079 if detail.mergeable == Mergeable::Conflicting
1080 && ours
1081 && rebases
1082 && let Err(reason) = self.remake(&repo, row).await
1083 {
1084 worker::console_error!("security: update {} not rebased: {reason}", row.id);
1085 }
1086 Ok(())
1087 }
1088
1089 /// An entry's `rebase-strategy`, as last read.
1090 fn rebase_strategy(&self, repo: &RepoRow, entry: &str) -> String {
1091 repo.version_updates()
1092 .updates
1093 .iter()
1094 .find(|found| found.id == entry)
1095 .and_then(|found| found.options.get("rebase-strategy").and_then(Value::as_str).map(str::to_owned))
1096 .unwrap_or_else(|| "auto".to_owned())
1097 }
1098
1099 /// A comment on an update pull request: a command, if it is one.
1100 pub async fn update_comment(&self, event: &Event) -> Result<()> {
1101 #[derive(Deserialize)]
1102 #[serde(rename_all = "camelCase")]
1103 struct Commented {
1104 comment_id: String,
1105 repo_id: String,
1106 number: u32,
1107 #[serde(default)]
1108 pull_id: Option<String>,
1109 }
1110 let Ok(commented) = serde_json::from_value::<Commented>(event.data.clone()) else { return Ok(()) };
1111 if commented.pull_id.is_none() || event.actor.as_deref().is_some_and(g1t_contracts::system::is_system_id) {
1112 return Ok(());
1113 }
1114 let row = self.store.update_pull_by_number(&commented.repo_id, commented.number).await?;
1115 let single = self.store.update_by_pull(&commented.repo_id, commented.number).await?;
1116 if row.is_none() && single.is_none() {
1117 return Ok(());
1118 }
1119 let Some(repo) = self.store.repo(&commented.repo_id).await? else { return Ok(()) };
1120 let Some(detail) = self.pull_detail(&repo, commented.number).await? else { return Ok(()) };
1121 let Some(comment) = detail.comments.iter().find(|comment| comment.id == commented.comment_id) else { return Ok(()) };
1122 let Some(command) = update_command(&comment.body) else { return Ok(()) };
1123 let author = serde_json::from_value::<CommentAuthor>(serde_json::to_value(&comment.author)?).map(|a| a.username).unwrap_or_default();
1124 let system = User::system(&repo.namespace);
1125 let path = Self::repo_path(&repo);
1126 let needed = if matches!(command, UpdateCommand::Merge | UpdateCommand::SquashAndMerge | UpdateCommand::CancelMerge) {
1127 Capability::Merge
1128 } else {
1129 Capability::Push
1130 };
1131 let permission: Outcome<PermissionInfo> = g1t_kit::call(
1132 &self.identity,
1133 "collaborator_permission",
1134 &CollaboratorPermissionArgs { viewer: Some(system.clone()), path: path.clone(), username: author.clone() },
1135 )
1136 .await?;
1137 let allowed = matches!(&permission, Outcome::Ok(info) if info.capabilities.contains(&needed));
1138 if !allowed {
1139 let text = format!("@{author}, that takes the {} role on this repository.", if needed == Capability::Merge { "Maintain or Write" } else { "Write" });
1140 return self.comment(&system, &path, commented.number, text).await;
1141 }
1142 let Some(row) = row else {
1143 // A security update for one package: the commands that make sense for it.
1144 return self.single_update_command(&repo, single.as_ref(), &command, &author, commented.number, &detail).await;
1145 };
1146 let reply = self.command(&repo, &row, &command, &author, &detail).await?;
1147 if let Some(reply) = reply {
1148 self.comment(&system, &path, commented.number, reply).await?;
1149 }
1150 Ok(())
1151 }
1152
1153 /// Acts on a command on an update pull request; what to say back.
1154 async fn command(&self, repo: &RepoRow, row: &PullRow, command: &UpdateCommand, author: &str, detail: &PullDetail) -> Result<Option<String>> {
1155 let number = detail.pull.number;
1156 let open = detail.pull.status.is_active();
1157 let ignore = |dependency: &str, versions: Option<String>, update_type: Option<String>| IgnoreCondition {
1158 ecosystem: row.ecosystem.clone(),
1159 dependency: dependency.to_owned(),
1160 versions,
1161 update_type,
1162 by: author.to_owned(),
1163 pull: Some(number),
1164 at: String::new(),
1165 };
1166 let dependencies = row.dependencies();
1167 Ok(Some(match command {
1168 UpdateCommand::Rebase | UpdateCommand::Recreate => {
1169 if !open {
1170 return Ok(Some(format!("@{author}, this pull request is {}; `@g1t reopen` opens it again.", detail.pull.status.as_str())));
1171 }
1172 let pushed_by_others = row.head.is_some() && row.head != detail.pull.head_commit;
1173 if *command == UpdateCommand::Rebase && pushed_by_others {
1174 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.")));
1175 }
1176 match self.remake(repo, row).await {
1177 Ok(()) => format!("@{author}, {} this pull request from its base branch now.", if *command == UpdateCommand::Rebase { "rebasing" } else { "recreating" }),
1178 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1179 }
1180 }
1181 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => {
1182 if !open {
1183 return Ok(Some(format!("@{author}, this pull request is already {}.", detail.pull.status.as_str())));
1184 }
1185 self.store.set_merge_by(&row.id, Some(author)).await?;
1186 match checks_verdict(detail) {
1187 Some(false) => format!("@{author}, its required checks are failing; it merges once they pass."),
1188 Some(true) => {
1189 self.watch_update_pull(row).await?;
1190 return Ok(None);
1191 }
1192 None => format!("@{author}, it merges once its required checks pass."),
1193 }
1194 }
1195 UpdateCommand::CancelMerge => {
1196 self.store.set_merge_by(&row.id, None).await?;
1197 format!("@{author}, it will not merge on its own now.")
1198 }
1199 UpdateCommand::Close => {
1200 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1201 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?;
1202 return Ok(None);
1203 }
1204 UpdateCommand::Reopen => {
1205 if open {
1206 return Ok(Some(format!("@{author}, this pull request is already open.")));
1207 }
1208 self.store.set_update_pull(&row.id, UpdateState::Requested, None, None, None).await?;
1209 match self.remake(repo, row).await {
1210 Ok(()) => format!("@{author}, making it again; it opens as a new pull request on the same branch."),
1211 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1212 }
1213 }
1214 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1215 if row.group_name.is_some() && dependencies.len() > 1 {
1216 return Ok(Some(format!(
1217 "@{author}, this pull request updates several dependencies. Name one: `@g1t ignore <dependency>`, or `@g1t ignore <dependency> major version`."
1218 )));
1219 }
1220 for dependency in &dependencies {
1221 let condition = match command {
1222 UpdateCommand::IgnoreVersion { level } => ignore(&dependency.name, Some(ranges::ignore_level(&dependency.to, level)), None),
1223 _ => ignore(&dependency.name, None, None),
1224 };
1225 self.store.add_ignore(&repo.repo_id, &condition).await?;
1226 }
1227 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1228 let what = match command {
1229 UpdateCommand::IgnoreVersion { level } => format!("this {level} version"),
1230 _ => "this dependency".to_owned(),
1231 };
1232 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("*")))
1233 .await?;
1234 return Ok(None);
1235 }
1236 UpdateCommand::IgnoreNamed { dependency, level } => {
1237 let Some(found) = dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency)) else {
1238 return Ok(Some(format!("@{author}, this pull request does not update {dependency}.")));
1239 };
1240 let condition = match level {
1241 Some(level) => ignore(&found.name, Some(ranges::ignore_level(&found.to, level)), None),
1242 None => ignore(&found.name, None, None),
1243 };
1244 self.store.add_ignore(&repo.repo_id, &condition).await?;
1245 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())
1246 }
1247 UpdateCommand::Unignore { dependency, level } => {
1248 let condition = match (level, dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency))) {
1249 (Some(level), Some(found)) => Some(ranges::ignore_level(&found.to, level)),
1250 _ => None,
1251 };
1252 let removed = self.store.remove_ignores(&repo.repo_id, &row.ecosystem, dependency, condition.as_deref()).await?;
1253 if removed == 0 {
1254 format!("@{author}, nothing was being ignored for {dependency}.")
1255 } else {
1256 format!("@{author}, removed {removed} ignore {} for {dependency}.", if removed == 1 { "condition" } else { "conditions" })
1257 }
1258 }
1259 UpdateCommand::ShowIgnores { dependency } => {
1260 let names: Vec<String> = match dependency {
1261 Some(name) => vec![name.clone()],
1262 None => dependencies.iter().map(|d| d.name.clone()).collect(),
1263 };
1264 self.ignore_report(repo, &row.ecosystem, &row.entry, &names).await?
1265 }
1266 }))
1267 }
1268
1269 /// The ignore conditions on some dependencies, from the file and from
1270 /// comments, as a reply.
1271 async fn ignore_report(&self, repo: &RepoRow, ecosystem: &str, entry: &str, names: &[String]) -> Result<String> {
1272 let state = repo.version_updates();
1273 let rules = state.updates.iter().find(|found| found.id == entry).map(|found| found.ignore.clone()).unwrap_or_default();
1274 let comments = self.store.ignores(&repo.repo_id).await?;
1275 let mut lines = Vec::new();
1276 for name in names {
1277 for rule in rules.iter().filter(|rule| config::matches(&rule.dependency, name)) {
1278 let what = if !rule.versions.is_empty() {
1279 format!("versions {}", rule.versions.join(", "))
1280 } else if !rule.update_types.is_empty() {
1281 rule.update_types.join(", ")
1282 } else {
1283 "every version".to_owned()
1284 };
1285 lines.push(format!("- `{name}`: {what} (in the dependency update file, `{}`)", rule.dependency));
1286 }
1287 for condition in comments.iter().filter(|c| c.ecosystem == ecosystem && c.dependency.eq_ignore_ascii_case(name)) {
1288 let what = condition
1289 .versions
1290 .clone()
1291 .map(|versions| format!("versions `{versions}`"))
1292 .or_else(|| condition.update_type.clone())
1293 .unwrap_or_else(|| "every version".to_owned());
1294 lines.push(format!(
1295 "- `{name}`: {what} (asked by @{}{})",
1296 condition.by,
1297 condition.pull.map(|pull| format!(" in #{pull}")).unwrap_or_default()
1298 ));
1299 }
1300 }
1301 Ok(if lines.is_empty() {
1302 format!("Nothing is ignored for {}.", names.join(", "))
1303 } else {
1304 format!("Ignore conditions:\n\n{}", lines.join("\n"))
1305 })
1306 }
1307
1308 /// Commands on a security update for one package.
1309 async fn single_update_command(
1310 &self,
1311 repo: &RepoRow,
1312 update: Option<&crate::store::UpdateRow>,
1313 command: &UpdateCommand,
1314 author: &str,
1315 number: u32,
1316 detail: &PullDetail,
1317 ) -> Result<()> {
1318 let Some(update) = update else { return Ok(()) };
1319 let system = User::system(&repo.namespace);
1320 let path = Self::repo_path(repo);
1321 let Some(ecosystem) = package_ecosystem(&update.ecosystem) else { return Ok(()) };
1322 let ignore = |versions: Option<String>| IgnoreCondition {
1323 ecosystem: ecosystem.to_owned(),
1324 dependency: update.package.clone(),
1325 versions,
1326 update_type: None,
1327 by: author.to_owned(),
1328 pull: Some(number),
1329 at: String::new(),
1330 };
1331 let reply = match command {
1332 UpdateCommand::Close => {
1333 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1334 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a security update for {} {} again.", update.package, update.target)).await?;
1335 return Ok(());
1336 }
1337 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1338 let versions = match command {
1339 UpdateCommand::IgnoreVersion { level } => Some(ranges::ignore_level(&update.target, level)),
1340 _ => None,
1341 };
1342 self.store.add_ignore(&repo.repo_id, &ignore(versions)).await?;
1343 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1344 self.close_with(repo, number, format!("@{author}, g1t will skip that for {} from now on. `@g1t unignore {}` undoes it.", update.package, update.package)).await?;
1345 return Ok(());
1346 }
1347 UpdateCommand::Unignore { dependency, .. } => {
1348 let removed = self.store.remove_ignores(&repo.repo_id, ecosystem, dependency, None).await?;
1349 format!("@{author}, removed {removed} ignore conditions for {dependency}.")
1350 }
1351 UpdateCommand::ShowIgnores { dependency } => {
1352 let name = dependency.clone().unwrap_or_else(|| update.package.clone());
1353 self.ignore_report(repo, ecosystem, "", &[name]).await?
1354 }
1355 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => match checks_verdict(detail) {
1356 Some(true) => {
1357 let merged: Outcome<Pull> = g1t_kit::call(
1358 &self.work,
1359 "merge_pull",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1360 &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 bar1361 )
1362 .await?;
1363 match merged {
1364 Outcome::Ok(_) => return Ok(()),
1365 Outcome::Fail(refused) => format!("@{author}, I could not merge this: {}", refused.message),
1366 }
1367 }
1368 _ => format!("@{author}, its required checks have not passed yet; merge it once they do."),
1369 },
1370 _ => format!("@{author}, a security update is made again by `Re-scan now` on the Security page; this command is for version updates."),
1371 };
1372 self.comment(&system, &path, number, reply).await
1373 }
1374
1375 /// A pull request that changes the dependency update file gets a
1376 /// status saying whether the file is valid.
1377 pub async fn check_dependabot_file(&self, event: &Event) -> Result<()> {
1378 #[derive(Deserialize)]
1379 #[serde(rename_all = "camelCase")]
1380 struct Changed {
1381 repo_id: String,
1382 number: u32,
1383 }
1384 let Ok(changed) = serde_json::from_value::<Changed>(event.data.clone()) else { return Ok(()) };
1385 let Some(repo) = self.store.repo(&changed.repo_id).await? else { return Ok(()) };
1386 let Some(detail) = self.pull_detail(&repo, changed.number).await? else { return Ok(()) };
1387 let pull = &detail.pull;
1388 let Some(file) = pull.files.iter().find(|file| DEPENDABOT_PATHS.contains(&file.path.as_str())) else { return Ok(()) };
1389 let Some(head) = pull.head_commit.clone() else { return Ok(()) };
1390 let found: Option<RawFile> = g1t_kit::call(
1391 &self.repos,
1392 "raw_file",
1393 &RawFileArgs {
1394 repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.repo_id.clone()),
1395 git_ref: head.clone(),
1396 path: file.path.clone(),
1397 max_bytes: 1_000_000,
1398 },
1399 )
1400 .await?;
1401 let (state, description) = match found.and_then(|raw| base64_decode(&raw.data)).map(String::from_utf8) {
1402 None => ("success".to_owned(), format!("{} is removed.", file.path)),
1403 Some(Err(_)) => ("failure".to_owned(), format!("{} is not text.", file.path)),
1404 Some(Ok(text)) => {
1405 let read = config::read(&text);
1406 match read.problems.first() {
1407 None => ("success".to_owned(), format!("{} is valid: {} entries.", file.path, read.config.updates.len())),
1408 Some(problem) => {
1409 let more = if read.problems.len() > 1 { format!(" (and {} more)", read.problems.len() - 1) } else { String::new() };
1410 ("failure".to_owned(), format!("{}{more}", problem.sentence()))
1411 }
1412 }
1413 }
1414 };
1415 let site = format!("https://g1t.sh/{}/{}/blob/{head}/{}", repo.namespace, repo.name, file.path);
1416 let _: Outcome<bool> = g1t_kit::call(
1417 &self.work,
1418 "set_commit_status",
1419 &SetCommitStatusArgs {
1420 repo_id: repo.repo_id.clone(),
1421 sha: head,
1422 context: DEPENDABOT_CHECK.to_owned(),
1423 state,
1424 description: Some(description.chars().take(400).collect()),
1425 target_url: Some(site),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1426 source: Some("security".to_owned()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1427 },
1428 )
1429 .await?;
1430 Ok(())
1431 }
1432
1433 /// What the Security page shows: the file as last read, with each
1434 /// entry's runs, the update pull requests, and comment ignores.
1435 pub async fn version_updates_view(&self, repo: &RepoRow) -> Result<VersionUpdatesState> {
1436 let mut state = repo.version_updates();
1437 let runs = self.store.runs(&repo.repo_id).await?;
1438 for entry in &mut state.updates {
1439 if let Some(run) = runs.iter().find(|run| run.entry == entry.id) {
1440 entry.next_run_at = run.next_run_at.clone();
1441 entry.last_checked_at = run.last_checked_at.clone();
1442 entry.last_result = run.last_result.clone();
1443 entry.last_error = run.last_error.clone();
1444 }
1445 }
1446 let rows = self.store.update_pulls(&repo.repo_id).await?;
1447 let (live, done): (Vec<&PullRow>, Vec<&PullRow>) = rows.iter().partition(|row| row.state().in_progress());
1448 state.pulls = live.into_iter().chain(done.into_iter().take(20)).map(PullRow::to_contract).collect();
1449 state.ignores = self.store.ignores(&repo.repo_id).await?;
1450 Ok(state)
1451 }
1452}
1453
1454/// Whether g1t runs an entry: an ecosystem it updates, and pull requests
1455/// it can open (`open-pull-requests-limit` above 0), into the default
1456/// branch or the entry's `target-branch`.
1457pub fn runnable(entry: &Entry, _default_branch: Option<&str>) -> bool {
1458 entry.supported() && entry.open_pull_requests_limit > 0 && entry.schedule.is_some()
1459}
1460
1461/// The branch an entry's updates start from and merge into, when it is not
1462/// the default branch.
1463pub fn target_of(entry: &Entry, default_branch: &str) -> Option<String> {
1464 entry.target_branch.clone().filter(|branch| !branch.is_empty() && branch != default_branch)
1465}
1466
1467fn updated(update: &Planned) -> UpdatedDependency {
1468 UpdatedDependency {
1469 name: update.name.clone(),
1470 from: update.from.clone(),
1471 to: update.to.clone(),
1472 directory: update.directory.clone(),
1473 dependency_type: update.kind.label().to_owned(),
1474 update_type: format!("version-update:semver-{}", update.level),
1475 }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use crate::config::read;
1482 use g1t_contracts::work::{CommitStatus, RequiredCheck};
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.1483 use serde_json::json;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1484
1485 fn entry(extra: &str) -> Entry {
1486 let found = read(&format!("version: 2\nupdates:\n - package-ecosystem: npm\n{extra} schedule: {{interval: daily}}\n"));
1487 assert!(found.problems.is_empty(), "{:?}", found.problems);
1488 found.config.updates.into_iter().next().unwrap()
1489 }
1490
1491 #[test]
1492 fn base64_round_trips() {
1493 for text in ["", "a", "ab", "abc", "user:pa$$word", "version: 2\n"] {
1494 assert_eq!(base64_decode(&base64_encode(text.as_bytes())).unwrap(), text.as_bytes());
1495 }
1496 assert_eq!(base64_encode(b"ci:secret"), "Y2k6c2VjcmV0");
1497 }
1498
1499 #[test]
1500 fn secrets_are_filled_in() {
1501 let mut secrets = serde_json::Map::new();
1502 secrets.insert("TOKEN".into(), json!("t0k"));
1503 assert_eq!(fill_secrets("${{secrets.TOKEN}}", &secrets), ("t0k".to_owned(), vec![]));
1504 assert_eq!(fill_secrets("Bearer ${{ secrets.TOKEN }}!", &secrets).0, "Bearer t0k!");
1505 assert_eq!(fill_secrets("${{secrets.NOPE}}", &secrets).1, ["NOPE"]);
1506 assert_eq!(fill_secrets("plain", &secrets).0, "plain");
1507 }
1508
1509 #[test]
1510 fn directories_from_globs_and_exclusions() {
1511 let files: Vec<String> = ["package.json", "apps/web/package.json", "apps/admin/package.json", "apps/web/vendor/x/package.json", "docs/readme.md"]
1512 .map(str::to_owned)
1513 .to_vec();
1514 assert_eq!(entry_directories(&entry(" directory: /\n"), &files), ["/"]);
1515 assert_eq!(entry_directories(&entry(" directories: [\"/apps/*\"]\n"), &files), ["/apps/admin", "/apps/web"]);
1516 assert_eq!(
1517 entry_directories(&entry(" directories: [\"/apps/**\"]\n exclude-paths: [\"**/vendor/**\"]\n"), &files),
1518 ["/apps/admin", "/apps/web"]
1519 );
1520 assert!(entry_directories(&entry(" directory: /missing\n"), &files).is_empty());
1521 let locks: Vec<String> = ["package-lock.json", "apps/web/package.json", "Cargo.lock"].map(str::to_owned).to_vec();
1522 assert_eq!(lockfiles_for("npm", "/apps/web", &locks), ["package-lock.json"]);
1523 assert!(lockfiles_for("pip", "/", &locks).is_empty());
1524 }
1525
1526 #[test]
1527 fn current_versions_come_from_the_lockfile() {
1528 let directory = Directory {
1529 path: "/".into(),
1530 declared: manifests::package_json(r#"{"dependencies":{"lodash":"^4.17.0","left-pad":"1.0.0"},"devDependencies":{"vitest":"^1"}}"#),
1531 locked: BTreeMap::from([
1532 ("lodash".to_owned(), vec!["4.17.20".to_owned(), "3.10.1".to_owned()]),
1533 ("minimist".to_owned(), vec!["1.2.0".to_owned()]),
1534 ]),
1535 lockfiles: vec!["package-lock.json".into()],
1536 };
1537 let found = directory_candidates(&entry(" directory: /\n"), &directory);
1538 assert_eq!(found, [("lodash".to_owned(), DependencyType::Production, "4.17.20".to_owned(), Some("^4.17.0".to_owned()))]);
1539 let go = Directory {
1540 path: "/".into(),
1541 declared: manifests::go_mod("require golang.org/x/net v0.7.0\n"),
1542 locked: BTreeMap::new(),
1543 lockfiles: vec!["go.mod".into()],
1544 };
1545 let mut go_entry = entry(" directory: /\n");
1546 go_entry.ecosystem = "gomod".into();
1547 let found = directory_candidates(&go_entry, &go);
1548 assert_eq!(found[0].2, "v0.7.0");
1549 }
1550
1551 fn row(subject: &str, signature: &str, state: &str) -> PullRow {
1552 PullRow {
1553 id: format!("upd_{subject}_{state}"),
1554 repo_id: "rep_1".into(),
1555 kind: "version".into(),
1556 entry: "npm:/".into(),
1557 ecosystem: "npm".into(),
1558 subject: subject.into(),
1559 signature: signature.into(),
1560 group_name: None,
1561 branch: "g1t/npm_and_yarn/x".into(),
1562 title: String::new(),
1563 body: String::new(),
1564 dependencies: "[]".into(),
1565 bump: "{}".into(),
1566 assignees: "[]".into(),
1567 reviewers: "[]".into(),
1568 state: state.into(),
1569 pull: Some(3),
1570 head: None,
1571 merge_by: None,
1572 error: None,
1573 updated_at: String::new(),
1574 }
1575 }
1576
1577 fn plan(name: &str, to: &str) -> PullPlan {
1578 PullPlan {
1579 group: None,
1580 by_name: false,
1581 updates: vec![Planned {
1582 name: name.into(),
1583 directory: "/".into(),
1584 kind: DependencyType::Production,
1585 from: "1.0.0".into(),
1586 to: to.into(),
1587 level: "minor",
1588 source: None,
1589 changelog: None,
1590 page: None,
1591 }],
1592 }
1593 }
1594
1595 #[test]
1596 fn the_limit_counts_open_pull_requests_and_replacements_do_not() {
1597 let plans = vec![plan("a", "1.1.0"), plan("b", "1.1.0"), plan("c", "1.1.0"), plan("d", "1.1.0")];
1598 let existing = vec![
1599 row("dependency:/:a", "/a@1.0.5", "open"),
1600 row("dependency:/:b", "/b@1.1.0", "open"),
1601 row("dependency:/:c", "/c@1.1.0", "closed"),
1602 ];
1603 let (admitted, held) = admit(&plans, &existing, 3);
1604 let names: Vec<(&str, bool)> = admitted.iter().map(|(plan, older)| (plan.updates[0].name.as_str(), older.is_some())).collect();
1605 // a replaces its older pull request; b is open already; c was closed by a person for this version; d is new, and fits.
1606 assert_eq!(names, [("a", true), ("d", false)]);
1607 assert_eq!(held, 0);
1608 let (admitted, held) = admit(&plans, &existing, 2);
1609 assert_eq!(admitted.len(), 1);
1610 assert_eq!(held, 1);
1611 }
1612
1613 #[test]
1614 fn checks_decide_failure_and_success() {
1615 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
Merge checks: statuses and check runs on every commit1616 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 bar1617 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
1618 let mut detail: PullDetail = serde_json::from_value(json!({
1619 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
1620 "status": "open", "fork": null, "forkRepoId": null, "branch": "b", "headCommit": "c", "mergeBase": null, "mergedBy": null,
1621 "mergedAt": null, "supersededBy": null, "checkStatus": null,
1622 "author": {"id": "g1t", "username": "g1t", "kind": "system"}, "createdAt": "", "updatedAt": ""},
1623 "issue": null, "comments": [], "checks": null
1624 }))
1625 .unwrap();
1626 detail.required_checks = required;
1627 detail.statuses = statuses;
1628 detail
1629 };
1630 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success)], vec![])), Some(true));
1631 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success), check(RequiredState::Failure)], vec![])), Some(false));
1632 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Pending)], vec![])), None);
1633 assert_eq!(checks_verdict(&detail(vec![], vec![status("error")])), Some(false));
1634 assert_eq!(checks_verdict(&detail(vec![], vec![status("pending")])), None);
1635 assert_eq!(checks_verdict(&detail(vec![], vec![])), Some(true));
1636 }
1637
1638 #[test]
1639 fn which_entries_run() {
1640 assert!(runnable(&entry(" directory: /\n"), Some("main")));
1641 assert!(!runnable(&entry(" directory: /\n open-pull-requests-limit: 0\n"), Some("main")));
1642 assert!(runnable(&entry(" directory: /\n target-branch: main\n"), Some("main")));
1643 assert!(runnable(&entry(" directory: /\n target-branch: develop\n"), Some("main")));
1644 assert_eq!(target_of(&entry(" directory: /\n target-branch: develop\n"), "main").as_deref(), Some("develop"));
1645 assert_eq!(target_of(&entry(" directory: /\n target-branch: main\n"), "main"), None);
1646 assert_eq!(target_of(&entry(" directory: /\n"), "main"), None);
1647 assert_eq!(osv_ecosystem("gomod"), Some("Go"));
1648 assert_eq!(package_ecosystem("crates.io"), Some("cargo"));
1649 }
1650}

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