g1t/services/automations/src/definition.rs
| 1 | //! Reading an automation's file, and the rules it gives: what starts it, |
| 2 | //! the conditions, the steps, and the words in `{{ }}` that steps fill in. |
| 3 | |
| 4 | use std::collections::BTreeMap; |
| 5 | |
| 6 | use g1t_contracts::webhooks::EVENT_TYPES; |
| 7 | use serde_yaml::Value; |
| 8 | |
| 9 | use crate::cron::Schedule; |
| 10 | |
| 11 | /// What starts an automation. |
| 12 | #[derive(Clone, Debug)] |
| 13 | pub enum Trigger { |
| 14 | Events(Vec<String>), |
| 15 | Schedule { text: String, schedule: Schedule }, |
| 16 | Manual, |
| 17 | } |
| 18 | |
| 19 | impl Trigger { |
| 20 | pub fn describe(&self) -> String { |
| 21 | match self { |
| 22 | Trigger::Events(events) => events.join(", "), |
| 23 | Trigger::Schedule { text, .. } => format!("on the schedule {text} (UTC)"), |
| 24 | Trigger::Manual => "by hand".to_owned(), |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /// One condition: a field, and the values any of which it may have. |
| 30 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 31 | pub struct Condition { |
| 32 | pub field: String, |
| 33 | pub values: Vec<String>, |
| 34 | } |
| 35 | |
| 36 | impl Condition { |
| 37 | pub fn describe(&self) -> String { |
| 38 | let values = self.values.join(" or "); |
| 39 | match self.field.as_str() { |
| 40 | "labels" => format!("labelled {values}"), |
| 41 | "actor" => format!("caused by {values}"), |
| 42 | "branch" => format!("on {values}"), |
| 43 | field => format!("{field} is {values}"), |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 49 | pub enum Step { |
| 50 | Comment(String), |
| 51 | Label(String), |
| 52 | Unlabel(String), |
| 53 | AssignAgent, |
| 54 | MessageAgent(String), |
| 55 | OpenIssue { title: String, body: String, labels: Vec<String>, assign_agent: bool }, |
| 56 | CloseIssue { not_planned: bool }, |
| 57 | ReopenIssue, |
| 58 | Notify { url: String, text: String }, |
| 59 | } |
| 60 | |
| 61 | impl Step { |
| 62 | pub fn describe(&self) -> String { |
| 63 | match self { |
| 64 | Step::Comment(_) => "comment".to_owned(), |
| 65 | Step::Label(label) => format!("label {label}"), |
| 66 | Step::Unlabel(label) => format!("remove the label {label}"), |
| 67 | Step::AssignAgent => "put a g1t agent on it".to_owned(), |
| 68 | Step::MessageAgent(_) => "message the agent working on it".to_owned(), |
| 69 | Step::OpenIssue { title, assign_agent, .. } => { |
| 70 | format!("open the issue \u{201c}{title}\u{201d}{}", if *assign_agent { " and put an agent on it" } else { "" }) |
| 71 | } |
| 72 | Step::CloseIssue { not_planned } => { |
| 73 | if *not_planned { "close it as not planned".to_owned() } else { "close it".to_owned() } |
| 74 | } |
| 75 | Step::ReopenIssue => "reopen it".to_owned(), |
| 76 | Step::Notify { url, .. } => format!("post to {}", host(url)), |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// Whether the step acts on the issue or pull request the event is about. |
| 81 | pub fn needs_target(&self) -> bool { |
| 82 | !matches!(self, Step::OpenIssue { .. } | Step::Notify { .. }) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | fn host(url: &str) -> &str { |
| 87 | url.trim_start_matches("https://").split('/').next().unwrap_or(url) |
| 88 | } |
| 89 | |
| 90 | #[derive(Clone, Debug)] |
| 91 | pub struct Definition { |
| 92 | pub name: String, |
| 93 | pub trigger: Trigger, |
| 94 | pub conditions: Vec<Condition>, |
| 95 | pub steps: Vec<Step>, |
| 96 | pub per_hour: u32, |
| 97 | } |
| 98 | |
| 99 | /// The default and the most runs an automation makes in an hour. |
| 100 | pub const DEFAULT_PER_HOUR: u32 = 30; |
| 101 | pub const MAX_PER_HOUR: u32 = 200; |
| 102 | |
| 103 | fn text(value: &Value) -> Option<String> { |
| 104 | match value { |
| 105 | Value::String(text) => Some(text.clone()), |
| 106 | Value::Number(number) => Some(number.to_string()), |
| 107 | Value::Bool(flag) => Some(flag.to_string()), |
| 108 | _ => None, |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | fn texts(value: &Value) -> Result<Vec<String>, String> { |
| 113 | match value { |
| 114 | Value::Sequence(items) => items.iter().map(|item| text(item).ok_or_else(|| "a list of words".to_owned())).collect(), |
| 115 | other => text(other).map(|one| vec![one]).ok_or_else(|| "a word or a list of words".to_owned()), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | fn step(value: &Value) -> Result<Step, String> { |
| 120 | let (name, argument) = match value { |
| 121 | Value::String(name) => (name.as_str(), &Value::Null), |
| 122 | Value::Mapping(map) if map.len() == 1 => { |
| 123 | let (key, argument) = map.iter().next().expect("one entry"); |
| 124 | (key.as_str().ok_or("a step's name is a word")?, argument) |
| 125 | } |
| 126 | _ => return Err("each step is a name, such as `assign_agent`, or a name and what it takes, such as `comment: Thanks!`".to_owned()), |
| 127 | }; |
| 128 | let field = |key: &str| match argument { |
| 129 | Value::Mapping(map) => map.get(key).and_then(text), |
| 130 | _ => None, |
| 131 | }; |
| 132 | let needs_text = |what: &str| text(argument).filter(|t| !t.trim().is_empty()).ok_or(format!("`{name}` needs {what}")); |
| 133 | Ok(match name { |
| 134 | "comment" => Step::Comment(needs_text("the comment's text")?), |
| 135 | "label" => Step::Label(needs_text("a label")?), |
| 136 | "unlabel" => Step::Unlabel(needs_text("a label")?), |
| 137 | "assign_agent" => Step::AssignAgent, |
| 138 | "message_agent" => Step::MessageAgent(needs_text("the message")?), |
| 139 | "close_issue" => Step::CloseIssue { |
| 140 | not_planned: matches!(text(argument).as_deref(), Some("not_planned")), |
| 141 | }, |
| 142 | "reopen_issue" => Step::ReopenIssue, |
| 143 | "open_issue" => Step::OpenIssue { |
| 144 | title: field("title").filter(|t| !t.trim().is_empty()).ok_or("`open_issue` needs a title")?, |
| 145 | body: field("body").unwrap_or_default(), |
| 146 | labels: match argument { |
| 147 | Value::Mapping(map) => map.get("labels").map(texts).transpose()?.unwrap_or_default(), |
| 148 | _ => Vec::new(), |
| 149 | }, |
| 150 | assign_agent: matches!(field("assign_agent").as_deref(), Some("true")), |
| 151 | }, |
| 152 | "notify" => { |
| 153 | let url = field("url").ok_or("`notify` needs a url")?; |
| 154 | if !url.starts_with("https://") { |
| 155 | return Err("`notify` posts only to https:// addresses".to_owned()); |
| 156 | } |
| 157 | Step::Notify { |
| 158 | url, |
| 159 | text: field("text").ok_or("`notify` needs text")?, |
| 160 | } |
| 161 | } |
| 162 | other => { |
| 163 | return Err(format!( |
| 164 | "there is no step called `{other}`; steps are comment, label, unlabel, assign_agent, message_agent, open_issue, close_issue, reopen_issue and notify" |
| 165 | )); |
| 166 | } |
| 167 | }) |
| 168 | } |
| 169 | |
| 170 | /// Reads an automation's file. `Err` says what is wrong with it, for the |
| 171 | /// person who wrote it. |
| 172 | pub fn parse(yaml: &str, file_name: &str) -> Result<Definition, String> { |
| 173 | let root: Value = serde_yaml::from_str(yaml).map_err(|error| format!("It is not valid YAML: {error}"))?; |
| 174 | let Value::Mapping(map) = &root else { |
| 175 | return Err("An automation is a mapping with `on` and `do`.".to_owned()); |
| 176 | }; |
| 177 | let name = map |
| 178 | .get("name") |
| 179 | .and_then(text) |
| 180 | .unwrap_or_else(|| file_name.trim_end_matches(".yml").trim_end_matches(".yaml").replace(['-', '_'], " ")); |
| 181 | let trigger = match map.get("on") { |
| 182 | None => return Err("`on` is missing: say which event starts it, a schedule, or manual.".to_owned()), |
| 183 | Some(Value::String(manual)) if manual == "manual" => Trigger::Manual, |
| 184 | Some(Value::Mapping(on)) if on.contains_key("schedule") => { |
| 185 | let text = on.get("schedule").and_then(text).ok_or("`schedule` takes a cron line, such as \"0 9 * * mon\".")?; |
| 186 | let schedule = Schedule::parse(&text).map_err(|problem| format!("The schedule does not read: {problem}."))?; |
| 187 | Trigger::Schedule { text, schedule } |
| 188 | } |
| 189 | Some(events) => { |
| 190 | let events = texts(events).map_err(|_| "`on` takes an event, a list of events, `manual`, or `schedule:`.".to_owned())?; |
| 191 | if let Some(unknown) = events.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) { |
| 192 | return Err(format!("There is no event called {unknown}. Events are {}.", EVENT_TYPES.join(", "))); |
| 193 | } |
| 194 | Trigger::Events(events) |
| 195 | } |
| 196 | }; |
| 197 | let mut conditions = Vec::new(); |
| 198 | if let Some(when) = map.get("if") { |
| 199 | let Value::Mapping(when) = when else { |
| 200 | return Err("`if` is a mapping of fields to the values they must have.".to_owned()); |
| 201 | }; |
| 202 | for (field, values) in when { |
| 203 | let field = field.as_str().ok_or("`if` keys are field names")?.to_owned(); |
| 204 | let values = texts(values).map_err(|problem| format!("`if` {field}: give {problem}."))?; |
| 205 | conditions.push(Condition { field, values }); |
| 206 | } |
| 207 | } |
| 208 | let steps = match map.get("do") { |
| 209 | Some(Value::Sequence(steps)) if !steps.is_empty() => steps.iter().map(step).collect::<Result<Vec<_>, _>>()?, |
| 210 | Some(single @ (Value::String(_) | Value::Mapping(_))) => vec![step(single)?], |
| 211 | _ => return Err("`do` is missing: give the steps to take, as a list.".to_owned()), |
| 212 | }; |
| 213 | if matches!(trigger, Trigger::Schedule { .. }) |
| 214 | && let Some(step) = steps.iter().find(|step| step.needs_target()) { |
| 215 | return Err(format!( |
| 216 | "A scheduled automation has no issue or pull request to act on, so it cannot {}. It can open_issue or notify.", |
| 217 | step.describe() |
| 218 | )); |
| 219 | } |
| 220 | let per_hour = match map.get("limits").and_then(|limits| limits.get("per_hour")) { |
| 221 | Some(value) => value |
| 222 | .as_u64() |
| 223 | .filter(|n| (1..=u64::from(MAX_PER_HOUR)).contains(n)) |
| 224 | .ok_or(format!("`limits.per_hour` is a number from 1 to {MAX_PER_HOUR}."))? as u32, |
| 225 | None => DEFAULT_PER_HOUR, |
| 226 | }; |
| 227 | Ok(Definition { |
| 228 | name, |
| 229 | trigger, |
| 230 | conditions, |
| 231 | steps, |
| 232 | per_hour, |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | /// What a run knows, by name: for conditions and for `{{ }}` in steps. |
| 237 | #[derive(Clone, Debug, Default)] |
| 238 | pub struct Context { |
| 239 | pub vars: BTreeMap<String, String>, |
| 240 | pub labels: Vec<String>, |
| 241 | } |
| 242 | |
| 243 | impl Context { |
| 244 | pub fn set(&mut self, key: &str, value: impl Into<String>) { |
| 245 | self.vars.insert(key.to_owned(), value.into()); |
| 246 | } |
| 247 | |
| 248 | /// An event's data, as `data.<field>` and, where nothing else claims |
| 249 | /// the name, as `<field>`. |
| 250 | pub fn add_data(&mut self, data: &serde_json::Value) { |
| 251 | let Some(fields) = data.as_object() else { return }; |
| 252 | for (key, value) in fields { |
| 253 | let value = match value { |
| 254 | serde_json::Value::String(text) => text.clone(), |
| 255 | serde_json::Value::Number(number) => number.to_string(), |
| 256 | serde_json::Value::Bool(flag) => flag.to_string(), |
| 257 | _ => continue, |
| 258 | }; |
| 259 | self.vars.entry(key.clone()).or_insert_with(|| value.clone()); |
| 260 | self.vars.insert(format!("data.{key}"), value); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | /// Whether every condition holds. `labels` matches when the issue or pull |
| 266 | /// request has any of the labels; other fields compare with the context. |
| 267 | pub fn holds(conditions: &[Condition], context: &Context) -> Result<(), String> { |
| 268 | for condition in conditions { |
| 269 | let ok = if condition.field == "labels" { |
| 270 | condition.values.iter().any(|value| context.labels.iter().any(|label| label.eq_ignore_ascii_case(value))) |
| 271 | } else { |
| 272 | let actual = context.vars.get(&condition.field).or_else(|| context.vars.get(&format!("data.{}", condition.field))); |
| 273 | actual.is_some_and(|actual| condition.values.iter().any(|value| value.eq_ignore_ascii_case(actual))) |
| 274 | }; |
| 275 | if !ok { |
| 276 | return Err(format!("not {}", condition.describe())); |
| 277 | } |
| 278 | } |
| 279 | Ok(()) |
| 280 | } |
| 281 | |
| 282 | /// Fills `{{ name }}` with what the context knows; an unknown name is left |
| 283 | /// empty. |
| 284 | pub fn render(template: &str, context: &Context) -> String { |
| 285 | let mut out = String::with_capacity(template.len()); |
| 286 | let mut rest = template; |
| 287 | while let Some(start) = rest.find("{{") { |
| 288 | out.push_str(&rest[..start]); |
| 289 | let after = &rest[start + 2..]; |
| 290 | let Some(end) = after.find("}}") else { |
| 291 | out.push_str(&rest[start..]); |
| 292 | return out; |
| 293 | }; |
| 294 | let name = after[..end].trim(); |
| 295 | out.push_str(context.vars.get(name).map(String::as_str).unwrap_or_default()); |
| 296 | rest = &after[end + 2..]; |
| 297 | } |
| 298 | out.push_str(rest); |
| 299 | out |
| 300 | } |
| 301 | |
| 302 | #[cfg(test)] |
| 303 | mod tests { |
| 304 | use super::*; |
| 305 | |
| 306 | const BUGS: &str = r#" |
| 307 | name: Put an agent on new bugs |
| 308 | on: issue.opened |
| 309 | if: |
| 310 | labels: [bug, regression] |
| 311 | do: |
| 312 | - comment: "Thanks, {{actor}}. An agent is on it." |
| 313 | - assign_agent |
| 314 | - notify: { url: "https://hooks.slack.com/x", text: "{{repo}}#{{number}}: {{title}}" } |
| 315 | limits: |
| 316 | per_hour: 5 |
| 317 | "#; |
| 318 | |
| 319 | #[test] |
| 320 | fn a_whole_automation_reads() { |
| 321 | let definition = parse(BUGS, "bugs.yml").unwrap(); |
| 322 | assert_eq!(definition.name, "Put an agent on new bugs"); |
| 323 | assert!(matches!(&definition.trigger, Trigger::Events(events) if events == &vec!["issue.opened".to_owned()])); |
| 324 | assert_eq!(definition.conditions, vec![Condition { field: "labels".into(), values: vec!["bug".into(), "regression".into()] }]); |
| 325 | assert_eq!(definition.steps.len(), 3); |
| 326 | assert_eq!(definition.steps[1], Step::AssignAgent); |
| 327 | assert_eq!(definition.per_hour, 5); |
| 328 | assert_eq!(definition.steps[2].describe(), "post to hooks.slack.com"); |
| 329 | } |
| 330 | |
| 331 | #[test] |
| 332 | fn mistakes_are_explained() { |
| 333 | let problem = |yaml: &str| parse(yaml, "x.yml").unwrap_err(); |
| 334 | assert!(problem("on: issue.exploded\ndo: [assign_agent]").contains("no event called issue.exploded")); |
| 335 | assert!(problem("on: issue.opened").contains("`do` is missing")); |
| 336 | assert!(problem("do: [assign_agent]").contains("`on` is missing")); |
| 337 | assert!(problem("on: issue.opened\ndo: [dance]").contains("no step called `dance`")); |
| 338 | assert!(problem("on: issue.opened\ndo: [{notify: {url: 'http://x', text: hi}}]").contains("https://")); |
| 339 | assert!(problem("on: { schedule: '0 9 * * mon' }\ndo: [assign_agent]").contains("cannot put a g1t agent on it")); |
| 340 | assert!(problem("on: { schedule: 'often' }\ndo: [{open_issue: {title: x}}]").contains("schedule does not read")); |
| 341 | assert!(problem("on: issue.opened\ndo: [assign_agent]\nlimits: { per_hour: 0 }").contains("per_hour")); |
| 342 | assert!(problem(": : :").contains("not valid YAML")); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn schedules_and_manual_runs_read() { |
| 347 | let weekly = parse("on: { schedule: '0 9 * * mon' }\ndo:\n - open_issue: { title: Weekly tidy, labels: chore, assign_agent: true }", "weekly.yml").unwrap(); |
| 348 | assert!(matches!(weekly.trigger, Trigger::Schedule { .. })); |
| 349 | assert_eq!(weekly.name, "weekly"); |
| 350 | assert!(matches!(&weekly.steps[0], Step::OpenIssue { labels, assign_agent: true, .. } if labels == &vec!["chore".to_owned()])); |
| 351 | assert!(matches!(parse("on: manual\ndo:\n comment: hi", "m.yml").unwrap().trigger, Trigger::Manual)); |
| 352 | } |
| 353 | |
| 354 | #[test] |
| 355 | fn conditions_check_labels_fields_and_data() { |
| 356 | let mut context = Context::default(); |
| 357 | context.labels = vec!["Bug".into()]; |
| 358 | context.set("actor", "ada"); |
| 359 | context.add_data(&serde_json::json!({ "status": "failed", "number": 7 })); |
| 360 | let condition = |field: &str, values: &[&str]| Condition { field: field.into(), values: values.iter().map(|v| v.to_string()).collect() }; |
| 361 | assert!(holds(&[condition("labels", &["bug"]), condition("status", &["failed", "errored"])], &context).is_ok()); |
| 362 | assert!(holds(&[condition("data.status", &["failed"])], &context).is_ok()); |
| 363 | assert_eq!(holds(&[condition("actor", &["grace"])], &context).unwrap_err(), "not caused by grace"); |
| 364 | assert!(holds(&[condition("verdict", &["approve"])], &context).is_err()); |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn templates_fill_in_what_is_known() { |
| 369 | let mut context = Context::default(); |
| 370 | context.set("repo", "acme/web"); |
| 371 | context.set("number", "12"); |
| 372 | assert_eq!(render("{{repo}}#{{ number }} by {{actor}}", &context), "acme/web#12 by "); |
| 373 | assert_eq!(render("no braces", &context), "no braces"); |
| 374 | assert_eq!(render("half {{open", &context), "half {{open"); |
| 375 | } |
| 376 | } |