diff --git a/README.md b/README.md index 4f36d297..09453aac 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Note: The `slack_access_token` must be configured in your secrets file for link ### Documentation -The bot expects two configuration files to be present. +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: * [Repository configuration](./documentation/config_docs.md) * [Secrets](./documentation/secret_docs.md) diff --git a/documentation/secret_docs.md b/documentation/secret_docs.md index 747beed1..406fd6a7 100644 --- a/documentation/secret_docs.md +++ b/documentation/secret_docs.md @@ -8,26 +8,58 @@ A secrets file stores sensitive information. Unlike the repository configuration ```json { - "gh_token": "", - "slack_access_token": "" + "repos": [ + { + "url": "https://github.com/ahrefs/monorobot", + "gh_token": "XXX" + } + ], + "slack_access_token": "XXX" } ``` | value | description | optional | default | |-|-|-|-| -| `gh_token` | specify to grant the bot access to private repositories; omit for public repositories | Yes | - | -| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - | +| `repos` | specify each target repository's url and its secrets | No | - | | `slack_access_token` | slack bot access token to enable message posting to the workspace | Yes | try to use webhooks defined in `slack_hooks` instead | | `slack_hooks` | list of channel names and their corresponding webhook endpoint | Yes | try to use token defined in `slack_access_token` instead | | `slack_signing_secret` | specify to verify incoming slack requests | Yes | - | Note that either `slack_access_token` or `slack_hooks` must be defined. If both are present, the bot will send notifications using webhooks. -## `gh_token` +## `repos` + +Specifies which repositories to accept events from, along with any repository-specific overrides to secrets. + +```json +[ + { + "url": "https://github.com/ahrefs/runner", + "gh_token": "XXX" + }, + { + "url": "https://git.ahrefs.com/ahrefs/coyote", + "gh_token": "XXX", + "gh_hook_token": "XXX" + } +] +``` + +| value | description | optional | default | +|-|-|-|-| +| `url` | the repository url. | No | - | +| `gh_token` | specify to grant the bot access to private repositories; omit for public repositories | Yes | - | +| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - | + +### `repos` + +Repository URLs should be fully qualified (include the protocol), with no trailing backslash. + +### `gh_token` 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. -## `gh_hook_token` +### `gh_hook_token` 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. diff --git a/lib/action.ml b/lib/action.ml index 2c54c1d1..8b662d50 100644 --- a/lib/action.ml +++ b/lib/action.ml @@ -93,13 +93,14 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct if List.is_empty matched_channel_names then default else matched_channel_names let partition_status (ctx : Context.t) (n : status_notification) = - let cfg = Context.get_config_exn ctx in + let repo = n.repository in + let cfg = Context.find_repo_config_exn ctx repo.url in let pipeline = n.context in let current_status = n.state in let rules = cfg.status_rules.rules in let action_on_match (branches : branch list) = let default = Option.to_list cfg.prefix_rules.default_channel in - let () = Context.refresh_pipeline_status ~pipeline ~branches ~status:current_status ctx in + let%lwt () = State.set_repo_pipeline_status ctx.state repo.url ~pipeline ~branches ~status:current_status in match List.is_empty branches with | true -> Lwt.return [] | false -> @@ -111,18 +112,18 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct | false -> Lwt.return default | true -> let sha = n.commit.sha in - let repo = n.repository in ( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with | Error e -> action_error e | Ok commit -> Lwt.return @@ partition_commit cfg commit.files ) in - if Context.is_pipeline_allowed ctx ~pipeline then begin + if Context.is_pipeline_allowed ctx repo.url ~pipeline then begin + let%lwt repo_state = State.find_or_add_repo ctx.state repo.url in match Rule.Status.match_rules ~rules n with | Some Ignore | None -> Lwt.return [] | Some Allow -> action_on_match n.branches | Some Allow_once -> - match Map.find ctx.state.pipeline_statuses pipeline with + match Map.find repo_state.pipeline_statuses pipeline with | Some branch_statuses -> let has_same_status_state_as_prev (branch : branch) = match Map.find branch_statuses branch.name with @@ -136,7 +137,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct else Lwt.return [] let partition_commit_comment (ctx : Context.t) n = - let cfg = Context.get_config_exn ctx in + let cfg = Context.find_repo_config_exn ctx n.repository.url in match n.comment.commit_id with | None -> action_error "unable to find commit id for this commit comment event" | Some sha -> @@ -155,7 +156,8 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct ) let generate_notifications (ctx : Context.t) req = - let cfg = Context.get_config_exn ctx in + let repo = Github.repo_of_notification req in + let cfg = Context.find_repo_config_exn ctx repo.url in match req with | Github.Push n -> 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 in Lwt_list.iter_s notify notifications - (** [refresh_config_of_context ctx n] updates the current context if the configuration - hasn't been loaded yet, or if the incoming request [n] is a push + (** [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_config_of_context (ctx : Context.t) notification = + let refresh_repo_config (ctx : Context.t) notification = + let repo = Github.repo_of_notification notification in let fetch_config () = - let repo = Github.repo_of_notification notification in match%lwt Github_api.get_config ~ctx ~repo with | Ok config -> - ctx.config <- Some config; - Context.print_config ctx; + Context.set_repo_config ctx repo.url config; + Context.print_config ctx repo.url; Lwt.return @@ Ok () | Error e -> action_error e in - match ctx.config with + match Context.find_repo_config ctx repo.url with | None -> fetch_config () | Some _ -> match notification with @@ -208,8 +210,8 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct if config_was_modified then fetch_config () else Lwt.return @@ Ok () | _ -> Lwt.return @@ Ok () - let do_github_tasks ctx (req : Github.t) = - let cfg = Context.get_config_exn ctx in + let do_github_tasks ctx (repo : repository) (req : Github.t) = + let cfg = Context.find_repo_config_exn ctx repo.url in let project_owners (pull_request : pull_request) repository number = match Github.get_project_owners pull_request cfg.project_owners with | Some reviewers -> @@ -230,16 +232,32 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct | _ -> Lwt.return_unit 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_token_of_secrets secrets repo.url in + Github.validate_signature ?signing_key ~headers body + in + let repo_is_supported secrets payload = + let repo = Github.repo_of_notification payload in + List.exists secrets.repos ~f:(fun r -> String.equal r.url repo.url) + in try%lwt let secrets = Context.get_secrets_exn ctx in - match Github.parse_exn ~secret:secrets.gh_hook_token headers body with + match Github.parse_exn headers body with | exception exn -> Exn_lwt.fail ~exn "failed to parse payload" | payload -> - ( match%lwt refresh_config_of_context ctx payload with + match validate_signature secrets payload with + | Error e -> action_error e + | Ok () -> + match repo_is_supported secrets payload with + | false -> action_error "unsupported repository" + | true -> + ( match%lwt refresh_repo_config ctx payload with | Error e -> action_error e | Ok () -> let%lwt notifications = generate_notifications ctx payload in - let%lwt () = Lwt.join [ send_notifications ctx notifications; do_github_tasks ctx payload ] in + let repo = Github.repo_of_notification payload in + let%lwt () = Lwt.join [ send_notifications ctx notifications; do_github_tasks ctx repo payload ] in ( match ctx.state_filepath with | None -> Lwt.return_unit | Some path -> @@ -264,7 +282,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct let fetch_bot_user_id () = match%lwt Slack_api.send_auth_test ~ctx () with | Ok { user_id; _ } -> - ctx.state.bot_user_id <- Some user_id; + State.set_bot_user_id ctx.state user_id; let%lwt () = Option.value_map ctx.state_filepath ~default:Lwt.return_unit ~f:(fun path -> match%lwt State.save ctx.state path with @@ -301,7 +319,7 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct ) in let%lwt bot_user_id = - match ctx.state.bot_user_id with + match State.get_bot_user_id ctx.state with | Some id -> Lwt.return_some id | None -> fetch_bot_user_id () in diff --git a/lib/api_remote.ml b/lib/api_remote.ml index e2843df2..60002fda 100644 --- a/lib/api_remote.ml +++ b/lib/api_remote.ml @@ -23,7 +23,8 @@ module Github : Api.Github = struct let get_config ~(ctx : Context.t) ~repo = let secrets = Context.get_secrets_exn ctx in let url = contents_url ~repo ~path:ctx.config_filename in - let headers = build_headers ?token:secrets.gh_token () in + let token = Context.gh_token_of_secrets secrets repo.url in + let headers = build_headers ?token () in match%lwt http_request ~headers `GET url with | Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get config from file %s" e url | Ok res -> @@ -44,35 +45,38 @@ module Github : Api.Github = struct @@ fmt_error "unexpected encoding '%s' in Github response\nfailed to get config from file %s" encoding url ) - let get_resource (ctx : Context.t) url = - let secrets = Context.get_secrets_exn ctx in - let headers = build_headers ?token:secrets.gh_token () in + let get_resource ~secrets ~repo_url url = + let token = Context.gh_token_of_secrets secrets repo_url in + let headers = build_headers ?token () in match%lwt http_request ~headers `GET url with | Ok res -> Lwt.return @@ Ok res | Error e -> Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to get resource from %s" e url - let post_resource (ctx : Context.t) body url = - let secrets = Context.get_secrets_exn ctx in - let headers = build_headers ?token:secrets.gh_token () in + let post_resource ~secrets ~repo_url body url = + let token = Context.gh_token_of_secrets secrets repo_url in + let headers = build_headers ?token () in match%lwt http_request ~headers ~body:(`Raw ("application/json; charset=utf-8", body)) `POST url with | Ok res -> Lwt.return @@ Ok res | Error e -> Lwt.return @@ fmt_error "POST to %s failed : %s" url e - let get_api_commit ~(ctx : Context.t) ~repo ~sha = - let%lwt res = commits_url ~repo ~sha |> get_resource ctx in + let get_api_commit ~(ctx : Context.t) ~(repo : Github_t.repository) ~sha = + let%lwt res = commits_url ~repo ~sha |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in Lwt.return @@ Result.map res ~f:Github_j.api_commit_of_string - let get_pull_request ~(ctx : Context.t) ~repo ~number = - let%lwt res = pulls_url ~repo ~number |> get_resource ctx in + let get_pull_request ~(ctx : Context.t) ~(repo : Github_t.repository) ~number = + let%lwt res = pulls_url ~repo ~number |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in Lwt.return @@ Result.map res ~f:Github_j.pull_request_of_string - let get_issue ~(ctx : Context.t) ~repo ~number = - let%lwt res = issues_url ~repo ~number |> get_resource ctx in + let get_issue ~(ctx : Context.t) ~(repo : Github_t.repository) ~number = + let%lwt res = issues_url ~repo ~number |> get_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url in Lwt.return @@ Result.map res ~f:Github_j.issue_of_string let request_reviewers ~(ctx : Context.t) ~repo ~number ~reviewers = let body = Github_j.string_of_request_reviewers_req reviewers in - let%lwt res = pulls_url ~repo ~number ^ "/requested_reviewers" |> post_resource ctx body in + let%lwt res = + pulls_url ~repo ~number ^ "/requested_reviewers" + |> post_resource ~secrets:(Context.get_secrets_exn ctx) ~repo_url:repo.url body + in Lwt.return @@ Result.map res ~f:(fun _ -> ()) end diff --git a/lib/common.atd b/lib/common.atd index 7354e2d2..48b05e41 100644 --- a/lib/common.atd +++ b/lib/common.atd @@ -1,3 +1,7 @@ type 'v map_as_object = (string * 'v) list wrap + +type 'v table_as_object = + (string * 'v) list + wrap diff --git a/lib/common.ml b/lib/common.ml index 0699e25f..811fbbc8 100644 --- a/lib/common.ml +++ b/lib/common.ml @@ -1,5 +1,4 @@ open Base -open Devkit module StringMap = struct type 'a t = 'a Map.M(String).t @@ -11,6 +10,18 @@ module StringMap = struct let unwrap = to_list end +module Stringtbl = struct + include Hashtbl + + type 'a t = 'a Hashtbl.M(String).t + + let empty () = Hashtbl.create (module String) + let to_list (l : 'a t) : (string * 'a) list = Hashtbl.to_alist l + let of_list (m : (string * 'a) list) : 'a t = Hashtbl.of_alist_exn (module String) m + let wrap = of_list + let unwrap = to_list +end + module Re2 = struct include Re2 @@ -18,6 +29,8 @@ module Re2 = struct let unwrap = Re2.to_string end +open Devkit + let fmt_error fmt = Printf.ksprintf (fun s -> Error s) fmt let first_line s = diff --git a/lib/config.atd b/lib/config.atd index 31ff3694..b02d3504 100644 --- a/lib/config.atd +++ b/lib/config.atd @@ -44,13 +44,20 @@ type webhook = { channel : string; (* name of the Slack channel to post the message *) } -(* This is the structure of the secrets file which stores sensitive information, and - shouldn't be checked into version control. *) -type secrets = { +type repo_config = { + (* Repository url. Fully qualified (include protocol), without trailing slash. e.g. https://github.com/ahrefs/monorobot *) + url : string; (* GitHub personal access token, if repo access requires it *) ?gh_token : string nullable; (* GitHub webhook token to secure the webhook *) ?gh_hook_token : string nullable; +} + +(* This is the structure of the secrets file which stores sensitive information, and + shouldn't be checked into version control. *) +type secrets = { + (* repo-specific secrets; overrides global values if defined for a given repo *) + repos : repo_config list; (* list of Slack webhook & channel name pairs *) ~slack_hooks : webhook list; (* Slack bot token (`xoxb-XXXX`), giving the bot capabilities to interact with the workspace *) diff --git a/lib/context.ml b/lib/context.ml index 361241b4..1df57c1a 100644 --- a/lib/context.ml +++ b/lib/context.ml @@ -4,62 +4,71 @@ open Devkit exception Context_error of string -let context_error msg = raise (Context_error msg) +let context_error fmt = Printf.ksprintf (fun msg -> raise (Context_error msg)) fmt type t = { config_filename : string; secrets_filepath : string; state_filepath : string option; mutable secrets : Config_t.secrets option; - mutable config : Config_t.config option; - state : State_t.state; + config : Config_t.config Stringtbl.t; + state : State.t; } -let default : t = +let default_config_filename = "monorobot.json" +let default_secrets_filepath = "secrets.json" + +let make ?config_filename ?secrets_filepath ?state_filepath () = { - config_filename = "monorobot.json"; - secrets_filepath = "secrets.json"; - state_filepath = None; + config_filename = Option.value config_filename ~default:default_config_filename; + secrets_filepath = Option.value secrets_filepath ~default:default_secrets_filepath; + state_filepath; secrets = None; - config = None; - state = State.empty; + config = Stringtbl.empty (); + state = State.empty (); } -let make ?config_filename ?secrets_filepath ?state_filepath () = - let config_filename = Option.value config_filename ~default:default.config_filename in - let secrets_filepath = Option.value secrets_filepath ~default:default.secrets_filepath in - { default with config_filename; secrets_filepath; state_filepath } - let get_secrets_exn ctx = match ctx.secrets with | None -> context_error "secrets is uninitialized" | Some secrets -> secrets -let get_config_exn ctx = - match ctx.config with - | None -> context_error "config is uninitialized" +let find_repo_config ctx repo_url = Stringtbl.find ctx.config repo_url + +let find_repo_config_exn ctx repo_url = + match find_repo_config ctx repo_url with + | None -> context_error "config uninitialized for repo %s" repo_url | Some config -> config +let set_repo_config ctx repo_url config = Stringtbl.set ctx.config ~key:repo_url ~data:config + +let gh_token_of_secrets (secrets : Config_t.secrets) repo_url = + match List.find secrets.repos ~f:(fun r -> String.equal r.Config_t.url repo_url) with + | None -> None + | Some repos -> repos.gh_token + +let gh_hook_token_of_secrets (secrets : Config_t.secrets) repo_url = + match List.find secrets.repos ~f:(fun r -> String.equal r.Config_t.url repo_url) with + | None -> None + | Some repos -> repos.gh_hook_token + let hook_of_channel ctx channel_name = let secrets = get_secrets_exn ctx in match List.find secrets.slack_hooks ~f:(fun webhook -> String.equal webhook.channel channel_name) with | Some hook -> Some hook.url | None -> None -(** [is_pipeline_allowed ctx p] returns [true] if [ctx.config.status_rules] - doesn't define a whitelist of allowed pipelines, or if the list - contains pipeline [p]; returns [false] otherwise. *) -let is_pipeline_allowed ctx ~pipeline = - match ctx.config with +(** [is_pipeline_allowed ctx repo_url ~pipeline] returns [true] if [status_rules] + doesn't define a whitelist of allowed pipelines in the config of [repo_url], + or if the list contains [pipeline]; returns [false] otherwise. *) +let is_pipeline_allowed ctx repo_url ~pipeline = + match find_repo_config ctx repo_url with | None -> true | Some config -> match config.status_rules.allowed_pipelines with | Some allowed_pipelines when not @@ List.exists allowed_pipelines ~f:(String.equal pipeline) -> false | _ -> true -let refresh_pipeline_status ctx ~pipeline ~(branches : Github_t.branch list) ~status = - if is_pipeline_allowed ctx ~pipeline then State.refresh_pipeline_status ctx.state ~pipeline ~branches ~status else () - let log = Log.from "context" let refresh_secrets ctx = @@ -72,6 +81,9 @@ let refresh_secrets ctx = match secrets.slack_access_token, secrets.slack_hooks with | None, [] -> fmt_error "either slack_access_token or slack_hooks must be defined in file '%s'" path | _ -> + match secrets.repos with + | [] -> fmt_error "at least one repository url must be specified in the 'repos' list in file %S" path + | _ :: _ -> ctx.secrets <- Some secrets; Ok ctx end @@ -85,16 +97,18 @@ let refresh_state ctx = match get_local_file path with | Error e -> fmt_error "error while getting local file: %s\nfailed to get state from file %s" e path | Ok file -> - let state = State_j.state_of_string file in + (* todo: extract state related parts to state.ml *) + let state = { ctx.state with state = State_j.state_of_string file } in Ok { ctx with state } end else Ok ctx -let print_config ctx = - let cfg = get_config_exn ctx in +let print_config ctx repo_url = + let cfg = find_repo_config_exn ctx repo_url in let secrets = get_secrets_exn ctx in + let token = gh_hook_token_of_secrets secrets repo_url in log#info "using prefix routing:"; Rule.Prefix.print_prefix_routing cfg.prefix_rules.rules; log#info "using label routing:"; Rule.Label.print_label_routing cfg.label_rules.rules; - log#info "signature checking %s" (if Option.is_some secrets.gh_hook_token then "enabled" else "disabled") + log#info "signature checking %s" (if Option.is_some token then "enabled" else "disabled") diff --git a/lib/dune b/lib/dune index e6c2cb2c..c71f0cb7 100644 --- a/lib/dune +++ b/lib/dune @@ -1,7 +1,7 @@ (library (name lib) (libraries atdgen atdgen-runtime base base64 base.caml biniou cstruct curl curl.lwt - devkit devkit.core extlib hex lwt lwt.unix nocrypto omd re2 stdio uri + devkit devkit.core extlib hex lwt lwt.unix nocrypto omd re2 sexplib0 stdio uri yojson) (preprocess (pps lwt_ppx))) diff --git a/lib/github.ml b/lib/github.ml index be1b6080..ae4f6661 100644 --- a/lib/github.ml +++ b/lib/github.ml @@ -64,16 +64,16 @@ let is_valid_signature ~secret headers_sig body = let (`Hex request_hash) = Hex.of_string request_hash in String.equal headers_sig (sprintf "sha1=%s" request_hash) +let validate_signature ?signing_key ~headers body = + match signing_key with + | None -> Ok () + | Some secret -> + match List.Assoc.find headers "x-hub-signature" ~equal:String.equal with + | None -> Error "unable to find header x-hub-signature" + | Some signature -> if is_valid_signature ~secret signature body then Ok () else Error "signatures don't match" + (* Parse a payload. The type of the payload is detected from the headers. *) -let parse_exn ~secret headers body = - begin - match secret with - | None -> () - | Some secret -> - match List.Assoc.find headers "x-hub-signature" ~equal:String.equal with - | None -> Exn.fail "unable to find header x-hub-signature" - | Some req_sig -> if not @@ is_valid_signature ~secret req_sig body then failwith "request signature invalid" - end; +let parse_exn headers body = match List.Assoc.find_exn headers "x-github-event" ~equal:String.equal with | exception exn -> Exn.fail ~exn "unable to read x-github-event" | "push" -> Push (commit_pushed_notification_of_string body) diff --git a/lib/state.atd b/lib/state.atd index eaadc845..86b18f06 100644 --- a/lib/state.atd +++ b/lib/state.atd @@ -1,5 +1,6 @@ type status_state = abstract type 'v map_as_object = abstract +type 'v table_as_object = abstract (* A map from branch names to build statuses *) type branch_statuses = status_state map_as_object @@ -9,8 +10,13 @@ type branch_statuses = status_state map_as_object branch *) type pipeline_statuses = branch_statuses map_as_object +(* The runtime state of a given GitHub repository *) +type repo_state = { + pipeline_statuses : pipeline_statuses +} + (* The serializable runtime state of the bot *) type state = { - pipeline_statuses : pipeline_statuses; + repos : repo_state table_as_object; ?bot_user_id : string nullable; } \ No newline at end of file diff --git a/lib/state.ml b/lib/state.ml index ce6f229e..6a53f361 100644 --- a/lib/state.ml +++ b/lib/state.ml @@ -2,19 +2,43 @@ open Base open Common open Devkit -let empty : State_t.state = { pipeline_statuses = StringMap.empty; bot_user_id = None } +type t = { + state : State_t.state; + lock : Lwt_mutex.t; (** protect access to mutable string map `pipeline_statuses` *) +} -let refresh_pipeline_status (state : State_t.state) ~pipeline ~(branches : Github_t.branch list) ~status = - let update_pipeline_status branch_statuses = +let empty_repo_state () : State_t.repo_state = { pipeline_statuses = StringMap.empty } + +let empty () : t = + let state = State_t.{ repos = Stringtbl.empty (); bot_user_id = None } in + { state; lock = Lwt_mutex.create () } + +let find_or_add_repo' state repo_url = Stringtbl.find_or_add state.State_t.repos repo_url ~default:empty_repo_state + +let set_repo_state { state; lock } repo_url repo_state = + Lwt_mutex.with_lock lock @@ fun () -> + Stringtbl.set state.repos ~key:repo_url ~data:repo_state; + Lwt.return_unit + +let find_or_add_repo { state; lock } repo_url = + Lwt_mutex.with_lock lock @@ fun () -> find_or_add_repo' state repo_url |> Lwt.return + +let set_repo_pipeline_status { state; lock } repo_url ~pipeline ~(branches : Github_t.branch list) ~status = + let set_branch_status branch_statuses = let new_statuses = List.map branches ~f:(fun b -> b.name, status) in let init = Option.value branch_statuses ~default:(Map.empty (module String)) in List.fold_left new_statuses ~init ~f:(fun m (key, data) -> Map.set m ~key ~data) in - state.pipeline_statuses <- Map.update state.pipeline_statuses pipeline ~f:update_pipeline_status + Lwt_mutex.with_lock lock @@ fun () -> + let repo_state = find_or_add_repo' state repo_url in + repo_state.pipeline_statuses <- Map.update repo_state.pipeline_statuses pipeline ~f:set_branch_status; + Lwt.return_unit +let set_bot_user_id { state; _ } user_id = state.State_t.bot_user_id <- Some user_id +let get_bot_user_id { state; _ } = state.State_t.bot_user_id let log = Log.from "state" -let save state path = +let save { state; _ } path = let data = State_j.string_of_state state |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in match write_to_local_file ~data path with | Ok () -> Lwt.return @@ Ok () diff --git a/test/secrets.json b/test/secrets.json index f5a7ea7e..13dc1e63 100644 --- a/test/secrets.json +++ b/test/secrets.json @@ -1,3 +1,8 @@ { + "repos": [ + { + "url": "" + } + ], "slack_access_token": "" } \ No newline at end of file diff --git a/test/test.ml b/test/test.ml index 916c6707..5bf1c045 100644 --- a/test/test.ml +++ b/test/test.ml @@ -18,24 +18,36 @@ let get_mock_payloads () = if Caml.Sys.file_exists state_path then kind, payload_path, Some state_path else kind, payload_path, None ) -let process ~(ctx : Context.t) (kind, path, state_path) = - let%lwt ctx = +let process ~(secrets : Config_t.secrets) ~config (kind, path, state_path) = + let headers = [ "x-github-event", kind ] in + let make_test_context event = + let repo = Github.repo_of_notification @@ Github.parse_exn headers event in + (* overwrite repo url in secrets with that of notification for this test case *) + let secrets = { secrets with repos = [ { url = repo.url; gh_token = None; gh_hook_token = None } ] } in + let ctx = Context.make () in + ctx.secrets <- Some secrets; + let%lwt _ = State.find_or_add_repo ctx.state repo.url in match state_path with - | None -> Lwt.return ctx + | None -> + Context.set_repo_config ctx repo.url config; + Lwt.return ctx | Some state_path -> match Common.get_local_file state_path with | Error e -> log#error "failed to read %s: %s" state_path e; Lwt.return ctx | Ok file -> - let state = State_j.state_of_string file in - Lwt.return { ctx with state } + let repo_state = State_j.repo_state_of_string file in + let%lwt () = State.set_repo_state ctx.state repo.url repo_state in + Context.set_repo_config ctx repo.url config; + Lwt.return ctx in Stdio.printf "===== file %s =====\n" path; let headers = [ "x-github-event", kind ] in match Common.get_local_file path with | Error e -> Lwt.return @@ log#error "failed to read %s: %s" path e | Ok event -> + let%lwt ctx = make_test_context event in let%lwt _ctx = Action_local.process_github_notification ctx headers event in Lwt.return_unit @@ -51,12 +63,10 @@ let () = log#error "%s" e; Lwt.return_unit | Ok config -> - let ctx = { ctx with config = Some config } in - ( match Context.refresh_secrets ctx with - | Ok ctx -> Lwt_list.iter_s (process ~ctx) payloads - | Error e -> - log#error "failed to read secrets:"; - log#error "%s" e; - Lwt.return_unit - ) + match Context.refresh_secrets ctx with + | Ok ctx -> Lwt_list.iter_s (process ~secrets:(Option.value_exn ctx.secrets) ~config) payloads + | Error e -> + log#error "failed to read secrets:"; + log#error "%s" e; + Lwt.return_unit )