Skip to content

g1t/crates/contracts/src/updates.rs

430 lines17,645 bytesCodeBlame

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//! Dependency updates: the file that asks for them, written in
2//! `dependabot.yml` (version 2) syntax, and the pull requests g1t opens
3//! from it. Version updates keep dependencies current on a schedule;
4//! security updates (see [`crate::security`]) raise a vulnerable one to its
5//! fix, and follow the same file. Mirrors `packages/contracts/src/updates.ts`.
6
7use serde::{Deserialize, Serialize};
8
9use crate::User;
10use crate::repos::RepoPath;
11
12/// Where the dependency update file may be, in the order it is looked
13/// for. A repository brought to g1t keeps its `.github/dependabot.yml` as
14/// it is. A file under `.g1t/` is read in place of one under `.github/`,
15/// which is then reported as ignored.
16pub const DEPENDABOT_PATHS: [&str; 4] =
17 [".g1t/dependabot.yml", ".g1t/dependabot.yaml", ".github/dependabot.yml", ".github/dependabot.yaml"];
18
19/// The status a pull request that changes the dependency update file gets
20/// on its head: whether the file is valid.
21pub const DEPENDABOT_CHECK: &str = "g1t / dependabot.yml";
22
23/// One thing wrong with the dependency update file, and where.
24#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct ConfigProblem {
27 /// 1-based; 0 when the problem is with the file as a whole.
28 pub line: u32,
29 pub column: u32,
30 /// The key it is about, as a path: `updates[0].schedule.interval`.
31 pub key: String,
32 pub message: String,
33}
34
35impl ConfigProblem {
36 /// `line 4, updates[0].schedule.interval: …`, as one line of text.
37 pub fn sentence(&self) -> String {
38 let at = if self.key.is_empty() { String::new() } else { format!("{}: ", self.key) };
39 if self.line == 0 { format!("{at}{}", self.message) } else { format!("line {}, {at}{}", self.line, self.message) }
40 }
41}
42
43/// What the dependency update file asks for, as last read from the
44/// default branch, and where each entry's version updates stand.
45#[derive(Clone, Debug, Default, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct VersionUpdatesState {
48 /// Whether a file was found on the default branch.
49 pub found: bool,
50 /// The file read: one of [`DEPENDABOT_PATHS`].
51 #[serde(default)]
52 pub path: Option<String>,
53 /// Other dependency update files on the branch, not read because
54 /// `path` comes first.
55 #[serde(default)]
56 pub ignored_paths: Vec<String>,
57 /// The first problem, as a sentence; none when the file is valid.
58 pub error: Option<String>,
59 /// Every problem, with its line. A file with problems is not acted on.
60 #[serde(default)]
61 pub problems: Vec<ConfigProblem>,
62 /// Each entry under `updates`, as read.
63 pub updates: Vec<VersionUpdateEntry>,
64 /// The private registries under `registries`, without their secrets.
65 #[serde(default)]
66 pub registries: Vec<UpdateRegistry>,
67 /// When it was last read, RFC 3339.
68 pub read_at: Option<String>,
69 /// The commit it was read at.
70 #[serde(default)]
71 pub commit: Option<String>,
72 /// Version and security update pull requests g1t has open or is
73 /// making, newest first.
74 #[serde(default)]
75 pub pulls: Vec<UpdatePull>,
76 /// Ignore conditions people set with `@g1t ignore …` comments. Those
77 /// in the file are on each entry.
78 #[serde(default)]
79 pub ignores: Vec<IgnoreCondition>,
80}
81
82/// One entry of the file's `updates`.
83#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct VersionUpdateEntry {
86 /// Stable while the entry's ecosystem, directories and target branch
87 /// stay the same: what "Check for updates" names.
88 pub id: String,
89 /// `package-ecosystem`, as written: `npm`, `cargo`, `gomod`, `pip`, …
90 pub ecosystem: String,
91 /// `directory`, or each of `directories`, from the repository's root.
92 pub directories: Vec<String>,
93 /// Whether g1t opens version update pull requests for this ecosystem.
94 /// One it does not is still read and checked.
95 pub supported: bool,
96 /// `schedule.interval`: `daily`, `weekly`, … or `cron`.
97 pub interval: String,
98 /// The schedule in words: "Weekdays at 05:00 (UTC)".
99 pub schedule: String,
100 pub open_pull_requests_limit: u32,
101 #[serde(default)]
102 pub target_branch: Option<String>,
103 #[serde(default)]
104 pub multi_ecosystem_group: Option<String>,
105 pub groups: Vec<UpdateGroup>,
106 pub ignore: Vec<UpdateIgnore>,
107 #[serde(default)]
108 pub allow: Vec<UpdateAllow>,
109 /// None for the default labels; empty for none.
110 #[serde(default)]
111 pub labels: Option<Vec<String>>,
112 #[serde(default)]
113 pub assignees: Vec<String>,
114 #[serde(default)]
115 pub reviewers: Vec<String>,
116 #[serde(default)]
117 pub milestone: Option<u32>,
118 #[serde(default)]
119 pub versioning_strategy: Option<String>,
120 /// The entry as read, with the file's own key names, to show in full.
121 #[serde(default)]
122 pub options: serde_json::Value,
123 /// Options the entry sets that g1t reads but does not act on, each
124 /// with why.
125 #[serde(default)]
126 pub notes: Vec<String>,
127 /// When it is next checked, RFC 3339. None for an ecosystem g1t does
128 /// not update, or with `open-pull-requests-limit: 0`.
129 #[serde(default)]
130 pub next_run_at: Option<String>,
131 #[serde(default)]
132 pub last_checked_at: Option<String>,
133 /// What the last check found, in a sentence.
134 #[serde(default)]
135 pub last_result: Option<String>,
136 /// Why the last check failed, if it did.
137 #[serde(default)]
138 pub last_error: Option<String>,
139}
140
141/// A `groups` rule.
142#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct UpdateGroup {
145 pub name: String,
146 /// `version-updates` or `security-updates`.
147 pub applies_to: String,
148 /// Package names, with `*` for any run of characters; every package
149 /// when empty.
150 pub patterns: Vec<String>,
151 #[serde(default)]
152 pub exclude_patterns: Vec<String>,
153 /// `major`, `minor`, `patch`; every one when empty.
154 #[serde(default)]
155 pub update_types: Vec<String>,
156 /// `production` or `development`.
157 #[serde(default)]
158 pub dependency_type: Option<String>,
159 /// `dependency-name`: one pull request per dependency across every
160 /// directory.
161 #[serde(default)]
162 pub group_by: Option<String>,
163}
164
165/// An `ignore` rule.
166#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct UpdateIgnore {
169 /// A package name, with `*` for any run of characters; `*` when the
170 /// rule names none.
171 pub dependency: String,
172 /// Version requirements to skip, such as `>=5`; all when empty.
173 pub versions: Vec<String>,
174 /// `version-update:semver-major`, `…-minor`, `…-patch`.
175 #[serde(default)]
176 pub update_types: Vec<String>,
177}
178
179/// An `allow` rule.
180#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct UpdateAllow {
183 #[serde(default)]
184 pub dependency: Option<String>,
185 /// `direct`, `indirect`, `all`, `production` or `development`.
186 #[serde(default)]
187 pub dependency_type: Option<String>,
188 #[serde(default)]
189 pub update_types: Vec<String>,
190}
191
192/// A private registry from the file's top-level `registries`. Credentials
193/// are never kept or shown: only the secrets they name.
194#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct UpdateRegistry {
197 pub name: String,
198 /// `npm-registry`, `cargo-registry`, `python-index`, …
199 pub kind: String,
200 pub url: String,
201 /// The secrets its credentials name: `${{secrets.NAME}}`.
202 #[serde(default)]
203 pub secrets: Vec<String>,
204}
205
206/// One dependency an update pull request raises.
207#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct UpdatedDependency {
210 pub name: String,
211 pub from: String,
212 pub to: String,
213 /// From the repository's root: `/`, `/web`.
214 #[serde(default)]
215 pub directory: String,
216 /// `direct:production`, `direct:development` or `indirect`.
217 #[serde(default)]
218 pub dependency_type: String,
219 /// `version-update:semver-major`, `…-minor` or `…-patch`.
220 #[serde(default)]
221 pub update_type: String,
222}
223
224/// A pull request g1t opened, or is making, to update dependencies.
225#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct UpdatePull {
228 /// `version` or `security`.
229 pub kind: String,
230 /// The `updates` entry it is for ([`VersionUpdateEntry::id`]).
231 pub entry: String,
232 /// `package-ecosystem`.
233 pub ecosystem: String,
234 /// The `groups` rule it is for, if any.
235 #[serde(default)]
236 pub group: Option<String>,
237 pub branch: String,
238 pub title: String,
239 /// `requested`, `open`, `merged`, `closed`, `superseded`,
240 /// `needs_code` or `failed`.
241 pub state: String,
242 pub pull: Option<u32>,
243 pub dependencies: Vec<UpdatedDependency>,
244 /// Who asked for it to merge once its checks pass (`@g1t merge`).
245 #[serde(default)]
246 pub merge_requested_by: Option<String>,
247 #[serde(default)]
248 pub error: Option<String>,
249 /// RFC 3339.
250 pub updated_at: String,
251}
252
253/// A dependency, or some of its versions, that updates skip because
254/// someone said so in a comment (`@g1t ignore this major version`).
255#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct IgnoreCondition {
258 /// `package-ecosystem`.
259 pub ecosystem: String,
260 pub dependency: String,
261 /// A version requirement such as `>= 5.a, < 6`; every version when absent.
262 #[serde(default)]
263 pub versions: Option<String>,
264 /// `version-update:semver-major`, … when the condition is an update type.
265 #[serde(default)]
266 pub update_type: Option<String>,
267 /// Who said so.
268 pub by: String,
269 /// The pull request it was said on.
270 #[serde(default)]
271 pub pull: Option<u32>,
272 /// RFC 3339.
273 pub at: String,
274}
275
276/// `check_updates`: checks one `updates` entry for new versions now,
277/// rather than at its next scheduled time. Write and up. Returns
278/// `Outcome<VersionUpdatesState>`.
279#[derive(Debug, Serialize, Deserialize)]
280pub struct CheckUpdatesArgs {
281 pub actor: User,
282 pub repo: RepoPath,
283 /// [`VersionUpdateEntry::id`].
284 pub entry: String,
285}
286
287/// One package of a grouped `bump` (see `security::BumpArgs`).
288#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct BumpPackage {
290 pub package: String,
291 pub version: String,
292}
293
294/// A private registry a `bump` sandbox's tools may read: a `registries`
295/// entry of the dependency update file with its secrets filled in.
296#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct BumpRegistry {
299 /// `npm-registry`, `cargo-registry`, `python-index` or `goproxy-server`.
300 #[serde(rename = "type")]
301 pub kind: String,
302 pub url: String,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub username: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub password: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub token: Option<String>,
309 /// Used in place of the ecosystem's public registry.
310 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
311 pub replaces_base: bool,
312 /// npm scopes it serves: `@acme`.
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 pub scopes: Vec<String>,
315}
316
317/// What a comment on a version or security update pull request asks g1t
318/// to do, read by [`update_command`].
319#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case", tag = "command")]
321pub enum UpdateCommand {
322 /// Bring it up to date with its base, unless someone else pushed to it.
323 Rebase,
324 /// Make it again from scratch, dropping anything pushed to it.
325 Recreate,
326 /// Merge it once its required checks pass.
327 Merge,
328 /// The same; g1t merges every pull request one way.
329 SquashAndMerge,
330 /// Forget an earlier `merge`.
331 CancelMerge,
332 /// Close it, and do not open one for these versions again.
333 Close,
334 /// Open it again.
335 Reopen,
336 /// Close it and stop updating its dependency.
337 IgnoreDependency,
338 /// Close it and skip this `major`, `minor` or `patch` version.
339 IgnoreVersion { level: String },
340 /// On a grouped pull request: skip one dependency, or one level of it.
341 IgnoreNamed { dependency: String, level: Option<String> },
342 /// Undo the ignores of one dependency (`*` for every one), or one
343 /// level of it.
344 Unignore { dependency: String, level: Option<String> },
345 /// Say which ignore conditions apply to a dependency (the pull
346 /// request's own when absent).
347 ShowIgnores { dependency: Option<String> },
348}
349
350/// The command a comment gives, if it is one: its first line, `@g1t`
351/// followed by a command, in any case. Anything else is an ordinary
352/// mention.
353pub fn update_command(body: &str) -> Option<UpdateCommand> {
354 let line = body.trim().lines().next()?.trim();
355 let handle = line.get(..4)?;
356 let rest = &line[4..];
357 if !handle.eq_ignore_ascii_case("@g1t") || !rest.starts_with(char::is_whitespace) {
358 return None;
359 }
360 let words: Vec<&str> = rest.split_whitespace().collect();
361 let lower: Vec<String> = words.iter().map(|word| word.to_lowercase()).collect();
362 let lower: Vec<&str> = lower.iter().map(String::as_str).collect();
363 let level = |word: &str| matches!(word, "major" | "minor" | "patch").then(|| word.to_owned());
364 let named = || words[1].to_owned();
365 Some(match lower.as_slice() {
366 ["rebase"] => UpdateCommand::Rebase,
367 ["recreate"] => UpdateCommand::Recreate,
368 ["merge"] => UpdateCommand::Merge,
369 ["squash", "and", "merge"] => UpdateCommand::SquashAndMerge,
370 ["cancel", "merge"] => UpdateCommand::CancelMerge,
371 ["close"] => UpdateCommand::Close,
372 ["reopen"] => UpdateCommand::Reopen,
373 ["ignore", "this", "dependency"] => UpdateCommand::IgnoreDependency,
374 ["ignore", "this", which, "version"] if level(which).is_some() => {
375 UpdateCommand::IgnoreVersion { level: (*which).to_owned() }
376 }
377 ["show", "ignore", "conditions"] => UpdateCommand::ShowIgnores { dependency: None },
378 ["show", _, "ignore", "conditions"] => UpdateCommand::ShowIgnores { dependency: Some(named()) },
379 ["ignore", _] => UpdateCommand::IgnoreNamed { dependency: named(), level: None },
380 ["ignore", _, which, "version"] if level(which).is_some() => {
381 UpdateCommand::IgnoreNamed { dependency: named(), level: level(which) }
382 }
383 ["unignore", _] => UpdateCommand::Unignore { dependency: named(), level: None },
384 ["unignore", _, which, "version"] if level(which).is_some() => {
385 UpdateCommand::Unignore { dependency: named(), level: level(which) }
386 }
387 _ => return None,
388 })
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn commands_are_read_from_the_first_line() {
397 for (body, expected) in [
398 ("@g1t rebase", Some(UpdateCommand::Rebase)),
399 ("@G1T Recreate\n\nplease", Some(UpdateCommand::Recreate)),
400 ("@g1t merge", Some(UpdateCommand::Merge)),
401 ("@g1t squash and merge", Some(UpdateCommand::SquashAndMerge)),
402 ("@g1t cancel merge", Some(UpdateCommand::CancelMerge)),
403 ("@g1t close", Some(UpdateCommand::Close)),
404 ("@g1t reopen", Some(UpdateCommand::Reopen)),
405 ("@g1t ignore this dependency", Some(UpdateCommand::IgnoreDependency)),
406 ("@g1t ignore this major version", Some(UpdateCommand::IgnoreVersion { level: "major".into() })),
407 ("@g1t ignore this patch version", Some(UpdateCommand::IgnoreVersion { level: "patch".into() })),
408 ("@g1t show ignore conditions", Some(UpdateCommand::ShowIgnores { dependency: None })),
409 ("@g1t show @babel/core ignore conditions", Some(UpdateCommand::ShowIgnores { dependency: Some("@babel/core".into()) })),
410 ("@g1t ignore eslint", Some(UpdateCommand::IgnoreNamed { dependency: "eslint".into(), level: None })),
411 ("@g1t ignore eslint minor version", Some(UpdateCommand::IgnoreNamed { dependency: "eslint".into(), level: Some("minor".into()) })),
412 ("@g1t unignore *", Some(UpdateCommand::Unignore { dependency: "*".into(), level: None })),
413 ("@g1t unignore Eslint major version", Some(UpdateCommand::Unignore { dependency: "Eslint".into(), level: Some("major".into()) })),
414 ("@g1t can you rebase this?", None),
415 ("@g1tbot rebase", None),
416 ("please @g1t rebase", None),
417 ("@g1t ignore this huge version", None),
418 ("", None),
419 ] {
420 assert_eq!(update_command(body), expected, "{body:?}");
421 }
422 }
423
424 #[test]
425 fn a_problem_reads_as_one_line() {
426 let problem = ConfigProblem { line: 4, column: 7, key: "updates[0].schedule.interval".into(), message: "hourly is not an interval.".into() };
427 assert_eq!(problem.sentence(), "line 4, updates[0].schedule.interval: hourly is not an interval.");
428 assert_eq!(ConfigProblem { message: "Not YAML.".into(), ..ConfigProblem::default() }.sentence(), "Not YAML.");
429 }
430}