Skip to content

Commit 16827d9

Browse files
committed
Merge pull request #93 from ahrefs/yasu/upgrade-status-rules
Upgrade status rules syntax and differentiate status state tracking b/w pipelines
2 parents fa53571 + 82aea92 commit 16827d9

21 files changed

+792
-273
lines changed

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
1-
# Notabot
1+
# Monorobot
22

3-
Notifications bot server to receive notifications from webhooks and post them to slack
3+
A Slackbot for GitHub monorepos. Configure how repo notifications should be routed to specified Slack channels based on file prefixes, issue/PR labels, and CI build statuses.
44

55
## Setting Up
66

7-
Install dependencies, if needed:
7+
Install dependencies via OPAM.
88

99
```sh
1010
opam install --deps-only .
1111
```
1212

13-
Build with
13+
Then, build with Dune.
1414

1515
```sh
1616
make
1717
```
1818

19-
and use resulting `_build/default/src/notabot.exe` binary.
20-
2119
## Running
2220

23-
At startup time, secrets are read from the local `secrets.json` file. The main configuration is read remotely from a `notabot.json` file in the default branch, and its schema is defined in `lib/notabot.atd`.
21+
Run the `_build/default/src/notabot.exe` binary. The following commands are supported.
22+
23+
- `run`: Launch the HTTP server
24+
- `check_gh <GH_PAYLOAD>`: read a Github notification from a file and display the actions that will be taken (used for testing)
25+
- `check_slack <SLACK_PAYLOAD> <SLACK_WEBHOOK>`: read a Slack notification from a file and send it to a webhook (used for testing)
2426

2527
### Documentation
2628

27-
* [config](./documentation/config_docs.md)
28-
* [secret](./documentation/secret_docs.md)
29+
The bot expects two configuration files to be present.
30+
31+
* [Repository configuration](./documentation/config_docs.md)
32+
* [Secrets](./documentation/secret_docs.md)
2933

3034
## Testing (development)
3135

documentation/config_docs.md

Lines changed: 104 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,67 +8,30 @@ To update the configuration, simply edit the configuration file and push your ch
88

99
Refer [here](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads) for more information on GitHub event payload structure.
1010

11-
# Configuration values
11+
## Options
1212

13-
**example**
13+
**Example**
1414
```json
1515
{
1616
"main_branch_name": "develop",
17-
"status_rules": {
18-
...
19-
},
2017
"prefix_rules": {
2118
...
2219
},
2320
"label_rules": {
2421
...
22+
},
23+
"status_rules": {
24+
...
2525
}
2626
}
2727
```
2828

2929
| value | description | optional | default |
3030
|-|-|-|-|
3131
| `main_branch_name` | main branch used for the repo; filtering notifications about merges of main into other branches | Yes | - |
32-
| `status_rules` | status rules config object | No | - |
3332
| `label_rules` | label rules config object | No | - |
3433
| `prefix_rules` | prefix rules config object | No | - |
35-
36-
## Status Config
37-
38-
**example**
39-
```json
40-
"status_rules": {
41-
"allowed_pipelines": [
42-
"default",
43-
"buildkite/pipeline2"
44-
],
45-
"status": {
46-
"pending": false,
47-
"success": "once",
48-
"failure": true,
49-
"error": true,
50-
"cancelled": "^\\(Build #[0-9]+ canceled by .+\\|Failed (exit status 255)\\)$"
51-
}
52-
},
53-
```
54-
55-
| value | description | optional | default |
56-
|-|-|-|-|
57-
| `title` | if defines a whitelist of values for the github payload. If not specified, all is permitted. | Yes | - |
58-
| `status` | a `status_state` config object | No | - |
59-
60-
### Status State
61-
62-
A json object with fields of bools for each status type.
63-
64-
| value | description | optional | default |
65-
|-|-|-|-|
66-
| `pending` | `true` to notify; `false` to ignore | No | - |
67-
| `success` | `true` to notify; `false` to notify all; `"once"` to notify the first and ignore subsequent consecutive successes| No | - |
68-
| `failure` | `true` to notify; `false` to ignore | No | - |
69-
| `error` | `true` to notify; `false` to ignore | No | - |
70-
| `cancelled` | provide regex to ignore `failure` notifications with a description that matches it | Yes | - |
71-
34+
| `status_rules` | status rules config object | No | - |
7235

