Skip to content
408 linesCodeBlameRaw
1//! 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
17fn 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
38fn 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
50fn 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
61fn 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 &params.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(&params.paths, "path")?;
99 Rule::RequiredStatusChecks(params)
100 }
101 Rule::MergeQueue(params) => {
102 if !(1..=20).contains(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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 &params.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 &params.freezes {
197 period(freeze, "freeze")?;
198 }
199 for exception in &params.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.
213fn 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.
228pub 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)]
308mod tests {
309 use super::*;
310 use g1t_contracts::rules::{
311 BypassActor, BypassMode, Conditions, MergeQueueRule, MergeWindowRule, NoParameters, PatternOperator,
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}