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::{ | |
| 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 bar | 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; | |
| 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. | 46 | use serde_json::Value; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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. | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 98 | pub(crate) fn normalize(ecosystem: &str, name: &str) -> String { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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>> { | |
| 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 bar | 606 | } |
| 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 bar | 620 | }, |
| 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, ®istries)); | |
| 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, ®istries, 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(¬e); | |
| 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 branches | 905 | // 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 bar | 910 | _ => 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": true | 930 | draft: false, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 931 | }, |
| 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 branches | 990 | 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 bar | 997 | } |
| 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 merge | 1065 | bypass_rules: false, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1066 | }, |
| 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 merge | 1360 | &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 | 1361 | ) |
| 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 merge | 1426 | source: Some("security".to_owned()), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1427 | }, |
| 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`. | |
| 1457 | pub 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. | |
| 1463 | pub 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 | ||
| 1467 | fn 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)] | |
| 1479 | mod 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 bar | 1484 | |
| 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 commit | 1616 | 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 | 1617 | 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.