Skip to content

Commit 0b08002

Browse files
authored
Merge pull request #143 from phongulus/phong/github-username-with-slack
Support for Slack mentions
2 parents 0dc6c1d + 94d723f commit 0b08002

18 files changed

+1683
-41
lines changed

lib/action.ml

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,46 @@ let action_error msg = raise (Action_error msg)
1010
let log = Log.from "action"
1111

1212
module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
13+
let canonical_regex = Re2.create_exn {|\.|\-|\+.*|@.*|}
14+
(* Match email domain, everything after '+', as well as dots and hyphens *)
15+
16+
let username_to_slack_id_tbl = Stringtbl.empty ()
17+
18+
let canonicalize_email_username email =
19+
email |> Re2.rewrite_exn ~template:"" canonical_regex |> String.lowercase_ascii
20+
21+
let refresh_username_to_slack_id_tbl ~ctx =
22+
log#info "updating github to slack username mapping";
23+
match%lwt Slack_api.list_users ~ctx () with
24+
| Error e ->
25+
log#warn "couldn't fetch list of Slack users: %s" e;
26+
Lwt.return_unit
27+
| Ok res ->
28+
List.iter
29+
(fun (user : Slack_t.user) ->
30+
match user.profile.email with
31+
| None -> ()
32+
| Some email ->
33+
let username = canonicalize_email_username email in
34+
Stringtbl.replace username_to_slack_id_tbl username user.id
35+
)
36+
res.members;
37+
Lwt.return_unit
38+
39+
let rec refresh_username_to_slack_id_tbl_background_lwt ~ctx : unit Lwt.t =
40+
let%lwt () = refresh_username_to_slack_id_tbl ~ctx in
41+
let%lwt () = Lwt_unix.sleep (Time.days 1) in
42+
(* Updates mapping every 24 hours *)
43+
refresh_username_to_slack_id_tbl_background_lwt ~ctx
44+
45+
let match_github_login_to_slack_id cfg_opt login =
46+
let login =
47+
match cfg_opt with
48+
| None -> login
49+
| Some cfg -> List.assoc_opt login cfg.user_mappings |> Option.default login
50+
in
51+
login |> canonicalize_email_username |> Stringtbl.find_opt username_to_slack_id_tbl
52+
1353
let partition_push (cfg : Config_t.config) n =
1454
let default = Stdlib.Option.to_list cfg.prefix_rules.default_channel in
1555
let rules = cfg.prefix_rules.rules in
@@ -191,21 +231,27 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
191231
let generate_notifications (ctx : Context.t) (req : Github.t) =
192232
let repo = Github.repo_of_notification req in
193233
let cfg = Context.find_repo_config_exn ctx repo.url in
234+
let slack_match_func = match_github_login_to_slack_id (Some cfg) in
194235
match ignore_notifications_from_user cfg req with
195236
| true -> Lwt.return []
196237
| false ->
197238
match req with
198239
| Github.Push n ->
199240
partition_push cfg n |> List.map (fun (channel, n) -> generate_push_notification n channel) |> Lwt.return
200-
| Pull_request n -> partition_pr cfg n |> List.map (generate_pull_request_notification n) |> Lwt.return
201-
| PR_review n -> partition_pr_review cfg n |> List.map (generate_pr_review_notification n) |> Lwt.return
241+
| Pull_request n ->
242+
partition_pr cfg n |> List.map (generate_pull_request_notification ~slack_match_func n) |> Lwt.return
243+
| PR_review n ->
244+
partition_pr_review cfg n |> List.map (generate_pr_review_notification ~slack_match_func n) |> Lwt.return
202245
| PR_review_comment n ->
203-
partition_pr_review_comment cfg n |> List.map (generate_pr_review_comment_notification n) |> Lwt.return
204-
| Issue n -> partition_issue cfg n |> List.map (generate_issue_notification n) |> Lwt.return
205-
| Issue_comment n -> partition_issue_comment cfg n |> List.map (generate_issue_comment_notification n) |> Lwt.return
246+
partition_pr_review_comment cfg n
247+
|> List.map (generate_pr_review_comment_notification ~slack_match_func n)
248+
|> Lwt.return
249+
| Issue n -> partition_issue cfg n |> List.map (generate_issue_notification ~slack_match_func n) |> Lwt.return
250+
| Issue_comment n ->
251+
partition_issue_comment cfg n |> List.map (generate_issue_comment_notification ~slack_match_func n) |> Lwt.return
206252
| Commit_comment n ->
207253
let%lwt channels, api_commit = partition_commit_comment ctx n in
208-
let notifs = List.map (generate_commit_comment_notification api_commit n) channels in
254+
let notifs = List.map (generate_commit_comment_notification ~slack_match_func api_commit n) channels in
209255
Lwt.return notifs
210256
| Status n ->
211257
let%lwt channels = partition_status ctx n in
@@ -220,28 +266,28 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
220266
in
221267
Lwt_list.iter_s notify notifications
222268

