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