Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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, | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 27 | GetActionsAccess, |
| 28 | SetActionsAccess, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 29 | CreateRepositoryDispatch, |
| 30 | GetWorkspaceWorkflowPermissions, | |
| 31 | SetWorkspaceWorkflowPermissions, | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 32 | ListWorkspaceManagedWorkflows, |
| 33 | CreateWorkspaceManagedWorkflow, | |
| 34 | UpdateWorkspaceManagedWorkflow, | |
| 35 | DeleteWorkspaceManagedWorkflow, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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)] | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 42 | pub const ALL: [ProtectionOp; 18] = [ |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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, | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 52 | ProtectionOp::GetActionsAccess, |
| 53 | ProtectionOp::SetActionsAccess, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 54 | ProtectionOp::CreateRepositoryDispatch, |
| 55 | ProtectionOp::GetWorkspaceWorkflowPermissions, | |
| 56 | ProtectionOp::SetWorkspaceWorkflowPermissions, | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 57 | ProtectionOp::ListWorkspaceManagedWorkflows, |
| 58 | ProtectionOp::CreateWorkspaceManagedWorkflow, | |
| 59 | ProtectionOp::UpdateWorkspaceManagedWorkflow, | |
| 60 | ProtectionOp::DeleteWorkspaceManagedWorkflow, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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", | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 74 | ProtectionOp::GetActionsAccess => "get_actions_access", |
| 75 | ProtectionOp::SetActionsAccess => "set_actions_access", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 76 | ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch", |
| 77 | ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions", | |
| 78 | ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions", | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 { | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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 | ) | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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", | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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", | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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.", | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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.", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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.", | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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.", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 156 | | ProtectionOp::GetActionsAccess |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 157 | | ProtectionOp::GetWorkspaceWorkflowPermissions |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 158 | | ProtectionOp::ListWorkspaceManagedWorkflows |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | ), | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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 | ), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | ), | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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"]), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | ||
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | }; | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 597 | if op.managed() { |
| 598 | return run_managed(op, actions, viewer, actor(), workspace, input).await; | |
| 599 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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. | |
| A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so. | 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."), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.