Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
789 linesCodeBlameRaw

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
9use g1t_contracts::{FailureCode, Outcome, Viewer};
10use serde_json::{Map, Value, json};
11use worker::Result;
12
13use crate::operations::{Services, repo_path};
14
15/// One operation.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub 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 timeouts27 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
38impl 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 timeouts52 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 timeouts74 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 timeouts114 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 timeouts137 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 timeouts156 | 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 timeouts220 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.290fn managed_name() -> Value {
291 json!({ "type": "string", "description": "The managed workflow's name, as in managed/<name>.yml." })
292}
293
294fn 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
298fn 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
302fn 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
306fn 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.
311fn 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.
330async 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'373fn 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
381fn 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.
395pub(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.
455pub(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.
495fn 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
521fn 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.
531pub(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.
579fn 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
587pub 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 timeouts704 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)]
742mod 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.