Skip to content

Commit 1eab7b5

Browse files
committed
Merge branch 'yasu/support-multiple-repos'
* yasu/support-multiple-repos: cfg: make specifying repos mandatory fmt cfg: unify repos and allowed_repos into singlee field add comments state: add lock docs: add info on multi-repo setup test: recreate context per test case cfg: add option to restrict handling of GH payloads to certain repo urls cfg: allow repo-specific configuration of gh_token and gh_hook_token github: move webhook signature checking logic out of parse_exn ctx: allocate new hash table for every Context.make () state: store runtime repo state in a hash table indexed by repo url cfg: make ctx store a hash tbl mapping urls to cfgs add atd wrapper for hash tables
2 parents 3e80a04 + 49a0150 commit 1eab7b5

File tree

14 files changed

+241
-104
lines changed

14 files changed

+241
-104
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Note: The `slack_access_token` must be configured in your secrets file for link
5555

5656
### Documentation
5757

58-
The bot expects two configuration files to be present.
58+
Commit a configuration file to the root of each repository you want to support, and add a secrets file on the bot server itself. Read on for instructions to set up each file:
5959

6060
* [Repository configuration](./documentation/config_docs.md)
6161
* [Secrets](./documentation/secret_docs.md)

documentation/secret_docs.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,58 @@ A secrets file stores sensitive information. Unlike the repository configuration
88

99
```json
1010
{
11-
"gh_token": "",
12-
"slack_access_token": ""
11+
"repos": [
12+
{
13+
"url": "https://github.com/ahrefs/monorobot",
14+
"gh_token": "XXX"
15+
}
16+
],
17+
"slack_access_token": "XXX"
1318
}
1419
```
1520

1621
| value | description | optional | default |
1722
|-|-|-|-|
18-
| `gh_token` | specify to grant the bot access to private repositories; omit for public repositories | Yes | - |
19-
| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - |
23+
| `repos` | specify each target repository's url and its secrets | No | - |
2024
| `slack_access_token` | slack bot access token to enable message posting to the workspace | Yes | try to use webhooks defined in `slack_hooks` instead |
2125
| `slack_hooks` | list of channel names and their corresponding webhook endpoint | Yes | try to use token defined in `slack_access_token` instead |
2226
| `slack_signing_secret` | specify to verify incoming slack requests | Yes | - |
2327

2428
Note that either `slack_access_token` or `slack_hooks` must be defined. If both are present, the bot will send notifications using webhooks.
2529

26-
## `gh_token`
30+
## `repos`
31+
32+
Specifies which repositories to accept events from, along with any repository-specific overrides to secrets.
33+
34+
```json
35+
[
36+
{
37+
"url": "https://github.com/ahrefs/runner",
38+
"gh_token": "XXX"
39+
},
40+
{
41+
"url": "https://git.ahrefs.com/ahrefs/coyote",
42+
"gh_token": "XXX",
43+
"gh_hook_token": "XXX"
44+
}
45+
]
46+
```
47+
48+
| value | description | optional | default |
49+
|-|-|-|-|
50+
| `url` | the repository url. | No | - |
51+
| `gh_token` | specify to grant the bot access to private repositories; omit for public repositories | Yes | - |
52+
| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - |
53+
54+
### `repos`
55+
56+
Repository URLs should be fully qualified (include the protocol), with no trailing backslash.
57+
58+
### `gh_token`
2759

2860
Some operations, such as fetching a config file from a private repository, or the commit corresponding to a commit comment event, require a personal access token. Refer [here](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) for detailed instructions on token generation.
2961

30-
## `gh_hook_token`
62+
### `gh_hook_token`
3163

3264
Refer [here](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/securing-your-webhooks) for more information on securing webhooks with a token.
3365

lib/action.ml

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,14 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
9393
if List.is_empty matched_channel_names then default else matched_channel_names
9494