7336
## Label Options
7437

@@ -163,3 +126,101 @@ A **prefix rule** specifies whether or not a Slack channel should be notified, b
163126
| `allow` | if commit files match any prefix in this list, they should be routed to the channel | Yes | all prefixes allowed if no list provided |
164127
| `ignore` | if commit files match any prefix in this list, they shouldn't be routed to the channel (even if they match any allow prefixes) | Yes | - |
165128
| `channel` | channel to use as webhook if the rule is matched | No | - |
129+
130+
## Status Options
131+
132+
Monorobot supports additional behavior for GitHub status notifications, which are typically triggered by CI builds. A payload of this type contains:
133+
134+
- A `context` field, whose value is the name of the CI pipeline the notificiation is about
135+
- A `state` field, whose value is either `success`, `failure`, `pending`, or `error`
136+
137+
The following takes place when a status notification is received.
138+
139+
1. Check whether a status notification should be *allowed* for further processing or *ignored*, according to a list of **status rules**. The bot will check the list *in order*, and use the policy defined by the first rule that the notification satisfies. If no rule matches, the default behavior of the status state is used:
140+
- `pending`: `ignore`
141+
- `failure`: `allow`
142+
- `error`: `allow`
143+
- `success`: `allow_once`
144+
1. For those payloads allowed by step 1, if it isn't a main branch build notification, route to the default channel to reduce spam in topic channels. Otherwise, check the notification commit's files according to the prefix rules.
145+
146+
Internally, the bot keeps track of the status of the last allowed payload, for a given pipeline and branch. This information is used to evaluate the status rules (see below).
147+
148+
**Example**
149+
150+
```json
151+
"status_rules": {
152+
"allowed_pipelines": [
153+
"default",
154+
"buildkite/pipeline2"
155+
],
156+
"rules": [
157+
{
158+
"on": ["failure"],
159+
"when": {
160+
"match": {
161+
"field": "description",
162+
"re": "^\\(Build #[0-9]+ canceled by .+\\|Failed (exit status 255)\\)$"
163+
}
164+
},
165+
"policy": "ignore"
166+
},
167+
{ "on": ["pending"], "policy": "ignore"},
168+
{ "on": ["failure", "error"], "policy": "allow"},
169+
{ "on": ["success"], "policy": "allow_once"}
170+
]
171+
}
172+
```
173+
174+
| value | description | optional | default |
175+
|-|-|-|-|
176+
| `allowed_pipelines` | a list of pipeline names; if specified, payloads whose pipeline name is not in the list will be ignored immediately, without checking the **status rules**; otherwise, all pipelines will be included in the status rule check | Yes | - |
177+
| `rules` | a list of **status rules** to determine whether to *allow* or *ignore* a payload for further processing | No | - |
178+
179+
### Status Rules
180+
181+
A **status rule** specifies whether a GitHub status notification should generate a Slack notification, given the notification's status and the last allowed build status. There are three policy options for handling payloads:
182+
183+
- `allow`: Notify every time.
184+
- `ignore`: Suppress every time.
185+
- `allow_once`: Only notify if the last allowed status notification's build status for the given pipeline and branch differs from the current one. Useful for suppressing consecutive build success notifications.
186+
187+
For example, the rule:
188+
```
189+
{ "on": A, "when": B, "policy": C }
190+
```
191+
is interpreted as:
192+
193+
> "on a notification with a build state in `A`, when condition `B` is met, adopt the policy `C`".
194+
195+
| value | description | optional | default |
196+
|-|-|-|-|
197+
| `on` | a list of build states that can trigger this rule | No | - |
198+
| `when` | a **status condition** object which, if specified, must be true for the rule to match | Yes | - |
199+
| `policy` | a policy option (one of `allow`, `ignore`, `allow_once`) | No | - |
200+
201+
### Status Conditions
202+
203+
You can optionally provide a **status condition** to specify additional requirements that a notification payload should meet, in order for a status rule's policy to apply.
204+
205+
- `all_of`: Matches if every sub-condition in a list is true. Value should be the list of sub-conditions.
206+
- `one_of`: Matches if at least one sub-condition in a list is true. Value should be the list of sub-conditions.
207+
```json
208+
{
209+
"all_of/one_of": [ condition1, condition2, ...]
210+
}
211+
```
212+
- `not`: Matches if a sub-condition is false. Value should be the sub-condition.
213+
```json
214+
{
215+
"not": condition
216+
}
217+
```
218+
- `match`: Matches a text field in the payload against a regular expression. Value should be an object of the form:
219+
```json
220+
{
221+
"match": {
222+
"field": "context" | "description" | "target_url",
223+
"re": string // a regular expression
224+
}
225+
}
226+
```

