Skip to content

Commit

Permalink
Merge pull request #143 from phongulus/phong/github-username-with-slack
Browse files Browse the repository at this point in the history
Support for Slack mentions
  • Loading branch information
yasunariw committed Apr 22, 2024
2 parents 0dc6c1d + 94d723f commit 0b08002
Show file tree
Hide file tree
Showing 18 changed files with 1,683 additions and 41 deletions.
88 changes: 66 additions & 22 deletions lib/action.ml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ let action_error msg = raise (Action_error msg)
let log = Log.from "action"

module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
let canonical_regex = Re2.create_exn {|\.|\-|\+.*|@.*|}
(* Match email domain, everything after '+', as well as dots and hyphens *)

let username_to_slack_id_tbl = Stringtbl.empty ()

let canonicalize_email_username email =
email |> Re2.rewrite_exn ~template:"" canonical_regex |> String.lowercase_ascii

let refresh_username_to_slack_id_tbl ~ctx =
log#info "updating github to slack username mapping";
match%lwt Slack_api.list_users ~ctx () with
| Error e ->
log#warn "couldn't fetch list of Slack users: %s" e;
Lwt.return_unit
| Ok res ->
List.iter
(fun (user : Slack_t.user) ->
match user.profile.email with
| None -> ()
| Some email ->
let username = canonicalize_email_username email in
Stringtbl.replace username_to_slack_id_tbl username user.id
)
res.members;
Lwt.return_unit

let rec refresh_username_to_slack_id_tbl_background_lwt ~ctx : unit Lwt.t =
let%lwt () = refresh_username_to_slack_id_tbl ~ctx in
let%lwt () = Lwt_unix.sleep (Time.days 1) in
(* Updates mapping every 24 hours *)
refresh_username_to_slack_id_tbl_background_lwt ~ctx

let match_github_login_to_slack_id cfg_opt login =
let login =
match cfg_opt with
| None -> login
| Some cfg -> List.assoc_opt login cfg.user_mappings |> Option.default login
in
login |> canonicalize_email_username |> Stringtbl.find_opt username_to_slack_id_tbl

let partition_push (cfg : Config_t.config) n =
let default = Stdlib.Option.to_list cfg.prefix_rules.default_channel in
let rules = cfg.prefix_rules.rules in
Expand Down Expand Up @@ -191,21 +231,27 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
let generate_notifications (ctx : Context.t) (req : Github.t) =
let repo = Github.repo_of_notification req in
let cfg = Context.find_repo_config_exn ctx repo.url in
let slack_match_func = match_github_login_to_slack_id (Some cfg) in
match ignore_notifications_from_user cfg req with
| true -> Lwt.return []
| false ->
match req with
| Github.Push n ->
partition_push cfg n |> List.map (fun (channel, n) -> generate_push_notification n channel) |> Lwt.return
| Pull_request n -> partition_pr cfg n |> List.map (generate_pull_request_notification n) |> Lwt.return
| PR_review n -> partition_pr_review cfg n |> List.map (generate_pr_review_notification n) |> Lwt.return
| Pull_request n ->
partition_pr cfg n |> List.map (generate_pull_request_notification ~slack_match_func n) |> Lwt.return
| PR_review n ->
partition_pr_review cfg n |> List.map (generate_pr_review_notification ~slack_match_func n) |> Lwt.return
| PR_review_comment n ->
partition_pr_review_comment cfg n |> List.map (generate_pr_review_comment_notification n) |> Lwt.return
| Issue n -> partition_issue cfg n |> List.map (generate_issue_notification n) |> Lwt.return
| Issue_comment n -> partition_issue_comment cfg n |> List.map (generate_issue_comment_notification n) |> Lwt.return
partition_pr_review_comment cfg n
|> List.map (generate_pr_review_comment_notification ~slack_match_func n)
|> Lwt.return
| Issue n -> partition_issue cfg n |> List.map (generate_issue_notification ~slack_match_func n) |> Lwt.return
| Issue_comment n ->
partition_issue_comment cfg n |> List.map (generate_issue_comment_notification ~slack_match_func n) |> Lwt.return
| Commit_comment n ->
let%lwt channels, api_commit = partition_commit_comment ctx n in
let notifs = List.map (generate_commit_comment_notification api_commit n) channels in
let notifs = List.map (generate_commit_comment_notification ~slack_match_func api_commit n) channels in
Lwt.return notifs
| Status n ->
let%lwt channels = partition_status ctx n in
Expand All @@ -220,28 +266,28 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
in
Lwt_list.iter_s notify notifications

let fetch_config ~ctx ~repo =
match%lwt Github_api.get_config ~ctx ~repo with
| Ok config ->
Context.set_repo_config ctx repo.url config;
Context.print_config ctx repo.url;
Lwt.return @@ Ok ()
| Error e -> action_error e