9595
let partition_status (ctx : Context.t) (n : status_notification) =
96-
let cfg = Context.get_config_exn ctx in
96+
let repo = n.repository in
97+
let cfg = Context.find_repo_config_exn ctx repo.url in
9798
let pipeline = n.context in
9899
let current_status = n.state in
99100
let rules = cfg.status_rules.rules in
100101
let action_on_match (branches : branch list) =
101102
let default = Option.to_list cfg.prefix_rules.default_channel in
102-
let () = Context.refresh_pipeline_status ~pipeline ~branches ~status:current_status ctx in
103+
let%lwt () = State.set_repo_pipeline_status ctx.state repo.url ~pipeline ~branches ~status:current_status in
103104
match List.is_empty branches with
104105
| true -> Lwt.return []
105106
| false ->
@@ -111,18 +112,18 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
111112
| false -> Lwt.return default
112113
| true ->
113114
let sha = n.commit.sha in
114-
let repo = n.repository in
115115
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
116116
| Error e -> action_error e
117117
| Ok commit -> Lwt.return @@ partition_commit cfg commit.files
118118
)
119119
in
120-
if Context.is_pipeline_allowed ctx ~pipeline then begin
120+
if Context.is_pipeline_allowed ctx repo.url ~pipeline then begin
121+
let%lwt repo_state = State.find_or_add_repo ctx.state repo.url in
121122
match Rule.Status.match_rules ~rules n with
122123
| Some Ignore | None -> Lwt.return []
123124
| Some Allow -> action_on_match n.branches
124125
| Some Allow_once ->
125-
match Map.find ctx.state.pipeline_statuses pipeline with
126+
match Map.find repo_state.pipeline_statuses pipeline with
126127
| Some branch_statuses ->
127128
let has_same_status_state_as_prev (branch : branch) =
128129
match Map.find branch_statuses branch.name with
@@ -136,7 +137,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
136137
else Lwt.return []
137138

138139
let partition_commit_comment (ctx : Context.t) n =
139-
let cfg = Context.get_config_exn ctx in
140+
let cfg = Context.find_repo_config_exn ctx n.repository.url in
140141
match n.comment.commit_id with
141142
| None -> action_error "unable to find commit id for this commit comment event"
142143
| Some sha ->
@@ -155,7 +156,8 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
155156
)
156157

157158
let generate_notifications (ctx : Context.t) req =
158-
let cfg = Context.get_config_exn ctx in
159+
let repo = Github.repo_of_notification req in
160+
let cfg = Context.find_repo_config_exn ctx repo.url in
159161
match req with
160162
| Github.Push n ->
161163
partition_push cfg n |> List.map ~f:(fun (channel, n) -> generate_push_notification n channel) |> Lwt.return
@@ -184,20 +186,20 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
184186
in
185187
Lwt_list.iter_s notify notifications
186188

