Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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.", | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 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, 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.", |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 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.", | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.