Skip to content

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

g1t.sh
1,662 linesCodeBlameRaw

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

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! Version updates: pull requests that keep dependencies current, as the
2//! dependency update file (`dependabot.yml`, version 2) asks.
3//!
4//! 1. Reading the file (`deps::read_version_updates`, on every dependency
5//! scan) schedules each entry g1t can act on: a new entry runs at once,
6//! then on its `schedule`. A sweep every few minutes runs those due.
7//! 2. A run reads the entry's manifests and lockfiles on the default
8//! branch, asks each dependency's registry for its versions, and picks
9//! each one's target with `allow`, `ignore`, people's `@g1t ignore`
10//! comments, `cooldown` and `versioning-strategy` (`planning`). Updates
11//! are gathered by `groups`, and up to `open-pull-requests-limit` pull
12//! requests are asked of the runner's `bump` sandbox, the same one
13//! security updates use.
14//! 3. The sandbox's push opens the pull request as g1t (`User::system`),
15//! titled and described as `pull_text` says, assigned and with reviews
16//! asked as the file says. An older one for the same dependency or group
17//! is closed as superseded.
18//! 4. The sweep watches open ones: a failing required check closes it and
19//! puts g1t on an issue to make the code changes, as security updates
20//! do; one asked to merge (`@g1t merge`) merges once its checks pass;
21//! one in conflict is made again from its base (`rebase-strategy`).
22//! 5. `@g1t` comments on these pull requests are commands
23//! (`g1t_contracts::updates::update_command`).
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use g1t_contracts::access::{Capability, CollaboratorPermissionArgs, PermissionInfo};
28use g1t_contracts::actions::{ResolveSettingsArgs, ResolvedSettings};
29use g1t_contracts::events::Event;
30use g1t_contracts::repos::{BlobArgs, BlobView, BlobText, FileList, ListFilesArgs, RawFile, RawFileArgs, ReadBlobsArgs, RepoPath};
31use g1t_contracts::security::{BumpArgs, UpdateState};
32use g1t_contracts::time::{parse_rfc3339, rfc3339};
33use g1t_contracts::updates::{
34 BumpPackage, BumpRegistry, CheckUpdatesArgs, DEPENDABOT_CHECK, DEPENDABOT_PATHS, IgnoreCondition, UpdateCommand, UpdatedDependency,
35 VersionUpdatesState, update_command,
36};
37use g1t_contracts::work::{
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.38 Mergeable, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, RequiredState, Runtime, SetCommitStatusArgs,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar39 UpdatePullArgs, ViewArgs,
40};
41use g1t_contracts::{FailureCode, Outcome, User};
42use g1t_kit::now_ms;
43use g1t_scan::lockfiles::Lockfile;
44use g1t_scan::version;
45use serde::Deserialize;
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.46use serde_json::Value;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar47use worker::{Fetch, Headers, Method, Request, RequestInit, Result};
48
49use crate::Security;
50use crate::config::{self, Config, Entry, Registry, glob};
51use crate::manifests::{self, Declared, DependencyType};
52use crate::planning::{self, Candidate, Planned, PullPlan, Skip};
53use crate::pull_text;
54use crate::ranges::{self, Bare};
55use crate::registries::{self, Package, Source};
56use crate::store::RepoRow;
57use crate::update_store::{NewPull, PullRow};
58use crate::updates;
59
60/// Entries run per sweep.
61const DUE_PER_SWEEP: u32 = 3;
62/// Open update pull requests looked at per sweep.
63const WATCHED_PER_SWEEP: u32 = 10;
64/// Dependencies one run asks registries about, at most.
65const MAX_PACKAGES: usize = 200;
66/// Registry requests in flight at once.
67const FETCH_AT_ONCE: usize = 8;
68/// Versions whose publish time a Go module's run asks the proxy for.
69const GO_TIMES: usize = 3;
70/// A sandbox that has not pushed after this long is taken to have failed.
71const STALLED_MS: u64 = 45 * 60 * 1000;
72const MAX_BLOB_BYTES: u32 = 5_000_000;
73const SKIPPED_DIRECTORIES: [&str; 6] = ["node_modules", ".git", "target", "dist", "build", ".venv"];
74
75/// OSV's name for a `package-ecosystem`, as lockfiles and the bump sandbox name it.
76pub fn osv_ecosystem(ecosystem: &str) -> Option<&'static str> {
77 Some(match ecosystem {
78 "npm" => "npm",
79 "cargo" => "crates.io",
80 "gomod" => "Go",
81 "pip" => "PyPI",
82 _ => return None,
83 })
84}
85
86/// The `package-ecosystem` for one of OSV's names.
87pub fn package_ecosystem(osv: &str) -> Option<&'static str> {
88 Some(match osv {
89 "npm" => "npm",
90 "crates.io" => "cargo",
91 "Go" => "gomod",
92 "PyPI" => "pip",
93 _ => return None,
94 })
95}
96
97/// A package name as its ecosystem compares names.
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches98pub(crate) fn normalize(ecosystem: &str, name: &str) -> String {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar99 if ecosystem == "pip" { manifests::python_name(name) } else { name.to_owned() }
100}
101
102fn bare(ecosystem: &str) -> Bare {
103 if ecosystem == "cargo" { Bare::Caret } else { Bare::Exact }
104}
105
106const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
107
108pub fn base64_encode(bytes: &[u8]) -> String {
109 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
110 for chunk in bytes.chunks(3) {
111 let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
112 for (index, shift) in [18, 12, 6, 0].into_iter().enumerate() {
113 if index <= chunk.len() {
114 out.push(BASE64[(n >> shift & 63) as usize] as char);
115 } else {
116 out.push('=');
117 }
118 }
119 }
120 out
121}
122
123pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
124 let mut out = Vec::with_capacity(text.len() / 4 * 3);
125 let mut buffer = 0u32;
126 let mut bits = 0;
127 for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
128 let value = BASE64.iter().position(|known| *known == c)? as u32;
129 buffer = (buffer << 6) | value;
130 bits += 6;
131 if bits >= 8 {
132 bits -= 8;
133 out.push((buffer >> bits & 0xff) as u8);
134 }
135 }
136 Some(out)
137}
138
139/// `${{secrets.NAME}}` in `text` with each secret's value; the names of
140/// those that are not set.
141pub fn fill_secrets(text: &str, secrets: &serde_json::Map<String, Value>) -> (String, Vec<String>) {
142 let mut out = String::new();
143 let mut missing = Vec::new();
144 let mut rest = text;
145 while let Some(start) = rest.find("${{") {
146 out.push_str(&rest[..start]);
147 let after = &rest[start + 3..];
148 let Some(end) = after.find("}}") else {
149 out.push_str(&rest[start..]);
150 return (out, missing);
151 };
152 let inner = after[..end].trim();
153 match inner.strip_prefix("secrets.").map(str::trim) {
154 Some(name) => match secrets.get(name).and_then(Value::as_str) {
155 Some(value) => out.push_str(value),
156 None => missing.push(name.to_owned()),
157 },
158 None => out.push_str(&rest[start..start + 3 + end + 2]),
159 }
160 rest = &after[end + 2..];
161 }
162 out.push_str(rest);
163 (out, missing)
164}
165
166/// Whether a pull request's required checks passed (`Some(true)`), one
167/// failed (`Some(false)`), or they are still to come. Without required
168/// checks, what its workflows reported on its head.
169pub fn checks_verdict(detail: &PullDetail) -> Option<bool> {
170 if !detail.required_checks.is_empty() {
171 if detail.required_checks.iter().any(|check| check.state == RequiredState::Failure) {
172 return Some(false);
173 }
174 return detail.required_checks.iter().all(|check| check.state == RequiredState::Success).then_some(true);
175 }
176 if detail.statuses.iter().any(|status| matches!(status.state.as_str(), "failure" | "error")) {
177 return Some(false);
178 }
179 detail.statuses.iter().all(|status| status.state == "success").then_some(true)
180}
181
182/// The directories of `entry` that hold one of its manifests: each
183/// `directory`, and what each `directories` glob matches, without those
184/// `exclude-paths` covers. From the root, starting with `/`.
185pub fn entry_directories(entry: &Entry, files: &[String]) -> Vec<String> {
186 let names = manifests::manifest_names(&entry.ecosystem);
187 let mut found = BTreeSet::new();
188 for file in files {
189 let (directory, name) = file.rsplit_once('/').map_or(("", file.as_str()), |(directory, name)| (directory, name));
190 if !names.contains(&name) {
191 continue;
192 }
193 let directory = format!("/{directory}");
194 let directory = if directory == "/" { directory } else { directory.trim_end_matches('/').to_owned() };
195 for wanted in &entry.directories {
196 let hit = if wanted.contains(['*', '?']) { glob(wanted, &directory) } else { *wanted == directory };
197 if !hit {
198 continue;
199 }
200 let relative = file.strip_prefix(wanted.trim_start_matches('/')).unwrap_or(file).trim_start_matches('/');
201 let excluded = entry.exclude_paths.iter().any(|pattern| {
202 let pattern = pattern.trim_start_matches("./").trim_start_matches('/');
203 glob(pattern, relative) || relative.starts_with(&format!("{}/", pattern.trim_end_matches('/')))
204 });
205 if !excluded {
206 found.insert(directory.clone());
207 }
208 }
209 }
210 found.into_iter().collect()
211}
212
213/// The lockfiles that resolve `directory`'s dependencies in `ecosystem`:
214/// its own, or the nearest above it (a workspace's).
215pub fn lockfiles_for(ecosystem: &str, directory: &str, files: &[String]) -> Vec<String> {
216 let Some(osv) = osv_ecosystem(ecosystem) else { return Vec::new() };
217 let mut at = directory.trim_matches('/').to_owned();
218 loop {
219 let found: Vec<String> = files
220 .iter()
221 .filter(|file| {
222 let (dir, _) = file.rsplit_once('/').unwrap_or(("", file));
223 dir == at && Lockfile::for_path(file).is_some_and(|lockfile| lockfile.ecosystem().osv() == osv)
224 })
225 .cloned()
226 .collect();
227 if !found.is_empty() || at.is_empty() {
228 return found;
229 }
230 at = at.rsplit_once('/').map_or(String::new(), |(parent, _)| parent.to_owned());
231 }
232}
233
234/// What a run reads in one directory.
235pub struct Directory {
236 pub path: String,
237 pub declared: Vec<Declared>,
238 /// Each package the lockfiles resolve, by name, with every version.
239 pub locked: BTreeMap<String, Vec<String>>,
240 pub lockfiles: Vec<String>,
241}
242
243/// The dependencies a run looks at in one directory, with the version
244/// each is at now. A declared dependency with no version to be found is
245/// left out.
246pub fn directory_candidates(entry: &Entry, directory: &Directory) -> Vec<(String, DependencyType, String, Option<String>)> {
247 let ecosystem = entry.ecosystem.as_str();
248 let mut out = Vec::new();
249 let mut named = BTreeSet::new();
250 for declared in &directory.declared {
251 let name = normalize(ecosystem, &declared.name);
252 named.insert(name.clone());
253 let locked = directory.locked.get(&name).cloned().unwrap_or_default();
254 let fitting: Vec<&String> = locked
255 .iter()
256 .filter(|version| declared.requirement.as_deref().is_none_or(|req| ranges::satisfies(req, version, bare(ecosystem)) != Some(false)))
257 .collect();
258 let current = fitting
259 .into_iter()
260 .max_by(|a, b| version::compare(a, b))
261 .cloned()
262 .or_else(|| locked.iter().max_by(|a, b| version::compare(a, b)).cloned())
263 .or_else(|| match (ecosystem, declared.requirement.as_deref()) {
264 ("gomod", Some(version)) => Some(version.to_owned()),
265 ("pip", Some(requirement)) => requirement.strip_prefix("==").filter(|v| !v.contains([',', '*'])).map(str::to_owned),
266 _ => None,
267 });
268 if let Some(current) = current {
269 out.push((declared.name.clone(), declared.kind, current, declared.requirement.clone()));
270 }
271 }
272 let wants_indirect = entry.allow.iter().any(|rule| matches!(rule.dependency_type.as_deref(), Some("indirect" | "all")));
273 if wants_indirect && matches!(ecosystem, "cargo" | "gomod" | "pip") {
274 for (name, versions) in &directory.locked {
275 if named.contains(name) {
276 continue;
277 }
278 if let Some(current) = versions.iter().max_by(|a, b| version::compare(a, b)) {
279 out.push((name.clone(), DependencyType::Indirect, current.clone(), None));
280 }
281 }
282 }
283 out
284}
285
286/// What the run decided for the open-pull-requests limit: the plans to
287/// ask for now (replacing an older one when `Some`), and how many waited.
288pub fn admit<'a>(plans: &'a [PullPlan], existing: &'a [PullRow], limit: u32) -> (Vec<(&'a PullPlan, Option<&'a PullRow>)>, usize) {
289 let in_progress = |row: &&PullRow| matches!(row.state(), UpdateState::Requested | UpdateState::Open);
290 let mut open = existing.iter().filter(in_progress).count();
291 let mut admitted = Vec::new();
292 let mut held = 0;
293 let mut fresh = Vec::new();
294 for plan in plans {
295 let subject = plan.subject();
296 let signature = plan.signature();
297 let mine: Vec<&PullRow> = existing.iter().filter(|row| row.subject == subject).collect();
298 // Already open for these versions, or closed by a person for them.
299 if mine.iter().any(|row| row.signature == signature && (in_progress(row) || matches!(row.state(), UpdateState::Closed | UpdateState::NeedsCode))) {
300 continue;
301 }
302 match mine.into_iter().find(in_progress) {
303 Some(older) => admitted.push((plan, Some(older))),
304 None => fresh.push(plan),
305 }
306 }
307 for plan in fresh {
308 if open < limit as usize {
309 open += 1;
310 admitted.push((plan, None));
311 } else {
312 held += 1;
313 }
314 }
315 (admitted, held)
316}
317
318/// What the dependency update file says, as a run uses it.
319pub struct Loaded {
320 pub config: Config,
321 pub file: String,
322 pub default_branch: String,
323}
324
325#[derive(Deserialize)]
326struct CommentAuthor {
327 username: String,
328}
329
330impl Security {
331 fn repo_path(repo: &RepoRow) -> RepoPath {
332 RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }
333 }
334
335 /// The dependency update file on the default branch, if it is there
336 /// and has no problems.
337 pub(crate) async fn load_config(&self, repo: &RepoRow) -> Result<Option<Loaded>> {
338 let path = Self::repo_path(repo);
339 let mut found = Vec::new();
340 let mut default_branch = None;
341 for file in DEPENDABOT_PATHS {
342 let read: Outcome<BlobView> = g1t_kit::call(
343 &self.repos,
344 "blob",
345 &BlobArgs { path: path.clone(), viewer: Some(User::system(&repo.namespace)), git_ref: String::new(), file_path: file.to_owned() },
346 )
347 .await?;
348 if let Outcome::Ok(blob) = read {
349 default_branch = Some(blob.repo.default_branch.clone());
350 found.push((file.to_owned(), blob.text));
351 }
352 }
353 let Some(((file, Some(text)), _)) = updates::choose(found) else { return Ok(None) };
354 let read = config::read(&text);
355 if !read.problems.is_empty() {
356 return Ok(None);
357 }
358 Ok(Some(Loaded { config: read.config, file, default_branch: default_branch.unwrap_or_default() }))
359 }
360
361 /// Schedules each entry g1t can act on, after the file is read: a new
362 /// one runs now, the rest when their schedule next says since they last
363 /// ran. A file with problems runs nothing.
364 pub(crate) async fn schedule_entries(&self, repo: &RepoRow, config: Option<&Config>, default_branch: Option<&str>) -> Result<()> {
365 let known: BTreeMap<String, Option<String>> =
366 self.store.runs(&repo.repo_id).await?.into_iter().map(|run| (run.entry, run.last_checked_at)).collect();
367 let now = now_ms();
368 let mut rows = Vec::new();
369 for entry in config.map(|config| config.updates.as_slice()).unwrap_or_default() {
370 if !runnable(entry, default_branch) {
371 continue;
372 }
373 let Some(schedule) = &entry.schedule else { continue };
374 let next = match known.get(&entry.id()) {
375 Some(Some(last)) => schedule.next_run(parse_rfc3339(last).unwrap_or(now), &updates::seed(&repo.repo_id, entry)),
376 _ => Some(now),
377 };
378 rows.push((entry.id(), next.map(rfc3339)));
379 }
380 self.store.set_runs(&repo.repo_id, &rows).await
381 }
382
383 /// The sweep's part for version updates: due entries, open update
384 /// pull requests, and sandboxes that never pushed.
385 pub async fn version_update_sweep(&self) -> Result<()> {
386 for run in self.store.due_runs(&rfc3339(now_ms()), DUE_PER_SWEEP).await? {
387 let Some(repo) = self.store.repo(&run.repo_id).await? else { continue };
388 if let Err(error) = self.run_claimed(&repo, &run.entry).await {
389 worker::console_error!("security: version updates for {} {} failed: {error}", run.repo_id, run.entry);
390 }
391 }
392 for row in self.store.open_update_pulls(WATCHED_PER_SWEEP).await? {
393 if let Err(error) = self.watch_update_pull(&row).await {
394 worker::console_error!("security: update pull request {} not looked at: {error}", row.id);
395 }
396 }
397 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
398 for row in self.store.stalled_update_pulls(&before, 10).await? {
399 let error = "The update could not be made in a sandbox: its packages may need code changes to move, or a registry could not be reached.";
400 self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(error)).await?;
401 }
Updates that g1t opened a "needs code changes" issue for are put where updates go now: the sweep closes the issue as not planned, saying where it is tracked, and marks the update failed, so a security update shows why on its alert and is tried again, and a version update opens again as a pull request with its checks. An alert's history no longer repeats an entry that says the same as its latest one, so an update that fails the same way each day adds it once.402 // Left from when g1t opened an issue for an update whose checks
403 // failed: the issue closes, and the next run opens the update again
404 // as a pull request that stays open with its failing checks.
405 for row in self.store.needs_code_update_pulls(10).await? {
406 if let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.issue()) {
407 let why = "g1t no longer opens issues for updates whose checks fail. The entry's next run opens this update again \
408 as a pull request, which stays open with its checks for you to decide on.";
409 self.close_issue_with(&repo, number, why.to_owned()).await?;
410 }
411 let error = "Raising the versions failed this branch's required checks.";
412 self.store.set_update_pull(&row.id, UpdateState::Failed, row.pull(), None, Some(error)).await?;
413 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar414 Ok(())
415 }
416
417 /// Runs one entry if no one else is, and schedules its next run.
418 async fn run_claimed(&self, repo: &RepoRow, entry_id: &str) -> Result<()> {
419 if !self.store.claim_run(&repo.repo_id, entry_id).await? {
420 return Ok(());
421 }
422 let loaded = self.load_config(repo).await;
423 let (result, error, next) = match loaded {
424 Ok(Some(loaded)) => match loaded.config.updates.iter().find(|entry| entry.id() == entry_id && runnable(entry, Some(&loaded.default_branch))) {
425 Some(entry) => {
426 let next = entry
427 .schedule
428 .as_ref()
429 .and_then(|schedule| schedule.next_run(now_ms(), &updates::seed(&repo.repo_id, entry)))
430 .map(rfc3339);
431 match self.run_entry(repo, &loaded, entry).await {
432 Ok(Ok(summary)) => (Some(summary), None, next),
433 Ok(Err(problem)) => (None, Some(problem), next),
434 Err(error) => (None, Some(format!("The check could not finish: {error}")), next),
435 }
436 }
437 None => (None, None, None),
438 },
439 Ok(None) => (None, Some("The dependency update file is missing or has problems.".to_owned()), None),
440 Err(error) => (None, Some(format!("The dependency update file could not be read: {error}")), None),
441 };
442 self.store.finish_run(&repo.repo_id, entry_id, next.as_deref(), result.as_deref(), error.as_deref()).await
443 }
444
445 /// `check_updates`: one entry, now.
446 pub async fn check_updates(&self, a: CheckUpdatesArgs) -> Result<Outcome<VersionUpdatesState>> {
447 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::Push).await? {
448 Outcome::Ok(repo) => repo,
449 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
450 };
451 if !a.actor.verified {
452 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
453 }
454 let state = repo.version_updates();
455 let Some(entry) = state.updates.iter().find(|entry| entry.id == a.entry) else {
456 return Ok(Outcome::fail(FailureCode::NotFound, "The dependency update file has no such entry."));
457 };
458 if !entry.supported {
459 return Ok(Outcome::fail(FailureCode::Invalid, format!("g1t does not open version updates for {} yet.", entry.ecosystem)));
460 }
461 if !self.store.claim_run(&repo.repo_id, &a.entry).await? {
462 return Ok(Outcome::fail(FailureCode::Conflict, "This entry is being checked now."));
463 }
464 // Claimed: run_claimed would claim again, so run it here.
465 let loaded = self.load_config(&repo).await?;
466 let (result, error) = match loaded.as_ref().and_then(|loaded| loaded.config.updates.iter().find(|entry| entry.id() == a.entry).map(|entry| (loaded, entry))) {
467 Some((loaded, entry)) => match self.run_entry(&repo, loaded, entry).await {
468 Ok(Ok(summary)) => (Some(summary), None),
469 Ok(Err(problem)) => (None, Some(problem)),
470 Err(error) => (None, Some(format!("The check could not finish: {error}"))),
471 },
472 None => (None, Some("The dependency update file is missing or has problems.".to_owned())),
473 };
474 let next = self
475 .store
476 .runs(&repo.repo_id)
477 .await?
478 .into_iter()
479 .find(|run| run.entry == a.entry)
480 .and_then(|run| run.next_run_at);
481 self.store.finish_run(&repo.repo_id, &a.entry, next.as_deref(), result.as_deref(), error.as_deref()).await?;
482 Ok(Outcome::Ok(self.version_updates_view(&repo).await?))
483 }
484
485 /// The registries an entry may use, ready for the sandbox and for
486 /// asking about versions, and what kept any from being used.
487 async fn entry_registries(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<(Vec<(Registry, BumpRegistry, Source)>, Vec<String>)> {
488 let kind_for = |ecosystem: &str| match ecosystem {
489 "npm" => "npm-registry",
490 "cargo" => "cargo-registry",
491 "pip" => "python-index",
492 "gomod" => "goproxy-server",
493 _ => "",
494 };
495 let wanted: Vec<&Registry> = entry
496 .registries
497 .iter()
498 .filter_map(|name| loaded.config.registries.iter().find(|registry| &registry.name == name))
499 .filter(|registry| registry.kind == kind_for(&entry.ecosystem))
500 .collect();
501 if wanted.is_empty() {
502 return Ok((Vec::new(), Vec::new()));
503 }
504 let settings: ResolvedSettings = g1t_kit::call(
505 &self.actions,
506 "resolve_settings",
507 &ResolveSettingsArgs {
508 repo_id: repo.repo_id.clone(),
509 repo: Self::repo_path(repo),
510 project_id: None,
511 project_slug: None,
512 consumer: "workflows".to_owned(),
513 environment: None,
514 trusted: true,
515 },
516 )
517 .await
518 .unwrap_or_default();
519 let mut ready = Vec::new();
520 let mut notes = Vec::new();
521 for registry in wanted {
522 if registry.oidc {
523 notes.push(format!("Registry {} signs in with OIDC, which g1t cannot do, so it was not used.", registry.name));
524 continue;
525 }
526 let mut missing = Vec::new();
527 let mut fill = |text: &Option<String>| {
528 text.as_deref().map(|text| {
529 let (filled, absent) = fill_secrets(text, &settings.secrets);
530 missing.extend(absent);
531 filled
532 })
533 };
534 let username = fill(&registry.username);
535 let password = fill(&registry.password);
536 let token = fill(&registry.token).or_else(|| fill(&registry.key));
537 if !missing.is_empty() {
538 notes.push(format!(
539 "Registry {} names secrets that are not set for this repository's workflows: {}.",
540 registry.name,
541 missing.join(", ")
542 ));
543 continue;
544 }
545 let url = registry.url.trim_end_matches('/').to_owned();
546 let authorization = match (&token, &username, &password) {
547 (Some(token), _, _) if registry.kind == "cargo-registry" => Some(token.clone()),
548 (Some(token), _, _) if registry.kind == "npm-registry" => Some(format!("Bearer {token}")),
549 (Some(token), _, _) => Some(format!("Basic {}", base64_encode(format!("__token__:{token}").as_bytes()))),
550 (None, Some(user), Some(password)) => Some(format!("Basic {}", base64_encode(format!("{user}:{password}").as_bytes()))),
551 _ => None,
552 };
553 let bump = BumpRegistry {
554 kind: registry.kind.clone(),
555 url: url.clone(),
556 username,
557 password,
558 token,
559 replaces_base: registry.replaces_base,
560 scopes: registry.scopes.clone(),
561 };
562 ready.push((registry.clone(), bump, Source { url, authorization }));
563 }
564 Ok((ready, notes))
565 }
566
567 async fn get(&self, url: &str, source: Option<&Source>, accept: &str) -> Result<Option<String>> {
568 let headers = Headers::new();
569 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
570 headers.set("accept", accept)?;
571 if let Some(authorization) = source.and_then(|source| source.authorization.as_deref()) {
572 headers.set("authorization", authorization)?;
573 }
574 let mut init = RequestInit::new();
575 init.with_method(Method::Get).with_headers(headers);
576 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
577 match response.status_code() {
578 200..=299 => Ok(Some(response.text().await?)),
579 404 | 410 => Ok(None),
580 status => Err(worker::Error::RustError(format!("{} answered {status}", url.split('/').take(3).collect::<Vec<_>>().join("/")))),
581 }
582 }
583
584 /// What `name`'s registry says, from the private one that serves it if
585 /// the entry names one.
586 async fn package(&self, ecosystem: &str, name: &str, current: &str, registries: &[(Registry, BumpRegistry, Source)]) -> Result<Option<Package>> {
587 let private = registries.iter().find(|(registry, _, _)| {
588 registry.replaces_base || registry.scopes.iter().any(|scope| name.starts_with(&format!("{scope}/")))
589 });
590 let source = private.map(|(_, _, source)| source);
591 let Some(url) = registries::package_url(ecosystem, name, source) else { return Ok(None) };
592 let accept = if ecosystem == "pip" && source.is_some() { "application/vnd.pypi.simple.v1+json" } else { "application/json, text/plain" };
593 let Some(body) = self.get(&url, source, accept).await? else { return Ok(None) };
594 let mut package = registries::read(ecosystem, name, source.is_some(), &body);
595 if ecosystem == "gomod" {
596 // When the newest few were published, for the cooldown.
597 let mut newer: Vec<usize> = (0..package.releases.len())
598 .filter(|at| version::compare(&package.releases[*at].version, current).is_gt())
599 .collect();
600 newer.sort_by(|a, b| version::compare(&package.releases[*b].version, &package.releases[*a].version));
601 for at in newer.into_iter().take(GO_TIMES) {
602 let info = url.replace("/@v/list", &format!("/@v/{}.info", package.releases[at].version));
603 if let Some(text) = self.get(&info, source, "application/json").await? {
604 package.releases[at].published_ms = serde_json::from_str(&text).ok().and_then(|value: Value| registries::go_info_time(&value));
605 }
606 }
607 }
608 Ok(Some(package))
609 }
610
611 /// One run of one entry: what it found, as a sentence, or why it could
612 /// not run.
613 async fn run_entry(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<std::result::Result<String, String>> {
Merge branch 'rm-mirroring'614 // Archived or deleted: nothing opens here on its own.
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.615 if let Err(why) = self.takes_updates(repo).await? {
Merge branch 'rm-mirroring'616 return Ok(Err(why));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar617 }
618 if entry.open_pull_requests_limit == 0 {
619 return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned()));
620 }
621 let listed: FileList = g1t_kit::call(
622 &self.repos,
623 "list_files",
624 &ListFilesArgs {
625 repo_id: repo.repo_id.clone(),
626 // The branch its updates are for: `target-branch`, or the default.
627 git_ref: target_of(entry, &loaded.default_branch),
628 skip_dirs: SKIPPED_DIRECTORIES.iter().map(|dir| (*dir).to_owned()).collect(),
629 limit: g1t_contracts::repos::MAX_LISTED_FILES,
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.630 links: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar631 },
632 )
633 .await?;
634 let paths: Vec<String> = listed.files.iter().filter(|file| file.hash.is_some()).map(|file| file.path.clone()).collect();
635 let hash_of = |path: &str| listed.files.iter().find(|file| file.path == path).and_then(|file| file.hash.clone());
636 let directories = entry_directories(entry, &paths);
637 if directories.is_empty() {
638 return Ok(Err(format!("No {} manifest was found in {}.", entry.ecosystem, entry.directories.join(", "))));
639 }
640 // Each directory's manifests and lockfiles, read at once.
641 let mut wanted: BTreeMap<String, String> = BTreeMap::new();
642 let mut plan: Vec<(String, Vec<String>, Vec<String>)> = Vec::new();
643 for directory in &directories {
644 let base = directory.trim_start_matches('/');
645 let manifests: Vec<String> = manifests::manifest_names(&entry.ecosystem)
646 .iter()
647 .map(|name| if base.is_empty() { (*name).to_owned() } else { format!("{base}/{name}") })
648 .filter(|path| paths.contains(path))
649 .collect();
650 let lockfiles = lockfiles_for(&entry.ecosystem, directory, &paths);
651 for path in manifests.iter().chain(&lockfiles) {
652 if let Some(hash) = hash_of(path) {
653 wanted.insert(path.clone(), hash);
654 }
655 }
656 plan.push((directory.clone(), manifests, lockfiles));
657 }
658 let mut texts: BTreeMap<String, String> = BTreeMap::new();
659 let entries: Vec<(String, String)> = wanted.into_iter().collect();
660 for chunk in entries.chunks(g1t_contracts::repos::MAX_READ_BLOBS) {
661 let blobs: Vec<BlobText> = g1t_kit::call(
662 &self.repos,
663 "read_blobs",
664 &ReadBlobsArgs { repo_id: repo.repo_id.clone(), hashes: chunk.iter().map(|(_, hash)| hash.clone()).collect(), max_bytes: MAX_BLOB_BYTES },
665 )
666 .await?;
667 for ((path, _), blob) in chunk.iter().zip(blobs) {
668 if let Some(text) = blob.text {
669 texts.insert(path.clone(), text);
670 }
671 }
672 }
673 let mut read: Vec<Directory> = Vec::new();
674 for (path, manifest_paths, lockfiles) in plan {
675 let mut declared = Vec::new();
676 for manifest in &manifest_paths {
677 let name = manifest.rsplit('/').next().unwrap_or(manifest);
678 // A pinned requirements.txt is also read as a lockfile.
679 declared.extend(texts.get(manifest).map(|text| manifests::declared(name, text)).unwrap_or_default());
680 }
681 let mut locked: BTreeMap<String, Vec<String>> = BTreeMap::new();
682 for lockfile in &lockfiles {
683 let Some(kind) = Lockfile::for_path(lockfile) else { continue };
684 for package in texts.get(lockfile).map(|text| kind.parse(text)).unwrap_or_default() {
685 locked.entry(normalize(&entry.ecosystem, &package.name)).or_default().push(package.version);
686 }
687 }
688 read.push(Directory { path, declared, locked, lockfiles });
689 }
690 let (registries, mut notes) = self.entry_registries(repo, loaded, entry).await?;
691 let comments = self.store.ignores(&repo.repo_id).await?;
692 // Every dependency to ask about, once.
693 let mut candidates: Vec<(String, String, DependencyType, String, Option<String>)> = Vec::new();
694 for directory in &read {
695 for (name, kind, current, requirement) in directory_candidates(entry, directory) {
696 if planning::allowed(entry, &name, kind) {
697 candidates.push((directory.path.clone(), name, kind, current, requirement));
698 }
699 }
700 }
701 let names: Vec<(String, String)> = {
702 let mut seen = BTreeMap::new();
703 for (_, name, _, current, _) in &candidates {
704 let lowest = seen.entry(name.clone()).or_insert_with(|| current.clone());
705 if version::compare(current, lowest).is_lt() {
706 *lowest = current.clone();
707 }
708 }
709 seen.into_iter().collect()
710 };
711 if names.len() > MAX_PACKAGES {
712 notes.push(format!("Only the first {MAX_PACKAGES} of {} dependencies were checked this time.", names.len()));
713 }
714 let mut packages: BTreeMap<String, Package> = BTreeMap::new();
715 let mut failures = Vec::new();
716 for chunk in names.iter().take(MAX_PACKAGES).collect::<Vec<_>>().chunks(FETCH_AT_ONCE) {
717 let asks = chunk.iter().map(|(name, current)| self.package(&entry.ecosystem, name, current, &registries));
718 for ((name, _), found) in chunk.iter().zip(futures_util::future::join_all(asks).await) {
719 match found {
720 Ok(Some(package)) => {
721 packages.insert(name.clone(), package);
722 }
723 Ok(None) => {}
724 Err(error) => failures.push(format!("{name} ({error})")),
725 }
726 }
727 }
728 let now = now_ms();
729 let mut planned = Vec::new();
730 let mut up_to_date = 0;
731 let mut ignored = 0;
732 for (directory, name, kind, current, requirement) in candidates {
733 let Some(package) = packages.get(&name) else { continue };
734 let candidate = Candidate { name, directory, kind, current, requirement, package: package.clone() };
735 match planning::target(entry, &comments, &candidate, now) {
736 Ok(update) => planned.push(update),
737 Err(Skip::UpToDate) => up_to_date += 1,
738 Err(Skip::Ignored) => ignored += 1,
739 Err(Skip::NotAllowed) => {}
740 }
741 }
742 let plans = planning::gather(&entry.groups, "version-updates", planned);
743 let existing: Vec<PullRow> = self
744 .store
745 .update_pulls(&repo.repo_id)
746 .await?
747 .into_iter()
748 .filter(|row| row.kind == "version" && row.entry == entry.id())
749 .collect();
750 let (admitted, held) = admit(&plans, &existing, entry.open_pull_requests_limit);
751 let lockfiles_of = |plan: &PullPlan| -> Vec<String> {
752 let mut all: Vec<String> = read
753 .iter()
754 .filter(|directory| plan.directories().contains(&directory.path))
755 .flat_map(|directory| directory.lockfiles.clone())
756 .collect();
757 all.sort();
758 all.dedup();
759 all
760 };
761 let mut asked = 0;
762 for (plan, _) in &admitted {
763 let lockfiles = lockfiles_of(plan);
764 if lockfiles.is_empty() {
765 notes.push(format!("{} has no lockfile, so it was not updated.", plan.directories().join(", ")));
766 continue;
767 }
768 // The same branch as one in progress is replaced in place.
769 let branch = pull_text::branch(entry, plan);
770 let force = existing.iter().any(|row| row.branch == branch && row.state().in_progress());
771 self.ask_version_update(repo, loaded, entry, plan, lockfiles, &registries, force).await?;
772 asked += 1;
773 }
774 let mut summary = format!(
775 "Checked {} {}: {} up to date{}, {} {} asked for",
776 names.len().min(MAX_PACKAGES),
777 if names.len() == 1 { "dependency" } else { "dependencies" },
778 up_to_date,
779 if ignored > 0 { format!(", {ignored} ignored") } else { String::new() },
780 asked,
781 if asked == 1 { "pull request" } else { "pull requests" },
782 );
783 if held > 0 {
784 summary.push_str(&format!(", {held} waiting for open-pull-requests-limit"));
785 }
786 summary.push('.');
787 if !failures.is_empty() {
788 notes.push(format!("These could not be looked up: {}.", failures.join(", ")));
789 }
790 for note in notes {
791 summary.push(' ');
792 summary.push_str(&note);
793 }
794 Ok(Ok(summary))
795 }
796
797 /// Asks the runner for one version update pull request.
798 #[allow(clippy::too_many_arguments)]
799 async fn ask_version_update(
800 &self,
801 repo: &RepoRow,
802 loaded: &Loaded,
803 entry: &Entry,
804 plan: &PullPlan,
805 lockfiles: Vec<String>,
806 registries: &[(Registry, BumpRegistry, Source)],
807 force: bool,
808 ) -> Result<()> {
809 let Some(osv) = osv_ecosystem(&entry.ecosystem) else { return Ok(()) };
810 let branch = pull_text::branch(entry, plan);
811 let title = pull_text::title(entry, plan);
812 let body = pull_text::body(entry, plan, &loaded.file);
813 // One version per package: the highest any directory needs.
814 let mut highest: BTreeMap<String, String> = BTreeMap::new();
815 for update in &plan.updates {
816 let known = highest.entry(update.name.clone()).or_insert_with(|| update.to.clone());
817 if version::compare(&update.to, known).is_gt() {
818 *known = update.to.clone();
819 }
820 }
821 let packages: Vec<BumpPackage> = highest.into_iter().map(|(package, version)| BumpPackage { package, version }).collect();
822 let strategy = match entry.versioning_strategy.as_deref() {
823 Some("lockfile-only") => "lockfile-only",
824 Some("increase-if-necessary") => "increase-if-necessary",
825 Some("widen") => "widen",
826 _ => "increase",
827 };
828 let mut bump = BumpArgs {
829 repo: Self::repo_path(repo),
830 ecosystem: osv.to_owned(),
831 package: packages[0].package.clone(),
832 version: packages[0].version.clone(),
833 lockfiles,
834 branch: branch.clone(),
835 message: pull_text::commit_message(entry, plan),
836 kind: Some("version".to_owned()),
837 packages,
838 strategy: Some(strategy.to_owned()),
839 force,
840 registries: Vec::new(),
841 base: target_of(entry, &loaded.default_branch),
842 };
843 let dependencies: Vec<UpdatedDependency> = plan.updates.iter().map(updated).collect();
844 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
845 let id = self
846 .store
847 .add_update_pull(&NewPull {
848 repo_id: &repo.repo_id,
849 kind: "version",
850 entry: &entry.id(),
851 ecosystem: &entry.ecosystem,
852 subject: &plan.subject(),
853 signature: &plan.signature(),
854 group: plan.group.as_deref(),
855 branch: &branch,
856 title: &title,
857 body: &body,
858 dependencies: &dependencies,
859 bump: &bump,
860 assignees: &people(&entry.assignees),
861 reviewers: &people(&entry.reviewers),
862 })
863 .await?;
864 bump.registries = registries.iter().map(|(_, ready, _)| ready.clone()).collect();
865 if let Err(reason) = self.start_bump(&bump).await {
866 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the update: {reason}"))).await?;
867 }
868 Ok(())
869 }
870
871 async fn start_bump(&self, bump: &BumpArgs) -> std::result::Result<(), String> {
872 match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", bump).await {
873 Ok(Outcome::Ok(_)) => Ok(()),
874 Ok(Outcome::Fail(refused)) => Err(refused.message),
875 Err(error) => Err(format!("the runner could not be reached: {error}")),
876 }
877 }
878
879 /// Makes an open update pull request again from its base, on the same
880 /// branch: a rebase, a recreate, or a reopen.
881 async fn remake(&self, repo: &RepoRow, row: &PullRow) -> std::result::Result<(), String> {
882 let Some(mut bump) = row.bump() else { return Err("it was made before g1t kept how".to_owned()) };
883 bump.force = true;
884 bump.registries.clear();
885 // Credentials are never stored, and g1t's own push is awaited: see
886 // `update_pull_pushed`.
887 self.store.remake_update_pull(&row.id, &bump).await.map_err(|error| error.to_string())?;
888 if row.kind == "version"
889 && let Ok(Some(loaded)) = self.load_config(repo).await
890 && let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == row.entry)
891 && let Ok((registries, _)) = self.entry_registries(repo, &loaded, entry).await
892 {
893 bump.registries = registries.into_iter().map(|(_, ready, _)| ready).collect();
894 }
895 self.start_bump(&bump).await
896 }
897
898 /// An update branch was pushed: its pull request opens, or a remade one
899 /// carries on. Returns whether the branch was one of these.
900 pub async fn update_pull_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<bool> {
901 let Some(row) = self.store.update_pull_by_branch(repo_id, branch).await? else { return Ok(false) };
902 match row.state() {
903 UpdateState::Open => {
904 // g1t's own push after a rebase leaves `head` empty; anyone
905 // else's leaves it, so a rebase knows not to overwrite them.
906 if row.head.is_none() {
907 self.store.set_update_pull_head(&row.id, after).await?;
908 }
909 Ok(true)
910 }
911 UpdateState::Requested => {
912 let Some(repo) = self.store.repo(repo_id).await? else { return Ok(true) };
913 self.open_update_pull(&repo, &row, after).await?;
914 Ok(true)
915 }
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches916 // No longer needed by the time its sandbox pushed: the branch goes.
917 UpdateState::Superseded if row.pull().is_none() => {
918 self.drop_pushed(repo_id, branch, after).await?;
919 Ok(true)
920 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar921 _ => Ok(true),
922 }
923 }
924
925 async fn open_update_pull(&self, repo: &RepoRow, row: &PullRow, after: &str) -> Result<()> {
926 let system = User::system(&repo.namespace);
927 let opened: Outcome<Pull> = g1t_kit::call(
928 &self.work,
929 "open_pull",
930 &OpenPullArgs {
931 actor: system.clone(),
932 repo: Self::repo_path(repo),
933 issue: None,
934 title: row.title.clone(),
935 body: row.body.clone(),
936 branch: Some(row.branch.clone()),
937 agent: String::new(),
938 runtime: Runtime::External,
939 // Into `target-branch`, which it was made from.
940 base: row.bump().and_then(|bump| bump.base),
The API opens a pull request as a draft with "draft": true941 draft: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar942 },
943 )
944 .await?;
945 let pull = match opened {
946 Outcome::Ok(pull) => pull,
947 Outcome::Fail(refused) => {
948 let error = format!("The pull request could not be opened: {}", refused.message);
949 return self.store.set_update_pull(&row.id, UpdateState::Failed, None, None, Some(&error)).await;
950 }
951 };
952 self.store.set_update_pull(&row.id, UpdateState::Open, Some(pull.number), None, None).await?;
953 self.store.set_update_pull_head(&row.id, after).await?;
954 let (assignees, reviewers) = (row.names("assignees"), row.names("reviewers"));
955 // Its labels and milestone, as the entry says now.
956 let entry = match self.load_config(repo).await {
957 Ok(Some(loaded)) => loaded.config.updates.into_iter().find(|entry| entry.id() == row.entry),
958 _ => None,
959 };
960 let labels = config::update_labels(entry.as_ref().and_then(|entry| entry.labels.as_deref()), &row.ecosystem);
961 let milestone = entry.as_ref().and_then(|entry| entry.milestone);
962 let _: Outcome<Value> = g1t_kit::call(
963 &self.work,
964 "update_pull",
965 &UpdatePullArgs {
966 actor: system.clone(),
967 repo: Self::repo_path(repo),
968 number: pull.number,
969 assignees: (!assignees.is_empty()).then_some(assignees),
970 reviewers: (!reviewers.is_empty()).then_some(reviewers),
971 labels: (!labels.is_empty()).then_some(labels),
972 milestone: None,
973 base: None,
974 },
975 )
976 .await?;
977 // Apart, so that a milestone the repository lacks leaves the rest.
978 if milestone.is_some() {
979 let _: Outcome<Value> = g1t_kit::call(
980 &self.work,
981 "update_pull",
982 &UpdatePullArgs {
983 actor: system.clone(),
984 repo: Self::repo_path(repo),
985 number: pull.number,
986 assignees: None,
987 reviewers: None,
988 labels: None,
989 milestone,
990 base: None,
991 },
992 )
993 .await?;
994 }
995 // Older ones for the same dependency or group are replaced.
996 for older in self.store.update_pulls(&repo.repo_id).await? {
997 if older.id == row.id || older.subject != row.subject || older.entry != row.entry || older.state() != UpdateState::Open {
998 continue;
999 }
1000 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 branches1001 if let Some(number) = older.pull()
1002 && let Some(found) = self.get_pull(repo, number).await?
1003 && matches!(found.status, PullStatus::Open | PullStatus::Draft)
1004 && self.close_with(repo, number, format!("Closed: superseded by #{}.", pull.number)).await?
1005 && older.branch != row.branch
1006 {
1007 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 bar1008 }
1009 }
1010 // A grouped security update stands for each package's own.
1011 if row.kind == "security" {
1012 for dependency in row.dependencies() {
1013 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1014 if let Some(update) = self.store.update(&repo.repo_id, osv, &dependency.name).await? {
1015 self.store.set_update(&update, UpdateState::Open, Some(pull.number), None, None).await?;
1016 }
1017 }
1018 }
1019 Ok(())
1020 }
1021
1022 /// A pull request merged or closed: if it is an update's, where it
1023 /// stands now. Returns whether it was one.
1024 pub async fn update_pull_closed(&self, kind: &str, repo_id: &str, number: u32) -> Result<bool> {
1025 let Some(row) = self.store.update_pull_by_number(repo_id, number).await? else { return Ok(false) };
1026 if row.state() != UpdateState::Open {
1027 return Ok(true);
1028 }
1029 let state = if kind == "pull.merged" { UpdateState::Merged } else { UpdateState::Closed };
1030 self.store.set_update_pull(&row.id, state, Some(number), None, None).await?;
1031 if row.kind == "security" {
1032 for dependency in row.dependencies() {
1033 let Some(osv) = osv_ecosystem(&row.ecosystem) else { continue };
1034 if let Some(update) = self.store.update(repo_id, osv, &dependency.name).await? {
1035 self.store.set_update(&update, state, Some(number), None, None).await?;
1036 }
1037 }
1038 }
1039 Ok(true)
1040 }
1041
1042 async fn pull_detail(&self, repo: &RepoRow, number: u32) -> Result<Option<PullDetail>> {
1043 let found: Outcome<PullDetail> = g1t_kit::call(
1044 &self.work,
1045 "get_pull",
1046 &ViewArgs { repo: Self::repo_path(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
1047 )
1048 .await?;
1049 Ok(found.into_result().ok())
1050 }
1051
1052 /// Looks at one open update pull request: merged or closed meanwhile,
1053 /// its checks failing or passing, or in conflict with its base.
1054 async fn watch_update_pull(&self, row: &PullRow) -> Result<()> {
1055 self.store.touch_update_pull(&row.id).await?;
1056 let (Some(repo), Some(number)) = (self.store.repo(&row.repo_id).await?, row.pull()) else { return Ok(()) };
1057 let Some(detail) = self.pull_detail(&repo, number).await? else { return Ok(()) };
1058 match detail.pull.status {
1059 PullStatus::Merged => return self.update_pull_closed("pull.merged", &row.repo_id, number).await.map(|_| ()),
1060 PullStatus::Closed => return self.update_pull_closed("pull.closed", &row.repo_id, number).await.map(|_| ()),
1061 _ => {}
1062 }
1063 let ours = row.head.is_some() && row.head == detail.pull.head_commit;
1064 match checks_verdict(&detail) {
1065 Some(true) if row.merge_by.is_some() => {
1066 let merged: Outcome<Pull> = g1t_kit::call(
1067 &self.work,
1068 "merge_pull",
1069 &PullActionArgs {
1070 actor: User::system(&repo.namespace),
1071 repo: Self::repo_path(&repo),
1072 number,
1073 summary: String::new(),
1074 keep_issue_open: false,
1075 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1076 bypass_rules: false,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1077 },
1078 )
1079 .await?;
1080 if let Outcome::Fail(refused) = merged {
1081 self.store.set_merge_by(&row.id, None).await?;
1082 self.comment(&User::system(&repo.namespace), &Self::repo_path(&repo), number, format!("I could not merge this: {}", refused.message))
1083 .await?;
1084 }
1085 return Ok(());
1086 }
1087 _ => {}
1088 }
1089 let rebases = row.kind == "security" || self.rebase_strategy(&repo, &row.entry) != "disabled";
1090 if detail.mergeable == Mergeable::Conflicting
1091 && ours
1092 && rebases
1093 && let Err(reason) = self.remake(&repo, row).await
1094 {
1095 worker::console_error!("security: update {} not rebased: {reason}", row.id);
1096 }
1097 Ok(())
1098 }
1099
1100 /// An entry's `rebase-strategy`, as last read.
1101 fn rebase_strategy(&self, repo: &RepoRow, entry: &str) -> String {
1102 repo.version_updates()
1103 .updates
1104 .iter()
1105 .find(|found| found.id == entry)
1106 .and_then(|found| found.options.get("rebase-strategy").and_then(Value::as_str).map(str::to_owned))
1107 .unwrap_or_else(|| "auto".to_owned())
1108 }
1109
1110 /// A comment on an update pull request: a command, if it is one.
1111 pub async fn update_comment(&self, event: &Event) -> Result<()> {
1112 #[derive(Deserialize)]
1113 #[serde(rename_all = "camelCase")]
1114 struct Commented {
1115 comment_id: String,
1116 repo_id: String,
1117 number: u32,
1118 #[serde(default)]
1119 pull_id: Option<String>,
1120 }
1121 let Ok(commented) = serde_json::from_value::<Commented>(event.data.clone()) else { return Ok(()) };
1122 if commented.pull_id.is_none() || event.actor.as_deref().is_some_and(g1t_contracts::system::is_system_id) {
1123 return Ok(());
1124 }
1125 let row = self.store.update_pull_by_number(&commented.repo_id, commented.number).await?;
1126 let single = self.store.update_by_pull(&commented.repo_id, commented.number).await?;
1127 if row.is_none() && single.is_none() {
1128 return Ok(());
1129 }
1130 let Some(repo) = self.store.repo(&commented.repo_id).await? else { return Ok(()) };
1131 let Some(detail) = self.pull_detail(&repo, commented.number).await? else { return Ok(()) };
1132 let Some(comment) = detail.comments.iter().find(|comment| comment.id == commented.comment_id) else { return Ok(()) };
1133 let Some(command) = update_command(&comment.body) else { return Ok(()) };
1134 let author = serde_json::from_value::<CommentAuthor>(serde_json::to_value(&comment.author)?).map(|a| a.username).unwrap_or_default();
1135 let system = User::system(&repo.namespace);
1136 let path = Self::repo_path(&repo);
1137 let needed = if matches!(command, UpdateCommand::Merge | UpdateCommand::SquashAndMerge | UpdateCommand::CancelMerge) {
1138 Capability::Merge
1139 } else {
1140 Capability::Push
1141 };
1142 let permission: Outcome<PermissionInfo> = g1t_kit::call(
1143 &self.identity,
1144 "collaborator_permission",
1145 &CollaboratorPermissionArgs { viewer: Some(system.clone()), path: path.clone(), username: author.clone() },
1146 )
1147 .await?;
1148 let allowed = matches!(&permission, Outcome::Ok(info) if info.capabilities.contains(&needed));
1149 if !allowed {
1150 let text = format!("@{author}, that takes the {} role on this repository.", if needed == Capability::Merge { "Maintain or Write" } else { "Write" });
1151 return self.comment(&system, &path, commented.number, text).await;
1152 }
1153 let Some(row) = row else {
1154 // A security update for one package: the commands that make sense for it.
1155 return self.single_update_command(&repo, single.as_ref(), &command, &author, commented.number, &detail).await;
1156 };
1157 let reply = self.command(&repo, &row, &command, &author, &detail).await?;
1158 if let Some(reply) = reply {
1159 self.comment(&system, &path, commented.number, reply).await?;
1160 }
1161 Ok(())
1162 }
1163
1164 /// Acts on a command on an update pull request; what to say back.
1165 async fn command(&self, repo: &RepoRow, row: &PullRow, command: &UpdateCommand, author: &str, detail: &PullDetail) -> Result<Option<String>> {
1166 let number = detail.pull.number;
1167 let open = detail.pull.status.is_active();
1168 let ignore = |dependency: &str, versions: Option<String>, update_type: Option<String>| IgnoreCondition {
1169 ecosystem: row.ecosystem.clone(),
1170 dependency: dependency.to_owned(),
1171 versions,
1172 update_type,
1173 by: author.to_owned(),
1174 pull: Some(number),
1175 at: String::new(),
1176 };
1177 let dependencies = row.dependencies();
1178 Ok(Some(match command {
1179 UpdateCommand::Rebase | UpdateCommand::Recreate => {
1180 if !open {
1181 return Ok(Some(format!("@{author}, this pull request is {}; `@g1t reopen` opens it again.", detail.pull.status.as_str())));
1182 }
1183 let pushed_by_others = row.head.is_some() && row.head != detail.pull.head_commit;
1184 if *command == UpdateCommand::Rebase && pushed_by_others {
1185 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.")));
1186 }
1187 match self.remake(repo, row).await {
1188 Ok(()) => format!("@{author}, {} this pull request from its base branch now.", if *command == UpdateCommand::Rebase { "rebasing" } else { "recreating" }),
1189 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1190 }
1191 }
1192 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => {
1193 if !open {
1194 return Ok(Some(format!("@{author}, this pull request is already {}.", detail.pull.status.as_str())));
1195 }
1196 self.store.set_merge_by(&row.id, Some(author)).await?;
1197 match checks_verdict(detail) {
1198 Some(false) => format!("@{author}, its required checks are failing; it merges once they pass."),
1199 Some(true) => {
1200 self.watch_update_pull(row).await?;
1201 return Ok(None);
1202 }
1203 None => format!("@{author}, it merges once its required checks pass."),
1204 }
1205 }
1206 UpdateCommand::CancelMerge => {
1207 self.store.set_merge_by(&row.id, None).await?;
1208 format!("@{author}, it will not merge on its own now.")
1209 }
1210 UpdateCommand::Close => {
1211 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1212 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?;
1213 return Ok(None);
1214 }
1215 UpdateCommand::Reopen => {
1216 if open {
1217 return Ok(Some(format!("@{author}, this pull request is already open.")));
1218 }
1219 self.store.set_update_pull(&row.id, UpdateState::Requested, None, None, None).await?;
1220 match self.remake(repo, row).await {
1221 Ok(()) => format!("@{author}, making it again; it opens as a new pull request on the same branch."),
1222 Err(reason) => format!("@{author}, I could not start that: {reason}"),
1223 }
1224 }
1225 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1226 if row.group_name.is_some() && dependencies.len() > 1 {
1227 return Ok(Some(format!(
1228 "@{author}, this pull request updates several dependencies. Name one: `@g1t ignore <dependency>`, or `@g1t ignore <dependency> major version`."
1229 )));
1230 }
1231 for dependency in &dependencies {
1232 let condition = match command {
1233 UpdateCommand::IgnoreVersion { level } => ignore(&dependency.name, Some(ranges::ignore_level(&dependency.to, level)), None),
1234 _ => ignore(&dependency.name, None, None),
1235 };
1236 self.store.add_ignore(&repo.repo_id, &condition).await?;
1237 }
1238 self.store.set_update_pull(&row.id, UpdateState::Closed, row.pull(), None, None).await?;
1239 let what = match command {
1240 UpdateCommand::IgnoreVersion { level } => format!("this {level} version"),
1241 _ => "this dependency".to_owned(),
1242 };
1243 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("*")))
1244 .await?;
1245 return Ok(None);
1246 }
1247 UpdateCommand::IgnoreNamed { dependency, level } => {
1248 let Some(found) = dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency)) else {
1249 return Ok(Some(format!("@{author}, this pull request does not update {dependency}.")));
1250 };
1251 let condition = match level {
1252 Some(level) => ignore(&found.name, Some(ranges::ignore_level(&found.to, level)), None),
1253 None => ignore(&found.name, None, None),
1254 };
1255 self.store.add_ignore(&repo.repo_id, &condition).await?;
1256 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())
1257 }
1258 UpdateCommand::Unignore { dependency, level } => {
1259 let condition = match (level, dependencies.iter().find(|d| d.name.eq_ignore_ascii_case(dependency))) {
1260 (Some(level), Some(found)) => Some(ranges::ignore_level(&found.to, level)),
1261 _ => None,
1262 };
1263 let removed = self.store.remove_ignores(&repo.repo_id, &row.ecosystem, dependency, condition.as_deref()).await?;
1264 if removed == 0 {
1265 format!("@{author}, nothing was being ignored for {dependency}.")
1266 } else {
1267 format!("@{author}, removed {removed} ignore {} for {dependency}.", if removed == 1 { "condition" } else { "conditions" })
1268 }
1269 }
1270 UpdateCommand::ShowIgnores { dependency } => {
1271 let names: Vec<String> = match dependency {
1272 Some(name) => vec![name.clone()],
1273 None => dependencies.iter().map(|d| d.name.clone()).collect(),
1274 };
1275 self.ignore_report(repo, &row.ecosystem, &row.entry, &names).await?
1276 }
1277 }))
1278 }
1279
1280 /// The ignore conditions on some dependencies, from the file and from
1281 /// comments, as a reply.
1282 async fn ignore_report(&self, repo: &RepoRow, ecosystem: &str, entry: &str, names: &[String]) -> Result<String> {
1283 let state = repo.version_updates();
1284 let rules = state.updates.iter().find(|found| found.id == entry).map(|found| found.ignore.clone()).unwrap_or_default();
1285 let comments = self.store.ignores(&repo.repo_id).await?;
1286 let mut lines = Vec::new();
1287 for name in names {
1288 for rule in rules.iter().filter(|rule| config::matches(&rule.dependency, name)) {
1289 let what = if !rule.versions.is_empty() {
1290 format!("versions {}", rule.versions.join(", "))
1291 } else if !rule.update_types.is_empty() {
1292 rule.update_types.join(", ")
1293 } else {
1294 "every version".to_owned()
1295 };
1296 lines.push(format!("- `{name}`: {what} (in the dependency update file, `{}`)", rule.dependency));
1297 }
1298 for condition in comments.iter().filter(|c| c.ecosystem == ecosystem && c.dependency.eq_ignore_ascii_case(name)) {
1299 let what = condition
1300 .versions
1301 .clone()
1302 .map(|versions| format!("versions `{versions}`"))
1303 .or_else(|| condition.update_type.clone())
1304 .unwrap_or_else(|| "every version".to_owned());
1305 lines.push(format!(
1306 "- `{name}`: {what} (asked by @{}{})",
1307 condition.by,
1308 condition.pull.map(|pull| format!(" in #{pull}")).unwrap_or_default()
1309 ));
1310 }
1311 }
1312 Ok(if lines.is_empty() {
1313 format!("Nothing is ignored for {}.", names.join(", "))
1314 } else {
1315 format!("Ignore conditions:\n\n{}", lines.join("\n"))
1316 })
1317 }
1318
1319 /// Commands on a security update for one package.
1320 async fn single_update_command(
1321 &self,
1322 repo: &RepoRow,
1323 update: Option<&crate::store::UpdateRow>,
1324 command: &UpdateCommand,
1325 author: &str,
1326 number: u32,
1327 detail: &PullDetail,
1328 ) -> Result<()> {
1329 let Some(update) = update else { return Ok(()) };
1330 let system = User::system(&repo.namespace);
1331 let path = Self::repo_path(repo);
1332 let Some(ecosystem) = package_ecosystem(&update.ecosystem) else { return Ok(()) };
1333 let ignore = |versions: Option<String>| IgnoreCondition {
1334 ecosystem: ecosystem.to_owned(),
1335 dependency: update.package.clone(),
1336 versions,
1337 update_type: None,
1338 by: author.to_owned(),
1339 pull: Some(number),
1340 at: String::new(),
1341 };
1342 let reply = match command {
1343 UpdateCommand::Close => {
1344 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1345 self.close_with(repo, number, format!("@{author}, closed. g1t will not open a security update for {} {} again.", update.package, update.target)).await?;
1346 return Ok(());
1347 }
1348 UpdateCommand::IgnoreDependency | UpdateCommand::IgnoreVersion { .. } => {
1349 let versions = match command {
1350 UpdateCommand::IgnoreVersion { level } => Some(ranges::ignore_level(&update.target, level)),
1351 _ => None,
1352 };
1353 self.store.add_ignore(&repo.repo_id, &ignore(versions)).await?;
1354 self.store.set_update(update, UpdateState::Closed, Some(number), None, None).await?;
1355 self.close_with(repo, number, format!("@{author}, g1t will skip that for {} from now on. `@g1t unignore {}` undoes it.", update.package, update.package)).await?;
1356 return Ok(());
1357 }
1358 UpdateCommand::Unignore { dependency, .. } => {
1359 let removed = self.store.remove_ignores(&repo.repo_id, ecosystem, dependency, None).await?;
1360 format!("@{author}, removed {removed} ignore conditions for {dependency}.")
1361 }
1362 UpdateCommand::ShowIgnores { dependency } => {
1363 let name = dependency.clone().unwrap_or_else(|| update.package.clone());
1364 self.ignore_report(repo, ecosystem, "", &[name]).await?
1365 }
1366 UpdateCommand::Merge | UpdateCommand::SquashAndMerge => match checks_verdict(detail) {
1367 Some(true) => {
1368 let merged: Outcome<Pull> = g1t_kit::call(
1369 &self.work,
1370 "merge_pull",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1371 &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 bar1372 )
1373 .await?;
1374 match merged {
1375 Outcome::Ok(_) => return Ok(()),
1376 Outcome::Fail(refused) => format!("@{author}, I could not merge this: {}", refused.message),
1377 }
1378 }
1379 _ => format!("@{author}, its required checks have not passed yet; merge it once they do."),
1380 },
1381 _ => format!("@{author}, a security update is made again by `Re-scan now` on the Security page; this command is for version updates."),
1382 };
1383 self.comment(&system, &path, number, reply).await
1384 }
1385
1386 /// A pull request that changes the dependency update file gets a
1387 /// status saying whether the file is valid.
1388 pub async fn check_dependabot_file(&self, event: &Event) -> Result<()> {
1389 #[derive(Deserialize)]
1390 #[serde(rename_all = "camelCase")]
1391 struct Changed {
1392 repo_id: String,
1393 number: u32,
1394 }
1395 let Ok(changed) = serde_json::from_value::<Changed>(event.data.clone()) else { return Ok(()) };
1396 let Some(repo) = self.store.repo(&changed.repo_id).await? else { return Ok(()) };
1397 let Some(detail) = self.pull_detail(&repo, changed.number).await? else { return Ok(()) };
1398 let pull = &detail.pull;
1399 let Some(file) = pull.files.iter().find(|file| DEPENDABOT_PATHS.contains(&file.path.as_str())) else { return Ok(()) };
1400 let Some(head) = pull.head_commit.clone() else { return Ok(()) };
1401 let found: Option<RawFile> = g1t_kit::call(
1402 &self.repos,
1403 "raw_file",
1404 &RawFileArgs {
1405 repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.repo_id.clone()),
1406 git_ref: head.clone(),
1407 path: file.path.clone(),
1408 max_bytes: 1_000_000,
1409 },
1410 )
1411 .await?;
1412 let (state, description) = match found.and_then(|raw| base64_decode(&raw.data)).map(String::from_utf8) {
1413 None => ("success".to_owned(), format!("{} is removed.", file.path)),
1414 Some(Err(_)) => ("failure".to_owned(), format!("{} is not text.", file.path)),
1415 Some(Ok(text)) => {
1416 let read = config::read(&text);
1417 match read.problems.first() {
1418 None => ("success".to_owned(), format!("{} is valid: {} entries.", file.path, read.config.updates.len())),
1419 Some(problem) => {
1420 let more = if read.problems.len() > 1 { format!(" (and {} more)", read.problems.len() - 1) } else { String::new() };
1421 ("failure".to_owned(), format!("{}{more}", problem.sentence()))
1422 }
1423 }
1424 }
1425 };
1426 let site = format!("https://g1t.sh/{}/{}/blob/{head}/{}", repo.namespace, repo.name, file.path);
1427 let _: Outcome<bool> = g1t_kit::call(
1428 &self.work,
1429 "set_commit_status",
1430 &SetCommitStatusArgs {
1431 repo_id: repo.repo_id.clone(),
1432 sha: head,
1433 context: DEPENDABOT_CHECK.to_owned(),
1434 state,
1435 description: Some(description.chars().take(400).collect()),
1436 target_url: Some(site),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1437 source: Some("security".to_owned()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1438 },
1439 )
1440 .await?;
1441 Ok(())
1442 }
1443
1444 /// What the Security page shows: the file as last read, with each
1445 /// entry's runs, the update pull requests, and comment ignores.
1446 pub async fn version_updates_view(&self, repo: &RepoRow) -> Result<VersionUpdatesState> {
1447 let mut state = repo.version_updates();
1448 let runs = self.store.runs(&repo.repo_id).await?;
1449 for entry in &mut state.updates {
1450 if let Some(run) = runs.iter().find(|run| run.entry == entry.id) {
1451 entry.next_run_at = run.next_run_at.clone();
1452 entry.last_checked_at = run.last_checked_at.clone();
1453 entry.last_result = run.last_result.clone();
1454 entry.last_error = run.last_error.clone();
1455 }
1456 }
1457 let rows = self.store.update_pulls(&repo.repo_id).await?;
1458 let (live, done): (Vec<&PullRow>, Vec<&PullRow>) = rows.iter().partition(|row| row.state().in_progress());
1459 state.pulls = live.into_iter().chain(done.into_iter().take(20)).map(PullRow::to_contract).collect();
1460 state.ignores = self.store.ignores(&repo.repo_id).await?;
1461 Ok(state)
1462 }
1463}
1464
1465/// Whether g1t runs an entry: an ecosystem it updates, and pull requests
1466/// it can open (`open-pull-requests-limit` above 0), into the default
1467/// branch or the entry's `target-branch`.
1468pub fn runnable(entry: &Entry, _default_branch: Option<&str>) -> bool {
1469 entry.supported() && entry.open_pull_requests_limit > 0 && entry.schedule.is_some()
1470}
1471
1472/// The branch an entry's updates start from and merge into, when it is not
1473/// the default branch.
1474pub fn target_of(entry: &Entry, default_branch: &str) -> Option<String> {
1475 entry.target_branch.clone().filter(|branch| !branch.is_empty() && branch != default_branch)
1476}
1477
1478fn updated(update: &Planned) -> UpdatedDependency {
1479 UpdatedDependency {
1480 name: update.name.clone(),
1481 from: update.from.clone(),
1482 to: update.to.clone(),
1483 directory: update.directory.clone(),
1484 dependency_type: update.kind.label().to_owned(),
1485 update_type: format!("version-update:semver-{}", update.level),
1486 }
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use super::*;
1492 use crate::config::read;
1493 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.1494 use serde_json::json;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1495
1496 fn entry(extra: &str) -> Entry {
1497 let found = read(&format!("version: 2\nupdates:\n - package-ecosystem: npm\n{extra} schedule: {{interval: daily}}\n"));
1498 assert!(found.problems.is_empty(), "{:?}", found.problems);
1499 found.config.updates.into_iter().next().unwrap()
1500 }
1501
1502 #[test]
1503 fn base64_round_trips() {
1504 for text in ["", "a", "ab", "abc", "user:pa$$word", "version: 2\n"] {
1505 assert_eq!(base64_decode(&base64_encode(text.as_bytes())).unwrap(), text.as_bytes());
1506 }
1507 assert_eq!(base64_encode(b"ci:secret"), "Y2k6c2VjcmV0");
1508 }
1509
1510 #[test]
1511 fn secrets_are_filled_in() {
1512 let mut secrets = serde_json::Map::new();
1513 secrets.insert("TOKEN".into(), json!("t0k"));
1514 assert_eq!(fill_secrets("${{secrets.TOKEN}}", &secrets), ("t0k".to_owned(), vec![]));
1515 assert_eq!(fill_secrets("Bearer ${{ secrets.TOKEN }}!", &secrets).0, "Bearer t0k!");
1516 assert_eq!(fill_secrets("${{secrets.NOPE}}", &secrets).1, ["NOPE"]);
1517 assert_eq!(fill_secrets("plain", &secrets).0, "plain");
1518 }
1519
1520 #[test]
1521 fn directories_from_globs_and_exclusions() {
1522 let files: Vec<String> = ["package.json", "apps/web/package.json", "apps/admin/package.json", "apps/web/vendor/x/package.json", "docs/readme.md"]
1523 .map(str::to_owned)
1524 .to_vec();
1525 assert_eq!(entry_directories(&entry(" directory: /\n"), &files), ["/"]);
1526 assert_eq!(entry_directories(&entry(" directories: [\"/apps/*\"]\n"), &files), ["/apps/admin", "/apps/web"]);
1527 assert_eq!(
1528 entry_directories(&entry(" directories: [\"/apps/**\"]\n exclude-paths: [\"**/vendor/**\"]\n"), &files),
1529 ["/apps/admin", "/apps/web"]
1530 );
1531 assert!(entry_directories(&entry(" directory: /missing\n"), &files).is_empty());
1532 let locks: Vec<String> = ["package-lock.json", "apps/web/package.json", "Cargo.lock"].map(str::to_owned).to_vec();
1533 assert_eq!(lockfiles_for("npm", "/apps/web", &locks), ["package-lock.json"]);
1534 assert!(lockfiles_for("pip", "/", &locks).is_empty());
1535 }
1536
1537 #[test]
1538 fn current_versions_come_from_the_lockfile() {
1539 let directory = Directory {
1540 path: "/".into(),
1541 declared: manifests::package_json(r#"{"dependencies":{"lodash":"^4.17.0","left-pad":"1.0.0"},"devDependencies":{"vitest":"^1"}}"#),
1542 locked: BTreeMap::from([
1543 ("lodash".to_owned(), vec!["4.17.20".to_owned(), "3.10.1".to_owned()]),
1544 ("minimist".to_owned(), vec!["1.2.0".to_owned()]),
1545 ]),
1546 lockfiles: vec!["package-lock.json".into()],
1547 };
1548 let found = directory_candidates(&entry(" directory: /\n"), &directory);
1549 assert_eq!(found, [("lodash".to_owned(), DependencyType::Production, "4.17.20".to_owned(), Some("^4.17.0".to_owned()))]);
1550 let go = Directory {
1551 path: "/".into(),
1552 declared: manifests::go_mod("require golang.org/x/net v0.7.0\n"),
1553 locked: BTreeMap::new(),
1554 lockfiles: vec!["go.mod".into()],
1555 };
1556 let mut go_entry = entry(" directory: /\n");
1557 go_entry.ecosystem = "gomod".into();
1558 let found = directory_candidates(&go_entry, &go);
1559 assert_eq!(found[0].2, "v0.7.0");
1560 }
1561
1562 fn row(subject: &str, signature: &str, state: &str) -> PullRow {
1563 PullRow {
1564 id: format!("upd_{subject}_{state}"),
1565 repo_id: "rep_1".into(),
1566 kind: "version".into(),
1567 entry: "npm:/".into(),
1568 ecosystem: "npm".into(),
1569 subject: subject.into(),
1570 signature: signature.into(),
1571 group_name: None,
1572 branch: "g1t/npm_and_yarn/x".into(),
1573 title: String::new(),
1574 body: String::new(),
1575 dependencies: "[]".into(),
1576 bump: "{}".into(),
1577 assignees: "[]".into(),
1578 reviewers: "[]".into(),
1579 state: state.into(),
1580 pull: Some(3),
Updates that g1t opened a "needs code changes" issue for are put where updates go now: the sweep closes the issue as not planned, saying where it is tracked, and marks the update failed, so a security update shows why on its alert and is tried again, and a version update opens again as a pull request with its checks. An alert's history no longer repeats an entry that says the same as its latest one, so an update that fails the same way each day adds it once.1581 issue: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1582 head: None,
1583 merge_by: None,
1584 error: None,
1585 updated_at: String::new(),
1586 }
1587 }
1588
1589 fn plan(name: &str, to: &str) -> PullPlan {
1590 PullPlan {
1591 group: None,
1592 by_name: false,
1593 updates: vec![Planned {
1594 name: name.into(),
1595 directory: "/".into(),
1596 kind: DependencyType::Production,
1597 from: "1.0.0".into(),
1598 to: to.into(),
1599 level: "minor",
1600 source: None,
1601 changelog: None,
1602 page: None,
1603 }],
1604 }
1605 }
1606
1607 #[test]
1608 fn the_limit_counts_open_pull_requests_and_replacements_do_not() {
1609 let plans = vec![plan("a", "1.1.0"), plan("b", "1.1.0"), plan("c", "1.1.0"), plan("d", "1.1.0")];
1610 let existing = vec![
1611 row("dependency:/:a", "/a@1.0.5", "open"),
1612 row("dependency:/:b", "/b@1.1.0", "open"),
1613 row("dependency:/:c", "/c@1.1.0", "closed"),
1614 ];
1615 let (admitted, held) = admit(&plans, &existing, 3);
1616 let names: Vec<(&str, bool)> = admitted.iter().map(|(plan, older)| (plan.updates[0].name.as_str(), older.is_some())).collect();
1617 // a replaces its older pull request; b is open already; c was closed by a person for this version; d is new, and fits.
1618 assert_eq!(names, [("a", true), ("d", false)]);
1619 assert_eq!(held, 0);
1620 let (admitted, held) = admit(&plans, &existing, 2);
1621 assert_eq!(admitted.len(), 1);
1622 assert_eq!(held, 1);
1623 }
1624
1625 #[test]
1626 fn checks_decide_failure_and_success() {
1627 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
Merge checks: statuses and check runs on every commit1628 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 bar1629 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
1630 let mut detail: PullDetail = serde_json::from_value(json!({
1631 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
1632 "status": "open", "fork": null, "forkRepoId": null, "branch": "b", "headCommit": "c", "mergeBase": null, "mergedBy": null,
1633 "mergedAt": null, "supersededBy": null, "checkStatus": null,
1634 "author": {"id": "g1t", "username": "g1t", "kind": "system"}, "createdAt": "", "updatedAt": ""},
1635 "issue": null, "comments": [], "checks": null
1636 }))
1637 .unwrap();
1638 detail.required_checks = required;
1639 detail.statuses = statuses;
1640 detail
1641 };
1642 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success)], vec![])), Some(true));
1643 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Success), check(RequiredState::Failure)], vec![])), Some(false));
1644 assert_eq!(checks_verdict(&detail(vec![check(RequiredState::Pending)], vec![])), None);
1645 assert_eq!(checks_verdict(&detail(vec![], vec![status("error")])), Some(false));
1646 assert_eq!(checks_verdict(&detail(vec![], vec![status("pending")])), None);
1647 assert_eq!(checks_verdict(&detail(vec![], vec![])), Some(true));
1648 }
1649
1650 #[test]
1651 fn which_entries_run() {
1652 assert!(runnable(&entry(" directory: /\n"), Some("main")));
1653 assert!(!runnable(&entry(" directory: /\n open-pull-requests-limit: 0\n"), Some("main")));
1654 assert!(runnable(&entry(" directory: /\n target-branch: main\n"), Some("main")));
1655 assert!(runnable(&entry(" directory: /\n target-branch: develop\n"), Some("main")));
1656 assert_eq!(target_of(&entry(" directory: /\n target-branch: develop\n"), "main").as_deref(), Some("develop"));
1657 assert_eq!(target_of(&entry(" directory: /\n target-branch: main\n"), "main"), None);
1658 assert_eq!(target_of(&entry(" directory: /\n"), "main"), None);
1659 assert_eq!(osv_ecosystem("gomod"), Some("Go"));
1660 assert_eq!(package_ecosystem("crates.io"), Some("cargo"));
1661 }
1662}

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