187-
(** [refresh_config_of_context ctx n] updates the current context if the configuration
188-
hasn't been loaded yet, or if the incoming request [n] is a push
189+
(** [refresh_repo_config ctx n] fetches the latest repo config if it's
190+
uninitialized, or if the incoming request [n] is a push
189191
notification containing commits that touched the config file. *)
190-
let refresh_config_of_context (ctx : Context.t) notification =
192+
let refresh_repo_config (ctx : Context.t) notification =
193+
let repo = Github.repo_of_notification notification in
191194
let fetch_config () =
192-
let repo = Github.repo_of_notification notification in
193195
match%lwt Github_api.get_config ~ctx ~repo with
194196
| Ok config ->
195-
ctx.config <- Some config;
196-
Context.print_config ctx;
197+
Context.set_repo_config ctx repo.url config;
198+
Context.print_config ctx repo.url;
197199
Lwt.return @@ Ok ()
198200
| Error e -> action_error e
199201
in
200-
match ctx.config with
202+
match Context.find_repo_config ctx repo.url with
201203
| None -> fetch_config ()
202204
| Some _ ->
203205
match notification with
@@ -208,8 +210,8 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
208210
if config_was_modified then fetch_config () else Lwt.return @@ Ok ()
209211
| _ -> Lwt.return @@ Ok ()
210212

211-
let do_github_tasks ctx (req : Github.t) =
212-
let cfg = Context.get_config_exn ctx in
213+
let do_github_tasks ctx (repo : repository) (req : Github.t) =
214+
let cfg = Context.find_repo_config_exn ctx repo.url in
213215
let project_owners (pull_request : pull_request) repository number =
214216
match Github.get_project_owners pull_request cfg.project_owners with
215217
| Some reviewers ->
@@ -230,16 +232,32 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
230232
| _ -> Lwt.return_unit
231233

232234
let process_github_notification (ctx : Context.t) headers body =
235+
let validate_signature secrets payload =
236+
let repo = Github.repo_of_notification payload in
237+
let signing_key = Context.gh_hook_token_of_secrets secrets repo.url in
238+
Github.validate_signature ?signing_key ~headers body
239+
in
240+
let repo_is_supported secrets payload =
241+
let repo = Github.repo_of_notification payload in
242+
List.exists secrets.repos ~f:(fun r -> String.equal r.url repo.url)
243+
in
233244
try%lwt
234245
let secrets = Context.get_secrets_exn ctx in
235-
match Github.parse_exn ~secret:secrets.gh_hook_token headers body with
246+
match Github.parse_exn headers body with
236247
| exception exn -> Exn_lwt.fail ~exn "failed to parse payload"
237248
| payload ->
238-
( match%lwt refresh_config_of_context ctx payload with
249+
match validate_signature secrets payload with
250+
| Error e -> action_error e
251+
| Ok () ->
252+
match repo_is_supported secrets payload with
253+
| false -> action_error "unsupported repository"
254+
| true ->
255+
( match%lwt refresh_repo_config ctx payload with
239256
| Error e -> action_error e
240257
| Ok () ->
241258
let%lwt notifications = generate_notifications ctx payload in
242-
let%lwt () = Lwt.join [ send_notifications ctx notifications; do_github_tasks ctx payload ] in
259+
let repo = Github.repo_of_notification payload in
260+
let%lwt () = Lwt.join [ send_notifications ctx notifications; do_github_tasks ctx repo payload ] in
243261
( match ctx.state_filepath with
244262
| None -> Lwt.return_unit
245263
| Some path ->
@@ -264,7 +282,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
264282
let fetch_bot_user_id () =
265283
match%lwt Slack_api.send_auth_test ~ctx () with
266284
| Ok { user_id; _ } ->
267-
ctx.state.bot_user_id <- Some user_id;
285+
State.set_bot_user_id ctx.state user_id;
268286
let%lwt () =
269287
Option.value_map ctx.state_filepath ~default:Lwt.return_unit ~f:(fun path ->
270288
match%lwt State.save ctx.state path with
@@ -301,7 +319,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
301319
)
302320
in
303321
let%lwt bot_user_id =
304-
match ctx.state.bot_user_id with
322+
match State.get_bot_user_id ctx.state with
305323
| Some id -> Lwt.return_some id
306324
| None -> fetch_bot_user_id ()
307325
in

lib/api_remote.ml

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ module Github : Api.Github = struct
2323
let get_config ~(ctx : Context.t) ~repo =
2424
let secrets = Context.get_secrets_exn ctx in
2525
let url = contents_url ~repo ~path:ctx.config_filename in
26-
let headers = build_headers ?token:secrets.gh_token () in
26+
let token = Context.gh_token_of_secrets secrets repo.url in
27+
let headers = build_headers ?token () in
2728
match%lwt http_request ~headers `GET url with
2829
| Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get config from file %s" e url
2930
| Ok res ->
@@ -44,35 +45,38 @@ module Github : Api.Github = struct
4445
@@ fmt_error "unexpected encoding '%s' in Github response\nfailed to get config from file %s" encoding url
4546
)
4647

47-
let get_resource (ctx : Context.t) url =
48-
let secrets = Context.get_secrets_exn ctx in
49-
let headers = build_headers ?token:secrets.gh_token () in
48+
let get_resource ~secrets ~repo_url url =
49+
let token = Context.gh_token_of_secrets secrets repo_url in
50+
let headers = build_headers ?token () in
5051
match%lwt http_request ~headers `GET url with
5152
| Ok res -> Lwt.return @@ Ok res
5253
| Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get resource from %s" e url
5354

54-
let post_resource (ctx : Context.t) body url =
55-
let secrets = Context.get_secrets_exn ctx in
56-
let headers = build_headers ?token:secrets.gh_token () in
55+
let post_resource ~secrets ~repo_url body url =
56+
let token = Context.gh_token_of_secrets secrets repo_url in
57+
let headers = build_headers ?token () in
5758
match%lwt http_request ~headers ~body:(`Raw ("application/json; charset=utf-8", body)) `POST url with
5859
| Ok res -> Lwt.return @@ Ok res
5960
| Error e -> Lwt.return @@ fmt_error "POST to %s failed : %s" url e
6061

61-
let get_api_commit ~(ctx : Context.t) ~repo ~sha =
62-
let%lwt res = commits_url ~repo ~sha |> get_resource ctx in
62+
let get_api_commit ~(ctx : Context.t) ~(repo : Github_t.repository) ~sha =
63+
let%lwt res = commits_url ~repo ~sha |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in
6364
Lwt.return @@ Result.map res ~f:Github_j.api_commit_of_string
6465

65-
let get_pull_request ~(ctx : Context.t) ~repo ~number =
66-
let%lwt res = pulls_url ~repo ~number |> get_resource ctx in
66+
let get_pull_request ~(ctx : Context.t) ~(repo : Github_t.repository) ~number =
67+
let%lwt res = pulls_url ~repo ~number |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in
6768
Lwt.return @@ Result.map res ~f:Github_j.pull_request_of_string
6869

69-
let get_issue ~(ctx : Context.t) ~repo ~number =
70-
let%lwt res = issues_url ~repo ~number |> get_resource ctx in
70+
let get_issue ~(ctx : Context.t) ~(repo : Github_t.repository) ~number =
71+
let%lwt res = issues_url ~repo ~number |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in
7172
Lwt.return @@ Result.map res ~f:Github_j.issue_of_string
7273

7374
let request_reviewers ~(ctx : Context.t) ~repo ~number ~reviewers =
7475
let body = Github_j.string_of_request_reviewers_req reviewers in
75-
let%lwt res = pulls_url ~repo ~number ^ "/requested_reviewers" |> post_resource ctx body in
76+
let%lwt res =
77+
pulls_url ~repo ~number ^ "/requested_reviewers"
78+
|> post_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url body
79+
in
7680
Lwt.return @@ Result.map res ~f:(fun _ -> ())
7781
end
7882

lib/common.atd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
type 'v map_as_object =
22
(string * 'v) list <json repr="object">
33
wrap <ocaml module="Common.StringMap" t="'v Common.StringMap.t">
4+
5+
type 'v table_as_object =
6+
(string * 'v) list <json repr="object">
7+
wrap <ocaml module="Common.Stringtbl" t="'v Common.Stringtbl.t">

lib/common.ml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
open Base
2-
open Devkit
32

43
module StringMap = struct
54
type 'a t = 'a Map.M(String).t
@@ -11,13 +10,27 @@ module StringMap = struct
1110
let unwrap = to_list
1211
end
1312

13+
module Stringtbl = struct
14+
include Hashtbl
15+
16+
type 'a t = 'a Hashtbl.M(String).t
17+
18+
let empty () = Hashtbl.create (module String)
19+
let to_list (l : 'a t) : (string * 'a) list = Hashtbl.to_alist l
20+
let of_list (m : (string * 'a) list) : 'a t = Hashtbl.of_alist_exn (module String) m
21+
let wrap = of_list
22+
let unwrap = to_list
23+
end
24+
1425
module Re2 = struct
1526
include Re2
1627

1728
let wrap s = create_exn s
1829
let unwrap = Re2.to_string
1930
end
2031

32+
open Devkit
33+
2134
let fmt_error fmt = Printf.ksprintf (fun s -> Error s) fmt
2235

2336
let first_line s =

lib/config.atd

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,20 @@ type webhook = {
4444
channel : string; (* name of the Slack channel to post the message *)
4545
}
4646

47-
(* This is the structure of the secrets file which stores sensitive information, and
48-
shouldn't be checked into version control. *)
49-
type secrets = {
47+
type repo_config = {
48+
(* Repository url. Fully qualified (include protocol), without trailing slash. e.g. https://github.com/ahrefs/monorobot *)
49+
url : string;
5050
(* GitHub personal access token, if repo access requires it *)
5151
?gh_token : string nullable;
5252
(* GitHub webhook token to secure the webhook *)
5353
?gh_hook_token : string nullable;
54+
}
55+
56+
(* This is the structure of the secrets file which stores sensitive information, and
57+
shouldn't be checked into version control. *)
58+
type secrets = {
59+
(* repo-specific secrets; overrides global values if defined for a given repo *)
60+
repos : repo_config list;
5461
(* list of Slack webhook & channel name pairs *)
5562
~slack_hooks <ocaml default="[]"> : webhook list;
5663
(* Slack bot token (`xoxb-XXXX`), giving the bot capabilities to interact with the workspace *)

0 commit comments

Comments
 (0)