g1t/services/automations/src/definition.rs

376 lines15,819 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.

Automations: rules in .g1t/automations that act when something happens1//! 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
4use std::collections::BTreeMap;
5
6use g1t_contracts::webhooks::EVENT_TYPES;
7use serde_yaml::Value;
8
9use crate::cron::Schedule;
10
11/// What starts an automation.
12#[derive(Clone, Debug)]
13pub enum Trigger {
14 Events(Vec<String>),
15 Schedule { text: String, schedule: Schedule },
16 Manual,
17}
18
19impl 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)]
31pub struct Condition {
32 pub field: String,
33 pub values: Vec<String>,
34}
35
36impl 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)]
49pub 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
61impl 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
86fn host(url: &str) -> &str {
87 url.trim_start_matches("https://").split('/').next().unwrap_or(url)
88}
89
90#[derive(Clone, Debug)]
91pub 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.
100pub const DEFAULT_PER_HOUR: u32 = 30;
101pub const MAX_PER_HOUR: u32 = 200;
102
103fn 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
112fn 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
119fn 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.
172pub 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)]
238pub struct Context {
239 pub vars: BTreeMap<String, String>,
240 pub labels: Vec<String>,
241}
242
243impl 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.
267pub 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.
284pub 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)]
303mod tests {
304 use super::*;
305
306 const BUGS: &str = r#"
307name: Put an agent on new bugs
308on: issue.opened
309if:
310 labels: [bug, regression]
311do:
312 - comment: "Thanks, {{actor}}. An agent is on it."
313 - assign_agent
314 - notify: { url: "https://hooks.slack.com/x", text: "{{repo}}#{{number}}: {{title}}" }
315limits:
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}