Skip to content
408 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//! 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::{
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 merge312 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.