(** [refresh_repo_config ctx n] fetches the latest repo config if it's
uninitialized, or if the incoming request [n] is a push
notification containing commits that touched the config file. *)
let refresh_repo_config (ctx : Context.t) notification =
let repo = Github.repo_of_notification notification in
let fetch_config () =
match%lwt Github_api.get_config ~ctx ~repo with
| Ok config ->
Context.set_repo_config ctx repo.url config;
Context.print_config ctx repo.url;
Lwt.return @@ Ok ()
| Error e -> action_error e
in
match Context.find_repo_config ctx repo.url with
| None -> fetch_config ()
| None -> fetch_config ~ctx ~repo
| Some _ ->
match notification with
| Github.Push commit_pushed_notification ->
let commits = commit_pushed_notification.commits in
let modified_files = List.concat_map Github.modified_files_of_commit commits in
let config_was_modified = List.exists (String.equal ctx.config_filename) modified_files in
if config_was_modified then fetch_config () else Lwt.return @@ Ok ()
if config_was_modified then fetch_config ~ctx ~repo else Lwt.return @@ Ok ()
| _ -> Lwt.return @@ Ok ()

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

let repo_is_supported secrets (repo : Github_t.repository) =
List.exists (fun (r : repo_config) -> String.equal r.url repo.url) secrets.repos

let process_github_notification (ctx : Context.t) headers body =
let validate_signature secrets payload =
let repo = Github.repo_of_notification payload in
let signing_key = Context.gh_hook_secret_token_of_secrets secrets repo.url in
Github.validate_signature ?signing_key ~headers body
in
let repo_is_supported secrets (repo : Github_t.repository) =
List.exists (fun (r : repo_config) -> String.equal r.url repo.url) secrets.repos
in
try%lwt
let secrets = Context.get_secrets_exn ctx in
match Github.parse_exn headers body with
Expand Down Expand Up @@ -337,9 +383,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
Lwt.return_none
in
let process link =
let with_gh_result_populate_slack (type a) ~(api_result : (a, string) Result.t)
~(populate : repository -> a -> Slack_t.message_attachment) ~repo
=
let with_gh_result_populate_slack (type a) ~(api_result : (a, string) Result.t) ~populate ~repo =
match api_result with
| Error _ -> Lwt.return_none
| Ok item -> Lwt.return_some @@ (link, populate repo item)
Expand Down
1 change: 1 addition & 0 deletions lib/api.ml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module type Slack = sig
unit ->
lookup_user_res slack_response Lwt.t

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

val send_chat_unfurl
Expand Down
11 changes: 9 additions & 2 deletions lib/api_local.ml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ module Filename = Stdlib.Filename
module Sys = Stdlib.Sys

let cwd = Sys.getcwd ()
let cache_dir = Filename.concat cwd "github-api-cache"
let github_cache_dir = Filename.concat cwd "github-api-cache"
let slack_cache_dir = Filename.concat cwd "slack-api-cache"

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

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

let list_users ?cursor:_ ?limit:_ ~ctx:_ () =
let url = Filename.concat slack_cache_dir "users-list" in
with_cache_file url Slack_j.list_users_res_of_string

let send_notification ~ctx:_ ~msg =
let json = msg |> Slack_j.string_of_post_message_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
Printf.printf "will notify #%s\n" msg.channel;
Expand Down
6 changes: 6 additions & 0 deletions lib/api_remote.ml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ module Slack : Api.Slack = struct
| Some user -> Lwt.return_ok user
| None -> lookup_user' ~ctx ~cfg ~email ()