269+
let fetch_config ~ctx ~repo =
270+
match%lwt Github_api.get_config ~ctx ~repo with
271+
| Ok config ->
272+
Context.set_repo_config ctx repo.url config;
273+
Context.print_config ctx repo.url;
274+
Lwt.return @@ Ok ()
275+
| Error e -> action_error e
276+
223277
(** [refresh_repo_config ctx n] fetches the latest repo config if it's
224278
uninitialized, or if the incoming request [n] is a push
225279
notification containing commits that touched the config file. *)
226280
let refresh_repo_config (ctx : Context.t) notification =
227281
let repo = Github.repo_of_notification notification in
228-
let fetch_config () =
229-
match%lwt Github_api.get_config ~ctx ~repo with
230-
| Ok config ->
231-
Context.set_repo_config ctx repo.url config;
232-
Context.print_config ctx repo.url;
233-
Lwt.return @@ Ok ()
234-
| Error e -> action_error e
235-
in
236282
match Context.find_repo_config ctx repo.url with
237-
| None -> fetch_config ()
283+
| None -> fetch_config ~ctx ~repo
238284
| Some _ ->
239285
match notification with
240286
| Github.Push commit_pushed_notification ->
241287
let commits = commit_pushed_notification.commits in
242288
let modified_files = List.concat_map Github.modified_files_of_commit commits in
243289
let config_was_modified = List.exists (String.equal ctx.config_filename) modified_files in
244-
if config_was_modified then fetch_config () else Lwt.return @@ Ok ()
290+
if config_was_modified then fetch_config ~ctx ~repo else Lwt.return @@ Ok ()
245291
| _ -> Lwt.return @@ Ok ()
246292

247293
let do_github_tasks ctx (repo : repository) (req : Github.t) =
@@ -265,15 +311,15 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
265311
end
266312
| _ -> Lwt.return_unit
267313

314+
let repo_is_supported secrets (repo : Github_t.repository) =
315+
List.exists (fun (r : repo_config) -> String.equal r.url repo.url) secrets.repos
316+
268317
let process_github_notification (ctx : Context.t) headers body =
269318
let validate_signature secrets payload =
270319
let repo = Github.repo_of_notification payload in
271320
let signing_key = Context.gh_hook_secret_token_of_secrets secrets repo.url in
272321
Github.validate_signature ?signing_key ~headers body
273322
in
274-
let repo_is_supported secrets (repo : Github_t.repository) =
275-
List.exists (fun (r : repo_config) -> String.equal r.url repo.url) secrets.repos
276-
in
277323
try%lwt
278324
let secrets = Context.get_secrets_exn ctx in
279325
match Github.parse_exn headers body with
@@ -337,9 +383,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
337383
Lwt.return_none
338384
in
339385
let process link =
340-
let with_gh_result_populate_slack (type a) ~(api_result : (a, string) Result.t)
341-
~(populate : repository -> a -> Slack_t.message_attachment) ~repo
342-
=
386+
let with_gh_result_populate_slack (type a) ~(api_result : (a, string) Result.t) ~populate ~repo =
343387
match api_result with
344388
| Error _ -> Lwt.return_none
345389
| Ok item -> Lwt.return_some @@ (link, populate repo item)