lib/action.ml

Lines changed: 32 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ open Devkit
22
open Base
33
open Slack
44
open Config_t
5-
open Config
65
open Common
76
open Github_j
87

@@ -13,17 +12,7 @@ let action_error msg = raise (Action_error msg)
1312
let log = Log.from "action"
1413

1514
module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
16-
(* this should move to context.ml once rule.ml is refactored to not depend on it *)
17-
let print_config ctx =
18-
let cfg = Context.get_config_exn ctx in
19-
let secrets = Context.get_secrets_exn ctx in
20-
log#info "using prefix routing:";
21-
Rule.Prefix.print_prefix_routing cfg.prefix_rules.rules;
22-
log#info "using label routing:";
23-
Rule.Label.print_label_routing cfg.label_rules.rules;
24-
log#info "signature checking %s" (if Option.is_some secrets.gh_hook_token then "enabled" else "disabled")
25-
26-
let partition_push cfg n =
15+
let partition_push (cfg : Config_t.config) n =
2716
let default = Option.to_list cfg.prefix_rules.default_channel in
2817
let rules = cfg.prefix_rules.rules in
2918
n.commits
@@ -45,7 +34,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
4534
|> Map.map ~f:(fun commits -> { n with commits })
4635
|> Map.to_alist
4736

48-
let partition_label (cfg : Config.t) (labels : label list) =
37+
let partition_label (cfg : Config_t.config) (labels : label list) =
4938
let default = cfg.label_rules.default_channel in
5039
let rules = cfg.label_rules.rules in
5140
labels |> List.concat_map ~f:(Rule.Label.match_rules ~rules) |> List.dedup_and_sort ~compare:String.compare
@@ -87,7 +76,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
8776
| Submitted, _, _ -> partition_label cfg n.pull_request.labels
8877
| _ -> []
8978