let list_users ?cursor ?limit ~(ctx : Context.t) () =
let cursor_option = Option.map (fun c -> "cursor", c) cursor in
let limit_option = Option.map (fun l -> "limit", Int.to_string l) limit in
let url_args = Web.make_url_args @@ List.filter_map id [ cursor_option; limit_option ] in
request_token_auth ~name:"list users" ~ctx `GET (sprintf "users.list?%s" url_args) Slack_j.read_list_users_res

(** [send_notification ctx msg] notifies [msg.channel] with the payload [msg];
uses web API with access token if available, or with webhook otherwise *)
let send_notification ~(ctx : Context.t) ~(msg : Slack_t.post_message_req) =
Expand Down
9 changes: 9 additions & 0 deletions lib/slack.atd
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,19 @@ type lookup_user_res = {
user: user;
}

type profile = {
?email: string nullable
}

type user = {
id: string;
name: string;
real_name: string;
profile: profile
}

type list_users_res = {
members: user list;
}

type link_shared_link = {
Expand Down
50 changes: 38 additions & 12 deletions lib/slack.ml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,24 @@ let markdown_text_attachment ~footer markdown_body =
let make_message ?username ?text ?attachments ?blocks ~channel () =
{ channel; text; attachments; blocks; username; unfurl_links = Some false; unfurl_media = None }

let generate_pull_request_notification notification channel =
let format_slack_mention = Option.map_default (sprintf " (<@%s>)") ""
let github_handle_regex = Re2.create_exn {|(?:^|\s)@([\w][\w-]{1,})|} (* Match GH handles in messages *)

let add_slack_mentions_to_body slack_match_func body =
let replace_match m =
let gh_handle = Re2.Match.get_exn ~sub:(`Index 0) m in
let gh_handle_without_at = Re2.Match.get_exn ~sub:(`Index 1) m in
sprintf "%s%s" gh_handle (format_slack_mention (slack_match_func gh_handle_without_at))
in
Re2.replace_exn github_handle_regex body ~f:replace_match

let format_attachments ~slack_match_func ~footer ~body =
let format_mention_in_markdown (md : unfurl) =
{ md with text = Option.map (add_slack_mentions_to_body slack_match_func) md.text }
in
Option.map (fun t -> markdown_text_attachment ~footer t |> List.map format_mention_in_markdown) body

let generate_pull_request_notification ~slack_match_func notification channel =
let { action; number; sender; pull_request; repository } = notification in
let ({ body; title; html_url; labels; merged; _ } : pull_request) = pull_request in
let action, body =
Expand All @@ -65,9 +82,9 @@ let generate_pull_request_notification notification channel =
sprintf "<%s|[%s]> Pull request #%d %s %s by *%s*" repository.url repository.full_name number
(pp_link ~url:html_url title) action sender.login
in
make_message ~text:summary ?attachments:(Option.map (markdown_text_attachment ~footer:None) body) ~channel ()
make_message ~text:summary ?attachments:(format_attachments ~slack_match_func ~footer:None ~body) ~channel ()

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

let generate_pr_review_comment_notification notification channel =
let generate_pr_review_comment_notification ~slack_match_func notification channel =
let { action; pull_request; sender; comment; repository } = notification in
let ({ number; title; html_url; _ } : pull_request) = pull_request in
let action_str =
Expand All @@ -112,9 +131,11 @@ let generate_pr_review_comment_notification notification channel =
| None -> None
| Some a -> Some (sprintf "New comment by %s in <%s|%s>" sender.login comment.html_url a)
in
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:file comment.body) ~channel ()
make_message ~text:summary
?attachments:(format_attachments ~slack_match_func ~footer:file ~body:(Some comment.body))
~channel ()

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

let generate_issue_comment_notification notification channel =
make_message ~text:summary ?attachments:(format_attachments ~slack_match_func ~footer:None ~body) ~channel ()

let generate_issue_comment_notification ~slack_match_func notification channel =
let { action; issue; sender; comment; repository } = notification in
let { number; title; _ } = issue in
let action_str =
Expand All @@ -152,7 +174,9 @@ let generate_issue_comment_notification notification channel =
sprintf "<%s|[%s]> *%s* <%s|%s> on #%d %s" repository.url repository.full_name sender.login comment.html_url
action_str number (pp_link ~url:issue.html_url title)
in
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:None comment.body) ~channel ()
make_message ~text:summary
?attachments:(format_attachments ~slack_match_func ~footer:None ~body:(Some comment.body))
~channel ()

let git_short_sha_hash hash = String.sub hash 0 8

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

let generate_commit_comment_notification api_commit notification channel =
let generate_commit_comment_notification ~slack_match_func api_commit notification channel =
let { commit; _ } = api_commit in
let { sender; comment; repository; _ } = notification in
let commit_id =
Expand All @@ -330,7 +354,9 @@ let generate_commit_comment_notification api_commit notification channel =
| None -> None
| Some p -> Some (sprintf "New comment by %s in <%s|%s>" sender.login comment.html_url p)
in
make_message ~text:summary ~attachments:(markdown_text_attachment ~footer:path comment.body) ~channel ()
make_message ~text:summary
?attachments:(format_attachments ~slack_match_func ~footer:path ~body:(Some comment.body))
~channel ()

let validate_signature ?(version = "v0") ?signing_key ~headers body =
match signing_key with
Expand Down
5 changes: 1 addition & 4 deletions lib/slack_message.ml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ let base_attachment repository = { empty_attachment with footer = Some (simple_f
let pp_label (label : label) = label.name
let pp_github_user (user : github_user) = gh_name_of_string user.login
let pp_github_team (team : github_team) = gh_name_of_string team.slug
let pretext_slack_mention = Option.map (sprintf "<@%s>")

let populate_pull_request repository (pull_request : pull_request) =
let ({
Expand Down Expand Up @@ -182,10 +183,6 @@ let populate_commit ?(include_changes = true) repository (api_commit : api_commi
{
(base_attachment repository) with
footer = Some (simple_footer repository ^ " " ^ commit.committer.date);
(*
author_name = Some author.login;
author_link = Some author.html_url;
*)
author_icon =
( match author with
| Some author -> Some author.avatar_url
Expand Down

0 comments on commit 0b08002

Please sign in to comment.