Skip to content

Commit 5083f31

Browse files
committed
Merge branch 'yasu/slack-unfurl-links-pr-issue'
* yasu/slack-unfurl-links-pr-issue: only show comments if non-zero omit pr/issue description make fields short and space-pad list fields update docs with link unfurl for pr and issue tests: add test cases for PR and issue url handling handle incoming PR and issue links and parse them add api routes to retrieve GH issues and PRs tests: add new repo fields to tests define slack message generation logic for PRs and issues
2 parents ea80939 + b7ccbe7 commit 5083f31

File tree

12 files changed

+205
-10
lines changed

12 files changed

+205
-10
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Run the `_build/default/src/monorobot.exe` binary. The following commands are su
4242

4343
### Link Unfurling
4444

45-
You can configure Monorobot to [unfurl GitHub links](https://api.slack.com/reference/messaging/link-unfurling) in Slack messages. Currently, commit links are supported.
45+
You can configure Monorobot to [unfurl GitHub links](https://api.slack.com/reference/messaging/link-unfurling) in Slack messages. Currently, commit, pull request, and issue links are supported.
4646

4747
1. Give your app `links:read` and `links:write` [permissions](https://api.slack.com/apps).
4848
1. Configure your app to [support the Events API](https://api.slack.com/events-api#prepare). During the [url verification handshake](https://api.slack.com/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification__url-verification-handshake), you should tell Slack to direct event notifications to `<server_domain>/slack/events`. Ensure the server is running before triggering the handshake.

lib/action.ml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,16 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
239239
| None -> Lwt.return_none
240240
| Some gh_link ->
241241
match gh_link with
242+
| Pull_request (repo, number) ->
243+
( match%lwt Github_api.get_pull_request ~ctx ~repo ~number with
244+
| Error _ -> Lwt.return_none
245+
| Ok pr -> Lwt.return_some @@ (link, Slack_message.populate_pull_request repo pr)
246+
)
247+
| Issue (repo, number) ->
248+
( match%lwt Github_api.get_issue ~ctx ~repo ~number with
249+
| Error _ -> Lwt.return_none
250+
| Ok issue -> Lwt.return_some @@ (link, Slack_message.populate_issue repo issue)
251+
)
242252
| Commit (repo, sha) ->
243253
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
244254
| Error _ -> Lwt.return_none

lib/api.ml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ module type Github = sig
66
val get_config : ctx:Context.t -> repo:repository -> (Config_t.config, string) Result.t Lwt.t
77

88
val get_api_commit : ctx:Context.t -> repo:repository -> sha:string -> (api_commit, string) Result.t Lwt.t
9+
10+
val get_pull_request : ctx:Context.t -> repo:repository -> number:int -> (pull_request, string) Result.t Lwt.t
11+
12+
val get_issue : ctx:Context.t -> repo:repository -> number:int -> (issue, string) Result.t Lwt.t
913
end
1014

1115
module type Slack = sig

lib/api_local.ml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ module Github : Api.Github = struct
1818
match get_local_file url with
1919
| Error e -> Lwt.return @@ fmt_error "error while getting local file: %s\nfailed to get api commit %s" e url
2020
| Ok file -> Lwt.return @@ Ok (Github_j.api_commit_of_string file)
21+
22+
let get_pull_request ~ctx:_ ~repo:_ ~number:_ = Lwt.return @@ Error "undefined for local setup"
23+
24+
let get_issue ~ctx:_ ~repo:_ ~number:_ = Lwt.return @@ Error "undefined for local setup"
2125
end
2226

2327
module Slack_base : Api.Slack = struct

lib/api_remote.ml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ module Github : Api.Github = struct
1010
let contents_url ~(repo : Github_t.repository) ~path =
1111
String.substr_replace_first ~pattern:"{+path}" ~with_:path repo.contents_url
1212

13+
let pulls_url ~(repo : Github_t.repository) ~number =
14+
String.substr_replace_first ~pattern:"{/number}" ~with_:(sprintf "/%d" number) repo.pulls_url
15+
16+
let issues_url ~(repo : Github_t.repository) ~number =
17+
String.substr_replace_first ~pattern:"{/number}" ~with_:(sprintf "/%d" number) repo.issues_url
18+
1319
let build_headers ?token () =
1420
let headers = [ "Accept: application/vnd.github.v3+json" ] in
1521
Option.value_map token ~default:headers ~f:(fun v -> sprintf "Authorization: token %s" v :: headers)
@@ -38,13 +44,24 @@ module Github : Api.Github = struct
3844
@@ fmt_error "unexpected encoding '%s' in Github response\nfailed to get config from file %s" encoding url
3945
)
4046

41-
let get_api_commit ~(ctx : Context.t) ~repo ~sha =
47+
let get_resource (ctx : Context.t) url =
4248
let secrets = Context.get_secrets_exn ctx in
43-
let url = commits_url ~repo ~sha in
4449
let headers = build_headers ?token:secrets.gh_token () in
4550
match%lwt http_request ~headers `GET url with
46-
| Ok res -> Lwt.return @@ Ok (Github_j.api_commit_of_string res)
47-
| Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get api commit from file %s" e url
51+
| Ok res -> Lwt.return @@ Ok res
52+
| Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get resource from %s" e url
53+
54+
let get_api_commit ~(ctx : Context.t) ~repo ~sha =
55+
let%lwt res = commits_url ~repo ~sha |> get_resource ctx in
56+
Lwt.return @@ Result.map res ~f:Github_j.api_commit_of_string
57+
58+
let get_pull_request ~(ctx : Context.t) ~repo ~number =
59+
let%lwt res = pulls_url ~repo ~number |> get_resource ctx in
60+
Lwt.return @@ Result.map res ~f:Github_j.pull_request_of_string
61+
62+
let get_issue ~(ctx : Context.t) ~repo ~number =
63+
let%lwt res = issues_url ~repo ~number |> get_resource ctx in
64+
Lwt.return @@ Result.map res ~f:Github_j.issue_of_string
4865
end
4966

5067
module Slack : Api.Slack = struct

lib/github.atd

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,23 @@ type github_user = {
2929
avatar_url: string;
3030
}
3131

32+
type github_team = {
33+
id: int;
34+
name: string;
35+
slug: string;
36+
url: string;
37+
html_url: string;
38+
~description <ocaml default="\"\"">: string;
39+
}
40+
3241
type repository = {
3342
name: string;
3443
full_name: string;
3544
html_url <ocaml name="url"> : string;
3645
commits_url: string;
3746
contents_url: string;
47+
pulls_url: string;
48+
issues_url: string;
3849
}
3950

4051
type commit_pushed_notification = {
@@ -55,13 +66,25 @@ type label = {
5566
name: string;
5667
}
5768

69+
type abstract_issue_state = [
70+
| Open <json name="open">
71+
| Closed <json name="closed">
72+
] <ocaml repr="classic">
73+
5874
type pull_request = {
5975
user: github_user;
6076
number: int;
6177
body: string;
6278
title: string;
6379
html_url: string;
6480
labels: label list;
81+
state: abstract_issue_state;
82+
~requested_reviewers <ocaml default="[]">: github_user list;
83+
~requested_teams <ocaml default="[]">: github_team list;
84+
~assignees <ocaml default="[]">: github_user list;
85+
~merged <ocaml default="false">: bool;
86+
~draft <ocaml default="false">: bool;
87+
~comments <ocaml default="0">: int;
6588
}
6689

6790
type issue = {
@@ -72,6 +95,9 @@ type issue = {
7295
html_url: string;
7396
labels: label list;
7497
?pull_request: basic_json nullable;
98+
state: abstract_issue_state;
99+
~assignees <ocaml default="[]">: github_user list;
100+
~comments <ocaml default="0">: int;
75101
}
76102

77103
type pr_action = [

lib/github.ml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ let parse_exn ~secret headers body =
8787
| "member" | "create" | "delete" | "release" -> Event (event_notification_of_string body)
8888
| event -> failwith @@ sprintf "unsupported event : %s" event
8989

90-
type gh_link = Commit of repository * commit_hash
90+
type gh_link =
91+
| Pull_request of repository * int
92+
| Issue of repository * int
93+
| Commit of repository * commit_hash
9194

9295
(** `gh_link_of_string s` parses a URL string `s` to try to match a supported
9396
GitHub link type, generating repository endpoints if necessary *)
@@ -100,7 +103,7 @@ let gh_link_of_string url_str =
100103
let custom_api_base ?(scheme = "https") base owner name =
101104
sprintf "%s://%s/api/v3/repos/%s/%s" scheme base owner name
102105
in
103-
let re = Re.Str.regexp {|^\(.*\)/\(.+\)/\(.+\)/\(commit\)/\([a-z0-9]+\)/?$|} in
106+
let re = Re.Str.regexp {|^\(.*\)/\(.+\)/\(.+\)/\(commit\|pull\|issues\)/\([a-z0-9]+\)/?$|} in
104107
match Uri.host url with
105108
| None -> None
106109
| Some host ->
@@ -124,11 +127,15 @@ let gh_link_of_string url_str =
124127
url = html_base;
125128
commits_url = sprintf "%s/commits{/sha}" api_base;
126129
contents_url = sprintf "%s/contents/{+path}" api_base;
130+
pulls_url = sprintf "%s/pulls{/number}" api_base;
131+
issues_url = sprintf "%s/issues{/number}" api_base;
127132
}
128133
in
129134
begin
130135
try
131136
match link_type with
137+
| "pull" -> Some (Pull_request (repo, Int.of_string item))
138+
| "issues" -> Some (Issue (repo, Int.of_string item))
132139
| "commit" -> Some (Commit (repo, item))
133140
| _ -> None
134141
with _ -> None

lib/slack.atd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ type 'v map_as_object <ocaml from="State"> = abstract
33
type message_field = {
44
?title: string nullable;
55
value: string;
6+
~short <ocaml default="false">: bool;
67
}
78

89
type message_attachment = {

lib/slack.ml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ let generate_push_notification notification channel =
259259
mrkdwn_in = Some [ "fields" ];
260260
fallback = Some "Commit pushed notification";
261261
color = Some "#ccc";
262-
fields = Some [ { value = String.concat ~sep:"\n" commits; title = None } ];
262+
fields = Some [ { value = String.concat ~sep:"\n" commits; title = None; short = false } ];
263263
};
264264
];
265265
blocks = None;
@@ -315,6 +315,7 @@ let generate_status_notification (cfg : Config_t.config) (notification : status_
315315
(sprintf "<%s|[%s]> CI Build Status notification for <%s|%s>: %s" repository.url repository.full_name t context
316316
state_info)
317317
in
318+
let msg = String.concat ~sep:"\n" @@ List.concat [ commit_info; branches_info ] in
318319
let attachment =
319320
{
320321
empty_attachments with
@@ -323,7 +324,7 @@ let generate_status_notification (cfg : Config_t.config) (notification : status_
323324
pretext = summary;
324325
color = Some color_info;
325326
text = description_info;
326-
fields = Some [ { title = None; value = String.concat ~sep:"\n" @@ List.concat [ commit_info; branches_info ] } ];
327+
fields = Some [ { title = None; value = msg; short = false } ];
327328
}
328329
in
329330
{ channel; text = None; attachments = Some [ attachment ]; blocks = None }

lib/slack_message.ml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ open Slack_t
55
open Common
66
open Mrkdwn
77

8+
let color_of_state ?(draft = false) ?(merged = false) state =
9+
match draft with
10+
| true -> Colors.gray
11+
| false ->
12+
match merged with
13+
| true -> Colors.purple
14+
| false ->
15+
match state with
16+
| Open -> Colors.green
17+
| Closed -> Colors.red
18+
19+
let gh_name_of_string = sprintf "@%s"
20+
821
let empty_attachment =
922
{
1023
mrkdwn_in = None;
@@ -27,6 +40,84 @@ let empty_attachment =
2740
let base_attachment (repository : repository) =
2841
{ empty_attachment with footer = Some (sprintf "<%s|%s>" repository.url (escape_mrkdwn repository.full_name)) }
2942

43+
let pp_label (label : label) = label.name
44+
45+
let pp_github_user (user : github_user) = gh_name_of_string user.login
46+
47+
let pp_github_team (team : github_team) = gh_name_of_string team.slug
48+
49+
let populate_pull_request repository (pull_request : pull_request) =
50+
let ({
51+
title;
52+
number;
53+
html_url;
54+
user;
55+
assignees;
56+
comments;
57+
labels;
58+
requested_reviewers;
59+
requested_teams;
60+
state;
61+
draft;
62+
merged;
63+
_;
64+
}
65+
: pull_request)
66+
=
67+
pull_request
68+
in
69+
let get_reviewers () =
70+
List.concat [ List.map requested_reviewers ~f:pp_github_user; List.map requested_teams ~f:pp_github_team ]
71+
in
72+
let fields =
73+
[
74+
"Assignees", List.map assignees ~f:pp_github_user;
75+
"Labels", List.map labels ~f:pp_label;
76+
("Comments", if comments > 0 then [ Int.to_string comments ] else []);
77+
"Reviewers", get_reviewers ();
78+
]
79+
|> List.filter_map ~f:(fun (t, v) -> if List.is_empty v then None else Some (t, String.concat v ~sep:", "))
80+
|> List.map ~f:(fun (t, v) -> { title = Some t; value = v; short = true })
81+
in
82+
let get_title () = sprintf "#%d %s" number (Mrkdwn.escape_mrkdwn title) in
83+
{
84+
(base_attachment repository) with
85+
author_name = Some user.login;
86+
author_link = Some user.html_url;
87+
author_icon = Some user.avatar_url;
88+
color = Some (color_of_state ~draft ~merged state);
89+
fields = Some fields;
90+
mrkdwn_in = Some [ "text" ];
91+
title = Some (get_title ());
92+
title_link = Some html_url;
93+
fallback = Some (sprintf "[%s] %s" repository.full_name title);
94+
}
95+
96+
let populate_issue repository (issue : issue) =
97+
let ({ title; number; html_url; user; assignees; comments; labels; state; _ } : issue) = issue in
98+
let fields =
99+
[
100+
"Assignees", List.map assignees ~f:pp_github_user;
101+
"Labels", List.map labels ~f:pp_label;
102+
("Comments", if comments > 0 then [ Int.to_string comments ] else []);
103+
]
104+
|> List.filter_map ~f:(fun (t, v) -> if List.is_empty v then None else Some (t, String.concat v ~sep:", "))
105+
|> List.map ~f:(fun (t, v) -> { title = Some t; value = v; short = true })
106+
in
107+
let get_title () = sprintf "#%d %s" number (Mrkdwn.escape_mrkdwn title) in
108+
{
109+
(base_attachment repository) with
110+
author_name = Some user.login;
111+
author_link = Some user.html_url;
112+
author_icon = Some user.avatar_url;
113+
color = Some (color_of_state state);
114+
fields = Some fields;
115+
mrkdwn_in = Some [ "text" ];
116+
title = Some (get_title ());
117+
title_link = Some html_url;
118+
fallback = Some (sprintf "[%s] %s" repository.full_name title);
119+
}
120+
30121
let populate_commit repository (commit : api_commit) =
31122
let ({ sha; commit; url; author; files; _ } : api_commit) = commit in
32123
let title =

0 commit comments

Comments
 (0)