Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! 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 | ||
| 7 | use serde::{Deserialize, Serialize}; | |
| 8 | ||
| 9 | use crate::User; | |
| 10 | use 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. | |
| 16 | pub 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. | |
| 21 | pub 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")] | |
| 26 | pub 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 | ||
| 35 | impl 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")] | |
| 47 | pub 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")] | |
| 85 | pub 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")] | |
| 144 | pub 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")] | |
| 168 | pub 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")] | |
| 182 | pub 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")] | |
| 196 | pub 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")] | |
| 209 | pub 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")] | |
| 227 | pub 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")] | |
| 257 | pub 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)] | |
| 280 | pub 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)] | |
| 289 | pub 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")] | |
| 298 | pub 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")] | |
| 321 | pub 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. | |
| 353 | pub 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)] | |
| 392 | mod 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 | } |