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