Skip to content
427 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1//! 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
10use g1t_contracts::repos::RepoPath;
11use g1t_contracts::rules::*;
12use g1t_contracts::{FailureCode, Outcome, Viewer};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value, json};
16use worker::Result;
17
18use crate::operations::Services;
19
20/// One operation on rulesets.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub 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.
39const SPEC_KEYS: [&str; 6] = ["name", "enforcement", "target", "conditions", "bypass_actors", "rules"];
40
41impl 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 tokens103 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 merge105 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
230fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
231 Ok(Outcome::Ok(serde_json::to_value(value)?))
232}
233
234fn 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
238fn 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
246async 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.
252pub(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
274fn 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
281pub 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(&current.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)]
381mod 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.