Skip to content

Commit 93ccfca

Browse files
authored
Merge pull request #127 from sewenthy/119-unfurling-commit-range-links
Add support for compare link unfurling
2 parents ec61592 + 14f0d86 commit 93ccfca

File tree

39 files changed

+2859
-126
lines changed

39 files changed

+2859
-126
lines changed

lib/action.ml

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -320,25 +320,29 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
320320
Lwt.return_none
321321
in
322322
let process link =
323+
let with_gh_result_populate_slack (type a) ~(api_result : (a, string) Result.t)
324+
~(populate : repository -> a -> Slack_t.message_attachment) ~repo
325+
=
326+
match api_result with
327+
| Error _ -> Lwt.return_none
328+
| Ok item -> Lwt.return_some @@ (link, populate repo item)
329+
in
323330
match Github.gh_link_of_string link with
324331
| None -> Lwt.return_none
325332
| Some gh_link ->
326333
match gh_link with
327334
| Pull_request (repo, number) ->
328-
( match%lwt Github_api.get_pull_request ~ctx ~repo ~number with
329-
| Error _ -> Lwt.return_none
330-
| Ok pr -> Lwt.return_some @@ (link, Slack_message.populate_pull_request repo pr)
331-
)
335+
let%lwt result = Github_api.get_pull_request ~ctx ~repo ~number in
336+
with_gh_result_populate_slack ~api_result:result ~populate:Slack_message.populate_pull_request ~repo
332337
| Issue (repo, number) ->
333-
( match%lwt Github_api.get_issue ~ctx ~repo ~number with
334-
| Error _ -> Lwt.return_none
335-
| Ok issue -> Lwt.return_some @@ (link, Slack_message.populate_issue repo issue)
336-
)
338+
let%lwt result = Github_api.get_issue ~ctx ~repo ~number in
339+
with_gh_result_populate_slack ~api_result:result ~populate:Slack_message.populate_issue ~repo
337340
| Commit (repo, sha) ->
338-
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
339-
| Error _ -> Lwt.return_none
340-
| Ok commit -> Lwt.return_some @@ (link, Slack_message.populate_commit repo commit)
341-
)
341+
let%lwt result = Github_api.get_api_commit ~ctx ~repo ~sha in
342+
with_gh_result_populate_slack ~api_result:result ~populate:Slack_message.populate_commit ~repo
343+
| Compare (repo, basehead) ->
344+
let%lwt result = Github_api.get_compare ~ctx ~repo ~basehead in
345+
with_gh_result_populate_slack ~api_result:result ~populate:Slack_message.populate_compare ~repo
342346
in
343347
let%lwt bot_user_id =
344348
match State.get_bot_user_id ctx.state with

lib/api.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module type Github = sig
77
val get_api_commit : ctx:Context.t -> repo:repository -> sha:string -> (api_commit, string) Result.t Lwt.t
88
val get_pull_request : ctx:Context.t -> repo:repository -> number:int -> (pull_request, string) Result.t Lwt.t
99
val get_issue : ctx:Context.t -> repo:repository -> number:int -> (issue, string) Result.t Lwt.t
10+
val get_compare : ctx:Context.t -> repo:repository -> basehead:Github.basehead -> (compare, string) Result.t Lwt.t
1011

1112
val request_reviewers
1213
: ctx:Context.t ->

lib/api_local.ml

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,63 @@
11
open Base
22
open Common
33
open Devkit
4+
open Printf
45

56
let cwd = Caml.Sys.getcwd ()
67
let cache_dir = Caml.Filename.concat cwd "github-api-cache"
78