90-
let partition_commit (cfg : Config.t) files =
79+
let partition_commit (cfg : Config_t.config) files =
9180
let default = Option.to_list cfg.prefix_rules.default_channel in
9281
let rules = cfg.prefix_rules.rules in
9382
let matched_channel_names =
@@ -101,18 +90,18 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
10190
let cfg = Context.get_config_exn ctx in
10291
let pipeline = n.context in
10392
let current_status = n.state in
104-
let updated_at = n.updated_at in
105-
let get_commit_info () =
93+
let rules = cfg.status_rules.rules in
94+
let action_on_match (branches : branch list) =
10695
let default = Option.to_list cfg.prefix_rules.default_channel in
107-
let () = Context.refresh_pipeline_status ~pipeline ~branches:n.branches ~status:current_status ~updated_at ctx in
108-
match List.is_empty n.branches with
96+
let () = Context.refresh_pipeline_status ~pipeline ~branches ~status:current_status ctx in
97+
match List.is_empty branches with
10998
| true -> Lwt.return []
11099
| false ->
111100
match cfg.main_branch_name with
112101
| None -> Lwt.return default
113102
| Some main_branch_name ->
114103
(* non-main branch build notifications go to default channel to reduce spam in topic channels *)
115-
match List.exists n.branches ~f:(fun { name } -> String.equal name main_branch_name) with
104+
match List.exists branches ~f:(fun { name } -> String.equal name main_branch_name) with
116105
| false -> Lwt.return default
117106
| true ->
118107
let sha = n.commit.sha in
@@ -122,27 +111,23 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
122111
| Ok commit -> Lwt.return @@ partition_commit cfg commit.files
123112
)
124113
in
125-
let res =
126-
match
127-
List.exists cfg.status_rules.status ~f:(fun x ->
128-
match x with
129-
| State s -> Poly.equal s n.state
130-
| HideConsecutiveSuccess -> Poly.equal Success n.state
131-
| _ -> false)
132-
with
133-
| false -> Lwt.return []
134-
| true ->
135-
match List.exists ~f:id [ Rule.Status.hide_cancelled n cfg; Rule.Status.hide_success n ctx ] with
136-
| true -> Lwt.return []
137-
| false ->
138-
match cfg.status_rules.title with
139-
| None -> get_commit_info ()
140-
| Some status_filter ->
141-
match List.exists status_filter ~f:(String.equal n.context) with
142-
| false -> Lwt.return []
143-
| true -> get_commit_info ()
144-
in
145-
res
114+
if Context.is_pipeline_allowed ctx ~pipeline then begin
115+
match Rule.Status.match_rules ~rules n with
116+
| Some Ignore | None -> Lwt.return []
117+
| Some Allow -> action_on_match n.branches
118+
| Some Allow_once ->
119+
match Map.find ctx.state.pipeline_statuses pipeline with
120+
| Some branch_statuses ->
121+
let has_same_status_state_as_prev (branch : branch) =
122+
match Map.find branch_statuses branch.name with
123+
| None -> false
124+
| Some state -> Poly.equal state current_status
125+
in
126+
let branches = List.filter n.branches ~f:(Fn.non @@ has_same_status_state_as_prev) in
127+
action_on_match branches
128+
| None -> action_on_match n.branches
129+
end
130+
else Lwt.return []
146131

147132
let partition_commit_comment (ctx : Context.t) n =
148133
let cfg = Context.get_config_exn ctx in
@@ -213,9 +198,8 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
213198
let repo = Github.repo_of_notification notification in
214199
match%lwt Github_api.get_config ~ctx ~repo with
215200
| Ok config ->
216-
print_config ctx;
217-
(* can remove this wrapper once status_rules doesn't depend on Config.t *)
218-
ctx.config <- Some (Config.make config);
201+
Context.print_config ctx;
202+
ctx.config <- Some config;
219203
Lwt.return @@ Ok ()
220204
| Error e -> action_error e
221205
in
@@ -243,7 +227,11 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
243227
let%lwt () = send_notifications ctx notifications in
244228
( match ctx.state_filepath with
245229
| None -> Lwt.return_unit
246-
| Some path -> Lwt.return @@ State.save path ctx.state
230+
| Some path ->
231+
( match%lwt State.save ctx.state path with
232+
| Ok () -> Lwt.return_unit
233+
| Error e -> action_error e
234+
)
247235
)
248236
)
249237
with

lib/common.ml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,6 @@ let first_line s =
2222
| x :: _ -> x
2323
| [] -> s
2424

25-
module Tristate : Atdgen_runtime.Json_adapter.S = struct
26-
let normalize = function
27-
| `Bool true -> `String "true"
28-
| `Bool false -> `String "false"
29-
| x -> x
30-
31-
let restore = function
32-
| `String "true" -> `Bool true
33-
| `String "false" -> `Bool false
34-
| x -> x
35-
end
36-
3725
let decode_string_pad s =
3826
String.rstrip ~drop:(List.mem [ '='; ' '; '\n'; '\r'; '\t' ] ~equal:Char.equal) s |> Base64.decode_string
3927

lib/config.atd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
type status_state <ocaml from="Rule"> = abstract
1+
type status_rule <ocaml from="Rule"> = abstract
22
type prefix_rule <ocaml from="Rule"> = abstract
33
type label_rule <ocaml from="Rule"> = abstract
44

55
(* This type of rule is used for CI build notifications. *)
66
type status_rules = {
77
?allowed_pipelines : string list nullable; (* keep only status events with a title matching this list *)
8-
rules: status_state;
8+
rules: status_rule list;
99
}
1010

1111
(* This type of rule is used for events that must be routed based on the

0 commit comments

Comments
 (0)