lib/api.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module type Slack = sig
2525
unit ->
2626
lookup_user_res slack_response Lwt.t
2727

28+
val list_users : ?cursor:string -> ?limit:int -> ctx:Context.t -> unit -> list_users_res slack_response Lwt.t
2829
val send_notification : ctx:Context.t -> msg:post_message_req -> unit slack_response Lwt.t
2930

3031
val send_chat_unfurl

lib/api_local.ml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ module Filename = Stdlib.Filename
55
module Sys = Stdlib.Sys
66

77
let cwd = Sys.getcwd ()
8-
let cache_dir = Filename.concat cwd "github-api-cache"
8+
let github_cache_dir = Filename.concat cwd "github-api-cache"
9+
let slack_cache_dir = Filename.concat cwd "slack-api-cache"
910

1011
(** return the file with a function f applied unless the file is empty;
1112
empty file:this is needed to simulate 404 returns from github *)
@@ -29,7 +30,7 @@ and its Github_j.<kind>_of_string function.
2930
NB: please save the cache file in the same format *)
3031
let get_repo_member_cache ~(repo : Github_t.repository) ~kind ~ref_ ~of_string =
3132
let file = clean_forward_slashes (sprintf "%s_%s_%s" repo.full_name kind ref_) in
32-
let url = Filename.concat cache_dir file in
33+
let url = Filename.concat github_cache_dir file in
3334
with_cache_file url of_string
3435

3536
module Github : Api.Github = struct
@@ -56,6 +57,7 @@ end
5657
(** The base implementation for local check payload debugging and mocking tests *)
5758
module Slack_base : Api.Slack = struct
5859
let lookup_user ?cache:_ ~ctx:_ ~cfg:_ ~email:_ () = Lwt.return @@ Error "undefined for local setup"
60+
let list_users ?cursor:_ ?limit:_ ~ctx:_ () = Lwt.return @@ Error "undefined for local setup"
5961
let send_notification ~ctx:_ ~msg:_ = Lwt.return @@ Error "undefined for local setup"
6062
let send_chat_unfurl ~ctx:_ ~channel:_ ~ts:_ ~unfurls:_ () = Lwt.return @@ Error "undefined for local setup"
6163
let send_auth_test ~ctx:_ () = Lwt.return @@ Error "undefined for local setup"
@@ -72,11 +74,16 @@ module Slack : Api.Slack = struct
7274
Slack_t.id = sprintf "id[%s]" email;
7375
name = sprintf "name[%s]" email;
7476
real_name = sprintf "real_name[%s]" email;
77+
profile = { email = Some email };
7578
}
7679
in
7780
let mock_response = { Slack_t.user = mock_user } in
7881
Lwt.return @@ Ok mock_response
7982

83+
let list_users ?cursor:_ ?limit:_ ~ctx:_ () =
84+
let url = Filename.concat slack_cache_dir "users-list" in
85+
with_cache_file url Slack_j.list_users_res_of_string
86+
8087
let send_notification ~ctx:_ ~msg =
8188
let json = msg |> Slack_j.string_of_post_message_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
8289
Printf.printf "will notify #%s\n" msg.channel;

lib/api_remote.ml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ module Slack : Api.Slack = struct
149149
| Some user -> Lwt.return_ok user
150150
| None -> lookup_user' ~ctx ~cfg ~email ()
151151