9+
(** return the file with a function f applied unless the file is empty;
10+
empty file:this is needed to simulate 404 returns from github *)
11+
let with_cache_file url f =
12+
match get_local_file url with
13+
| Error e ->
14+
let err_msg = sprintf "error while getting local file: %s\ncached for url: %s" e url in
15+
Stdio.print_endline err_msg;
16+
Lwt.return_error err_msg
17+
| Ok "" -> Lwt.return_error "empty file"
18+
| Ok file -> Lwt.return_ok (f file)
19+
20+
let clean_forward_slashes = String.substr_replace_all ~pattern:"/" ~with_:"_"
21+
22+
(** get a member of the repo cached API call providing
23+
the member kind (pull, issue, commit, compare, etc),
24+
_ref (pr number, issue number, commit sha, compare basehead, etc),
25+
and its Github_j.<kind>_of_string function.
26+
NB: please save the cache file in the same format *)
27+
let get_repo_member_cache ~(repo : Github_t.repository) ~kind ~ref_ ~of_string =
28+
let file = clean_forward_slashes (sprintf "%s_%s_%s" repo.full_name kind ref_) in
29+
let url = Caml.Filename.concat cache_dir file in
30+
with_cache_file url of_string
31+
832
module Github : Api.Github = struct
933
let get_config ~(ctx : Context.t) ~repo:_ =
1034
let url = Caml.Filename.concat cwd ctx.config_filename in
11-
match get_local_file url with
12-
| Error e -> Lwt.return @@ fmt_error "error while getting local file: %s\nfailed to get config %s" e url
13-
| Ok file -> Lwt.return @@ Ok (Config_j.config_of_string file)
14-
15-
let get_api_commit ~ctx:_ ~repo:_ ~sha =
16-
let url = Caml.Filename.concat cache_dir sha in
17-
match get_local_file url with
18-
| Error e -> Lwt.return @@ fmt_error "error while getting local file: %s\nfailed to get api commit %s" e url
19-
| Ok file -> Lwt.return @@ Ok (Github_j.api_commit_of_string file)
20-
21-
let get_pull_request ~ctx:_ ~repo:_ ~number:_ = Lwt.return @@ Error "undefined for local setup"
22-
let get_issue ~ctx:_ ~repo:_ ~number:_ = Lwt.return @@ Error "undefined for local setup"
35+
with_cache_file url Config_j.config_of_string
36+
37+
let get_api_commit ~ctx:_ ~repo ~sha =
38+
get_repo_member_cache ~repo ~kind:"commit" ~ref_:sha ~of_string:Github_j.api_commit_of_string
39+
40+
let get_pull_request ~ctx:_ ~(repo : Github_t.repository) ~number =
41+
get_repo_member_cache ~repo ~kind:"pull" ~ref_:(Int.to_string number) ~of_string:Github_j.pull_request_of_string
42+
43+
let get_issue ~ctx:_ ~(repo : Github_t.repository) ~number =
44+
get_repo_member_cache ~repo ~kind:"issue" ~ref_:(Int.to_string number) ~of_string:Github_j.issue_of_string
45+
46+
let get_compare ~ctx:_ ~(repo : Github_t.repository) ~basehead:(base, merge) =
47+
get_repo_member_cache ~repo ~kind:"compare" ~ref_:(sprintf "%s...%s" base merge)
48+
~of_string:Github_j.compare_of_string
49+
2350
let request_reviewers ~ctx:_ ~repo:_ ~number:_ ~reviewers:_ = Lwt.return @@ Error "undefined for local setup"
2451
end
2552

53+
(** The base implementation for local check payload debugging and mocking tests *)
2654
module Slack_base : Api.Slack = struct
2755
let send_notification ~ctx:_ ~msg:_ = Lwt.return @@ Error "undefined for local setup"
2856
let send_chat_unfurl ~ctx:_ ~channel:_ ~ts:_ ~unfurls:_ () = Lwt.return @@ Error "undefined for local setup"
2957
let send_auth_test ~ctx:_ () = Lwt.return @@ Error "undefined for local setup"
3058
end
3159

60+
(** Module for mocking test requests to slack--will output on Stdio *)
3261
module Slack : Api.Slack = struct
3362
include Slack_base
3463

@@ -37,8 +66,20 @@ module Slack : Api.Slack = struct
3766
Stdio.printf "will notify #%s\n" msg.channel;
3867
Stdio.printf "%s\n" json;
3968
Lwt.return @@ Ok ()
69+
70+
let send_chat_unfurl ~ctx:_ ~channel ~ts ~unfurls () =
71+
let req = Slack_j.{ channel; ts; unfurls } in
72+
let data = req |> Slack_j.string_of_chat_unfurl_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
73+
Stdio.printf "will unfurl in #%s\n" channel;
74+
Stdio.printf "%s\n" data;
75+
Lwt.return @@ Ok ()
76+
77+
let send_auth_test ~ctx:_ () =
78+
Lwt.return
79+
@@ Ok ({ url = ""; team = ""; user = ""; team_id = ""; user_id = "test_slack_user" } : Slack_t.auth_test_res)
4080
end
4181

82+
(** Simple messages (only the actual text messages that users see) output to log for checking payload commands *)
4283
module Slack_simple : Api.Slack = struct
4384
include Slack_base
4485

@@ -48,11 +89,22 @@ module Slack_simple : Api.Slack = struct
4889
log#info "will notify %s%s" msg.channel
4990
( match msg.Slack_t.text with
5091
| None -> ""
51-
| Some s -> Printf.sprintf " with %S" s
92+
| Some s -> sprintf " with %S" s
5293
);
5394
Lwt.return @@ Ok ()
95+
96+
let send_chat_unfurl ~ctx:_ ~channel ~ts:_ ~(unfurls : Slack_t.message_attachment Common.StringMap.t) () =
97+
Stdio.printf "will unfurl in #%s\n" channel;
98+
let unfurl_text = List.map (StringMap.to_list unfurls) ~f:(fun (_, unfurl) -> unfurl.text) in
99+
Stdio.printf "%s\n" (String.concat ~sep:"\n" (List.filter_opt unfurl_text));
100+
Lwt.return @@ Ok ()
101+
102+
let send_auth_test ~ctx:_ () =
103+
Lwt.return
104+
@@ Ok ({ url = ""; team = ""; user = ""; team_id = ""; user_id = "test_slack_user" } : Slack_t.auth_test_res)
54105
end
55106

