g1t/services/automations/src/definition.rs
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.
| Automations: rules in .g1t/automations that act when something happens | 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 | } |