| 1 | //! Rulesets over REST and MCP: a repository's and a workspace's rulesets, |
| 2 | //! the rules that hold for one branch or tag, and how the rules judged |
| 3 | //! pushes and merges (the evaluations, with insights). |
| 4 | //! |
| 5 | //! Rulesets travel as the API shows them, `snake_case` between services |
| 6 | //! too, so a ruleset read here, exported from the site or written by hand |
| 7 | //! is created and updated unchanged. The work service decides who may see |
| 8 | //! and change them and validates every one (`g1t_rules::validate`). |
| 9 | |
| 10 | use g1t_contracts::repos::RepoPath; |
| 11 | use g1t_contracts::rules::*; |
| 12 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 13 | use serde::Serialize; |
| 14 | use serde::de::DeserializeOwned; |
| 15 | use serde_json::{Map, Value, json}; |
| 16 | use worker::Result; |
| 17 | |
| 18 | use crate::operations::Services; |
| 19 | |
| 20 | /// One operation on rulesets. |
| 21 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 22 | pub enum RulesOp { |
| 23 | ListRepoRulesets, |
| 24 | GetRepoRuleset, |
| 25 | CreateRepoRuleset, |
| 26 | UpdateRepoRuleset, |
| 27 | DeleteRepoRuleset, |
| 28 | GetBranchRules, |
| 29 | ListRuleEvaluations, |
| 30 | ListWorkspaceRulesets, |
| 31 | GetWorkspaceRuleset, |
| 32 | CreateWorkspaceRuleset, |
| 33 | UpdateWorkspaceRuleset, |
| 34 | DeleteWorkspaceRuleset, |
| 35 | ListWorkspaceRuleEvaluations, |
| 36 | } |
| 37 | |
| 38 | /// The keys of a ruleset in a request body. |
| 39 | const SPEC_KEYS: [&str; 6] = ["name", "enforcement", "target", "conditions", "bypass_actors", "rules"]; |
| 40 | |
| 41 | impl RulesOp { |
| 42 | /// Every one: `Op::ALL` lists each as `Op::Rules(…)`, which a test |
| 43 | /// checks against this. |
| 44 | #[cfg(test)] |
| 45 | pub const ALL: [RulesOp; 13] = [ |
| 46 | RulesOp::ListRepoRulesets, |
| 47 | RulesOp::GetRepoRuleset, |
| 48 | RulesOp::CreateRepoRuleset, |
| 49 | RulesOp::UpdateRepoRuleset, |
| 50 | RulesOp::DeleteRepoRuleset, |
| 51 | RulesOp::GetBranchRules, |
| 52 | RulesOp::ListRuleEvaluations, |
| 53 | RulesOp::ListWorkspaceRulesets, |
| 54 | RulesOp::GetWorkspaceRuleset, |
| 55 | RulesOp::CreateWorkspaceRuleset, |
| 56 | RulesOp::UpdateWorkspaceRuleset, |
| 57 | RulesOp::DeleteWorkspaceRuleset, |
| 58 | RulesOp::ListWorkspaceRuleEvaluations, |
| 59 | ]; |
| 60 | |
| 61 | pub fn name(self) -> &'static str { |
| 62 | match self { |
| 63 | RulesOp::ListRepoRulesets => "list_repo_rulesets", |
| 64 | RulesOp::GetRepoRuleset => "get_repo_ruleset", |
| 65 | RulesOp::CreateRepoRuleset => "create_repo_ruleset", |
| 66 | RulesOp::UpdateRepoRuleset => "update_repo_ruleset", |
| 67 | RulesOp::DeleteRepoRuleset => "delete_repo_ruleset", |
| 68 | RulesOp::GetBranchRules => "get_branch_rules", |
| 69 | RulesOp::ListRuleEvaluations => "list_rule_evaluations", |
| 70 | RulesOp::ListWorkspaceRulesets => "list_workspace_rulesets", |
| 71 | RulesOp::GetWorkspaceRuleset => "get_workspace_ruleset", |
| 72 | RulesOp::CreateWorkspaceRuleset => "create_workspace_ruleset", |
| 73 | RulesOp::UpdateWorkspaceRuleset => "update_workspace_ruleset", |
| 74 | RulesOp::DeleteWorkspaceRuleset => "delete_workspace_ruleset", |
| 75 | RulesOp::ListWorkspaceRuleEvaluations => "list_workspace_rule_evaluations", |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// For the API reference: "List a repository's rulesets". |
| 80 | pub fn title(self) -> &'static str { |
| 81 | match self { |
| 82 | RulesOp::ListRepoRulesets => "List a repository's rulesets", |
| 83 | RulesOp::GetRepoRuleset => "Get a repository ruleset", |
| 84 | RulesOp::CreateRepoRuleset => "Create a repository ruleset", |
| 85 | RulesOp::UpdateRepoRuleset => "Update a repository ruleset", |
| 86 | RulesOp::DeleteRepoRuleset => "Delete a repository ruleset", |
| 87 | RulesOp::GetBranchRules => "Get the rules for a branch", |
| 88 | RulesOp::ListRuleEvaluations => "List a repository's rule evaluations", |
| 89 | RulesOp::ListWorkspaceRulesets => "List a workspace's rulesets", |
| 90 | RulesOp::GetWorkspaceRuleset => "Get a workspace ruleset", |
| 91 | RulesOp::CreateWorkspaceRuleset => "Create a workspace ruleset", |
| 92 | RulesOp::UpdateWorkspaceRuleset => "Update a workspace ruleset", |
| 93 | RulesOp::DeleteWorkspaceRuleset => "Delete a workspace ruleset", |
| 94 | RulesOp::ListWorkspaceRuleEvaluations => "List a workspace's rule evaluations", |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | pub fn description(self) -> &'static str { |
| 99 | match self { |
| 100 | RulesOp::ListRepoRulesets => "List a repository's rulesets: what may happen to its branches and tags, and what a pull request needs before it merges. With include_parents, also its workspace's rulesets that hold in it (level workspace). Each has its enforcement (active, evaluate: a dry run that records what it would have refused, or disabled), target (branch or tag), conditions (ref_name include and exclude patterns: fnmatch, ~DEFAULT_BRANCH, ~ALL), bypass_actors and rules. The one made from branch protection settings has source branch_protection.", |
| 101 | RulesOp::GetRepoRuleset => "Get one of a repository's rulesets by id (rs_…), or one of its workspace's that holds in it.", |
| 102 | RulesOp::CreateRepoRuleset => "Create a repository ruleset: name, enforcement (active, evaluate or disabled; active by default), target (branch or tag), conditions.ref_name (include and exclude patterns), bypass_actors (each a kind: role, team, user, token or g1t, a value, and a mode: always or pull_requests; nobody bypasses unless listed, g1t included) and rules (each a type, its parameters, and applies_to: everyone, agents or people). Rule types: creation, update, deletion, non_fast_forward, required_linear_history, required_signatures, pull_request, required_status_checks, merge_queue, required_deployments, commit_message_pattern, commit_author_email_pattern, committer_email_pattern, branch_name_pattern, tag_name_pattern, file_path_restriction, file_extension_restriction, max_file_size, max_file_path_length, max_files_changed, secret_scanning, confidence_threshold, cost_cap, path_review, merge_window and agent_auto_merge. Several rulesets stack: every rule of each holds. Takes the Admin role. Returns the ruleset as saved, tidied.", |
| 103 | RulesOp::UpdateRepoRuleset => "Change a repository ruleset. Fields left out stay as they are; rules and bypass_actors, when given, replace the whole list. Takes the Admin role.", |
| 104 | RulesOp::DeleteRepoRuleset => "Delete a repository ruleset. Its evaluations stay in the log. Takes the Admin role.", |
| 105 | RulesOp::GetBranchRules => "Every rule that holds for a branch (or a tag, with target tag) of a repository, from every ruleset that targets it, the repository's and its workspace's: each with its type, parameters and applies_to, and the ruleset_id, ruleset_name, level and enforcement it comes from. Active rules come first, then those of rulesets in evaluate. rulesets lists the rulesets with who may bypass each. A branch name with slashes is URL-encoded in the path.", |
| 106 | RulesOp::ListRuleEvaluations => "List how a repository's rulesets judged pushes, merges and other changes to its branches and tags, newest first: the ruleset, the action (push, merge, create_ref, delete_ref, rename_ref or commit), the ref, the actor and whether they are a person, an agent or g1t, the verdict (pass, fail or bypass) and each rule broken with why. A fail of a ruleset in evaluate is what it would have refused. Filter by ruleset_id or verdict, or problems_only; page with before. insights counts the last 30 days by ruleset and by rule. Takes the Write role.", |
| 107 | RulesOp::ListWorkspaceRulesets => "List a workspace's own rulesets. Each holds in the repositories its conditions.repository selects: names matching include (fnmatch, or ~ALL) and not exclude, of a visibility (any, public or private), and carrying one of topics when given. Members only.", |
| 108 | RulesOp::GetWorkspaceRuleset => "Get one of a workspace's own rulesets by id (rs_…), with its conditions, bypass actors and rules. Members only.", |
| 109 | RulesOp::CreateWorkspaceRuleset => "Create a workspace ruleset, as for a repository, plus conditions.repository: which of the workspace's repositories it holds in (include and exclude name patterns, visibility, topics). Owners only.", |
| 110 | RulesOp::UpdateWorkspaceRuleset => "Change a workspace ruleset. Fields left out stay as they are; rules and bypass_actors, when given, replace the whole list. Owners only.", |
| 111 | RulesOp::DeleteWorkspaceRuleset => "Delete a workspace ruleset: it stops holding in every repository it selected. Its evaluations stay in the log. Owners only.", |
| 112 | RulesOp::ListWorkspaceRuleEvaluations => "List how a workspace's rulesets, and its repositories' own, judged changes across its repositories, newest first, with 30 days of insights. Members only.", |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// Whether the operation is about one repository named by `repo`. |
| 117 | pub fn needs_repo(self) -> bool { |
| 118 | matches!( |
| 119 | self, |
| 120 | RulesOp::ListRepoRulesets |
| 121 | | RulesOp::GetRepoRuleset |
| 122 | | RulesOp::CreateRepoRuleset |
| 123 | | RulesOp::UpdateRepoRuleset |
| 124 | | RulesOp::DeleteRepoRuleset |
| 125 | | RulesOp::GetBranchRules |
| 126 | | RulesOp::ListRuleEvaluations |
| 127 | ) |
| 128 | } |
| 129 | |
| 130 | pub fn input(self) -> Value { |
| 131 | let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); |
| 132 | let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); |
| 133 | let id = || json!({ "type": "string", "description": "The ruleset's id: rs_…" }); |
| 134 | let spec = |mut properties: Value, workspace_level: bool| { |
| 135 | properties["ruleset_name"] = json!({ "type": "string", "description": "What people call it, at most 100 characters. A ruleset as exported names it `name`, which is read too." }); |
| 136 | properties["enforcement"] = json!({ "type": "string", "enum": ["active", "evaluate", "disabled"], "description": "active: its rules hold. evaluate: nothing is refused, and what would have been is recorded. disabled: kept, not evaluated. Default active." }); |
| 137 | properties["target"] = json!({ "type": "string", "enum": ["branch", "tag"], "description": "What its name conditions match. Default branch." }); |
| 138 | let mut conditions = json!({ |
| 139 | "ref_name": { |
| 140 | "type": "object", |
| 141 | "description": "Which branches or tags: include and exclude, each a list of fnmatch patterns (* within a path segment, ** across them), ~DEFAULT_BRANCH or ~ALL.", |
| 142 | "properties": { |
| 143 | "include": { "type": "array", "items": { "type": "string" } }, |
| 144 | "exclude": { "type": "array", "items": { "type": "string" } }, |
| 145 | }, |
| 146 | }, |
| 147 | }); |
| 148 | if workspace_level { |
| 149 | conditions["repository"] = json!({ |
| 150 | "type": "object", |
| 151 | "description": "Which of the workspace's repositories: include and exclude name patterns (or ~ALL), visibility (any, public, private) and topics (any of).", |
| 152 | "properties": { |
| 153 | "include": { "type": "array", "items": { "type": "string" } }, |
| 154 | "exclude": { "type": "array", "items": { "type": "string" } }, |
| 155 | "visibility": { "type": "string", "enum": ["any", "public", "private"] }, |
| 156 | "topics": { "type": "array", "items": { "type": "string" } }, |
| 157 | }, |
| 158 | }); |
| 159 | } |
| 160 | properties["conditions"] = json!({ "type": "object", "properties": conditions }); |
| 161 | properties["bypass_actors"] = json!({ |
| 162 | "type": "array", |
| 163 | "description": "Who it does not hold for. Nobody bypasses unless listed, g1t included. kind role takes read, triage, write, maintain, admin (that role or higher) or owner; team its slug or workspace/slug; user a username; token a token id, or workspace for any of the workspace's tokens; g1t no value. mode always (pushes and merges) or pull_requests (merges only; a person merging asks to, with bypass_rules).", |
| 164 | "items": { |
| 165 | "type": "object", |
| 166 | "properties": { |
| 167 | "kind": { "type": "string", "enum": ["role", "team", "user", "token", "g1t"] }, |
| 168 | "value": { "type": "string" }, |
| 169 | "mode": { "type": "string", "enum": ["always", "pull_requests"] }, |
| 170 | }, |
| 171 | "required": ["kind"], |
| 172 | }, |
| 173 | }); |
| 174 | properties["rules"] = json!({ |
| 175 | "type": "array", |
| 176 | "description": "Its rules. Each: type, parameters (left-out parameters take their defaults) and applies_to (everyone, agents or people). See the Rules guide for every type's parameters.", |
| 177 | "items": { |
| 178 | "type": "object", |
| 179 | "properties": { |
| 180 | "type": { "type": "string" }, |
| 181 | "parameters": { "type": "object" }, |
| 182 | "applies_to": { "type": "string", "enum": ["everyone", "agents", "people"] }, |
| 183 | }, |
| 184 | "required": ["type"], |
| 185 | }, |
| 186 | }); |
| 187 | properties |
| 188 | }; |
| 189 | let evaluations = |mut properties: Value| { |
| 190 | properties["ruleset_id"] = json!({ "type": "string", "description": "Only this ruleset's evaluations." }); |
| 191 | properties["verdict"] = json!({ "type": "string", "enum": ["pass", "fail", "bypass"], "description": "Only evaluations that came out this way." }); |
| 192 | properties["problems_only"] = json!({ "type": "boolean", "description": "Only evaluations that broke a rule: failed, would have failed, or bypassed." }); |
| 193 | properties["before"] = json!({ "type": "string", "description": "An evaluation's id (rev_…): only older ones. The page's next." }); |
| 194 | properties["limit"] = json!({ "type": "integer", "description": "How many, 1 to 100; 30 by default." }); |
| 195 | properties |
| 196 | }; |
| 197 | let (properties, required): (Value, &[&str]) = match self { |
| 198 | RulesOp::ListRepoRulesets => ( |
| 199 | json!({ "repo": repo(), "include_parents": { "type": "boolean", "description": "Also list the workspace's rulesets that hold in it." } }), |
| 200 | &["repo"], |
| 201 | ), |
| 202 | RulesOp::GetRepoRuleset | RulesOp::DeleteRepoRuleset => (json!({ "repo": repo(), "id": id() }), &["repo", "id"]), |
| 203 | RulesOp::CreateRepoRuleset => (spec(json!({ "repo": repo() }), false), &["repo"]), |
| 204 | RulesOp::UpdateRepoRuleset => (spec(json!({ "repo": repo(), "id": id() }), false), &["repo", "id"]), |
| 205 | RulesOp::GetBranchRules => ( |
| 206 | json!({ |
| 207 | "repo": repo(), |
| 208 | "branch": { "type": "string", "description": "The branch (or tag) name, such as main or release/1.x." }, |
| 209 | "target": { "type": "string", "enum": ["branch", "tag"], "description": "branch (the default) or tag." }, |
| 210 | }), |
| 211 | &["repo", "branch"], |
| 212 | ), |
| 213 | RulesOp::ListRuleEvaluations => (evaluations(json!({ "repo": repo() })), &["repo"]), |
| 214 | RulesOp::ListWorkspaceRulesets => (json!({ "workspace": workspace() }), &["workspace"]), |
| 215 | RulesOp::GetWorkspaceRuleset | RulesOp::DeleteWorkspaceRuleset => { |
| 216 | (json!({ "workspace": workspace(), "id": id() }), &["workspace", "id"]) |
| 217 | } |
| 218 | RulesOp::CreateWorkspaceRuleset => (spec(json!({ "workspace": workspace() }), true), &["workspace"]), |
| 219 | RulesOp::UpdateWorkspaceRuleset => (spec(json!({ "workspace": workspace(), "id": id() }), true), &["workspace", "id"]), |
| 220 | RulesOp::ListWorkspaceRuleEvaluations => (evaluations(json!({ "workspace": workspace() })), &["workspace"]), |
| 221 | }; |
| 222 | let mut schema = json!({ "type": "object", "properties": properties }); |
| 223 | if !required.is_empty() { |
| 224 | schema["required"] = json!(required); |
| 225 | } |
| 226 | schema |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> { |
| 231 | Ok(Outcome::Ok(serde_json::to_value(value)?)) |
| 232 | } |
| 233 | |
| 234 | fn text(input: &Value, key: &str) -> Option<String> { |
| 235 | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) |
| 236 | } |
| 237 | |
| 238 | fn flag(input: &Value, key: &str) -> bool { |
| 239 | match &input[key] { |
| 240 | Value::Bool(value) => *value, |
| 241 | Value::String(text) => matches!(text.trim(), "true" | "1"), |
| 242 | _ => false, |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> { |
| 247 | g1t_kit::call(&services.work, method, args).await |
| 248 | } |
| 249 | |
| 250 | /// The ruleset in a request body, laid over `current` for an update: the |
| 251 | /// fields given replace those it had. |
| 252 | pub(crate) fn spec_of(input: &Value, current: Option<&RulesetSpec>) -> std::result::Result<RulesetSpec, String> { |
| 253 | let mut merged: Map<String, Value> = match current { |
| 254 | Some(current) => match serde_json::to_value(current) { |
| 255 | Ok(Value::Object(fields)) => fields, |
| 256 | _ => Map::new(), |
| 257 | }, |
| 258 | None => Map::new(), |
| 259 | }; |
| 260 | for key in SPEC_KEYS { |
| 261 | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { |
| 262 | merged.insert(key.to_owned(), value.clone()); |
| 263 | } |
| 264 | } |
| 265 | // Under a repository's address `name` is the repository's, so the API |
| 266 | // names the ruleset `ruleset_name`; an exported ruleset's `name` is read |
| 267 | // as well. |
| 268 | if let Some(name) = input.get("ruleset_name").filter(|value| !value.is_null()) { |
| 269 | merged.insert("name".to_owned(), name.clone()); |
| 270 | } |
| 271 | serde_json::from_value(Value::Object(merged)).map_err(|error| format!("The ruleset could not be read: {error}")) |
| 272 | } |
| 273 | |
| 274 | fn owner(op: RulesOp, input: &Value, repo: Option<RepoPath>) -> std::result::Result<Owner, String> { |
| 275 | if op.needs_repo() { |
| 276 | return repo.map(Owner::repo).ok_or_else(|| "Give the repository as \"owner/name\".".to_owned()); |
| 277 | } |
| 278 | text(input, "workspace").map(|slug| Owner::workspace(&slug)).ok_or_else(|| "Give the workspace's slug.".to_owned()) |
| 279 | } |
| 280 | |
| 281 | pub async fn run(op: RulesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 282 | let owner = match owner(op, input, crate::operations::repo_path(input)) { |
| 283 | Ok(owner) => owner, |
| 284 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 285 | }; |
| 286 | let actor = || viewer.clone().unwrap_or_default(); |
| 287 | let id = || text(input, "id").unwrap_or_default(); |
| 288 | match op { |
| 289 | RulesOp::ListRepoRulesets | RulesOp::ListWorkspaceRulesets => { |
| 290 | call( |
| 291 | services, |
| 292 | "list_rulesets", |
| 293 | &ListRulesetsArgs { viewer: viewer.clone(), owner, include_parents: flag(input, "include_parents") }, |
| 294 | ) |
| 295 | .await |
| 296 | } |
| 297 | RulesOp::GetRepoRuleset | RulesOp::GetWorkspaceRuleset => { |
| 298 | call(services, "get_ruleset", &GetRulesetArgs { viewer: viewer.clone(), owner, id: id() }).await |
| 299 | } |
| 300 | RulesOp::CreateRepoRuleset | RulesOp::CreateWorkspaceRuleset => { |
| 301 | let ruleset = match spec_of(input, None) { |
| 302 | Ok(ruleset) => ruleset, |
| 303 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 304 | }; |
| 305 | call(services, "save_ruleset", &SaveRulesetArgs { actor: actor(), owner, id: None, ruleset, from_api: true }).await |
| 306 | } |
| 307 | RulesOp::UpdateRepoRuleset | RulesOp::UpdateWorkspaceRuleset => { |
| 308 | let current: Outcome<Ruleset> = |
| 309 | call(services, "get_ruleset", &GetRulesetArgs { viewer: viewer.clone(), owner: owner.clone(), id: id() }).await?; |
| 310 | let current = match current { |
| 311 | Outcome::Ok(current) => current, |
| 312 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 313 | }; |
| 314 | if current.level == Level::Workspace && op == RulesOp::UpdateRepoRuleset { |
| 315 | return Ok(Outcome::fail( |
| 316 | FailureCode::Invalid, |
| 317 | "That is the workspace's ruleset: change it with update_workspace_ruleset.", |
| 318 | )); |
| 319 | } |
| 320 | let ruleset = match spec_of(input, Some(¤t.spec)) { |
| 321 | Ok(ruleset) => ruleset, |
| 322 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 323 | }; |
| 324 | call(services, "save_ruleset", &SaveRulesetArgs { actor: actor(), owner, id: Some(current.id), ruleset, from_api: true }) |
| 325 | .await |
| 326 | } |
| 327 | RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset => { |
| 328 | let deleted: Outcome<bool> = |
| 329 | call(services, "delete_ruleset", &DeleteRulesetArgs { actor: actor(), owner, id: id(), from_api: true }).await?; |
| 330 | match deleted { |
| 331 | Outcome::Ok(deleted) => ok(&json!({ "deleted": deleted })), |
| 332 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), |
| 333 | } |
| 334 | } |
| 335 | RulesOp::GetBranchRules => { |
| 336 | let Some(repo) = owner.repo else { |
| 337 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); |
| 338 | }; |
| 339 | let target = match text(input, "target").as_deref() { |
| 340 | None | Some("branch") => Target::Branch, |
| 341 | Some("tag") => Target::Tag, |
| 342 | Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a target: use branch or tag."))), |
| 343 | }; |
| 344 | let Some(name) = text(input, "branch") else { |
| 345 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the branch.")); |
| 346 | }; |
| 347 | call(services, "effective_rules", &EffectiveRulesArgs { viewer: viewer.clone(), repo, name, target }).await |
| 348 | } |
| 349 | RulesOp::ListRuleEvaluations | RulesOp::ListWorkspaceRuleEvaluations => { |
| 350 | let verdict = match text(input, "verdict").as_deref() { |
| 351 | None => None, |
| 352 | Some("pass") => Some(Verdict::Pass), |
| 353 | Some("fail") => Some(Verdict::Fail), |
| 354 | Some("bypass") => Some(Verdict::Bypass), |
| 355 | Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a verdict: use pass, fail or bypass."))), |
| 356 | }; |
| 357 | let limit = match &input["limit"] { |
| 358 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), |
| 359 | Value::String(digits) => digits.trim().parse().ok(), |
| 360 | _ => None, |
| 361 | }; |
| 362 | call( |
| 363 | services, |
| 364 | "rule_evaluations", |
| 365 | &EvaluationsArgs { |
| 366 | viewer: viewer.clone(), |
| 367 | owner, |
| 368 | ruleset_id: text(input, "ruleset_id"), |
| 369 | verdict, |
| 370 | problems_only: flag(input, "problems_only"), |
| 371 | before: text(input, "before"), |
| 372 | limit, |
| 373 | }, |
| 374 | ) |
| 375 | .await |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | #[cfg(test)] |
| 381 | mod tests { |
| 382 | use super::*; |
| 383 | |
| 384 | #[test] |
| 385 | fn a_body_is_a_ruleset_and_an_update_keeps_what_it_leaves_out() { |
| 386 | let body = json!({ |
| 387 | "repo": "acme/web", |
| 388 | "name": "Protect main", |
| 389 | "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"] } }, |
| 390 | "rules": [{ "type": "deletion" }, { "type": "pull_request", "parameters": { "required_approvals": 2 } }] |
| 391 | }); |
| 392 | let created = spec_of(&body, None).unwrap(); |
| 393 | assert_eq!(created.name, "Protect main"); |
| 394 | assert_eq!(created.enforcement, Enforcement::Active); |
| 395 | assert_eq!(created.rules.len(), 2); |
| 396 | let updated = spec_of(&json!({ "enforcement": "evaluate" }), Some(&created)).unwrap(); |
| 397 | assert_eq!(updated.enforcement, Enforcement::Evaluate); |
| 398 | assert_eq!(updated.rules, created.rules, "rules left out stay"); |
| 399 | let replaced = spec_of(&json!({ "rules": [] }), Some(&created)).unwrap(); |
| 400 | assert!(replaced.rules.is_empty(), "a list given replaces the list"); |
| 401 | let renamed = spec_of(&json!({ "ruleset_name": "Protect releases" }), Some(&created)).unwrap(); |
| 402 | assert_eq!(renamed.name, "Protect releases"); |
| 403 | assert!(spec_of(&json!({ "rules": [{ "type": "no_such_rule" }] }), None).is_err()); |
| 404 | } |
| 405 | |
| 406 | #[test] |
| 407 | fn whose_rulesets_comes_from_repo_or_workspace() { |
| 408 | let input = json!({ "workspace": "Acme" }); |
| 409 | assert_eq!(owner(RulesOp::ListWorkspaceRulesets, &input, None).unwrap(), Owner::workspace("acme")); |
| 410 | assert!(owner(RulesOp::ListRepoRulesets, &input, None).is_err()); |
| 411 | let path = RepoPath { namespace: "acme".into(), name: "web".into() }; |
| 412 | assert_eq!(owner(RulesOp::GetBranchRules, &json!({}), Some(path.clone())).unwrap(), Owner::repo(path)); |
| 413 | } |
| 414 | |
| 415 | #[test] |
| 416 | fn each_operation_is_described_with_a_schema() { |
| 417 | for op in RulesOp::ALL { |
| 418 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 419 | assert_eq!(op.input()["type"], "object"); |
| 420 | if op.needs_repo() { |
| 421 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); |
| 422 | } else { |
| 423 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | } |