Skip to content

Commit 98869da

Browse files
committed
refactor prefix + label rule checking
The config file now distinguishes between undefined lists and empty lists. This applies to `allowed_pipelines` in the main config, and `allow` and `ignore` in the prefix/label rules. Now, an undefined field is interpreted as a global whitelist, and an empty list is interpreted as a global blacklist.
1 parent 271cc44 commit 98869da

File tree

6 files changed

+156
-225
lines changed

6 files changed

+156
-225
lines changed

lib/action.ml

Lines changed: 59 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,46 @@ open Devkit
22
open Base
33
open Slack
44
open Config_t
5-
open Rule_t
65
open Config
76
open Common
87
open Github_j
98

9+
exception Action_error of string
10+
11+
let action_error msg = raise (Action_error msg)
12+
1013
let log = Log.from "action"
1114

15+
module Github_api = Api_remote.Github
16+
1217
module Action = struct
1318
let partition_push cfg n =
14-
let group_commit chan l =
15-
List.filter_map l ~f:(fun (chan', commit) ->
16-
match String.equal chan chan' with
17-
| false -> None
18-
| true -> Some commit)
19-
in
20-
let default commit =
21-
Option.value_map cfg.prefix_rules.default_channel ~default:[] ~f:(fun webhook -> [ webhook, commit ])
22-
in
19+
let default = Option.to_list cfg.prefix_rules.default_channel in
2320
let rules = cfg.prefix_rules.rules in
24-
let channels =
25-
n.commits
26-
|> List.filter ~f:(fun c -> c.distinct)
27-
|> List.filter ~f:(fun c ->
28-
let branch = Github.commits_branch_of_ref n.ref in
29-
let skip = Github.is_main_merge_message ~msg:c.message ~branch cfg in
30-
if skip then log#info "main branch merge, ignoring %s: %s" c.id (first_line c.message);
31-
not skip)
32-
|> List.map ~f:(fun commit ->
33-
match Rule.Prefix.filter_push rules commit with
34-
| [] -> default commit
35-
| l -> l)
36-
|> List.concat
37-
in
38-
let prefix_chans =
39-
let chans =
40-
Option.to_list cfg.prefix_rules.default_channel
41-
@ List.map rules ~f:(fun (rule : prefix_rule) -> rule.channel_name)
42-
in
43-
List.dedup_and_sort chans ~compare:String.compare
44-
in
45-
List.filter_map prefix_chans ~f:(fun chan ->
46-
match group_commit chan channels with
47-
| [] -> None
48-
| l -> Some (chan, { n with commits = l }))
49-
50-
let partition_label cfg (labels : Github_j.label list) =
51-
let default = Option.to_list cfg.label_rules.default_channel in
52-
match labels with
53-
| [] -> default
54-
| labels ->
55-
let rules = cfg.label_rules.rules in
56-
let channels =
57-
labels
58-
|> List.map ~f:(fun (label : Github_j.label) ->
59-
match Rule.Label.filter_label rules label with
60-
| [] -> default
61-
| l -> l)
62-
in
63-
List.dedup_and_sort ~compare:String.compare (List.concat channels)
21+
n.commits
22+
|> List.filter ~f:(fun c -> c.distinct)
23+
|> List.filter ~f:(fun c ->
24+
let branch = Github.commits_branch_of_ref n.ref in
25+
let skip = Github.is_main_merge_message ~msg:c.message ~branch cfg in
26+
if skip then log#info "main branch merge, ignoring %s: %s" c.id (first_line c.message);
27+
not skip)
28+
|> List.concat_map ~f:(fun commit ->
29+
let matched_channel_names =
30+
Github.modified_files_of_commit commit
31+
|> List.filter_map ~f:(Rule.Prefix.match_rules ~rules)
32+
|> List.dedup_and_sort ~compare:String.compare
33+
in
34+
let channel_names = if List.is_empty matched_channel_names then default else matched_channel_names in
35+
List.map channel_names ~f:(fun n -> n, commit))
36+
|> Map.of_alist_multi (module String)
37+
|> Map.map ~f:(fun commits -> { n with commits })
38+
|> Map.to_alist
39+
40+
let partition_label (cfg : Config.t) (labels : label list) =
41+
let default = cfg.label_rules.default_channel in
42+
let rules = cfg.label_rules.rules in
43+
labels |> List.concat_map ~f:(Rule.Label.match_rules ~rules) |> List.dedup_and_sort ~compare:String.compare
44+
|> fun channel_names -> if List.is_empty channel_names then Option.to_list default else channel_names
6445

6546
let partition_pr cfg (n : pr_notification) =
6647
match n.action with
@@ -98,11 +79,15 @@ module Action = struct
9879
| Submitted, _, _ -> partition_label cfg n.pull_request.labels
9980
| _ -> []
10081

101-
let partition_commit cfg files =
102-
let names = List.map ~f:(fun f -> f.filename) files in
103-
match Rule.Prefix.unique_chans_of_files cfg.prefix_rules.rules names with
104-
| _ :: _ as xs -> xs
105-
| [] -> Option.to_list cfg.prefix_rules.default_channel
82+
let partition_commit (cfg : Config.t) files =
83+
let default = Option.to_list cfg.prefix_rules.default_channel in
84+
let rules = cfg.prefix_rules.rules in
85+
let matched_channel_names =
86+
List.map ~f:(fun f -> f.filename) files
87+
|> List.filter_map ~f:(Rule.Prefix.match_rules ~rules)
88+
|> List.dedup_and_sort ~compare:String.compare
89+
in
90+
if List.is_empty matched_channel_names then default else matched_channel_names
10691

10792
let partition_status (ctx : Context.t) (n : status_notification) =
10893
let cfg = ctx.cfg in
@@ -153,18 +138,24 @@ module Action = struct
153138
Context.update_state ctx (Github.Status n);
154139
res
155140

156-
let partition_commit_comment cfg n =
157-
let default = Option.to_list cfg.prefix_rules.default_channel in
158-
match n.comment.path with
159-
| None ->
160-
( match%lwt Github.generate_commit_from_commit_comment cfg n with
161-
| None -> Lwt.return default
162-
| Some commit -> Lwt.return (partition_commit cfg commit.files)
141+
let partition_commit_comment (ctx : Context.t) n =
142+
let cfg = ctx.cfg in
143+
match n.comment.commit_id with
144+
| None -> action_error "unable to find commit id for this commit comment event"
145+
| Some sha ->
146+
( match%lwt Github_api.get_api_commit ~ctx ~repo:n.repository ~sha with
147+
| Error e -> action_error e
148+
| Ok commit ->
149+
let default = Option.to_list cfg.prefix_rules.default_channel in
150+
let rules = cfg.prefix_rules.rules in
151+
( match n.comment.path with
152+
| None -> Lwt.return @@ (partition_commit cfg commit.files, commit)
153+
| Some filename ->
154+
match Rule.Prefix.match_rules filename ~rules with
155+
| None -> Lwt.return (default, commit)
156+
| Some chan -> Lwt.return ([ chan ], commit)
157+
)
163158
)
164-
| Some p ->
165-
match Rule.Prefix.chan_of_file cfg.prefix_rules.rules p with
166-
| None -> Lwt.return default
167-
| Some chan -> Lwt.return [ chan ]
168159

169160
let generate_notifications (ctx : Context.t) req =
170161
let cfg = ctx.cfg in
@@ -186,8 +177,8 @@ module Action = struct
186177
|> List.map ~f:(fun webhook -> webhook, generate_issue_comment_notification n)
187178
|> Lwt.return
188179
| Commit_comment n ->
189-
let%lwt webhooks = partition_commit_comment cfg n in
190-
let%lwt notif = generate_commit_comment_notification cfg n in
180+
let%lwt webhooks, api_commit = partition_commit_comment ctx n in
181+
let%lwt notif = generate_commit_comment_notification api_commit n in
191182
let notifs = List.map ~f:(fun webhook -> webhook, notif) webhooks in
192183
Lwt.return notifs
193184
| Status n ->

lib/github.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ let is_main_merge_message ~msg:message ~branch (cfg : Config.t) =
115115
String.equal title expect || String.equal title expect2
116116
| _ -> false
117117

118+
let modified_files_of_commit commit = List.concat [ commit.added; commit.removed; commit.modified ]
119+
118120
let is_valid_signature ~secret headers_sig body =
119121
let request_hash =
120122
let key = Cstruct.of_string secret in

lib/rule.atd

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ type status_state = {
1010

1111
(* A filename matches a prefix rule with the channel name if it isn't in the ignore
1212
list and it is in the allow list. If multiple prefix rules match for a given
13-
file, the one to match with the longest prefix is used.
13+
file, the one to match with the longest prefix is used. Both `allow` and
14+
`ignore` are optional. If `allow` is undefined, then the rule matches all
15+
payloads.
1416

1517
If a commit affects 3 files:
1618
- some/dir/a
@@ -26,15 +28,16 @@ type status_state = {
2628
ignore should be ["some/other"]
2729
*)
2830
type prefix_rule = {
29-
allow : string list; (* empty list means match any *)
30-
~ignore : string list;
31-
channel_name <json name="channel"> : string;
31+
?allow : string list nullable;
32+
?ignore : string list nullable;
33+
channel_name <json name="channel">: string;
3234
}
3335

3436
(* A payload matches a label rule with a channel name if absent from the ignore list
35-
and present in the allow list. *)
37+
and present in the allow list. Both `allow` and `ignore` are optional. If `allow`
38+
is undefined, then the rule matches all payloads. *)
3639
type label_rule = {
37-
allow : string list;
38-
~ignore : string list;
39-
channel_name <json name="channel"> : string;
40+
?allow : string list nullable;
41+
?ignore : string list nullable;
42+
channel_name <json name="channel">: string;
4043
}

lib/rule.ml

Lines changed: 39 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -36,97 +36,48 @@ module Status = struct
3636
end
3737

3838
module Prefix = struct
39-
type prefix_match =
40-
| Match of int
41-
| NoMatch
42-
43-
let chan_of_prefix_rule (r : prefix_rule) = r.channel_name
44-
45-
let touching_prefix (rule : prefix_rule) name =
46-
let match_lengths filename prefixes =
47-
List.filter_map
48-
~f:(fun prefix -> if String.is_prefix filename ~prefix then Some (String.length prefix) else None)
49-
prefixes
50-
in
51-
match match_lengths name rule.ignore with
52-
| _ :: _ -> NoMatch
53-
| [] ->
54-
match rule.allow with
55-
| [] -> Match 0
56-
| _ ->
57-
match List.max_elt (match_lengths name rule.allow) ~compare:Int.compare with
58-
| Some x -> Match x
59-
| None -> NoMatch
60-
61-
let longest_touching_prefix_rule rules name =
62-
let get_m rule = touching_prefix rule name in
63-
let reduce_to_longest_match longest_rule_match_pair current_rule =
64-
let _, longest_match = longest_rule_match_pair in
65-
let current_match = get_m current_rule in
66-
let current_rule_match_pair = current_rule, current_match in
67-
match longest_match with
68-
| NoMatch -> current_rule_match_pair
69-
| Match x ->
70-
match current_match with
71-
| NoMatch -> longest_rule_match_pair
72-
| Match y -> if y > x then current_rule_match_pair else longest_rule_match_pair
39+
(** `match_rules f rs` returns the channel name of a rule in `rs` that matches
40+
file name `f` with the longest prefix, if one exists. A rule `r` matches
41+
`f` with prefix length `l`, if `f` has no prefix in `r.ignore` and `l` is
42+
the length of the longest prefix of `f` in `r.allow`. An undefined allow
43+
list is considered a prefix match of length 0. The ignore list is
44+
evaluated before the allow list. *)
45+
let match_rules filename ~rules =
46+
let compare a b = Int.compare (snd a) (snd b) in
47+
let is_prefix prefix = String.is_prefix filename ~prefix in
48+
let match_rule (rule : prefix_rule) =
49+
match rule.ignore with
50+
| Some ignore_list when List.exists ignore_list ~f:is_prefix -> None
51+
| _ ->
52+
match rule.allow with
53+
| None -> Some (rule, 0)
54+
| Some allow_list ->
55+
allow_list
56+
|> List.filter_map ~f:(fun p -> if is_prefix p then Some (rule, String.length p) else None)
57+
|> List.max_elt ~compare
7358
in
74-
match rules with
75-
| [] -> None
76-
| (x : prefix_rule) :: xs ->
77-
match List.fold_left xs ~init:(x, get_m x) ~f:reduce_to_longest_match with
78-
| _, NoMatch -> None
79-
| r, Match _ -> Some r
80-
81-
let chan_of_file rules file = Option.map ~f:chan_of_prefix_rule @@ longest_touching_prefix_rule rules file
82-
83-
let unique_chans_of_files rules files =
84-
List.dedup_and_sort ~compare:String.compare @@ List.filter_map files ~f:(chan_of_file rules)
85-
86-
let filter_push rules (commit : Github_t.commit) =
87-
let files = List.concat [ commit.added; commit.removed; commit.modified ] in
88-
List.map ~f:(fun chan -> chan, commit) @@ unique_chans_of_files rules files
89-
90-
let print_prefix_routing rules =
91-
let show_match l = String.concat ~sep:" or " @@ List.map ~f:(fun s -> s ^ "*") l in
9259
rules
93-
|> List.iter ~f:(fun (rule : prefix_rule) ->
94-
begin
95-
match rule.allow, rule.ignore with
96-
| [], [] -> Stdio.printf " any"
97-
| l, [] -> Stdio.printf " %s" (show_match l)
98-
| [], l -> Stdio.printf " not %s" (show_match l)
99-
| l, i -> Stdio.printf " %s and not %s" (show_match l) (show_match i)
100-
end;
101-
Stdio.printf " -> #%s\n%!" rule.channel_name)
60+
|> List.filter_map ~f:match_rule
61+
|> List.max_elt ~compare
62+
|> Option.map ~f:(fun (res : prefix_rule * int) -> (fst res).channel_name)
10263
end
10364

10465
module Label = struct
105-
let touching_label rule name =
106-
let name_lc = String.lowercase name in
107-
let label_lc = List.map rule.allow ~f:(fun l -> String.lowercase l) in
108-
let ignore_lc = List.map rule.ignore ~f:(fun l -> String.lowercase l) in
109-
(* convert both labels and config into lowe-case to make label matching case-insensitive *)
110-
(List.is_empty label_lc || List.mem ~equal:String.equal label_lc name_lc)
111-
&& not (List.mem ~equal:String.equal ignore_lc name_lc)
112-
113-
let filter_label rules (label : Github_j.label) =
114-
rules
115-
|> List.filter_map ~f:(fun rule ->
116-
match touching_label rule label.name with
117-
| false -> None
118-
| true -> Some rule.channel_name)
119-
120-
let print_label_routing rules =
121-
let show_match l = String.concat ~sep:" or " l in
122-
rules
123-
|> List.iter ~f:(fun (rule : label_rule) ->
124-
begin
125-
match rule.allow, rule.ignore with
126-
| [], [] -> Stdio.printf " any"
127-
| l, [] -> Stdio.printf " %s" (show_match l)
128-
| [], l -> Stdio.printf " not %s" (show_match l)
129-
| l, i -> Stdio.printf " %s and not %s" (show_match l) (show_match i)
130-
end;
131-
Stdio.printf " -> #%s\n%!" rule.channel_name)
66+
(** `match_rules l rs` returns the channel names of the rules in `rs` that
67+
allow label `l`, if one exists. A rule `r` matches label `l`, if `l` is
68+
not a member of `r.ignore` and is a member of `r.allow`. The label name
69+
comparison is case insensitive. An undefined allow list is considered a
70+
match. The ignore list is evaluated before the allow list. *)
71+
let match_rules (label : Github_t.label) ~rules =
72+
let label_name = String.lowercase label.name in
73+
let label_name_equal name = String.equal label_name (String.lowercase name) in
74+
let match_rule rule =
75+
match rule.ignore with
76+
| Some ignore_list when List.exists ignore_list ~f:label_name_equal -> None
77+
| _ ->
78+
match rule.allow with
79+
| None -> Some rule.channel_name
80+
| Some allow_list -> if List.exists allow_list ~f:label_name_equal then Some rule.channel_name else None
81+
in
82+
rules |> List.filter_map ~f:match_rule |> List.dedup_and_sort ~compare:String.compare
13283
end

0 commit comments

Comments
 (0)