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 | //! Whether a ruleset can be saved, and the tidy form it is saved in. |
| 2 | ||
| 3 | use g1t_contracts::access::RepoRole; | |
| 4 | use g1t_contracts::rules::{ | |
| 5 | ActorKind, AppliesTo, Level, MAX_APPROVALS, MAX_BYPASS_ACTORS, MAX_PATTERN_CHARS, MAX_PATTERNS, MAX_RULES, | |
| 6 | PatternRule, Rule, RulesetSpec, Target, | |
| 7 | }; | |
| 8 | use g1t_contracts::time::parse_rfc3339; | |
| 9 | ||
| 10 | use crate::{glob, text, window}; | |
| 11 | ||
| 12 | /// The longest ruleset name. | |
| 13 | pub const MAX_NAME_CHARS: usize = 100; | |
| 14 | /// Required checks in one rule. | |
| 15 | pub const MAX_CHECKS: usize = 50; | |
| 16 | ||
| 17 | fn tidy_list(list: &[String], what: &str) -> Result<Vec<String>, String> { | |
| 18 | let mut out: Vec<String> = Vec::new(); | |
| 19 | for item in list { | |
| 20 | let item = item.trim(); | |
| 21 | if item.is_empty() || out.iter().any(|have| have == item) { | |
| 22 | continue; | |
| 23 | } | |
| 24 | if item.chars().count() > MAX_PATTERN_CHARS { | |
| 25 | return Err(format!("{what} may be at most {MAX_PATTERN_CHARS} characters long.")); | |
| 26 | } | |
| 27 | if !glob::well_formed(item) { | |
| 28 | return Err(format!("{what} {item} has a [ without a closing ].")); | |
| 29 | } | |
| 30 | out.push(item.to_owned()); | |
| 31 | } | |
| 32 | if out.len() > MAX_PATTERNS { | |
| 33 | return Err(format!("A ruleset may list at most {MAX_PATTERNS} {what}s.")); | |
| 34 | } | |
| 35 | Ok(out) | |
| 36 | } | |
| 37 | ||
| 38 | fn pattern(rule: &PatternRule, what: &str) -> Result<PatternRule, String> { | |
| 39 | let tidy = PatternRule { name: rule.name.trim().to_owned(), pattern: rule.pattern.clone(), ..rule.clone() }; | |
| 40 | if tidy.pattern.is_empty() { | |
| 41 | return Err(format!("The {what} rule needs a pattern.")); | |
| 42 | } | |
| 43 | if tidy.pattern.chars().count() > MAX_PATTERN_CHARS { | |
| 44 | return Err(format!("The {what} pattern may be at most {MAX_PATTERN_CHARS} characters long.")); | |
| 45 | } | |
| 46 | text::compile(&tidy).map_err(|why| format!("The {what} rule: {why}"))?; | |
| 47 | Ok(tidy) | |
| 48 | } | |
| 49 | ||
| 50 | fn period(period: &g1t_contracts::rules::Period, what: &str) -> Result<(), String> { | |
| 51 | let start = parse_rfc3339(&period.start).ok_or_else(|| format!("A {what} needs a start time in RFC 3339, such as 2026-12-20T00:00:00Z."))?; | |
| 52 | if let Some(end) = &period.end { | |
| 53 | let end = parse_rfc3339(end).ok_or_else(|| format!("A {what}'s end must be an RFC 3339 time."))?; | |
| 54 | if end <= start { | |
| 55 | return Err(format!("A {what} must end after it starts.")); | |
| 56 | } | |
| 57 | } | |
| 58 | Ok(()) | |
| 59 | } | |
| 60 | ||
| 61 | fn rule(rule: &Rule, target: Target) -> Result<Rule, String> { | |
| 62 | let label = rule.label(); | |
| 63 | if target == Target::Tag && rule.for_branches_only() { | |
| 64 | return Err(format!("{label} is a rule for branches, and this ruleset targets tags.")); | |
| 65 | } | |
| 66 | if target == Target::Branch && rule.for_tags_only() { | |
| 67 | return Err(format!("{label} is a rule for tags, and this ruleset targets branches.")); | |
| 68 | } | |
| 69 | Ok(match rule { | |
| 70 | Rule::PullRequest(params) => { | |
| 71 | if params.required_approvals > MAX_APPROVALS { | |
| 72 | return Err(format!("A pull request rule may require at most {MAX_APPROVALS} approvals.")); | |
| 73 | } | |
| 74 | let mut params = params.clone(); | |
| 75 | params.allowed_merge_methods.dedup(); | |
| 76 | Rule::PullRequest(params) | |
| 77 | } | |
| 78 | Rule::RequiredStatusChecks(params) => { | |
| 79 | let mut params = params.clone(); | |
| 80 | let mut checks = Vec::new(); | |
| 81 | for check in ¶ms.checks { | |
| 82 | let context = check.context.trim(); | |
| 83 | if context.is_empty() || checks.iter().any(|have: &g1t_contracts::rules::RequiredCheck| have.context.eq_ignore_ascii_case(context)) { | |
| 84 | continue; | |
| 85 | } | |
| 86 | if context.chars().count() > 200 { | |
| 87 | return Err("A required check's name may be at most 200 characters long.".to_owned()); | |
| 88 | } | |
| 89 | checks.push(g1t_contracts::rules::RequiredCheck { context: context.to_owned(), integration: check.integration }); | |
| 90 | } | |
| 91 | if checks.len() > MAX_CHECKS { | |
| 92 | return Err(format!("A ruleset may require at most {MAX_CHECKS} checks in one rule.")); | |
| 93 | } | |
| 94 | if checks.is_empty() && !params.strict { | |
| 95 | return Err("Require status checks needs a check to require, or to require branches to be up to date.".to_owned()); | |
| 96 | } | |
| 97 | params.checks = checks; | |
| 98 | params.paths = tidy_list(¶ms.paths, "path")?; | |
| 99 | Rule::RequiredStatusChecks(params) | |
| 100 | } | |
| 101 | Rule::MergeQueue(params) => { | |
| 102 | if !(1..=20).contains(¶ms.max_entries_to_build) { | |
| 103 | return Err("The merge queue builds 1 to 20 pull requests at once.".to_owned()); | |
| 104 | } | |
| 105 | if params.min_entries_to_merge < 1 || params.min_entries_to_merge > params.max_entries_to_build { | |
| 106 | return Err("The merge queue's smallest batch is between 1 and how many it builds at once.".to_owned()); | |
| 107 | } | |
| 108 | if params.min_entries_wait_minutes > 360 { | |
| 109 | return Err("The merge queue waits at most 360 minutes for a batch to fill.".to_owned()); | |
| 110 | } | |
| 111 | if !(5..=360).contains(¶ms.check_response_timeout_minutes) { | |
| 112 | return Err("The merge queue's check timeout is 5 to 360 minutes.".to_owned()); | |
| 113 | } | |
| 114 | Rule::MergeQueue(params.clone()) | |
| 115 | } | |
| 116 | Rule::RequiredDeployments(params) => { | |
| 117 | let environments = tidy_list(¶ms.environments, "environment")?; | |
| 118 | if environments.is_empty() { | |
| 119 | return Err("Require deployments needs an environment, such as preview.".to_owned()); | |
| 120 | } | |
| 121 | Rule::RequiredDeployments(g1t_contracts::rules::DeploymentsRule { environments }) | |
| 122 | } | |
| 123 | Rule::CommitMessagePattern(params) => Rule::CommitMessagePattern(pattern(params, "commit message")?), | |
| 124 | Rule::CommitAuthorEmailPattern(params) => Rule::CommitAuthorEmailPattern(pattern(params, "commit author email")?), | |
| 125 | Rule::CommitterEmailPattern(params) => Rule::CommitterEmailPattern(pattern(params, "committer email")?), | |
| 126 | Rule::BranchNamePattern(params) => Rule::BranchNamePattern(pattern(params, "branch name")?), | |
| 127 | Rule::TagNamePattern(params) => Rule::TagNamePattern(pattern(params, "tag name")?), | |
| 128 | Rule::FilePathRestriction(params) => { | |
| 129 | let paths = tidy_list(¶ms.restricted_file_paths, "path")?; | |
| 130 | if paths.is_empty() { | |
| 131 | return Err("Restrict file paths needs a path pattern.".to_owned()); | |
| 132 | } | |
| 133 | Rule::FilePathRestriction(g1t_contracts::rules::FilePathRule { restricted_file_paths: paths }) | |
| 134 | } | |
| 135 | Rule::FileExtensionRestriction(params) => { | |
| 136 | let extensions: Vec<String> = tidy_list(¶ms.restricted_file_extensions, "extension")? | |
| 137 | .into_iter() | |
| 138 | .map(|extension| { | |
| 139 | let extension = extension.to_lowercase(); | |
| 140 | if extension.starts_with('.') { extension } else { format!(".{extension}") } | |
| 141 | }) | |
| 142 | .collect(); | |
| 143 | if extensions.is_empty() { | |
| 144 | return Err("Restrict file extensions needs an extension, such as .exe.".to_owned()); | |
| 145 | } | |
| 146 | Rule::FileExtensionRestriction(g1t_contracts::rules::FileExtensionRule { restricted_file_extensions: extensions }) | |
| 147 | } | |
| 148 | Rule::MaxFileSize(params) => { | |
| 149 | if !(1..=100).contains(¶ms.max_file_size_mb) { | |
| 150 | return Err("The largest file allowed is 1 to 100 MB.".to_owned()); | |
| 151 | } | |
| 152 | Rule::MaxFileSize(params.clone()) | |
| 153 | } | |
| 154 | Rule::MaxFilePathLength(params) => { | |
| 155 | if !(1..=4096).contains(¶ms.max_file_path_length) { | |
| 156 | return Err("The longest path allowed is 1 to 4096 characters.".to_owned()); | |
| 157 | } | |
| 158 | Rule::MaxFilePathLength(params.clone()) | |
| 159 | } | |
| 160 | Rule::MaxFilesChanged(params) => { | |
| 161 | if !(1..=100_000).contains(¶ms.max_files) { | |
| 162 | return Err("The most files changed is 1 to 100000.".to_owned()); | |
| 163 | } | |
| 164 | Rule::MaxFilesChanged(params.clone()) | |
| 165 | } | |
| 166 | Rule::PathReview(params) => { | |
| 167 | let paths = tidy_list(¶ms.paths, "path")?; | |
| 168 | if paths.is_empty() { | |
| 169 | return Err("Review for sensitive paths needs a path pattern.".to_owned()); | |
| 170 | } | |
| 171 | if !(1..=MAX_APPROVALS).contains(¶ms.required_approvals) { | |
| 172 | return Err(format!("Review for sensitive paths asks for 1 to {MAX_APPROVALS} approvals.")); | |
| 173 | } | |
| 174 | let team = params | |
| 175 | .team | |
| 176 | .as_deref() | |
| 177 | .map(|team| team.trim().trim_start_matches('@').to_lowercase()) | |
| 178 | .filter(|team| !team.is_empty()); | |
| 179 | Rule::PathReview(g1t_contracts::rules::PathReviewRule { paths, required_approvals: params.required_approvals, team }) | |
| 180 | } | |
| 181 | Rule::MergeWindow(params) => { | |
| 182 | if window::offset_minutes(¶ms.time_zone).is_none() { | |
| 183 | return Err("A merge window's time zone is an offset from UTC, such as +02:00, or UTC.".to_owned()); | |
| 184 | } | |
| 185 | for weekly in ¶ms.windows { | |
| 186 | if weekly.days.is_empty() { | |
| 187 | return Err("Each merge window needs at least one day.".to_owned()); | |
| 188 | } | |
| 189 | let (Some(start), Some(end)) = (window::clock(&weekly.start), window::clock(&weekly.end)) else { | |
| 190 | return Err("A merge window's hours are HH:MM, such as 09:00 to 17:00.".to_owned()); | |
| 191 | }; | |
| 192 | if start == end { | |
| 193 | return Err("A merge window must not start and end at the same time.".to_owned()); | |
| 194 | } | |
| 195 | } | |
| 196 | for freeze in ¶ms.freezes { | |
| 197 | period(freeze, "freeze")?; | |
| 198 | } | |
| 199 | for exception in ¶ms.exceptions { | |
| 200 | period(exception, "exception")?; | |
| 201 | } | |
| 202 | if params.windows.is_empty() && params.freezes.is_empty() { | |
| 203 | return Err("A merge window needs weekly hours or a freeze.".to_owned()); | |
| 204 | } | |
| 205 | Rule::MergeWindow(params.clone()) | |
| 206 | } | |
| 207 | other => other.clone(), | |
| 208 | }) | |
| 209 | } | |
| 210 | ||
| 211 | /// Rules that may appear more than once in a ruleset, each with its own | |
| 212 | /// parameters. | |
| 213 | fn repeatable(rule: &Rule) -> bool { | |
| 214 | matches!( | |
| 215 | rule, | |
| 216 | Rule::RequiredStatusChecks(_) | |
| 217 | | Rule::PathReview(_) | |
| 218 | | Rule::CommitMessagePattern(_) | |
| 219 | | Rule::CommitAuthorEmailPattern(_) | |
| 220 | | Rule::CommitterEmailPattern(_) | |
| 221 | | Rule::BranchNamePattern(_) | |
| 222 | | Rule::TagNamePattern(_) | |
| 223 | | Rule::FilePathRestriction(_) | |
| 224 | ) | |
| 225 | } | |
| 226 | ||
| 227 | /// The ruleset as it is saved, or why it cannot be. | |
| 228 | pub fn validate(spec: &RulesetSpec, level: Level) -> Result<RulesetSpec, String> { | |
| 229 | let name = spec.name.trim(); | |
| 230 | if name.is_empty() { | |
| 231 | return Err("Give the ruleset a name.".to_owned()); | |
| 232 | } | |
| 233 | if name.chars().count() > MAX_NAME_CHARS { | |
| 234 | return Err(format!("A ruleset's name may be at most {MAX_NAME_CHARS} characters long.")); | |
| 235 | } | |
| 236 | let mut out = spec.clone(); | |
| 237 | out.name = name.to_owned(); | |
| 238 | out.conditions.ref_name.include = tidy_list(&spec.conditions.ref_name.include, "branch or tag pattern")?; | |
| 239 | out.conditions.ref_name.exclude = tidy_list(&spec.conditions.ref_name.exclude, "branch or tag pattern")?; | |
| 240 | if out.conditions.ref_name.include.is_empty() { | |
| 241 | return Err(format!( | |
| 242 | "Say which {}es it holds for: a pattern such as release/*, ~DEFAULT_BRANCH or ~ALL.", | |
| 243 | if spec.target == Target::Tag { "tag" } else { "branch" } | |
| 244 | )); | |
| 245 | } | |
| 246 | out.conditions.repository = match level { | |
| 247 | Level::Repository => None, | |
| 248 | Level::Workspace => { | |
| 249 | let mut repository = spec.conditions.repository.clone().unwrap_or_default(); | |
| 250 | repository.include = tidy_list(&repository.include, "repository pattern")?; | |
| 251 | repository.exclude = tidy_list(&repository.exclude, "repository pattern")?; | |
| 252 | repository.topics = repository | |
| 253 | .topics | |
| 254 | .iter() | |
| 255 | .map(|topic| topic.trim().to_lowercase()) | |
| 256 | .filter(|topic| !topic.is_empty()) | |
| 257 | .collect(); | |
| 258 | if repository.include.is_empty() { | |
| 259 | return Err("Say which repositories it holds in: a name pattern, or ~ALL.".to_owned()); | |
| 260 | } | |
| 261 | Some(repository) | |
| 262 | } | |
| 263 | }; | |
| 264 | if spec.bypass_actors.len() > MAX_BYPASS_ACTORS { | |
| 265 | return Err(format!("A ruleset may list at most {MAX_BYPASS_ACTORS} bypass actors.")); | |
| 266 | } | |
| 267 | let mut bypass = Vec::new(); | |
| 268 | for actor in &spec.bypass_actors { | |
| 269 | let mut actor = actor.clone(); | |
| 270 | actor.value = actor.value.trim().trim_start_matches('@').to_owned(); | |
| 271 | match actor.kind { | |
| 272 | ActorKind::Role => { | |
| 273 | let role = actor.value.to_ascii_lowercase(); | |
| 274 | if role != "owner" && RepoRole::parse(&role).is_none() { | |
| 275 | return Err(format!("{} is not a role: use read, triage, write, maintain, admin or owner.", actor.value)); | |
| 276 | } | |
| 277 | actor.value = role; | |
| 278 | } | |
| 279 | ActorKind::G1t => actor.value.clear(), | |
| 280 | _ if actor.value.is_empty() => return Err("Each bypass actor needs to say who.".to_owned()), | |
| 281 | ActorKind::Team => actor.value = actor.value.to_lowercase(), | |
| 282 | _ => {} | |
| 283 | } | |
| 284 | if !bypass.contains(&actor) { | |
| 285 | bypass.push(actor); | |
| 286 | } | |
| 287 | } | |
| 288 | out.bypass_actors = bypass; | |
| 289 | if spec.rules.len() > MAX_RULES { | |
| 290 | return Err(format!("A ruleset may have at most {MAX_RULES} rules.")); | |
| 291 | } | |
| 292 | let mut seen: Vec<(&'static str, AppliesTo)> = Vec::new(); | |
| 293 | out.rules = Vec::new(); | |
| 294 | for entry in &spec.rules { | |
| 295 | let key = (entry.rule.kind(), entry.applies_to); | |
| 296 | if !repeatable(&entry.rule) { | |
| 297 | if seen.contains(&key) { | |
| 298 | return Err(format!("{} appears twice for the same changes; keep one.", entry.rule.label())); | |
| 299 | } | |
| 300 | seen.push(key); | |
| 301 | } | |
| 302 | out.rules.push(g1t_contracts::rules::RuleEntry { rule: rule(&entry.rule, spec.target)?, applies_to: entry.applies_to }); | |
| 303 | } | |
| 304 | Ok(out) | |
| 305 | } | |
| 306 | ||
| 307 | #[cfg(test)] | |
| 308 | mod tests { | |
| 309 | use super::*; | |
| 310 | use g1t_contracts::rules::{ | |
| 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. | 311 | BypassActor, BypassMode, Conditions, MergeQueueRule, MergeWindowRule, NoParameters, PatternOperator, |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 312 | Period, RefCondition, RuleEntry, StatusChecksRule, WeeklyWindow, Weekday, |
| 313 | }; | |
| 314 | ||
| 315 | fn spec(rules: Vec<Rule>) -> RulesetSpec { | |
| 316 | RulesetSpec { | |
| 317 | name: " Protect main ".into(), | |
| 318 | conditions: Conditions { | |
| 319 | ref_name: RefCondition { include: vec!["~DEFAULT_BRANCH".into(), " ".into(), "~DEFAULT_BRANCH".into()], exclude: Vec::new() }, | |
| 320 | repository: None, | |
| 321 | }, | |
| 322 | rules: rules.into_iter().map(RuleEntry::everyone).collect(), | |
| 323 | ..RulesetSpec::default() | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | #[test] | |
| 328 | fn a_good_ruleset_is_tidied() { | |
| 329 | let saved = validate(&spec(vec![Rule::Deletion(NoParameters {})]), Level::Repository).unwrap(); | |
| 330 | assert_eq!(saved.name, "Protect main"); | |
| 331 | assert_eq!(saved.conditions.ref_name.include, vec!["~DEFAULT_BRANCH"]); | |
| 332 | assert!(saved.conditions.repository.is_none()); | |
| 333 | let workspace = validate(&spec(vec![]), Level::Workspace).unwrap(); | |
| 334 | assert_eq!(workspace.conditions.repository.unwrap().include, vec!["~ALL"]); | |
| 335 | } | |
| 336 | ||
| 337 | #[test] | |
| 338 | fn names_and_targets_are_required() { | |
| 339 | let mut nameless = spec(vec![]); | |
| 340 | nameless.name = " ".into(); | |
| 341 | assert_eq!(validate(&nameless, Level::Repository).unwrap_err(), "Give the ruleset a name."); | |
| 342 | let mut nowhere = spec(vec![]); | |
| 343 | nowhere.conditions.ref_name.include.clear(); | |
| 344 | assert!(validate(&nowhere, Level::Repository).unwrap_err().starts_with("Say which branches")); | |
| 345 | let mut broken = spec(vec![]); | |
| 346 | broken.conditions.ref_name.include = vec!["release/[0-9".into()]; | |
| 347 | assert!(validate(&broken, Level::Repository).unwrap_err().contains("without a closing")); | |
| 348 | } | |
| 349 | ||
| 350 | #[test] | |
| 351 | fn rules_must_suit_the_target_and_appear_once() { | |
| 352 | let mut tags = spec(vec![Rule::PullRequest(Default::default())]); | |
| 353 | tags.target = Target::Tag; | |
| 354 | assert_eq!( | |
| 355 | validate(&tags, Level::Repository).unwrap_err(), | |
| 356 | "Require a pull request before merging is a rule for branches, and this ruleset targets tags." | |
| 357 | ); | |
| 358 | let twice = spec(vec![Rule::Deletion(NoParameters {}), Rule::Deletion(NoParameters {})]); | |
| 359 | assert!(validate(&twice, Level::Repository).unwrap_err().contains("appears twice")); | |
| 360 | let mut for_each = spec(vec![Rule::Deletion(NoParameters {})]); | |
| 361 | for_each.rules.push(RuleEntry { rule: Rule::Deletion(NoParameters {}), applies_to: AppliesTo::Agents }); | |
| 362 | assert!(validate(&for_each, Level::Repository).is_ok(), "once for everyone, once for agents"); | |
| 363 | } | |
| 364 | ||
| 365 | #[test] | |
| 366 | fn parameters_are_checked() { | |
| 367 | let bad_regex = spec(vec![Rule::CommitMessagePattern(PatternRule { | |
| 368 | name: String::new(), | |
| 369 | operator: PatternOperator::Regex, | |
| 370 | pattern: "(".into(), | |
| 371 | negate: false, | |
| 372 | })]); | |
| 373 | assert!(validate(&bad_regex, Level::Repository).unwrap_err().starts_with("The commit message rule: The regular expression is not valid")); | |
| 374 | let queue = spec(vec![Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 50, ..MergeQueueRule::default() })]); | |
| 375 | assert!(validate(&queue, Level::Repository).is_err()); | |
| 376 | let checks = spec(vec![Rule::RequiredStatusChecks(StatusChecksRule::default())]); | |
| 377 | assert!(validate(&checks, Level::Repository).is_err()); | |
| 378 | let window = spec(vec![Rule::MergeWindow(MergeWindowRule { | |
| 379 | time_zone: "Mars/Olympus".into(), | |
| 380 | windows: vec![WeeklyWindow { days: vec![Weekday::Mon], start: "09:00".into(), end: "17:00".into() }], | |
| 381 | ..MergeWindowRule::default() | |
| 382 | })]); | |
| 383 | assert!(validate(&window, Level::Repository).unwrap_err().contains("time zone")); | |
| 384 | let backwards = spec(vec![Rule::MergeWindow(MergeWindowRule { | |
| 385 | freezes: vec![Period { start: "2026-12-20T00:00:00Z".into(), end: Some("2026-12-19T00:00:00Z".into()), reason: String::new() }], | |
| 386 | ..MergeWindowRule::default() | |
| 387 | })]); | |
| 388 | assert_eq!(validate(&backwards, Level::Repository).unwrap_err(), "A freeze must end after it starts."); | |
| 389 | } | |
| 390 | ||
| 391 | #[test] | |
| 392 | fn bypass_actors_are_checked_and_tidied() { | |
| 393 | let mut with = spec(vec![]); | |
| 394 | with.bypass_actors = vec![ | |
| 395 | BypassActor { kind: ActorKind::Role, value: "Admin".into(), mode: BypassMode::Always }, | |
| 396 | BypassActor { kind: ActorKind::Team, value: "@Acme/Release".into(), mode: BypassMode::PullRequests }, | |
| 397 | BypassActor { kind: ActorKind::G1t, value: "anything".into(), mode: BypassMode::Always }, | |
| 398 | ]; | |
| 399 | let saved = validate(&with, Level::Repository).unwrap(); | |
| 400 | assert_eq!(saved.bypass_actors[0].value, "admin"); | |
| 401 | assert_eq!(saved.bypass_actors[1].value, "acme/release"); | |
| 402 | assert_eq!(saved.bypass_actors[2].value, ""); | |
| 403 | with.bypass_actors = vec![BypassActor { kind: ActorKind::Role, value: "boss".into(), mode: BypassMode::Always }]; | |
| 404 | assert!(validate(&with, Level::Repository).is_err()); | |
| 405 | with.bypass_actors = vec![BypassActor { kind: ActorKind::User, value: " ".into(), mode: BypassMode::Always }]; | |
| 406 | assert!(validate(&with, Level::Repository).is_err()); | |
| 407 | } | |
| 408 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.