Skip to content

g1t/crates/rules/src/validate.rs

426 lines20,664 bytesCodeBlame

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

This file's history is long; its oldest lines are credited to the oldest commit read.