Skip to content

Commit 008d611

Browse files
authored
Merge pull request #136 from ahrefs/louis/ci-dm
CI status updates sent as direct message to author of commit
2 parents eabca94 + 6a4ac5a commit 008d611

File tree

10 files changed

+167
-18
lines changed

10 files changed

+167
-18
lines changed

documentation/config_docs.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ The following takes place when a status notification is received.
163163
- `failure`: `allow`
164164
- `error`: `allow`
165165
- `success`: `allow_once`
166-
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.
166+
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. In addition, query for a slack profile that matches the author of the commit and direct message them.
167167

168168
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).
169169

@@ -175,6 +175,7 @@ Internally, the bot keeps track of the status of the last allowed payload, for a
175175
"default",
176176
"buildkite/monorobot-test"
177177
],
178+
"enable_direct_message": true,
178179
"rules": [
179180
{
180181
"on": ["failure"],
@@ -196,6 +197,7 @@ Internally, the bot keeps track of the status of the last allowed payload, for a
196197
| value | description | default |
197198
|-|-|-|
198199
| `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** | all pipelines included in the status rule check |
200+
| `enable_direct_message` | control direct message sent to notify about build status | false |
199201
| `rules` | a list of **status rules** to determine whether to *allow* or *ignore* a payload for further processing | required field |
200202

201203
### Status Rules

lib/action.ml

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -99,23 +99,34 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
9999
let current_status = n.state in
100100
let rules = cfg.status_rules.rules in
101101
let action_on_match (branches : branch list) =
102+
let%lwt direct_message =
103+
match%lwt Slack_api.lookup_user ~ctx ~cfg ~email:n.commit.commit.author.email with
104+
(* To send a DM, channel parameter is set to the user id of the recipient *)
105+
| Ok res -> Lwt.return [ res.user.id ]
106+
| Error e ->
107+
log#warn "couldn't match commit email to slack profile: %s" e;
108+
Lwt.return []
109+
in
102110
let default = Option.to_list cfg.prefix_rules.default_channel in
103111
let%lwt () = State.set_repo_pipeline_status ctx.state repo.url ~pipeline ~branches ~status:current_status in
104-
match List.is_empty branches with
105-
| true -> Lwt.return []
106-
| false ->
107-
match cfg.main_branch_name with
108-
| None -> Lwt.return default
109-
| Some main_branch_name ->
110-
(* non-main branch build notifications go to default channel to reduce spam in topic channels *)
111-
match List.exists branches ~f:(fun { name } -> String.equal name main_branch_name) with
112-
| false -> Lwt.return default
113-
| true ->
114-
let sha = n.commit.sha in
115-
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
116-
| Error e -> action_error e
117-
| Ok commit -> Lwt.return @@ partition_commit cfg commit.files
118-
)
112+
let%lwt chans =
113+
match List.is_empty branches with
114+
| true -> Lwt.return []
115+
| false ->
116+
match cfg.main_branch_name with
117+
| None -> Lwt.return default
118+
| Some main_branch_name ->
119+
(* non-main branch build notifications go to default channel to reduce spam in topic channels *)
120+
match List.exists branches ~f:(fun { name } -> String.equal name main_branch_name) with
121+
| false -> Lwt.return default
122+
| true ->
123+
let sha = n.commit.sha in
124+
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
125+
| Error e -> action_error e
126+
| Ok commit -> Lwt.return @@ partition_commit cfg commit.files
127+
)
128+
in
129+
Lwt.return (direct_message @ chans)
119130
in
120131
if Context.is_pipeline_allowed ctx repo.url ~pipeline then begin
121132
let%lwt repo_state = State.find_or_add_repo ctx.state repo.url in

lib/api.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module type Github = sig
1818
end
1919

2020
module type Slack = sig
21+
val lookup_user : ctx:Context.t -> cfg:Config_t.config -> email:string -> lookup_user_res slack_response Lwt.t
2122
val send_notification : ctx:Context.t -> msg:post_message_req -> unit slack_response Lwt.t
2223

2324
val send_chat_unfurl

lib/api_local.ml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ end
5454

5555
(** The base implementation for local check payload debugging and mocking tests *)
5656
module Slack_base : Api.Slack = struct
57+
let lookup_user ~ctx:_ ~cfg:_ ~email:_ = Lwt.return @@ Error "undefined for local setup"
5758
let send_notification ~ctx:_ ~msg:_ = Lwt.return @@ Error "undefined for local setup"
5859
let send_chat_unfurl ~ctx:_ ~channel:_ ~ts:_ ~unfurls:_ () = Lwt.return @@ Error "undefined for local setup"
5960
let send_auth_test ~ctx:_ () = Lwt.return @@ Error "undefined for local setup"
@@ -63,6 +64,18 @@ end
6364
module Slack : Api.Slack = struct
6465
include Slack_base
6566

67+
let lookup_user ~ctx:_ ~(cfg : Config_t.config) ~email =
68+
let email = List.Assoc.find cfg.user_mappings ~equal:String.equal email |> Option.value ~default:email in
69+
let mock_user =
70+
{
71+
Slack_t.id = sprintf "id[%s]" email;
72+
name = sprintf "name[%s]" email;
73+
real_name = sprintf "real_name[%s]" email;
74+
}
75+
in
76+
let mock_response = { Slack_t.user = mock_user } in
77+
Lwt.return @@ Ok mock_response
78+
6679
let send_notification ~ctx:_ ~msg =
6780
let json = msg |> Slack_j.string_of_post_message_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
6881
Stdio.printf "will notify #%s\n" msg.channel;

lib/api_remote.ml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ module Slack : Api.Slack = struct
117117
(* must read whole response to update lexer state *)
118118
ignore (Slack_j.read_ok_res s l)
119119

120+
(** [lookup_user cfg email] queries slack for a user profile with [email] *)
121+
let lookup_user ~(ctx : Context.t) ~(cfg : Config_t.config) ~email =
122+
(* Check if config holds the Github to Slack email mapping *)
123+
let email = List.Assoc.find cfg.user_mappings ~equal:String.equal email |> Option.value ~default:email in
124+
let data = Slack_j.string_of_lookup_user_req { Slack_t.email } in
125+
request_token_auth ~name:"lookup user by email"
126+
~body:(`Raw ("application/json", data))
127+
~ctx `GET "users.lookupByEmail" Slack_j.read_lookup_user_res
128+
120129
(** [send_notification ctx msg] notifies [msg.channel] with the payload [msg];
121130
uses web API with access token if available, or with webhook otherwise *)
122131
let send_notification ~(ctx : Context.t) ~(msg : Slack_t.post_message_req) =

lib/config.atd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type project_owners_rule <ocaml from="Rule"> = abstract
66
(* This type of rule is used for CI build notifications. *)
77
type status_rules = {
88
?allowed_pipelines : string list nullable; (* keep only status events with a title matching this list *)
9+
?enable_direct_message: bool nullable;
910
rules: status_rule list;
1011
}
1112

@@ -33,10 +34,11 @@ type project_owners = {
3334
type config = {
3435
prefix_rules : prefix_rules;
3536
label_rules : label_rules;
36-
~status_rules <ocaml default="{allowed_pipelines = Some []; rules = []}"> : status_rules;
37+
~status_rules <ocaml default="{allowed_pipelines = Some []; enable_direct_message = Some false; rules = []}"> : status_rules;
3738
~project_owners <ocaml default="{rules = []}"> : project_owners;
3839
~ignored_users <ocaml default="[]">: string list; (* list of ignored users *)
3940
?main_branch_name : string nullable; (* the name of the main branch; used to filter out notifications about merges of main branch into other branches *)
41+
~user_mappings <ocaml default="[]">: (string * string) list <json repr="object"> (* list of github to slack profile mappings *)
4042
}
4143

4244
(* This specifies the Slack webhook to query to post to the channel with the given name *)

lib/context.ml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ open Common
33
open Devkit
44
module Sys = Stdlib.Sys
55

6+
let log = Log.from "context"
7+
68
exception Context_error of string
79

810
let context_error fmt = Printf.ksprintf (fun msg -> raise (Context_error msg)) fmt
@@ -70,7 +72,10 @@ let is_pipeline_allowed ctx repo_url ~pipeline =
7072
| Some allowed_pipelines when not @@ List.exists allowed_pipelines ~f:(String.equal pipeline) -> false
7173
| _ -> true
7274

73-
let log = Log.from "context"
75+
let is_status_direct_message_enabled ctx repo_url =
76+
match find_repo_config ctx repo_url with
77+
| None -> true
78+
| Some config -> Option.value config.status_rules.enable_direct_message ~default:false
7479

7580
let refresh_secrets ctx =
7681
let path = ctx.secrets_filepath in

lib/slack.atd

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@ type post_message_res = {
7070
channel: string;
7171
}
7272

73+
type lookup_user_req = {
74+
email: string;
75+
}
76+
77+
type lookup_user_res = {
78+
user: user;
79+
}
80+
81+
type user = {
82+
id: string;
83+
name: string;
84+
real_name: string;
85+
}
86+
7387
type link_shared_link = {
7488
domain: string;
7589
url: string;

test/monorobot.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,8 @@
9595
"channel": "frontend-bot"
9696
}
9797
]
98+
},
99+
"user_mappings": {
100+
98101
}
99102
}

test/slack_payloads.expected

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,24 @@ will notify #longest-a1
431431
}
432432
===== file ../mock_payloads/status.cancelled_test.json =====
433433
===== file ../mock_payloads/status.failure_test.json =====
434+
will notify #id[[email protected]]
435+
{
436+
"channel": "id[[email protected]]",
437+
"text": "<https://github.com/ahrefs/monorepo|[ahrefs/monorepo]> CI Build Status notification for <https://buildkite.com/org/pipeline2/builds/2|buildkite/pipeline2>: failure",
438+
"attachments": [
439+
{
440+
"fallback": null,
441+
"mrkdwn_in": [ "fields", "text" ],
442+
"color": "danger",
443+
"text": "*Description*: Build #2 failed (20 seconds).",
444+
"fields": [
445+
{
446+
"value": "*Commit*: `<https://github.com/ahrefs/monorepo/commit/0d95302addd66c1816bce1b1d495ed1c93ccd478|0d95302a>` Update README.md - Khady\n*Branch*: master"
447+
}
448+
]
449+
}
450+
]
451+
}
434452
will notify #default
435453
{
436454
"channel": "default",
@@ -453,8 +471,43 @@ will notify #default
453471
===== file ../mock_payloads/status.merge_develop.json =====
454472
===== file ../mock_payloads/status.pending_test.json =====
455473
===== file ../mock_payloads/status.state_hide_success_test.json =====
474+
will notify #id[[email protected]]
475+
{
476+
"channel": "id[[email protected]]",
477+
"text": "<https://github.com/ahrefs/monorepo|[ahrefs/monorepo]> CI Build Status notification for <https://buildkite.com/org/pipeline2/builds/2|buildkite/pipeline2>: success",
478+
"attachments": [
479+
{
480+
"fallback": null,
481+
"mrkdwn_in": [ "fields", "text" ],
482+
"color": "good",
483+
"text": "*Description*: Build #2 passed (5 minutes, 19 seconds).",
484+
"fields": [
485+
{
486+
"value": "*Commit*: `<https://github.com/ahrefs/monorepo/commit/0d95302addd66c1816bce1b1d495ed1c93ccd478|0d95302a>` Update README.md - Khady\n*Branch*: master"
487+
}
488+
]
489+
}
490+
]
491+
}
456492
===== file ../mock_payloads/status.state_hide_success_test_disallowed_pipeline.json =====
457493
===== file ../mock_payloads/status.success_public_repo_no_buildkite.json =====
494+
will notify #id[[email protected]]
495+
{
496+
"channel": "id[[email protected]]",
497+
"text": "<https://github.com/Codertocat/Hello-World|[Codertocat/Hello-World]> CI Build Status notification: success",
498+
"attachments": [
499+
{
500+
"fallback": null,
501+
"mrkdwn_in": [ "fields", "text" ],
502+
"color": "good",
503+
"fields": [
504+
{
505+
"value": "*Commit*: `<https://github.com/Codertocat/Hello-World/commit/6113728f27ae82c7b1a177c8d03f9e96e0adf246|6113728f>` Initial commit - Codertocat\n*Branches*: master, changes, gh-pages"
506+
}
507+
]
508+
}
509+
]
510+
}
458511
will notify #default
459512
{
460513
"channel": "default",
@@ -474,6 +527,24 @@ will notify #default
474527
"unfurl_links": false
475528
}
476529
===== file ../mock_payloads/status.success_test_main_branch.json =====
530+
will notify #id[[email protected]]
531+
{
532+
"channel": "id[[email protected]]",
533+
"text": "<https://github.com/ahrefs/monorepo|[ahrefs/monorepo]> CI Build Status notification for <https://buildkite.com/org/pipeline2/builds/2|buildkite/pipeline2>: success",
534+
"attachments": [
535+
{
536+
"fallback": null,
537+
"mrkdwn_in": [ "fields", "text" ],
538+
"color": "good",
539+
"text": "*Description*: Build #2 passed (5 minutes, 19 seconds).",
540+
"fields": [
541+
{
542+
"value": "*Commit*: `<https://github.com/ahrefs/monorepo/commit/0d95302addd66c1816bce1b1d495ed1c93ccd478|0d95302a>` Update README.md - Khady\n*Branch*: develop"
543+
}
544+
]
545+
}
546+
]
547+
}
477548
will notify #all-push-events
478549
{
479550
"channel": "all-push-events",
@@ -494,6 +565,24 @@ will notify #all-push-events
494565
"unfurl_links": false
495566
}
496567
===== file ../mock_payloads/status.success_test_non_main_branch.json =====
568+
will notify #id[[email protected]]
569+
{
570+
"channel": "id[[email protected]]",
571+
"text": "<https://github.com/ahrefs/monorepo|[ahrefs/monorepo]> CI Build Status notification for <https://buildkite.com/org/pipeline2/builds/2|buildkite/pipeline2>: success",
572+
"attachments": [
573+
{
574+
"fallback": null,
575+
"mrkdwn_in": [ "fields", "text" ],
576+
"color": "good",
577+
"text": "*Description*: Build #2 passed (5 minutes, 19 seconds).",
578+
"fields": [
579+
{
580+
"value": "*Commit*: `<https://github.com/ahrefs/monorepo/commit/0d95302addd66c1816bce1b1d495ed1c93ccd478|0d95302a>` Update README.md - Khady\n*Branch*: master"
581+
}
582+
]
583+
}
584+
]
585+
}
497586
will notify #default
498587
{
499588
"channel": "default",

0 commit comments

Comments
 (0)