| 1 | //! Keeping workflow runs safe, over REST and MCP: environments' protection |
| 2 | //! rules, the reviews of the jobs they hold, approving a pull request's |
| 3 | //! run from outside, what a job's token gets when its workflow names no |
| 4 | //! `permissions:`, which pull requests' runs wait for approval, and |
| 5 | //! `repository_dispatch`. The actions service decides and keeps all of it |
| 6 | //! (services/actions/src/protection.rs); these shape requests and answers |
| 7 | //! as the standard Actions REST API does. |
| 8 | |
| 9 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 10 | use serde_json::{Map, Value, json}; |
| 11 | use worker::Result; |
| 12 | |
| 13 | use crate::operations::{Services, repo_path}; |
| 14 | |
| 15 | /// One operation. |
| 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 17 | pub enum ProtectionOp { |
| 18 | UpdateEnvironment, |
| 19 | DeleteEnvironment, |
| 20 | GetPendingDeployments, |
| 21 | ReviewPendingDeployments, |
| 22 | ApproveWorkflowRun, |
| 23 | GetWorkflowPermissions, |
| 24 | SetWorkflowPermissions, |
| 25 | GetForkPrApproval, |
| 26 | SetForkPrApproval, |
| 27 | GetActionsAccess, |
| 28 | SetActionsAccess, |
| 29 | CreateRepositoryDispatch, |
| 30 | GetWorkspaceWorkflowPermissions, |
| 31 | SetWorkspaceWorkflowPermissions, |
| 32 | ListWorkspaceManagedWorkflows, |
| 33 | CreateWorkspaceManagedWorkflow, |
| 34 | UpdateWorkspaceManagedWorkflow, |
| 35 | DeleteWorkspaceManagedWorkflow, |
| 36 | } |
| 37 | |
| 38 | impl ProtectionOp { |
| 39 | /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test |
| 40 | /// checks against this. |
| 41 | #[cfg(test)] |
| 42 | pub const ALL: [ProtectionOp; 18] = [ |
| 43 | ProtectionOp::UpdateEnvironment, |
| 44 | ProtectionOp::DeleteEnvironment, |
| 45 | ProtectionOp::GetPendingDeployments, |
| 46 | ProtectionOp::ReviewPendingDeployments, |
| 47 | ProtectionOp::ApproveWorkflowRun, |
| 48 | ProtectionOp::GetWorkflowPermissions, |
| 49 | ProtectionOp::SetWorkflowPermissions, |
| 50 | ProtectionOp::GetForkPrApproval, |
| 51 | ProtectionOp::SetForkPrApproval, |
| 52 | ProtectionOp::GetActionsAccess, |
| 53 | ProtectionOp::SetActionsAccess, |
| 54 | ProtectionOp::CreateRepositoryDispatch, |
| 55 | ProtectionOp::GetWorkspaceWorkflowPermissions, |
| 56 | ProtectionOp::SetWorkspaceWorkflowPermissions, |
| 57 | ProtectionOp::ListWorkspaceManagedWorkflows, |
| 58 | ProtectionOp::CreateWorkspaceManagedWorkflow, |
| 59 | ProtectionOp::UpdateWorkspaceManagedWorkflow, |
| 60 | ProtectionOp::DeleteWorkspaceManagedWorkflow, |
| 61 | ]; |
| 62 | |
| 63 | pub fn name(self) -> &'static str { |
| 64 | match self { |
| 65 | ProtectionOp::UpdateEnvironment => "update_environment", |
| 66 | ProtectionOp::DeleteEnvironment => "delete_environment", |
| 67 | ProtectionOp::GetPendingDeployments => "get_pending_deployments", |
| 68 | ProtectionOp::ReviewPendingDeployments => "review_pending_deployments", |
| 69 | ProtectionOp::ApproveWorkflowRun => "approve_workflow_run", |
| 70 | ProtectionOp::GetWorkflowPermissions => "get_workflow_permissions", |
| 71 | ProtectionOp::SetWorkflowPermissions => "set_workflow_permissions", |
| 72 | ProtectionOp::GetForkPrApproval => "get_fork_pr_approval", |
| 73 | ProtectionOp::SetForkPrApproval => "set_fork_pr_approval", |
| 74 | ProtectionOp::GetActionsAccess => "get_actions_access", |
| 75 | ProtectionOp::SetActionsAccess => "set_actions_access", |
| 76 | ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch", |
| 77 | ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions", |
| 78 | ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions", |
| 79 | ProtectionOp::ListWorkspaceManagedWorkflows => "list_workspace_managed_workflows", |
| 80 | ProtectionOp::CreateWorkspaceManagedWorkflow => "create_workspace_managed_workflow", |
| 81 | ProtectionOp::UpdateWorkspaceManagedWorkflow => "update_workspace_managed_workflow", |
| 82 | ProtectionOp::DeleteWorkspaceManagedWorkflow => "delete_workspace_managed_workflow", |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// Whether it is about one repository, named by `repo`; the rest are a |
| 87 | /// workspace's. |
| 88 | pub fn needs_repo(self) -> bool { |
| 89 | !self.managed() && !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions) |
| 90 | } |
| 91 | |
| 92 | /// Whether it is about a workspace's managed workflows. |
| 93 | fn managed(self) -> bool { |
| 94 | matches!( |
| 95 | self, |
| 96 | ProtectionOp::ListWorkspaceManagedWorkflows |
| 97 | | ProtectionOp::CreateWorkspaceManagedWorkflow |
| 98 | | ProtectionOp::UpdateWorkspaceManagedWorkflow |
| 99 | | ProtectionOp::DeleteWorkspaceManagedWorkflow |
| 100 | ) |
| 101 | } |
| 102 | |
| 103 | pub fn title(self) -> &'static str { |
| 104 | match self { |
| 105 | ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules", |
| 106 | ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules", |
| 107 | ProtectionOp::GetPendingDeployments => "Get a run's pending deployments", |
| 108 | ProtectionOp::ReviewPendingDeployments => "Review a run's pending deployments", |
| 109 | ProtectionOp::ApproveWorkflowRun => "Approve a workflow run", |
| 110 | ProtectionOp::GetWorkflowPermissions => "Get the default workflow permissions", |
| 111 | ProtectionOp::SetWorkflowPermissions => "Set the default workflow permissions", |
| 112 | ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests", |
| 113 | ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests", |
| 114 | ProtectionOp::GetActionsAccess => "Get who may use a repository's actions and workflows", |
| 115 | ProtectionOp::SetActionsAccess => "Set who may use a repository's actions and workflows", |
| 116 | ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event", |
| 117 | ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions", |
| 118 | ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions", |
| 119 | ProtectionOp::ListWorkspaceManagedWorkflows => "List the workflows a workspace manages", |
| 120 | ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow a workspace manages", |
| 121 | ProtectionOp::UpdateWorkspaceManagedWorkflow => "Update a workflow a workspace manages", |
| 122 | ProtectionOp::DeleteWorkspaceManagedWorkflow => "Remove a workflow a workspace manages", |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | pub fn description(self) -> &'static str { |
| 127 | match self { |
| 128 | ProtectionOp::UpdateEnvironment => "Create an environment's protection rules, or change them; fields left out stay as they are. A job that names the environment with `environment:` waits, once its needs are done, until the rules let it through, and only then gets the environment's secrets. reviewers: up to 6, each {\"type\": \"User\" or \"Team\", \"name\": a username or a team's slug} (id is read as the name too); a job waits until one of them approves it. prevent_self_review: whoever started the run may not approve it. wait_timer: minutes each job waits, 0 to 43200. deployment_branch_policy: null lets every branch deploy; {\"protected_branches\": true} only branches the repository's rules protect (the default branch included); {\"custom_branch_policies\": true} only the branches and tags in branch_policies, each {\"name\": a pattern such as release/*, \"type\": \"branch\" or \"tag\"}. can_admins_bypass (true unless you say): admins may approve without being reviewers, which also skips the wait. The environment's name is up to 40 letters, digits, - and _, matched without regard to case. Needs the Admin role. Returns the environment with its protection_rules.", |
| 129 | ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules: its jobs run without waiting from then on. Its secrets, variables and deployments stay. Needs the Admin role.", |
| 130 | ProtectionOp::GetPendingDeployments => "The environments whose protection rules hold a run's jobs, this attempt: each with the environment's name, state (waiting, approved or rejected), wait_timer and wait_until (when its timer lets its jobs start), its reviewers, the jobs it holds, who reviewed it and their comment, and current_user_can_approve. Needs the Read role.", |
| 131 | ProtectionOp::ReviewPendingDeployments => "Approve or reject the jobs a run's environments hold. environment_names names them (every waiting one if left out; environment_ids is read as names too); state is approved or rejected; comment is kept with the review. Only one of the environment's reviewers may, or an admin when can_admins_bypass is on, which also skips the wait timer; with prevent_self_review, not whoever started the run. A rejected environment's jobs fail. A workflow job's own token cannot review. Returns the pending deployments as they stand.", |
| 132 | ProtectionOp::ApproveWorkflowRun => "Let a run of a pull request from outside start: it waits as action_required, by the repository's approval policy (get_fork_pr_approval), until someone with the Write role approves it. A workflow job's own token cannot approve. Returns the run.", |
| 133 | ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read) or write (every permission). Unless the repository chose (default_chosen), a repository made before restricted tokens has write and a newer one its workspace's default; it is never more than the workspace's max_workflow_permissions. can_approve_pull_request_reviews says whether its jobs may open and approve pull requests (off unless chosen, and only where the workspace allows it). Needs the Read role.", |
| 134 | ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read, write (refused where the workspace's maximum is read) or inherit (back to the workspace's default, or write for a repository made before restricted tokens), and can_approve_pull_request_reviews, \"Allow g1t Actions to create and approve pull requests\" (refused where the workspace does not allow it). Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.", |
| 135 | ProtectionOp::GetForkPrApproval => "Which pull requests' runs wait for someone with the Write role to approve them before anything runs (approve_workflow_run): approval_policy is first_time_contributors (a pull request from someone outside the workspace who has not had one merged here), outside_contributors (the default: also everyone outside who cannot push here) or all_external_contributors (everyone outside the workspace, outside collaborators included). Members never wait, nor does g1t's own work. Needs the Read role.", |
| 136 | ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.", |
| 137 | ProtectionOp::GetActionsAccess => "Which other repositories' workflows may use this private repository's actions (uses: owner/repo@ref) and reusable workflows (jobs.<id>.uses: owner/repo/.g1t/workflows/build.yml@ref): access_level is none (the default: only this repository) or organization (private repositories in the same workspace). A public repository's actions and workflows are anyone's, whatever this says, and a public repository's workflows never use a private one's. Needs the Read role.", |
| 138 | ProtectionOp::SetActionsAccess => "Set access_level: none, or organization to let the workspace's other private repositories use this repository's actions and reusable workflows (user is read as organization). Needs the Admin role.", |
| 139 | ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.", |
| 140 | ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.", |
| 141 | ProtectionOp::ListWorkspaceManagedWorkflows => "The workflows a workspace's owners added to its repositories, g1t's required workflows: each runs in every repository it targets as that repository's own workflow (its token, its secrets, its checks), under the path managed/<name>.yml, from a copy of its source file taken when it was saved. Each has name, path, workflow_name (its `name:`), source (owner/repo/path@ref), source_sha (the commit it was copied at), source_private, mode, repositories, visibility, content (the copy that runs) and when and by whom it was last saved. Owners only.", |
| 142 | ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow to a workspace's repositories. name (lowercase letters, digits and hyphens) makes its path managed/<name>.yml. source is a workflow file in one of the workspace's repositories, owner/repo/.g1t/workflows/file.yml@ref; g1t copies it at the commit ref names, and the copy runs until it is copied again (update_workspace_managed_workflow with refresh). mode is required (a repository's workflow of the same `name:` is set aside, and no one in the repository can turn it off, so its check can be required by a ruleset) or default (a repository's own workflow of the same name runs in its place, and the repository's admins can turn it off). repositories lists names and patterns (* matches any characters, ! excludes), read in order, the last match deciding; empty or left out is every repository. visibility is all (the default), public or private. A source in a private repository runs in private repositories only, so visibility must be private. Owners only. Returns the workflow.", |
| 143 | ProtectionOp::UpdateWorkspaceManagedWorkflow => "Change a workflow a workspace manages: its source, mode, repositories or visibility; fields left out stay as they are. Giving source, or refresh: true, copies the source again at the commit its ref names now. Owners only. Returns the workflow.", |
| 144 | ProtectionOp::DeleteWorkspaceManagedWorkflow => "Take a workflow out of every repository of the workspace. Its runs stay. Owners only.", |
| 145 | ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.", |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /// Whether it changes anything (the caller is its actor). |
| 150 | pub fn writes(self) -> bool { |
| 151 | !matches!( |
| 152 | self, |
| 153 | ProtectionOp::GetPendingDeployments |
| 154 | | ProtectionOp::GetWorkflowPermissions |
| 155 | | ProtectionOp::GetForkPrApproval |
| 156 | | ProtectionOp::GetActionsAccess |
| 157 | | ProtectionOp::GetWorkspaceWorkflowPermissions |
| 158 | | ProtectionOp::ListWorkspaceManagedWorkflows |
| 159 | ) |
| 160 | } |
| 161 | |
| 162 | pub fn input(self) -> Value { |
| 163 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); |
| 164 | let run = json!({ "type": "string", "description": "The run's id, run_…." }); |
| 165 | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); |
| 166 | let environment = json!({ "type": "string", "description": "The environment's name, such as production." }); |
| 167 | let (properties, required): (Value, &[&str]) = match self { |
| 168 | ProtectionOp::UpdateEnvironment => ( |
| 169 | json!({ |
| 170 | "repo": repo, |
| 171 | "environment": environment, |
| 172 | "wait_timer": { "type": "integer", "description": "Minutes each job waits before it may start, 0 to 43200." }, |
| 173 | "prevent_self_review": { "type": "boolean", "description": "Whoever started a run may not approve its jobs." }, |
| 174 | "reviewers": { |
| 175 | "type": ["array", "null"], |
| 176 | "description": "Up to 6 people or teams who may approve its jobs; empty for none.", |
| 177 | "items": { |
| 178 | "type": "object", |
| 179 | "properties": { |
| 180 | "type": { "type": "string", "enum": ["User", "Team"] }, |
| 181 | "name": { "type": "string", "description": "A username, or a team's slug in the repository's workspace." }, |
| 182 | }, |
| 183 | }, |
| 184 | }, |
| 185 | "deployment_branch_policy": { |
| 186 | "type": ["object", "null"], |
| 187 | "description": "null: every branch may deploy. protected_branches: only protected ones. custom_branch_policies: only those in branch_policies.", |
| 188 | "properties": { |
| 189 | "protected_branches": { "type": "boolean" }, |
| 190 | "custom_branch_policies": { "type": "boolean" }, |
| 191 | }, |
| 192 | }, |
| 193 | "branch_policies": { |
| 194 | "type": "array", |
| 195 | "description": "With custom_branch_policies: the branches and tags that may deploy, at most 50.", |
| 196 | "items": { |
| 197 | "type": "object", |
| 198 | "properties": { |
| 199 | "name": { "type": "string", "description": "A pattern, such as main, release/* or v*." }, |
| 200 | "type": { "type": "string", "enum": ["branch", "tag"] }, |
| 201 | }, |
| 202 | }, |
| 203 | }, |
| 204 | "can_admins_bypass": { "type": "boolean", "description": "Admins may approve without being reviewers, skipping the wait. True unless you say." }, |
| 205 | }), |
| 206 | &["repo", "environment"], |
| 207 | ), |
| 208 | ProtectionOp::DeleteEnvironment => (json!({ "repo": repo, "environment": environment }), &["repo", "environment"]), |
| 209 | ProtectionOp::GetPendingDeployments | ProtectionOp::ApproveWorkflowRun => (json!({ "repo": repo, "id": run }), &["repo", "id"]), |
| 210 | ProtectionOp::ReviewPendingDeployments => ( |
| 211 | json!({ |
| 212 | "repo": repo, |
| 213 | "id": run, |
| 214 | "environment_names": { "type": "array", "items": { "type": "string" }, "description": "The environments to review; every waiting one if left out." }, |
| 215 | "state": { "type": "string", "enum": ["approved", "rejected"] }, |
| 216 | "comment": { "type": "string", "description": "Why, kept with the review." }, |
| 217 | }), |
| 218 | &["repo", "id", "state"], |
| 219 | ), |
| 220 | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval | ProtectionOp::GetActionsAccess => (json!({ "repo": repo }), &["repo"]), |
| 221 | ProtectionOp::SetActionsAccess => ( |
| 222 | json!({ |
| 223 | "repo": repo, |
| 224 | "access_level": { "type": "string", "enum": ["none", "organization", "user"] }, |
| 225 | }), |
| 226 | &["repo", "access_level"], |
| 227 | ), |
| 228 | ProtectionOp::SetWorkflowPermissions => ( |
| 229 | json!({ |
| 230 | "repo": repo, |
| 231 | "default_workflow_permissions": { "type": "string", "enum": ["read", "write", "inherit"] }, |
| 232 | "can_approve_pull_request_reviews": { "type": "boolean", "description": "Allow g1t Actions to create and approve pull requests." }, |
| 233 | }), |
| 234 | &["repo"], |
| 235 | ), |
| 236 | ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::ListWorkspaceManagedWorkflows => (json!({ "workspace": workspace }), &["workspace"]), |
| 237 | ProtectionOp::CreateWorkspaceManagedWorkflow => ( |
| 238 | json!({ |
| 239 | "workspace": workspace, |
| 240 | "name": { "type": "string", "description": "Lowercase letters, digits and hyphens: its path is managed/<name>.yml." }, |
| 241 | "source": managed_source(), |
| 242 | "mode": managed_mode(), |
| 243 | "repositories": managed_repositories(), |
| 244 | "visibility": managed_visibility(), |
| 245 | }), |
| 246 | &["workspace", "name", "source", "mode"], |
| 247 | ), |
| 248 | ProtectionOp::UpdateWorkspaceManagedWorkflow => ( |
| 249 | json!({ |
| 250 | "workspace": workspace, |
| 251 | "workflow": managed_name(), |
| 252 | "source": managed_source(), |
| 253 | "mode": managed_mode(), |
| 254 | "repositories": managed_repositories(), |
| 255 | "visibility": managed_visibility(), |
| 256 | "refresh": { "type": "boolean", "description": "Copy the source again, at the commit its ref names now." }, |
| 257 | }), |
| 258 | &["workspace", "workflow"], |
| 259 | ), |
| 260 | ProtectionOp::DeleteWorkspaceManagedWorkflow => (json!({ "workspace": workspace, "workflow": managed_name() }), &["workspace", "workflow"]), |
| 261 | ProtectionOp::SetWorkspaceWorkflowPermissions => ( |
| 262 | json!({ |
| 263 | "workspace": workspace, |
| 264 | "default_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "What new repositories get." }, |
| 265 | "max_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "The most any repository's default may be." }, |
| 266 | "can_approve_pull_request_reviews": { "type": "boolean", "description": "Let repositories allow jobs to open and approve pull requests." }, |
| 267 | }), |
| 268 | &["workspace"], |
| 269 | ), |
| 270 | ProtectionOp::SetForkPrApproval => ( |
| 271 | json!({ |
| 272 | "repo": repo, |
| 273 | "approval_policy": { "type": "string", "enum": ["first_time_contributors", "outside_contributors", "all_external_contributors"] }, |
| 274 | }), |
| 275 | &["repo", "approval_policy"], |
| 276 | ), |
| 277 | ProtectionOp::CreateRepositoryDispatch => ( |
| 278 | json!({ |
| 279 | "repo": repo, |
| 280 | "event_type": { "type": "string", "description": "What happened, 1 to 100 characters; workflows choose it with `types:`." }, |
| 281 | "client_payload": { "type": "object", "description": "Anything the workflows should read, as github.event.client_payload." }, |
| 282 | }), |
| 283 | &["repo", "event_type"], |
| 284 | ), |
| 285 | }; |
| 286 | json!({ "type": "object", "properties": properties, "required": required }) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | fn managed_name() -> Value { |
| 291 | json!({ "type": "string", "description": "The managed workflow's name, as in managed/<name>.yml." }) |
| 292 | } |
| 293 | |
| 294 | fn managed_source() -> Value { |
| 295 | json!({ "type": "string", "description": "A workflow file in one of the workspace's repositories: owner/repo/.g1t/workflows/file.yml@ref." }) |
| 296 | } |
| 297 | |
| 298 | fn managed_mode() -> Value { |
| 299 | json!({ "type": "string", "enum": ["required", "default"], "description": "required: a repository cannot take its place or turn it off. default: a repository's workflow of the same name takes its place." }) |
| 300 | } |
| 301 | |
| 302 | fn managed_repositories() -> Value { |
| 303 | json!({ "type": "array", "items": { "type": "string" }, "description": "Repository names and patterns (* matches any characters, ! excludes); empty is every repository." }) |
| 304 | } |
| 305 | |
| 306 | fn managed_visibility() -> Value { |
| 307 | json!({ "type": "string", "enum": ["all", "public", "private"], "description": "Which repositories, by visibility. A private source needs private." }) |
| 308 | } |
| 309 | |
| 310 | /// A workspace's managed workflow, in the API's shape. |
| 311 | fn managed_view(workflow: &Value) -> Value { |
| 312 | json!({ |
| 313 | "name": workflow["name"], |
| 314 | "path": workflow["path"], |
| 315 | "workflow_name": workflow["workflowName"], |
| 316 | "source": workflow["source"], |
| 317 | "source_sha": workflow["sourceSha"], |
| 318 | "source_private": workflow["sourcePrivate"], |
| 319 | "mode": workflow["mode"], |
| 320 | "repositories": workflow["repositories"], |
| 321 | "visibility": workflow["visibility"], |
| 322 | "content": workflow["content"], |
| 323 | "created_at": workflow["createdAt"], |
| 324 | "updated_at": workflow["updatedAt"], |
| 325 | "updated_by": workflow["updatedBy"], |
| 326 | }) |
| 327 | } |
| 328 | |
| 329 | /// A workspace's managed workflows: listed, added, changed and removed. |
| 330 | async fn run_managed(op: ProtectionOp, actions: &worker::Fetcher, viewer: &Viewer, actor: g1t_contracts::User, workspace: String, input: &Value) -> Result<Outcome<Value>> { |
| 331 | let strings = |key: &str| -> Option<Vec<String>> { |
| 332 | input.get(key).and_then(Value::as_array).map(|items| items.iter().filter_map(Value::as_str).map(str::to_owned).collect()) |
| 333 | }; |
| 334 | Ok(match op { |
| 335 | ProtectionOp::ListWorkspaceManagedWorkflows => { |
| 336 | let listed: Outcome<Vec<Value>> = g1t_kit::call(actions, "workspace_managed_workflows", &json!({ "viewer": viewer, "workspace": workspace })).await?; |
| 337 | map(listed, |list| json!({ "total_count": list.len(), "managed_workflows": list.iter().map(managed_view).collect::<Vec<_>>() })) |
| 338 | } |
| 339 | ProtectionOp::CreateWorkspaceManagedWorkflow | ProtectionOp::UpdateWorkspaceManagedWorkflow => { |
| 340 | let create = op == ProtectionOp::CreateWorkspaceManagedWorkflow; |
| 341 | let name = if create { text(input, "name") } else { text(input, "workflow") }; |
| 342 | let saved: Outcome<Value> = g1t_kit::call( |
| 343 | actions, |
| 344 | "set_workspace_managed_workflow", |
| 345 | &json!({ |
| 346 | "actor": actor, |
| 347 | "workspace": workspace, |
| 348 | "name": name.unwrap_or_default(), |
| 349 | "create": create, |
| 350 | "source": text(input, "source"), |
| 351 | "mode": text(input, "mode"), |
| 352 | "repositories": strings("repositories"), |
| 353 | "visibility": text(input, "visibility"), |
| 354 | "refresh": flag(input, "refresh").unwrap_or(false), |
| 355 | }), |
| 356 | ) |
| 357 | .await?; |
| 358 | map(saved, |workflow| managed_view(&workflow)) |
| 359 | } |
| 360 | ProtectionOp::DeleteWorkspaceManagedWorkflow => { |
| 361 | let removed: Outcome<bool> = g1t_kit::call( |
| 362 | actions, |
| 363 | "remove_workspace_managed_workflow", |
| 364 | &json!({ "actor": actor, "workspace": workspace, "name": text(input, "workflow").unwrap_or_default() }), |
| 365 | ) |
| 366 | .await?; |
| 367 | map(removed, |removed| json!({ "deleted": removed })) |
| 368 | } |
| 369 | _ => Outcome::fail(FailureCode::Invalid, "Not a managed workflow operation."), |
| 370 | }) |
| 371 | } |
| 372 | |
| 373 | fn text(input: &Value, key: &str) -> Option<String> { |
| 374 | match &input[key] { |
| 375 | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), |
| 376 | Value::Number(number) => Some(number.to_string()), |
| 377 | _ => None, |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | fn flag(input: &Value, key: &str) -> Option<bool> { |
| 382 | match &input[key] { |
| 383 | Value::Bool(value) => Some(*value), |
| 384 | Value::String(text) => match text.trim() { |
| 385 | "true" | "1" => Some(true), |
| 386 | "false" | "0" => Some(false), |
| 387 | _ => None, |
| 388 | }, |
| 389 | _ => None, |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | /// The actions service's arguments for an environment's change, from a |
| 394 | /// request shaped as the standard environments API is. |
| 395 | pub(crate) fn environment_change(input: &Value) -> std::result::Result<Map<String, Value>, String> { |
| 396 | let mut out = Map::new(); |
| 397 | if let Some(minutes) = input.get("wait_timer").filter(|v| !v.is_null()) { |
| 398 | let minutes = minutes.as_u64().or_else(|| minutes.as_str().and_then(|s| s.trim().parse().ok())).ok_or("wait_timer is a number of minutes.")?; |
| 399 | out.insert("waitMinutes".into(), minutes.into()); |
| 400 | } |
| 401 | if let Some(value) = flag(input, "prevent_self_review") { |
| 402 | out.insert("preventSelfReview".into(), value.into()); |
| 403 | } |
| 404 | if let Some(value) = flag(input, "can_admins_bypass") { |
| 405 | out.insert("adminsBypass".into(), value.into()); |
| 406 | } |
| 407 | match input.get("reviewers") { |
| 408 | None => {} |
| 409 | Some(Value::Null) => { |
| 410 | out.insert("reviewers".into(), json!([])); |
| 411 | } |
| 412 | Some(Value::Array(given)) => { |
| 413 | let mut reviewers = Vec::new(); |
| 414 | for reviewer in given { |
| 415 | let kind = reviewer["type"].as_str().unwrap_or("User").to_ascii_lowercase(); |
| 416 | let name = text(reviewer, "name").or_else(|| text(reviewer, "id")).or_else(|| text(reviewer, "login")).or_else(|| text(reviewer, "slug")); |
| 417 | let Some(name) = name else { return Err("Each reviewer has a name: a username or a team's slug.".to_owned()) }; |
| 418 | reviewers.push(json!({ "type": kind, "name": name })); |
| 419 | } |
| 420 | out.insert("reviewers".into(), Value::Array(reviewers)); |
| 421 | } |
| 422 | Some(_) => return Err("reviewers is a list of {\"type\", \"name\"}.".to_owned()), |
| 423 | } |
| 424 | match input.get("deployment_branch_policy") { |
| 425 | None => {} |
| 426 | Some(Value::Null) => { |
| 427 | out.insert("branchPolicy".into(), "all".into()); |
| 428 | } |
| 429 | Some(policy @ Value::Object(_)) => { |
| 430 | let protected = flag(policy, "protected_branches") == Some(true); |
| 431 | let custom = flag(policy, "custom_branch_policies") == Some(true); |
| 432 | let chosen = match (protected, custom) { |
| 433 | (true, true) => return Err("deployment_branch_policy is protected_branches or custom_branch_policies, not both.".to_owned()), |
| 434 | (true, false) => "protected", |
| 435 | (false, true) => "selected", |
| 436 | (false, false) => "all", |
| 437 | }; |
| 438 | out.insert("branchPolicy".into(), chosen.into()); |
| 439 | } |
| 440 | Some(_) => return Err("deployment_branch_policy is an object, or null.".to_owned()), |
| 441 | } |
| 442 | if let Some(Value::Array(patterns)) = input.get("branch_policies") { |
| 443 | let patterns: Vec<Value> = patterns |
| 444 | .iter() |
| 445 | .map(|pattern| json!({ "name": pattern["name"].as_str().unwrap_or_default(), "type": pattern["type"].as_str().unwrap_or("branch") })) |
| 446 | .collect(); |
| 447 | out.insert("branchPatterns".into(), Value::Array(patterns)); |
| 448 | } |
| 449 | Ok(out) |
| 450 | } |
| 451 | |
| 452 | /// An environment as the actions service keeps it (camelCase), in the |
| 453 | /// standard shape: `protection_rules`, `deployment_branch_policy` and |
| 454 | /// `can_admins_bypass`, with g1t's `branch_policies` beside them. |
| 455 | pub(crate) fn environment_view(env: &Value) -> Value { |
| 456 | let reviewers: Vec<Value> = env["reviewers"] |
| 457 | .as_array() |
| 458 | .map(|list| { |
| 459 | list.iter() |
| 460 | .map(|r| match r["type"].as_str() { |
| 461 | Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }), |
| 462 | _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }), |
| 463 | }) |
| 464 | .collect() |
| 465 | }) |
| 466 | .unwrap_or_default(); |
| 467 | let mut rules = Vec::new(); |
| 468 | if !reviewers.is_empty() { |
| 469 | rules.push(json!({ "type": "required_reviewers", "prevent_self_review": env["preventSelfReview"], "reviewers": reviewers })); |
| 470 | } |
| 471 | if env["waitMinutes"].as_u64().unwrap_or(0) > 0 { |
| 472 | rules.push(json!({ "type": "wait_timer", "wait_timer": env["waitMinutes"] })); |
| 473 | } |
| 474 | let policy = env["branchPolicy"].as_str().unwrap_or("all"); |
| 475 | if policy != "all" { |
| 476 | rules.push(json!({ "type": "branch_policy" })); |
| 477 | } |
| 478 | json!({ |
| 479 | "name": env["name"], |
| 480 | "protection_rules": rules, |
| 481 | "deployment_branch_policy": match policy { |
| 482 | "protected" => json!({ "protected_branches": true, "custom_branch_policies": false }), |
| 483 | "selected" => json!({ "protected_branches": false, "custom_branch_policies": true }), |
| 484 | _ => Value::Null, |
| 485 | }, |
| 486 | "branch_policies": env["branchPatterns"], |
| 487 | "can_admins_bypass": env["adminsBypass"], |
| 488 | "protected": env["protected"], |
| 489 | "updated_at": env["updatedAt"], |
| 490 | "updated_by": env["updatedBy"], |
| 491 | }) |
| 492 | } |
| 493 | |
| 494 | /// A pending deployment, in the standard shape. |
| 495 | fn pending_view(pending: &Value) -> Value { |
| 496 | let reviewers: Vec<Value> = pending["reviewers"] |
| 497 | .as_array() |
| 498 | .map(|list| { |
| 499 | list.iter() |
| 500 | .map(|r| match r["type"].as_str() { |
| 501 | Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }), |
| 502 | _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }), |
| 503 | }) |
| 504 | .collect() |
| 505 | }) |
| 506 | .unwrap_or_default(); |
| 507 | json!({ |
| 508 | "environment": { "name": pending["environment"] }, |
| 509 | "state": pending["state"], |
| 510 | "needs_review": pending["needsReview"], |
| 511 | "wait_until": pending["waitUntil"], |
| 512 | "current_user_can_approve": pending["canReview"], |
| 513 | "reviewers": reviewers, |
| 514 | "jobs": pending["jobs"], |
| 515 | "reviewed_by": pending["reviewedBy"], |
| 516 | "comment": pending["comment"], |
| 517 | "reviewed_at": pending["reviewedAt"], |
| 518 | }) |
| 519 | } |
| 520 | |
| 521 | fn map<T>(outcome: Outcome<T>, view: impl FnOnce(T) -> Value) -> Outcome<Value> { |
| 522 | match outcome { |
| 523 | Outcome::Ok(value) => Outcome::Ok(view(value)), |
| 524 | Outcome::Fail(refused) => Outcome::Fail(refused), |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | /// The protection rules of the environments `listed` (the deployments |
| 529 | /// service's answer to `list_environments` or `get_environment`) added to |
| 530 | /// it, and environments with rules but no deployments yet added to a list. |
| 531 | pub(crate) async fn with_protection(services: &Services, viewer: &Viewer, input: &Value, listed: Outcome<Value>, one: Option<&str>) -> Result<Outcome<Value>> { |
| 532 | let Some(repo) = repo_path(input) else { return Ok(listed) }; |
| 533 | let mut args = json!({ "viewer": viewer, "repo": repo }); |
| 534 | if let Some(name) = one { |
| 535 | args["name"] = json!(name); |
| 536 | } |
| 537 | let rules: Outcome<Vec<Value>> = g1t_kit::call(&services.actions, "environments", &args).await.unwrap_or(Outcome::Ok(Vec::new())); |
| 538 | let rules = match rules { |
| 539 | Outcome::Ok(rules) => rules, |
| 540 | Outcome::Fail(_) => return Ok(listed), |
| 541 | }; |
| 542 | let protection = |name: &str| rules.iter().find(|env| env["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))).map(environment_view); |
| 543 | let add = |env: &mut Value| { |
| 544 | if let Some(view) = env["name"].as_str().and_then(protection) { |
| 545 | for key in ["protection_rules", "deployment_branch_policy", "branch_policies", "can_admins_bypass"] { |
| 546 | env[key] = view[key].clone(); |
| 547 | } |
| 548 | } |
| 549 | }; |
| 550 | Ok(match (listed, one) { |
| 551 | (Outcome::Ok(mut env), Some(_)) => { |
| 552 | add(&mut env); |
| 553 | Outcome::Ok(env) |
| 554 | } |
| 555 | // Never deployed, but protected: still an environment. |
| 556 | (Outcome::Fail(refused), Some(name)) => match protection(name).filter(|view| view["protected"] == true) { |
| 557 | Some(view) => Outcome::Ok(view), |
| 558 | None => Outcome::Fail(refused), |
| 559 | }, |
| 560 | (Outcome::Ok(mut list), None) => { |
| 561 | if let Some(environments) = list["environments"].as_array_mut() { |
| 562 | for env in environments.iter_mut() { |
| 563 | add(env); |
| 564 | } |
| 565 | for env in rules.iter().filter(|env| env["protected"] == true) { |
| 566 | let name = env["name"].as_str().unwrap_or_default(); |
| 567 | if !environments.iter().any(|known| known["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) { |
| 568 | environments.push(environment_view(env)); |
| 569 | } |
| 570 | } |
| 571 | } |
| 572 | Outcome::Ok(list) |
| 573 | } |
| 574 | (failed, None) => failed, |
| 575 | }) |
| 576 | } |
| 577 | |
| 578 | /// A workspace's policy, in the standard shape. |
| 579 | fn workspace_view(settings: &Value) -> Value { |
| 580 | json!({ |
| 581 | "default_workflow_permissions": settings["defaultPermissions"], |
| 582 | "max_workflow_permissions": settings["maxPermissions"], |
| 583 | "can_approve_pull_request_reviews": settings["canApprovePullRequests"], |
| 584 | }) |
| 585 | } |
| 586 | |
| 587 | pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 588 | if op.writes() && viewer.is_none() { |
| 589 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); |
| 590 | } |
| 591 | let actor = || viewer.clone().unwrap_or_default(); |
| 592 | let actions = &services.actions; |
| 593 | if !op.needs_repo() { |
| 594 | let Some(workspace) = text(input, "workspace") else { |
| 595 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); |
| 596 | }; |
| 597 | if op.managed() { |
| 598 | return run_managed(op, actions, viewer, actor(), workspace, input).await; |
| 599 | } |
| 600 | let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions { |
| 601 | g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await? |
| 602 | } else { |
| 603 | g1t_kit::call( |
| 604 | actions, |
| 605 | "set_workspace_actions_settings", |
| 606 | &json!({ |
| 607 | "actor": actor(), |
| 608 | "workspace": workspace, |
| 609 | "defaultPermissions": text(input, "default_workflow_permissions"), |
| 610 | "maxPermissions": text(input, "max_workflow_permissions"), |
| 611 | "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"), |
| 612 | }), |
| 613 | ) |
| 614 | .await? |
| 615 | }; |
| 616 | return Ok(map(settings, |s| workspace_view(&s))); |
| 617 | } |
| 618 | let Some(repo) = repo_path(input) else { |
| 619 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); |
| 620 | }; |
| 621 | let id = text(input, "id").unwrap_or_default(); |
| 622 | let environment = text(input, "environment").unwrap_or_default(); |
| 623 | Ok(match op { |
| 624 | ProtectionOp::UpdateEnvironment => { |
| 625 | let mut args = match environment_change(input) { |
| 626 | Ok(args) => args, |
| 627 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 628 | }; |
| 629 | args.insert("actor".into(), serde_json::to_value(actor())?); |
| 630 | args.insert("repo".into(), serde_json::to_value(&repo)?); |
| 631 | args.insert("name".into(), environment.into()); |
| 632 | let saved: Outcome<Value> = g1t_kit::call(actions, "set_environment", &Value::Object(args)).await?; |
| 633 | map(saved, |env| environment_view(&env)) |
| 634 | } |
| 635 | ProtectionOp::DeleteEnvironment => { |
| 636 | let removed: Outcome<bool> = |
| 637 | g1t_kit::call(actions, "delete_environment", &json!({ "actor": actor(), "repo": repo, "name": environment })).await?; |
| 638 | map(removed, |removed| json!({ "deleted": removed })) |
| 639 | } |
| 640 | ProtectionOp::GetPendingDeployments => { |
| 641 | let pending: Outcome<Vec<Value>> = g1t_kit::call(actions, "pending_deployments", &json!({ "viewer": viewer, "repo": repo, "id": id })).await?; |
| 642 | map(pending, |list| Value::Array(list.iter().map(pending_view).collect())) |
| 643 | } |
| 644 | ProtectionOp::ReviewPendingDeployments => { |
| 645 | let names: Vec<String> = ["environment_names", "environments", "environment_ids"] |
| 646 | .iter() |
| 647 | .find_map(|key| input[*key].as_array()) |
| 648 | .map(|list| list.iter().filter_map(|v| v.as_str().map(str::to_owned).or_else(|| v.as_u64().map(|n| n.to_string()))).collect()) |
| 649 | .unwrap_or_default(); |
| 650 | let reviewed: Outcome<Vec<Value>> = g1t_kit::call( |
| 651 | actions, |
| 652 | "review_deployments", |
| 653 | &json!({ |
| 654 | "actor": actor(), |
| 655 | "repo": repo, |
| 656 | "id": id, |
| 657 | "environments": names, |
| 658 | "state": text(input, "state").unwrap_or_default(), |
| 659 | "comment": text(input, "comment"), |
| 660 | }), |
| 661 | ) |
| 662 | .await?; |
| 663 | map(reviewed, |list| Value::Array(list.iter().map(pending_view).collect())) |
| 664 | } |
| 665 | ProtectionOp::ApproveWorkflowRun => g1t_kit::call(actions, "approve_run", &json!({ "actor": actor(), "repo": repo, "id": id })).await?, |
| 666 | ProtectionOp::GetWorkflowPermissions | ProtectionOp::SetWorkflowPermissions => { |
| 667 | let settings: Outcome<Value> = if op == ProtectionOp::GetWorkflowPermissions { |
| 668 | g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await? |
| 669 | } else { |
| 670 | g1t_kit::call( |
| 671 | actions, |
| 672 | "set_actions_settings", |
| 673 | &json!({ |
| 674 | "actor": actor(), |
| 675 | "repo": repo, |
| 676 | "defaultPermissions": text(input, "default_workflow_permissions"), |
| 677 | "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"), |
| 678 | }), |
| 679 | ) |
| 680 | .await? |
| 681 | }; |
| 682 | map(settings, |s| { |
| 683 | json!({ |
| 684 | "default_workflow_permissions": s["defaultPermissions"], |
| 685 | "default_chosen": s["defaultChosen"], |
| 686 | "max_workflow_permissions": s["maxPermissions"], |
| 687 | "can_approve_pull_request_reviews": s["canApprovePullRequests"], |
| 688 | }) |
| 689 | }) |
| 690 | } |
| 691 | ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => { |
| 692 | let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval { |
| 693 | g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await? |
| 694 | } else { |
| 695 | g1t_kit::call( |
| 696 | actions, |
| 697 | "set_actions_settings", |
| 698 | &json!({ "actor": actor(), "repo": repo, "approvalPolicy": text(input, "approval_policy").unwrap_or_default() }), |
| 699 | ) |
| 700 | .await? |
| 701 | }; |
| 702 | map(settings, |s| json!({ "approval_policy": s["approvalPolicy"] })) |
| 703 | } |
| 704 | ProtectionOp::GetActionsAccess | ProtectionOp::SetActionsAccess => { |
| 705 | let settings: Outcome<Value> = if op == ProtectionOp::GetActionsAccess { |
| 706 | g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await? |
| 707 | } else { |
| 708 | g1t_kit::call( |
| 709 | actions, |
| 710 | "set_actions_settings", |
| 711 | &json!({ "actor": actor(), "repo": repo, "accessLevel": text(input, "access_level").unwrap_or_default() }), |
| 712 | ) |
| 713 | .await? |
| 714 | }; |
| 715 | map(settings, |s| json!({ "access_level": s["accessLevel"] })) |
| 716 | } |
| 717 | ProtectionOp::CreateRepositoryDispatch => { |
| 718 | let started: Outcome<u32> = g1t_kit::call( |
| 719 | actions, |
| 720 | "repository_dispatch", |
| 721 | &json!({ |
| 722 | "actor": actor(), |
| 723 | "repo": repo, |
| 724 | "eventType": text(input, "event_type").unwrap_or_default(), |
| 725 | "clientPayload": input.get("client_payload").cloned().unwrap_or(Value::Null), |
| 726 | }), |
| 727 | ) |
| 728 | .await?; |
| 729 | map(started, |runs| json!({ "runs": runs })) |
| 730 | } |
| 731 | // Answered above, before a repository is read. |
| 732 | ProtectionOp::GetWorkspaceWorkflowPermissions |
| 733 | | ProtectionOp::SetWorkspaceWorkflowPermissions |
| 734 | | ProtectionOp::ListWorkspaceManagedWorkflows |
| 735 | | ProtectionOp::CreateWorkspaceManagedWorkflow |
| 736 | | ProtectionOp::UpdateWorkspaceManagedWorkflow |
| 737 | | ProtectionOp::DeleteWorkspaceManagedWorkflow => Outcome::fail(FailureCode::Invalid, "Name the workspace."), |
| 738 | }) |
| 739 | } |
| 740 | |
| 741 | #[cfg(test)] |
| 742 | mod tests { |
| 743 | use super::*; |
| 744 | |
| 745 | #[test] |
| 746 | fn an_environment_change_reads_the_standard_shape() { |
| 747 | let args = environment_change(&json!({ |
| 748 | "wait_timer": 30, |
| 749 | "prevent_self_review": true, |
| 750 | "reviewers": [{ "type": "User", "id": "ada" }, { "type": "Team", "name": "deployers" }], |
| 751 | "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true }, |
| 752 | "branch_policies": [{ "name": "release/*", "type": "branch" }], |
| 753 | })) |
| 754 | .unwrap(); |
| 755 | assert_eq!(args["waitMinutes"], 30); |
| 756 | assert_eq!(args["preventSelfReview"], true); |
| 757 | assert_eq!(args["reviewers"], json!([{ "type": "user", "name": "ada" }, { "type": "team", "name": "deployers" }])); |
| 758 | assert_eq!(args["branchPolicy"], "selected"); |
| 759 | assert_eq!(args["branchPatterns"], json!([{ "name": "release/*", "type": "branch" }])); |
| 760 | // Left out stays; null clears. |
| 761 | let cleared = environment_change(&json!({ "deployment_branch_policy": null, "reviewers": null })).unwrap(); |
| 762 | assert_eq!(cleared["branchPolicy"], "all"); |
| 763 | assert_eq!(cleared["reviewers"], json!([])); |
| 764 | assert!(!environment_change(&json!({})).unwrap().contains_key("waitMinutes")); |
| 765 | assert!(environment_change(&json!({ "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": true } })).is_err()); |
| 766 | } |
| 767 | |
| 768 | #[test] |
| 769 | fn an_environment_reads_as_the_standard_shape() { |
| 770 | let view = environment_view(&json!({ |
| 771 | "name": "production", "reviewers": [{ "type": "user", "name": "ada" }], "preventSelfReview": true, |
| 772 | "waitMinutes": 10, "branchPolicy": "protected", "branchPatterns": [], "adminsBypass": false, "protected": true, |
| 773 | })); |
| 774 | let types: Vec<&str> = view["protection_rules"].as_array().unwrap().iter().map(|r| r["type"].as_str().unwrap()).collect(); |
| 775 | assert_eq!(types, ["required_reviewers", "wait_timer", "branch_policy"]); |
| 776 | assert_eq!(view["protection_rules"][0]["reviewers"][0]["reviewer"]["login"], "ada"); |
| 777 | assert_eq!(view["deployment_branch_policy"]["protected_branches"], true); |
| 778 | assert_eq!(view["can_admins_bypass"], false); |
| 779 | } |
| 780 | |
| 781 | #[test] |
| 782 | fn each_operation_is_described_with_a_schema() { |
| 783 | for op in ProtectionOp::ALL { |
| 784 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 785 | let needs = if op.needs_repo() { "repo" } else { "workspace" }; |
| 786 | assert!(op.input()["required"].as_array().unwrap().contains(&json!(needs)), "{}", op.name()); |
| 787 | } |
| 788 | } |
| 789 | } |