Skip to content
613 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 CreateRepositoryDispatch,
28 GetWorkspaceWorkflowPermissions,
29 SetWorkspaceWorkflowPermissions,
30}
31
32impl ProtectionOp {
33 /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
34 /// checks against this.
35 #[cfg(test)]
36 pub const ALL: [ProtectionOp; 12] = [
37 ProtectionOp::UpdateEnvironment,
38 ProtectionOp::DeleteEnvironment,
39 ProtectionOp::GetPendingDeployments,
40 ProtectionOp::ReviewPendingDeployments,
41 ProtectionOp::ApproveWorkflowRun,
42 ProtectionOp::GetWorkflowPermissions,
43 ProtectionOp::SetWorkflowPermissions,
44 ProtectionOp::GetForkPrApproval,
45 ProtectionOp::SetForkPrApproval,
46 ProtectionOp::CreateRepositoryDispatch,
47 ProtectionOp::GetWorkspaceWorkflowPermissions,
48 ProtectionOp::SetWorkspaceWorkflowPermissions,
49 ];
50
51 pub fn name(self) -> &'static str {
52 match self {
53 ProtectionOp::UpdateEnvironment => "update_environment",
54 ProtectionOp::DeleteEnvironment => "delete_environment",
55 ProtectionOp::GetPendingDeployments => "get_pending_deployments",
56 ProtectionOp::ReviewPendingDeployments => "review_pending_deployments",
57 ProtectionOp::ApproveWorkflowRun => "approve_workflow_run",
58 ProtectionOp::GetWorkflowPermissions => "get_workflow_permissions",
59 ProtectionOp::SetWorkflowPermissions => "set_workflow_permissions",
60 ProtectionOp::GetForkPrApproval => "get_fork_pr_approval",
61 ProtectionOp::SetForkPrApproval => "set_fork_pr_approval",
62 ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
63 ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions",
64 ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions",
65 }
66 }
67
68 /// Whether it is about one repository, named by `repo`; the rest are a
69 /// workspace's.
70 pub fn needs_repo(self) -> bool {
71 !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions)
72 }
73
74 pub fn title(self) -> &'static str {
75 match self {
76 ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules",
77 ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules",
78 ProtectionOp::GetPendingDeployments => "Get a run's pending deployments",
79 ProtectionOp::ReviewPendingDeployments => "Review a run's pending deployments",
80 ProtectionOp::ApproveWorkflowRun => "Approve a workflow run",
81 ProtectionOp::GetWorkflowPermissions => "Get the default workflow permissions",
82 ProtectionOp::SetWorkflowPermissions => "Set the default workflow permissions",
83 ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests",
84 ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests",
85 ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
86 ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions",
87 ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions",
88 }
89 }
90
91 pub fn description(self) -> &'static str {
92 match self {
93 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.",
94 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.",
95 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.",
96 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.",
97 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.",
98 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.",
99 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.",
100 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.",
101 ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.",
102 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.",
103 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.",
104 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.",
105 }
106 }
107
108 /// Whether it changes anything (the caller is its actor).
109 pub fn writes(self) -> bool {
110 !matches!(
111 self,
112 ProtectionOp::GetPendingDeployments
113 | ProtectionOp::GetWorkflowPermissions
114 | ProtectionOp::GetForkPrApproval
115 | ProtectionOp::GetWorkspaceWorkflowPermissions
116 )
117 }
118
119 pub fn input(self) -> Value {
120 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
121 let run = json!({ "type": "string", "description": "The run's id, run_…." });
122 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
123 let environment = json!({ "type": "string", "description": "The environment's name, such as production." });
124 let (properties, required): (Value, &[&str]) = match self {
125 ProtectionOp::UpdateEnvironment => (
126 json!({
127 "repo": repo,
128 "environment": environment,
129 "wait_timer": { "type": "integer", "description": "Minutes each job waits before it may start, 0 to 43200." },
130 "prevent_self_review": { "type": "boolean", "description": "Whoever started a run may not approve its jobs." },
131 "reviewers": {
132 "type": ["array", "null"],
133 "description": "Up to 6 people or teams who may approve its jobs; empty for none.",
134 "items": {
135 "type": "object",
136 "properties": {
137 "type": { "type": "string", "enum": ["User", "Team"] },
138 "name": { "type": "string", "description": "A username, or a team's slug in the repository's workspace." },
139 },
140 },
141 },
142 "deployment_branch_policy": {
143 "type": ["object", "null"],
144 "description": "null: every branch may deploy. protected_branches: only protected ones. custom_branch_policies: only those in branch_policies.",
145 "properties": {
146 "protected_branches": { "type": "boolean" },
147 "custom_branch_policies": { "type": "boolean" },
148 },
149 },
150 "branch_policies": {
151 "type": "array",
152 "description": "With custom_branch_policies: the branches and tags that may deploy, at most 50.",
153 "items": {
154 "type": "object",
155 "properties": {
156 "name": { "type": "string", "description": "A pattern, such as main, release/* or v*." },
157 "type": { "type": "string", "enum": ["branch", "tag"] },
158 },
159 },
160 },
161 "can_admins_bypass": { "type": "boolean", "description": "Admins may approve without being reviewers, skipping the wait. True unless you say." },
162 }),
163 &["repo", "environment"],
164 ),
165 ProtectionOp::DeleteEnvironment => (json!({ "repo": repo, "environment": environment }), &["repo", "environment"]),
166 ProtectionOp::GetPendingDeployments | ProtectionOp::ApproveWorkflowRun => (json!({ "repo": repo, "id": run }), &["repo", "id"]),
167 ProtectionOp::ReviewPendingDeployments => (
168 json!({
169 "repo": repo,
170 "id": run,
171 "environment_names": { "type": "array", "items": { "type": "string" }, "description": "The environments to review; every waiting one if left out." },
172 "state": { "type": "string", "enum": ["approved", "rejected"] },
173 "comment": { "type": "string", "description": "Why, kept with the review." },
174 }),
175 &["repo", "id", "state"],
176 ),
177 ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval => (json!({ "repo": repo }), &["repo"]),
178 ProtectionOp::SetWorkflowPermissions => (
179 json!({
180 "repo": repo,
181 "default_workflow_permissions": { "type": "string", "enum": ["read", "write", "inherit"] },
182 "can_approve_pull_request_reviews": { "type": "boolean", "description": "Allow g1t Actions to create and approve pull requests." },
183 }),
184 &["repo"],
185 ),
186 ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]),
187 ProtectionOp::SetWorkspaceWorkflowPermissions => (
188 json!({
189 "workspace": workspace,
190 "default_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "What new repositories get." },
191 "max_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "The most any repository's default may be." },
192 "can_approve_pull_request_reviews": { "type": "boolean", "description": "Let repositories allow jobs to open and approve pull requests." },
193 }),
194 &["workspace"],
195 ),
196 ProtectionOp::SetForkPrApproval => (
197 json!({
198 "repo": repo,
199 "approval_policy": { "type": "string", "enum": ["first_time_contributors", "outside_contributors", "all_external_contributors"] },
200 }),
201 &["repo", "approval_policy"],
202 ),
203 ProtectionOp::CreateRepositoryDispatch => (
204 json!({
205 "repo": repo,
206 "event_type": { "type": "string", "description": "What happened, 1 to 100 characters; workflows choose it with `types:`." },
207 "client_payload": { "type": "object", "description": "Anything the workflows should read, as github.event.client_payload." },
208 }),
209 &["repo", "event_type"],
210 ),
211 };
212 json!({ "type": "object", "properties": properties, "required": required })
213 }
214}
215
216fn text(input: &Value, key: &str) -> Option<String> {
217 match &input[key] {
218 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
219 Value::Number(number) => Some(number.to_string()),
220 _ => None,
221 }
222}
223
224fn flag(input: &Value, key: &str) -> Option<bool> {
225 match &input[key] {
226 Value::Bool(value) => Some(*value),
227 Value::String(text) => match text.trim() {
228 "true" | "1" => Some(true),
229 "false" | "0" => Some(false),
230 _ => None,
231 },
232 _ => None,
233 }
234}
235
236/// The actions service's arguments for an environment's change, from a
237/// request shaped as the standard environments API is.
238pub(crate) fn environment_change(input: &Value) -> std::result::Result<Map<String, Value>, String> {
239 let mut out = Map::new();
240 if let Some(minutes) = input.get("wait_timer").filter(|v| !v.is_null()) {
241 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.")?;
242 out.insert("waitMinutes".into(), minutes.into());
243 }
244 if let Some(value) = flag(input, "prevent_self_review") {
245 out.insert("preventSelfReview".into(), value.into());
246 }
247 if let Some(value) = flag(input, "can_admins_bypass") {
248 out.insert("adminsBypass".into(), value.into());
249 }
250 match input.get("reviewers") {
251 None => {}
252 Some(Value::Null) => {
253 out.insert("reviewers".into(), json!([]));
254 }
255 Some(Value::Array(given)) => {
256 let mut reviewers = Vec::new();
257 for reviewer in given {
258 let kind = reviewer["type"].as_str().unwrap_or("User").to_ascii_lowercase();
259 let name = text(reviewer, "name").or_else(|| text(reviewer, "id")).or_else(|| text(reviewer, "login")).or_else(|| text(reviewer, "slug"));
260 let Some(name) = name else { return Err("Each reviewer has a name: a username or a team's slug.".to_owned()) };
261 reviewers.push(json!({ "type": kind, "name": name }));
262 }
263 out.insert("reviewers".into(), Value::Array(reviewers));
264 }
265 Some(_) => return Err("reviewers is a list of {\"type\", \"name\"}.".to_owned()),
266 }
267 match input.get("deployment_branch_policy") {
268 None => {}
269 Some(Value::Null) => {
270 out.insert("branchPolicy".into(), "all".into());
271 }
272 Some(policy @ Value::Object(_)) => {
273 let protected = flag(policy, "protected_branches") == Some(true);
274 let custom = flag(policy, "custom_branch_policies") == Some(true);
275 let chosen = match (protected, custom) {
276 (true, true) => return Err("deployment_branch_policy is protected_branches or custom_branch_policies, not both.".to_owned()),
277 (true, false) => "protected",
278 (false, true) => "selected",
279 (false, false) => "all",
280 };
281 out.insert("branchPolicy".into(), chosen.into());
282 }
283 Some(_) => return Err("deployment_branch_policy is an object, or null.".to_owned()),
284 }
285 if let Some(Value::Array(patterns)) = input.get("branch_policies") {
286 let patterns: Vec<Value> = patterns
287 .iter()
288 .map(|pattern| json!({ "name": pattern["name"].as_str().unwrap_or_default(), "type": pattern["type"].as_str().unwrap_or("branch") }))
289 .collect();
290 out.insert("branchPatterns".into(), Value::Array(patterns));
291 }
292 Ok(out)
293}
294
295/// An environment as the actions service keeps it (camelCase), in the
296/// standard shape: `protection_rules`, `deployment_branch_policy` and
297/// `can_admins_bypass`, with g1t's `branch_policies` beside them.
298pub(crate) fn environment_view(env: &Value) -> Value {
299 let reviewers: Vec<Value> = env["reviewers"]
300 .as_array()
301 .map(|list| {
302 list.iter()
303 .map(|r| match r["type"].as_str() {
304 Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
305 _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
306 })
307 .collect()
308 })
309 .unwrap_or_default();
310 let mut rules = Vec::new();
311 if !reviewers.is_empty() {
312 rules.push(json!({ "type": "required_reviewers", "prevent_self_review": env["preventSelfReview"], "reviewers": reviewers }));
313 }
314 if env["waitMinutes"].as_u64().unwrap_or(0) > 0 {
315 rules.push(json!({ "type": "wait_timer", "wait_timer": env["waitMinutes"] }));
316 }
317 let policy = env["branchPolicy"].as_str().unwrap_or("all");
318 if policy != "all" {
319 rules.push(json!({ "type": "branch_policy" }));
320 }
321 json!({
322 "name": env["name"],
323 "protection_rules": rules,
324 "deployment_branch_policy": match policy {
325 "protected" => json!({ "protected_branches": true, "custom_branch_policies": false }),
326 "selected" => json!({ "protected_branches": false, "custom_branch_policies": true }),
327 _ => Value::Null,
328 },
329 "branch_policies": env["branchPatterns"],
330 "can_admins_bypass": env["adminsBypass"],
331 "protected": env["protected"],
332 "updated_at": env["updatedAt"],
333 "updated_by": env["updatedBy"],
334 })
335}
336
337/// A pending deployment, in the standard shape.
338fn pending_view(pending: &Value) -> Value {
339 let reviewers: Vec<Value> = pending["reviewers"]
340 .as_array()
341 .map(|list| {
342 list.iter()
343 .map(|r| match r["type"].as_str() {
344 Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
345 _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
346 })
347 .collect()
348 })
349 .unwrap_or_default();
350 json!({
351 "environment": { "name": pending["environment"] },
352 "state": pending["state"],
353 "needs_review": pending["needsReview"],
354 "wait_until": pending["waitUntil"],
355 "current_user_can_approve": pending["canReview"],
356 "reviewers": reviewers,
357 "jobs": pending["jobs"],
358 "reviewed_by": pending["reviewedBy"],
359 "comment": pending["comment"],
360 "reviewed_at": pending["reviewedAt"],
361 })
362}
363
364fn map<T>(outcome: Outcome<T>, view: impl FnOnce(T) -> Value) -> Outcome<Value> {
365 match outcome {
366 Outcome::Ok(value) => Outcome::Ok(view(value)),
367 Outcome::Fail(refused) => Outcome::Fail(refused),
368 }
369}
370
371/// The protection rules of the environments `listed` (the deployments
372/// service's answer to `list_environments` or `get_environment`) added to
373/// it, and environments with rules but no deployments yet added to a list.
374pub(crate) async fn with_protection(services: &Services, viewer: &Viewer, input: &Value, listed: Outcome<Value>, one: Option<&str>) -> Result<Outcome<Value>> {
375 let Some(repo) = repo_path(input) else { return Ok(listed) };
376 let mut args = json!({ "viewer": viewer, "repo": repo });
377 if let Some(name) = one {
378 args["name"] = json!(name);
379 }
380 let rules: Outcome<Vec<Value>> = g1t_kit::call(&services.actions, "environments", &args).await.unwrap_or(Outcome::Ok(Vec::new()));
381 let rules = match rules {
382 Outcome::Ok(rules) => rules,
383 Outcome::Fail(_) => return Ok(listed),
384 };
385 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);
386 let add = |env: &mut Value| {
387 if let Some(view) = env["name"].as_str().and_then(protection) {
388 for key in ["protection_rules", "deployment_branch_policy", "branch_policies", "can_admins_bypass"] {
389 env[key] = view[key].clone();
390 }
391 }
392 };
393 Ok(match (listed, one) {
394 (Outcome::Ok(mut env), Some(_)) => {
395 add(&mut env);
396 Outcome::Ok(env)
397 }
398 // Never deployed, but protected: still an environment.
399 (Outcome::Fail(refused), Some(name)) => match protection(name).filter(|view| view["protected"] == true) {
400 Some(view) => Outcome::Ok(view),
401 None => Outcome::Fail(refused),
402 },
403 (Outcome::Ok(mut list), None) => {
404 if let Some(environments) = list["environments"].as_array_mut() {
405 for env in environments.iter_mut() {
406 add(env);
407 }
408 for env in rules.iter().filter(|env| env["protected"] == true) {
409 let name = env["name"].as_str().unwrap_or_default();
410 if !environments.iter().any(|known| known["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) {
411 environments.push(environment_view(env));
412 }
413 }
414 }
415 Outcome::Ok(list)
416 }
417 (failed, None) => failed,
418 })
419}
420
421/// A workspace's policy, in the standard shape.
422fn workspace_view(settings: &Value) -> Value {
423 json!({
424 "default_workflow_permissions": settings["defaultPermissions"],
425 "max_workflow_permissions": settings["maxPermissions"],
426 "can_approve_pull_request_reviews": settings["canApprovePullRequests"],
427 })
428}
429
430pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
431 if op.writes() && viewer.is_none() {
432 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
433 }
434 let actor = || viewer.clone().unwrap_or_default();
435 let actions = &services.actions;
436 if !op.needs_repo() {
437 let Some(workspace) = text(input, "workspace") else {
438 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
439 };
440 let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions {
441 g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await?
442 } else {
443 g1t_kit::call(
444 actions,
445 "set_workspace_actions_settings",
446 &json!({
447 "actor": actor(),
448 "workspace": workspace,
449 "defaultPermissions": text(input, "default_workflow_permissions"),
450 "maxPermissions": text(input, "max_workflow_permissions"),
451 "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
452 }),
453 )
454 .await?
455 };
456 return Ok(map(settings, |s| workspace_view(&s)));
457 }
458 let Some(repo) = repo_path(input) else {
459 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
460 };
461 let id = text(input, "id").unwrap_or_default();
462 let environment = text(input, "environment").unwrap_or_default();
463 Ok(match op {
464 ProtectionOp::UpdateEnvironment => {
465 let mut args = match environment_change(input) {
466 Ok(args) => args,
467 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
468 };
469 args.insert("actor".into(), serde_json::to_value(actor())?);
470 args.insert("repo".into(), serde_json::to_value(&repo)?);
471 args.insert("name".into(), environment.into());
472 let saved: Outcome<Value> = g1t_kit::call(actions, "set_environment", &Value::Object(args)).await?;
473 map(saved, |env| environment_view(&env))
474 }
475 ProtectionOp::DeleteEnvironment => {
476 let removed: Outcome<bool> =
477 g1t_kit::call(actions, "delete_environment", &json!({ "actor": actor(), "repo": repo, "name": environment })).await?;
478 map(removed, |removed| json!({ "deleted": removed }))
479 }
480 ProtectionOp::GetPendingDeployments => {
481 let pending: Outcome<Vec<Value>> = g1t_kit::call(actions, "pending_deployments", &json!({ "viewer": viewer, "repo": repo, "id": id })).await?;
482 map(pending, |list| Value::Array(list.iter().map(pending_view).collect()))
483 }
484 ProtectionOp::ReviewPendingDeployments => {
485 let names: Vec<String> = ["environment_names", "environments", "environment_ids"]
486 .iter()
487 .find_map(|key| input[*key].as_array())
488 .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())
489 .unwrap_or_default();
490 let reviewed: Outcome<Vec<Value>> = g1t_kit::call(
491 actions,
492 "review_deployments",
493 &json!({
494 "actor": actor(),
495 "repo": repo,
496 "id": id,
497 "environments": names,
498 "state": text(input, "state").unwrap_or_default(),
499 "comment": text(input, "comment"),
500 }),
501 )
502 .await?;
503 map(reviewed, |list| Value::Array(list.iter().map(pending_view).collect()))
504 }
505 ProtectionOp::ApproveWorkflowRun => g1t_kit::call(actions, "approve_run", &json!({ "actor": actor(), "repo": repo, "id": id })).await?,
506 ProtectionOp::GetWorkflowPermissions | ProtectionOp::SetWorkflowPermissions => {
507 let settings: Outcome<Value> = if op == ProtectionOp::GetWorkflowPermissions {
508 g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
509 } else {
510 g1t_kit::call(
511 actions,
512 "set_actions_settings",
513 &json!({
514 "actor": actor(),
515 "repo": repo,
516 "defaultPermissions": text(input, "default_workflow_permissions"),
517 "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
518 }),
519 )
520 .await?
521 };
522 map(settings, |s| {
523 json!({
524 "default_workflow_permissions": s["defaultPermissions"],
525 "default_chosen": s["defaultChosen"],
526 "max_workflow_permissions": s["maxPermissions"],
527 "can_approve_pull_request_reviews": s["canApprovePullRequests"],
528 })
529 })
530 }
531 ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => {
532 let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval {
533 g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
534 } else {
535 g1t_kit::call(
536 actions,
537 "set_actions_settings",
538 &json!({ "actor": actor(), "repo": repo, "approvalPolicy": text(input, "approval_policy").unwrap_or_default() }),
539 )
540 .await?
541 };
542 map(settings, |s| json!({ "approval_policy": s["approvalPolicy"] }))
543 }
544 ProtectionOp::CreateRepositoryDispatch => {
545 let started: Outcome<u32> = g1t_kit::call(
546 actions,
547 "repository_dispatch",
548 &json!({
549 "actor": actor(),
550 "repo": repo,
551 "eventType": text(input, "event_type").unwrap_or_default(),
552 "clientPayload": input.get("client_payload").cloned().unwrap_or(Value::Null),
553 }),
554 )
555 .await?;
556 map(started, |runs| json!({ "runs": runs }))
557 }
558 // Answered above, before a repository is read.
559 ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => {
560 Outcome::fail(FailureCode::Invalid, "Name the workspace.")
561 }
562 })
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn an_environment_change_reads_the_standard_shape() {
571 let args = environment_change(&json!({
572 "wait_timer": 30,
573 "prevent_self_review": true,
574 "reviewers": [{ "type": "User", "id": "ada" }, { "type": "Team", "name": "deployers" }],
575 "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true },
576 "branch_policies": [{ "name": "release/*", "type": "branch" }],
577 }))
578 .unwrap();
579 assert_eq!(args["waitMinutes"], 30);
580 assert_eq!(args["preventSelfReview"], true);
581 assert_eq!(args["reviewers"], json!([{ "type": "user", "name": "ada" }, { "type": "team", "name": "deployers" }]));
582 assert_eq!(args["branchPolicy"], "selected");
583 assert_eq!(args["branchPatterns"], json!([{ "name": "release/*", "type": "branch" }]));
584 // Left out stays; null clears.
585 let cleared = environment_change(&json!({ "deployment_branch_policy": null, "reviewers": null })).unwrap();
586 assert_eq!(cleared["branchPolicy"], "all");
587 assert_eq!(cleared["reviewers"], json!([]));
588 assert!(!environment_change(&json!({})).unwrap().contains_key("waitMinutes"));
589 assert!(environment_change(&json!({ "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": true } })).is_err());
590 }
591
592 #[test]
593 fn an_environment_reads_as_the_standard_shape() {
594 let view = environment_view(&json!({
595 "name": "production", "reviewers": [{ "type": "user", "name": "ada" }], "preventSelfReview": true,
596 "waitMinutes": 10, "branchPolicy": "protected", "branchPatterns": [], "adminsBypass": false, "protected": true,
597 }));
598 let types: Vec<&str> = view["protection_rules"].as_array().unwrap().iter().map(|r| r["type"].as_str().unwrap()).collect();
599 assert_eq!(types, ["required_reviewers", "wait_timer", "branch_policy"]);
600 assert_eq!(view["protection_rules"][0]["reviewers"][0]["reviewer"]["login"], "ada");
601 assert_eq!(view["deployment_branch_policy"]["protected_branches"], true);
602 assert_eq!(view["can_admins_bypass"], false);
603 }
604
605 #[test]
606 fn each_operation_is_described_with_a_schema() {
607 for op in ProtectionOp::ALL {
608 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
609 let needs = if op.needs_repo() { "repo" } else { "workspace" };
610 assert!(op.input()["required"].as_array().unwrap().contains(&json!(needs)), "{}", op.name());
611 }
612 }
613}