152+
let list_users ?cursor ?limit ~(ctx : Context.t) () =
153+
let cursor_option = Option.map (fun c -> "cursor", c) cursor in
154+
let limit_option = Option.map (fun l -> "limit", Int.to_string l) limit in
155+
let url_args = Web.make_url_args @@ List.filter_map id [ cursor_option; limit_option ] in
156+
request_token_auth ~name:"list users" ~ctx `GET (sprintf "users.list?%s" url_args) Slack_j.read_list_users_res
157+
152158
(** [send_notification ctx msg] notifies [msg.channel] with the payload [msg];
153159
uses web API with access token if available, or with webhook otherwise *)
154160
let send_notification ~(ctx : Context.t) ~(msg : Slack_t.post_message_req) =

lib/slack.atd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,19 @@ type lookup_user_res = {
7474
user: user;
7575
}
7676

77+
type profile = {
78+
?email: string nullable
79+
}
80+
7781
type user = {
7882
id: string;
7983
name: string;
8084
real_name: string;
85+
profile: profile
86+
}
87+
88+
type list_users_res = {
89+
members: user list;
8190
}
8291

8392
type link_shared_link = {

lib/slack.ml

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,24 @@ let markdown_text_attachment ~footer markdown_body =
4646
let make_message ?username ?text ?attachments ?blocks ~channel () =
4747
{ channel; text; attachments; blocks; username; unfurl_links = Some false; unfurl_media = None }
4848

49-
let generate_pull_request_notification notification channel =
49+
let format_slack_mention = Option.map_default (sprintf " (<@%s>)") ""
50+
let github_handle_regex = Re2.create_exn {|(?:^|\s)@([\w][\w-]{1,})|} (* Match GH handles in messages *)
51+
52+
let add_slack_mentions_to_body slack_match_func body =
53+
let replace_match m =
54+
let gh_handle = Re2.Match.get_exn ~sub:(`Index 0) m in
55+
let gh_handle_without_at = Re2.Match.get_exn ~sub:(`Index 1) m in
56+
sprintf "%s%s" gh_handle (format_slack_mention (slack_match_func gh_handle_without_at))
57+
in
58+
Re2.replace_exn github_handle_regex body ~f:replace_match
59+
60+
let format_attachments ~slack_match_func ~footer ~body =
61+
let format_mention_in_markdown (md : unfurl) =
62+
{ md with text = Option.map (add_slack_mentions_to_body slack_match_func) md.text }
63+
in
64+
Option.map (fun t -> markdown_text_attachment ~footer t |> List.map format_mention_in_markdown) body
65+
66+
let generate_pull_request_notification ~slack_match_func notification channel =
5067
let { action; number; sender; pull_request; repository } = notification in
5168
let ({ body; title; html_url; labels; merged; _ } : pull_request) = pull_request in
5269
let action, body =
@@ -65,9 +82,9 @@ let generate_pull_request_notification notification channel =
6582
sprintf "<%s|[%s]> Pull request #%d %s %s by *%s*" repository.url repository.full_name number
6683
(pp_link ~url:html_url title) action sender.login
6784
in
68-
make_message ~text:summary ?attachments:(Option.map (markdown_text_attachment ~footer:None) body) ~channel ()
85+
make_message ~text:summary ?attachments:(format_attachments ~slack_match_func ~footer:None ~body) ~channel ()
6986

70-
let generate_pr_review_notification notification channel =
87+
let generate_pr_review_notification ~slack_match_func notification channel =
7188
let { action; sender; pull_request; review; repository } = notification in
7289
let ({ number; title; html_url; _ } : pull_request) = pull_request in
7390
let action_str =
@@ -89,9 +106,11 @@ let generate_pr_review_notification notification channel =
89106
sprintf "<%s|[%s]> *%s* <%s|%s> #%d %s" repository.url repository.full_name sender.login review.html_url action_str
90107
number (pp_link ~url:html_url title)
91108
in
92-
make_message ~text:summary ?attachments:(Option.map (markdown_text_attachment ~footer:None) review.body) ~channel ()
109+
make_message ~text:summary
110+
?attachments:(format_attachments ~slack_match_func ~footer:None ~body:review.body)
111+
~channel ()
93112

94-
let generate_pr_review_comment_notification notification channel =
113+
let generate_pr_review_comment_notification ~slack_match_func notification channel =
95114
let { action; pull_request; sender; comment; repository } = notification in
96115
let ({ number; title; html_url; _ } : pull_request) = pull_request in
97116
let action_str =
@@ -112,9 +131,11 @@ let generate_pr_review_comment_notification notification channel =
112131
| None -> None
113132
| Some a -> Some (sprintf "New comment by %s in <%s|%s>" sender.login comment.html_url a)
114133
in
115-
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:file comment.body) ~channel ()
134+
make_message ~text:summary
135+
?attachments:(format_attachments ~slack_match_func ~footer:file ~body:(Some comment.body))
136+
~channel ()
116137

117-
let generate_issue_notification notification channel =
138+
let generate_issue_notification ~slack_match_func notification channel =
118139
let ({ action; sender; issue; repository } : issue_notification) = notification in
119140
let { number; body; title; html_url; labels; _ } = issue in
120141
let action, body =
@@ -133,9 +154,10 @@ let generate_issue_notification notification channel =
133154
sprintf "<%s|[%s]> Issue #%d %s %s by *%s*" repository.url repository.full_name number (pp_link ~url:html_url title)
134155
action sender.login
135156
in
136-
make_message ~text:summary ?attachments:(Option.map (markdown_text_attachment ~footer:None) body) ~channel ()
137157

138-
let generate_issue_comment_notification notification channel =
158+
make_message ~text:summary ?attachments:(format_attachments ~slack_match_func ~footer:None ~body) ~channel ()
159+
160+
let generate_issue_comment_notification ~slack_match_func notification channel =
139161
let { action; issue; sender; comment; repository } = notification in
140162
let { number; title; _ } = issue in
141163
let action_str =
@@ -152,7 +174,9 @@ let generate_issue_comment_notification notification channel =
152174
sprintf "<%s|[%s]> *%s* <%s|%s> on #%d %s" repository.url repository.full_name sender.login comment.html_url
153175
action_str number (pp_link ~url:issue.html_url title)
154176
in
155-
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:None comment.body) ~channel ()
177+
make_message ~text:summary
178+
?attachments:(format_attachments ~slack_match_func ~footer:None ~body:(Some comment.body))
179+
~channel ()
156180

157181
let git_short_sha_hash hash = String.sub hash 0 8
158182

@@ -312,7 +336,7 @@ let generate_status_notification (cfg : Config_t.config) (notification : status_
312336
in
313337
make_message ~text:summary ~attachments:[ attachment ] ~channel ()
314338

315-
let generate_commit_comment_notification api_commit notification channel =
339+
let generate_commit_comment_notification ~slack_match_func api_commit notification channel =
316340
let { commit; _ } = api_commit in
317341
let { sender; comment; repository; _ } = notification in
318342
let commit_id =
@@ -330,7 +354,9 @@ let generate_commit_comment_notification api_commit notification channel =
330354
| None -> None
331355
| Some p -> Some (sprintf "New comment by %s in <%s|%s>" sender.login comment.html_url p)
332356
in
333-
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:path comment.body) ~channel ()
357+
make_message ~text:summary
358+
?attachments:(format_attachments ~slack_match_func ~footer:path ~body:(Some comment.body))
359+
~channel ()
334360

335361
let validate_signature ?(version = "v0") ?signing_key ~headers body =
336362
match signing_key with

lib/slack_message.ml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ let base_attachment repository = { empty_attachment with footer = Some (simple_f
4040
let pp_label (label : label) = label.name
4141
let pp_github_user (user : github_user) = gh_name_of_string user.login
4242
let pp_github_team (team : github_team) = gh_name_of_string team.slug
43+
let pretext_slack_mention = Option.map (sprintf "<@%s>")
4344

4445
let populate_pull_request repository (pull_request : pull_request) =
4546
let ({
@@ -182,10 +183,6 @@ let populate_commit ?(include_changes = true) repository (api_commit : api_commi
182183
{
183184
(base_attachment repository) with
184185
footer = Some (simple_footer repository ^ " " ^ commit.committer.date);
185-
(*
186-
author_name = Some author.login;
187-
author_link = Some author.html_url;
188-
*)
189186
author_icon =
190187
( match author with
191188
| Some author -> Some author.avatar_url

0 commit comments

Comments
 (0)