107+
(** Messages payload in json output to log for checking payload commands *)
56108
module Slack_json : Api.Slack = struct
57109
include Slack_base
58110

@@ -66,4 +118,16 @@ module Slack_json : Api.Slack = struct
66118
log#info "%s" (Uri.to_string url);
67119
log#info "%s" json;
68120
Lwt.return @@ Ok ()
121+
122+
let send_chat_unfurl ~ctx:_ ~channel ~ts:_ ~(unfurls : Slack_t.message_attachment Common.StringMap.t) () =
123+
log#info "will notify %s" channel;
124+
let json = List.map (StringMap.to_list unfurls) ~f:(fun (_, unfurl) -> Slack_j.string_of_unfurl unfurl) in
125+
let url = Uri.of_string "https://slack.com/api/chat.unfurl" in
126+
log#info "%s" (Uri.to_string url);
127+
log#info "%s" (String.concat ~sep:";\n" json);
128+
Lwt.return @@ Ok ()
129+
130+
let send_auth_test ~ctx:_ () =
131+
Lwt.return
132+
@@ Ok ({ url = ""; team = ""; user = ""; team_id = ""; user_id = "test_slack_user" } : Slack_t.auth_test_res)
69133
end

lib/api_remote.ml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ module Github : Api.Github = struct
1616
let issues_url ~(repo : Github_t.repository) ~number =
1717
String.substr_replace_first ~pattern:"{/number}" ~with_:(sprintf "/%d" number) repo.issues_url
1818

19+
let compare_url ~(repo : Github_t.repository) ~basehead:(base, merge) =
20+
String.substr_replace_first ~pattern:"{/basehead}" ~with_:(sprintf "/%s...%s" base merge) repo.compare_url
21+
1922
let build_headers ?token () =
2023
let headers = [ "Accept: application/vnd.github.v3+json" ] in
2124
Option.value_map token ~default:headers ~f:(fun v -> sprintf "Authorization: token %s" v :: headers)
@@ -71,6 +74,12 @@ module Github : Api.Github = struct
7174
let%lwt res = issues_url ~repo ~number |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in
7275
Lwt.return @@ Result.map res ~f:Github_j.issue_of_string
7376

77+
let get_compare ~(ctx : Context.t) ~(repo : Github_t.repository) ~basehead =
78+
let%lwt res =
79+
compare_url ~repo ~basehead |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url
80+
in
81+
Lwt.return @@ Result.map res ~f:Github_j.compare_of_string
82+
7483
let request_reviewers ~(ctx : Context.t) ~(repo : Github_t.repository) ~number ~reviewers =
7584
let body = Github_j.string_of_request_reviewers_req reviewers in
7685
let%lwt res =

lib/github.atd

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ type commit = {
2727
modified: string list;
2828
}
2929

30+
type file = {
31+
sha: commit_hash;
32+
filename: string;
33+
status: string;
34+
additions: int;
35+
deletions: int;
36+
changes: int;
37+
blob_url <ocaml name="url"> : string;
38+
}
39+
3040
type github_user = {
3141
login: string;
3242
id: int;
@@ -52,6 +62,7 @@ type repository = {
5262
contents_url: string;
5363
pulls_url: string;
5464
issues_url: string;
65+
compare_url: string;
5566
}
5667

5768
type commit_pushed_notification = {
@@ -106,6 +117,22 @@ type issue = {
106117
~comments <ocaml default="0">: int;
107118
}
108119

120+
type compare = {
121+
url: string;
122+
html_url: string;
123+
permalink_url: string;
124+
diff_url: string;
125+
patch_url: string;
126+
base_commit: api_commit;
127+
merge_base_commit: api_commit;
128+
status: string;
129+
ahead_by: int;
130+
behind_by: int;
131+
total_commits: int;
132+
commits: api_commit list;
133+
~files <ocaml default="[]">: file list;
134+
}
135+
109136
type pr_action = [
110137
Assigned <json name="assigned">
111138
| Unassigned <json name="unassigned">
@@ -244,16 +271,6 @@ type status_notification = {
244271
updated_at: string;
245272
}
246273

247-
type file = {
248-
sha: commit_hash;
249-
filename: string;
250-
status: string;
251-
additions: int;
252-
deletions: int;
253-
changes: int;
254-
blob_url <ocaml name="url"> : string;
255-
}
256-
257274
type api_commit_stats = {
258275
total: int;
259276
additions: int;
@@ -264,9 +281,9 @@ type api_commit = {
264281
sha: commit_hash;
265282
commit: inner_commit;
266283
html_url <ocaml name="url"> : string;
267-
author: github_user;
268-
files: file list;
269-
stats: api_commit_stats;
284+
?author: github_user option; (* will be none if author's email is not associated with any github account *)
285+
~files <ocaml default="[]">: file list;
286+
~stats <ocaml default="{total=0; additions=0; deletions=0;}">: api_commit_stats;
270287
}
271288

272289
type commit_comment_notification = {

lib/github.ml

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

90+
type basehead = string * string
91+
9092
type gh_link =
9193
| Pull_request of repository * int
9294
| Issue of repository * int
9395
| Commit of repository * commit_hash
96+
| Compare of repository * basehead
9497

95-
let gh_link_re = Re2.create_exn {|^(.*)/(.+)/(.+)/(commit|pull|issues)/([a-z0-9]+)/?$|}
98+
let gh_link_re = Re2.create_exn {|^(.*)/(.+)/(.+)/(commit|pull|issues|compare)/([a-zA-Z0-9/:\-_.~\^%]+)$|}
99+
let commit_sha_re = Re2.create_exn {|[a-f0-9]{4,40}|}
100+
let comparer_re = {|([a-zA-Z0-9/:\-_.~\^]+)|}
101+
let compare_basehead_re = Re2.create_exn (sprintf {|%s([.]{3})%s|} comparer_re comparer_re)
96102
let gh_org_team_re = Re2.create_exn {|[a-zA-Z0-9\-]+/([a-zA-Z0-9\-]+)|}
97103

98104
(** [gh_link_of_string s] parses a URL string [s] to try to match a supported
@@ -111,13 +117,14 @@ let gh_link_of_string url_str =
111117
| Some host ->
112118
match Re2.find_submatches_exn gh_link_re path with
113119
| [| _; prefix; Some owner; Some name; Some link_type; Some item |] ->
114-
let base = Option.value_map prefix ~default:host ~f:(fun p -> String.concat [ host; p ]) in
115-
let scheme = Uri.scheme url in
116-
let html_base, api_base =
117-
if String.is_suffix base ~suffix:"github.com" then gh_com_html_base owner name, gh_com_api_base owner name
118-
else custom_html_base ?scheme base owner name, custom_api_base ?scheme base owner name
119-
in
120+
let item = Base.String.chop_suffix_if_exists item ~suffix:"/" in
120121
let repo =
122+
let base = Option.value_map prefix ~default:host ~f:(fun p -> String.concat [ host; p ]) in
123+
let scheme = Uri.scheme url in
124+
let html_base, api_base =
125+
if String.is_suffix base ~suffix:"github.com" then gh_com_html_base owner name, gh_com_api_base owner name
126+
else custom_html_base ?scheme base owner name, custom_api_base ?scheme base owner name
127+
in
121128
{
122129
name;
123130
full_name = sprintf "%s/%s" owner name;
@@ -126,14 +133,30 @@ let gh_link_of_string url_str =
126133
contents_url = sprintf "%s/contents/{+path}" api_base;
127134
pulls_url = sprintf "%s/pulls{/number}" api_base;
128135
issues_url = sprintf "%s/issues{/number}" api_base;
136+
compare_url = sprintf "%s/compare{/basehead}" api_base;
129137
}
130138
in
139+
let verify_commit_sha repo item =
140+
try
141+
match Re2.find_submatches_exn commit_sha_re item with
142+
| [| Some sha |] -> Some (Commit (repo, sha))
143+
| _ -> None
144+
with _ -> None
145+
in
146+
let verify_compare_basehead repo basehead =
147+
match Re2.find_submatches_exn compare_basehead_re basehead with
148+
| [| _; Some base; _; Some merge |] -> Some (Compare (repo, (base, merge)))
149+
| _ | (exception Re2.Exceptions.Regex_match_failed _) -> None
150+
in
131151
begin
132152
try
133153
match link_type with
134154
| "pull" -> Some (Pull_request (repo, Int.of_string item))
135155
| "issues" -> Some (Issue (repo, Int.of_string item))
136-
| "commit" -> Some (Commit (repo, item))
156+
| "commit" -> verify_commit_sha repo item
157+
| "compare" ->
158+
let item = Uri.pct_decode item in
159+
verify_compare_basehead repo item
137160
| _ -> None
138161
with _ -> None
139162
end

0 commit comments

Comments
 (0)