Skip to content

Commit

Rules engine: rulesets for branches and tags, and the facts they judge

Rulesets replace one set of protection settings for the default branch. A ruleset belongs to a repository or a workspace, targets branches or tags by fnmatch pattern (~DEFAULT_BRANCH, ~ALL), is active, evaluate (a dry run that records what it would have refused) or disabled, lists who may bypass it (roles, teams, people, tokens, and g1t only when listed), and holds rules. Rulesets stack: the most restrictive wins. - crates/contracts rules.rs: the types, in the shape the API shows them (snake_case on the wire, so an export imports anywhere unchanged). - crates/rules: the pure engine. Which rulesets hold where and who may bypass them; judging a push (creations, updates, deletions, force pushes, pull request only, linear history, signatures, message and email and name patterns, restricted paths, extensions, sizes, path length, files changed, read-whole for secret scanning) and a merge (approvals with stale dismissal and last-push approval, code owners, merge methods, required checks with strict mode, per-path checks and integration pinning, deployments, and the agent-first rules: confidence thresholds, cost caps, review for sensitive paths by team, merge windows with freezes and exceptions, agent auto-merge). Each rule can hold for everyone, agents only or people only. Regular expressions run on a linear-time engine with a size limit. - legacy.rs: branch protection as a ruleset that behaves as it did. - Commit statuses record the integration that reported them (actions, deployments, security, g1t), so a required check can insist on one. - work migration 0028: rulesets, rule evaluations, the adoption marker, who pushed a pull request's head last, the status source, and every repository's branch protection made into its "Default branch protection" ruleset (checked against SQLite; a test reads its output).

syntaqxcommitted Parent5f8154bBrowse files
27 files+5159−70/27 viewed
+10−0
10761076 ]
10771077
10781078 [[package]]
1079+name = "g1t-rules"
1080+version = "0.1.0"
1081+dependencies = [
1082+ "g1t-contracts",
1083+ "regex",
1084+ "serde",
1085+ "serde_json",
1086+]
1087+
1088+[[package]]
10791089 name = "g1t-runner"
10801090 version = "0.1.0"
10811091 dependencies = [
+1−0
1212 g1t-blobstore = { path = "crates/blobstore" }
1313 g1t-contracts = { path = "crates/contracts" }
1414 g1t-kit = { path = "crates/kit" }
15+g1t-rules = { path = "crates/rules" }
1516 g1t-scan = { path = "crates/scan" }
1617 g1t-secrets = { path = "crates/secrets" }
1718 serde = { version = "1", features = ["derive"] }
+1−0
2626 pub mod packages;
2727 pub mod projects;
2828 pub mod repos;
29+pub mod rules;
2930 pub mod runners;
3031 pub mod scopes;
3132 pub mod search;
+1411−0
1+//! Rulesets: what may happen to a repository's branches and tags, and what
2+//! a pull request needs before it merges.
3+//!
4+//! A **ruleset** belongs to a repository, or to a workspace and through it
5+//! to every repository it selects. It targets branches or tags by name
6+//! (fnmatch patterns, `~DEFAULT_BRANCH`, `~ALL`), lists the rules that hold
7+//! there, and who may bypass them. Its enforcement is `active` (rules hold),
8+//! `evaluate` (nothing is refused; what would have been is recorded), or
9+//! `disabled`.
10+//!
11+//! Several rulesets can target the same branch. They stack: every rule of
12+//! every active ruleset holds, so the most restrictive wins (the largest
13+//! approval count, every required check, the narrowest merge window).
14+//!
15+//! Each rule can hold for everyone, only for agents' changes, or only for
16+//! people's ([`AppliesTo`]). Agents, g1t's own included, obey rules exactly
17+//! as people do unless a ruleset lists them as a bypass actor: nobody
18+//! bypasses by default.
19+//!
20+//! The rules engine (`crates/rules`) decides; the work service keeps the
21+//! rulesets and every evaluation, and enforces them on merge; the repos
22+//! service enforces them on push and on every change to a branch or tag.
23+//!
24+//! Rulesets travel in the shape the API shows them: `snake_case` fields,
25+//! between services too, so that an exported ruleset imports unchanged on
26+//! the site, through the API and through MCP. Mirrors
27+//! `packages/contracts/src/rules.ts`.
28+
29+use serde::{Deserialize, Serialize};
30+
31+use crate::repos::RepoPath;
32+pub use crate::work::ConfidenceLevel;
33+use crate::{User, Viewer};
34+
35+/// The repository's default branch, whatever it is called at the time.
36+pub const DEFAULT_BRANCH: &str = "~DEFAULT_BRANCH";
37+/// Every branch or tag, or every repository.
38+pub const ALL: &str = "~ALL";
39+/// Rulesets a repository, or a workspace, may have.
40+pub const MAX_RULESETS: usize = 75;
41+/// Rules in one ruleset.
42+pub const MAX_RULES: usize = 50;
43+/// Patterns in one list (branches, paths, extensions, repositories).
44+pub const MAX_PATTERNS: usize = 100;
45+/// The longest pattern, regular expression or name kept.
46+pub const MAX_PATTERN_CHARS: usize = 512;
47+/// Bypass actors in one ruleset.
48+pub const MAX_BYPASS_ACTORS: usize = 50;
49+/// Required approvals a pull request rule may ask for.
50+pub const MAX_APPROVALS: u32 = 10;
51+/// The ruleset made from a repository's branch protection, as it was
52+/// before rulesets: its `source`.
53+pub const BRANCH_PROTECTION: &str = "branch_protection";
54+
55+/// Whether a ruleset's rules hold.
56+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
57+#[serde(rename_all = "snake_case")]
58+pub enum Enforcement {
59+ /// Its rules hold, and what breaks them is refused.
60+ #[default]
61+ Active,
62+ /// A dry run: nothing is refused, and every push or merge it would
63+ /// have refused is recorded, for its insights.
64+ Evaluate,
65+ /// Kept, but not evaluated at all.
66+ Disabled,
67+}
68+
69+impl Enforcement {
70+ pub fn as_str(self) -> &'static str {
71+ match self {
72+ Enforcement::Active => "active",
73+ Enforcement::Evaluate => "evaluate",
74+ Enforcement::Disabled => "disabled",
75+ }
76+ }
77+}
78+
79+/// What a ruleset's name conditions match.
80+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
81+#[serde(rename_all = "snake_case")]
82+pub enum Target {
83+ #[default]
84+ Branch,
85+ Tag,
86+}
87+
88+impl Target {
89+ pub fn as_str(self) -> &'static str {
90+ match self {
91+ Target::Branch => "branch",
92+ Target::Tag => "tag",
93+ }
94+ }
95+
96+ /// The full ref of `name`: `refs/heads/<name>` or `refs/tags/<name>`.
97+ pub fn full_ref(self, name: &str) -> String {
98+ match self {
99+ Target::Branch => format!("refs/heads/{name}"),
100+ Target::Tag => format!("refs/tags/{name}"),
101+ }
102+ }
103+
104+ /// The target and short name of a full ref, if it is a branch or a tag.
105+ pub fn of_ref(git_ref: &str) -> Option<(Target, &str)> {
106+ if let Some(name) = git_ref.strip_prefix("refs/heads/") {
107+ return Some((Target::Branch, name));
108+ }
109+ git_ref.strip_prefix("refs/tags/").map(|name| (Target::Tag, name))
110+ }
111+}
112+
113+/// Whose ruleset it is.
114+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
115+#[serde(rename_all = "snake_case")]
116+pub enum Level {
117+ #[default]
118+ Repository,
119+ Workspace,
120+}
121+
122+impl Level {
123+ pub fn as_str(self) -> &'static str {
124+ match self {
125+ Level::Repository => "repository",
126+ Level::Workspace => "workspace",
127+ }
128+ }
129+}
130+
131+/// Which branches or tags a ruleset holds for, by name. A name matches when
132+/// it matches an `include` pattern and no `exclude` pattern. Patterns are
133+/// fnmatch: `*` matches within one path segment, `**` across them, `?` one
134+/// character, `[abc]` one of a set. `~DEFAULT_BRANCH` is the default
135+/// branch, `~ALL` everything. An empty `include` matches nothing.
136+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
137+#[serde(default)]
138+pub struct RefCondition {
139+ pub include: Vec<String>,
140+ pub exclude: Vec<String>,
141+}
142+
143+/// Which visibility of repository a workspace ruleset selects.
144+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
145+#[serde(rename_all = "snake_case")]
146+pub enum VisibilityCondition {
147+ #[default]
148+ Any,
149+ Public,
150+ Private,
151+}
152+
153+/// Which of a workspace's repositories its ruleset holds in: those whose
154+/// name matches an `include` pattern (fnmatch, or `~ALL`) and no `exclude`
155+/// one, of the `visibility` chosen, and, when `topics` is not empty,
156+/// carrying at least one of them.
157+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158+#[serde(default)]
159+pub struct RepositoryCondition {
160+ pub include: Vec<String>,
161+ pub exclude: Vec<String>,
162+ pub visibility: VisibilityCondition,
163+ pub topics: Vec<String>,
164+}
165+
166+impl Default for RepositoryCondition {
167+ fn default() -> Self {
168+ RepositoryCondition {
169+ include: vec![ALL.to_owned()],
170+ exclude: Vec::new(),
171+ visibility: VisibilityCondition::Any,
172+ topics: Vec::new(),
173+ }
174+ }
175+}
176+
177+/// Where a ruleset holds. `repository` is a workspace ruleset's only.
178+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
179+#[serde(default)]
180+pub struct Conditions {
181+ pub ref_name: RefCondition,
182+ #[serde(skip_serializing_if = "Option::is_none")]
183+ pub repository: Option<RepositoryCondition>,
184+}
185+
186+/// Who a bypass actor is.
187+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
188+#[serde(rename_all = "snake_case")]
189+pub enum ActorKind {
190+ /// Everyone with at least this repository role (`value`: `write`,
191+ /// `maintain` or `admin`), or the workspace's owners (`owner`).
192+ Role,
193+ /// The people of a team (`value`: its slug, or `workspace/slug`), its
194+ /// child teams' people included.
195+ Team,
196+ /// One person, by username.
197+ User,
198+ /// An access token, by its id; `value` `workspace` is any of the
199+ /// workspace's own tokens.
200+ Token,
201+ /// g1t: its agent at work in a sandbox, and the platform acting on its
202+ /// own (the merge queue, security updates). Never a bypass actor
203+ /// unless listed.
204+ G1t,
205+}
206+
207+/// When a bypass actor may bypass.
208+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
209+#[serde(rename_all = "snake_case")]
210+pub enum BypassMode {
211+ /// Always: pushes and merges alike.
212+ #[default]
213+ Always,
214+ /// Only when merging a pull request; their pushes obey the rules.
215+ PullRequests,
216+}
217+
218+impl BypassMode {
219+ pub fn as_str(self) -> &'static str {
220+ match self {
221+ BypassMode::Always => "always",
222+ BypassMode::PullRequests => "pull_requests",
223+ }
224+ }
225+}
226+
227+/// Someone a ruleset does not hold for.
228+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229+pub struct BypassActor {
230+ pub kind: ActorKind,
231+ /// Who, as [`ActorKind`] says. Empty for `g1t`.
232+ #[serde(default)]
233+ pub value: String,
234+ #[serde(default)]
235+ pub mode: BypassMode,
236+}
237+
238+/// Whose changes a rule holds for.
239+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
240+#[serde(rename_all = "snake_case")]
241+pub enum AppliesTo {
242+ #[default]
243+ Everyone,
244+ /// Only agents' changes: a push by an agent, a pull request an agent
245+ /// made (g1t's or another's through a token).
246+ Agents,
247+ /// Only people's changes.
248+ People,
249+}
250+
251+impl AppliesTo {
252+ pub fn as_str(self) -> &'static str {
253+ match self {
254+ AppliesTo::Everyone => "everyone",
255+ AppliesTo::Agents => "agents",
256+ AppliesTo::People => "people",
257+ }
258+ }
259+
260+ /// Whether it holds for a change by an agent (`agent`) or a person.
261+ pub fn covers(self, agent: bool) -> bool {
262+ match self {
263+ AppliesTo::Everyone => true,
264+ AppliesTo::Agents => agent,
265+ AppliesTo::People => !agent,
266+ }
267+ }
268+}
269+
270+/// A rule with no parameters.
271+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
272+pub struct NoParameters {}
273+
274+/// How a pull request is merged.
275+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
276+#[serde(rename_all = "snake_case")]
277+pub enum MergeMethod {
278+ /// The branch lands as it is, its commits included: how g1t merges.
279+ Merge,
280+ Squash,
281+ Rebase,
282+}
283+
284+impl MergeMethod {
285+ pub fn as_str(self) -> &'static str {
286+ match self {
287+ MergeMethod::Merge => "merge",
288+ MergeMethod::Squash => "squash",
289+ MergeMethod::Rebase => "rebase",
290+ }
291+ }
292+}
293+
294+/// `pull_request`: changes reach the branch only by merging a pull request,
295+/// and the pull request needs what this says first.
296+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
297+#[serde(default)]
298+pub struct PullRequestRule {
299+ /// Approving reviews needed. A reviewer who has since asked for
300+ /// changes blocks it; nobody approves their own.
301+ pub required_approvals: u32,
302+ /// Whether an agent's approval (g1t's reviewer) counts towards
303+ /// `required_approvals`. Off means only people's approvals count.
304+ pub count_agent_approvals: bool,
305+ /// Approvals given before the latest push no longer count.
306+ pub dismiss_stale_reviews_on_push: bool,
307+ /// The code owners of every file it changes must approve.
308+ pub require_code_owner_review: bool,
309+ /// Someone other than whoever pushed last must approve after that push.
310+ pub require_last_push_approval: bool,
311+ /// The ways it may be merged. Empty allows every one.
312+ pub allowed_merge_methods: Vec<MergeMethod>,
313+ /// Pull requests need what this rule says, but pushes straight to the
314+ /// branch are still allowed. Off (the default) refuses them. Only the
315+ /// ruleset made from branch protection that did not require pull
316+ /// requests turns it on.
317+ #[serde(skip_serializing_if = "std::ops::Not::not")]
318+ pub allow_direct_pushes: bool,
319+}
320+
321+impl Default for PullRequestRule {
322+ fn default() -> Self {
323+ PullRequestRule {
324+ required_approvals: 0,
325+ count_agent_approvals: true,
326+ dismiss_stale_reviews_on_push: false,
327+ require_code_owner_review: false,
328+ require_last_push_approval: false,
329+ allowed_merge_methods: Vec::new(),
330+ allow_direct_pushes: false,
331+ }
332+ }
333+}
334+
335+/// Where a required check's status must come from.
336+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
337+#[serde(rename_all = "snake_case")]
338+pub enum Integration {
339+ /// Workflow runs (`.g1t/workflows`).
340+ Actions,
341+ /// Deployments: `g1t / deploy`.
342+ Deployments,
343+ /// The security suite: code scanning and dependency review.
344+ Security,
345+ /// g1t itself, such as code owners.
346+ G1t,
347+}
348+
349+impl Integration {
350+ pub fn as_str(self) -> &'static str {
351+ match self {
352+ Integration::Actions => "actions",
353+ Integration::Deployments => "deployments",
354+ Integration::Security => "security",
355+ Integration::G1t => "g1t",
356+ }
357+ }
358+
359+ pub fn parse(text: &str) -> Option<Integration> {
360+ match text {
361+ "actions" => Some(Integration::Actions),
362+ "deployments" => Some(Integration::Deployments),
363+ "security" => Some(Integration::Security),
364+ "g1t" => Some(Integration::G1t),
365+ _ => None,
366+ }
367+ }
368+}
369+
370+/// One check that must pass: a workflow's name (`CI`) or another status's
371+/// context, and, if set, the integration that must have reported it.
372+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
373+pub struct RequiredCheck {
374+ pub context: String,
375+ #[serde(default, skip_serializing_if = "Option::is_none")]
376+ pub integration: Option<Integration>,
377+}
378+
379+/// `required_status_checks`: these checks must pass on a pull request's
380+/// head before it merges.
381+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
382+#[serde(default)]
383+pub struct StatusChecksRule {
384+ pub checks: Vec<RequiredCheck>,
385+ /// The pull request must contain the branch's latest commits, so that
386+ /// what merges is what was checked.
387+ pub strict: bool,
388+ /// Required only when the pull request changes a file matching one of
389+ /// these patterns. Empty: always.
390+ pub paths: Vec<String>,
391+ /// Someone who may merge can merge past checks that have not passed,
392+ /// saying so as they merge.
393+ pub allow_bypass_on_merge: bool,
394+}
395+
396+impl Default for StatusChecksRule {
397+ fn default() -> Self {
398+ StatusChecksRule {
399+ checks: Vec::new(),
400+ strict: false,
401+ paths: Vec::new(),
402+ allow_bypass_on_merge: false,
403+ }
404+ }
405+}
406+
407+/// `merge_queue`: merging joins the queue, which tests each pull request
408+/// together with those ahead of it. The queue lands on the default branch.
409+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
410+#[serde(default)]
411+pub struct MergeQueueRule {
412+ pub merge_method: MergeMethod,
413+ /// Entries tested at once.
414+ pub max_entries_to_build: u32,
415+ /// Entries a batch waits for before it starts, unless the oldest has
416+ /// waited `min_entries_wait_minutes`.
417+ pub min_entries_to_merge: u32,
418+ pub min_entries_wait_minutes: u32,
419+ /// How long a batch's checks may take before it is tested again.
420+ pub check_response_timeout_minutes: u32,
421+}
422+
423+impl Default for MergeQueueRule {
424+ fn default() -> Self {
425+ MergeQueueRule {
426+ merge_method: MergeMethod::Merge,
427+ max_entries_to_build: 4,
428+ min_entries_to_merge: 1,
429+ min_entries_wait_minutes: 0,
430+ check_response_timeout_minutes: 45,
431+ }
432+ }
433+}
434+
435+/// `required_deployments`: a pull request's head must have deployed
436+/// successfully to these environments: `preview` (its preview), or a
437+/// project's slug for a repository with several.
438+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
439+#[serde(default)]
440+pub struct DeploymentsRule {
441+ pub environments: Vec<String>,
442+}
443+
444+/// How a pattern rule compares.
445+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
446+#[serde(rename_all = "snake_case")]
447+pub enum PatternOperator {
448+ #[default]
449+ StartsWith,
450+ EndsWith,
451+ Contains,
452+ /// A regular expression, run by a linear-time engine.
453+ Regex,
454+}
455+
456+/// A rule about text: a commit message, an author's or committer's email
457+/// address, a branch's or tag's name. The text must match the pattern, or
458+/// with `negate`, must not.
459+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
460+#[serde(default)]
461+pub struct PatternRule {
462+ /// What people are told the rule is, such as "Conventional commits".
463+ pub name: String,
464+ pub operator: PatternOperator,
465+ pub pattern: String,
466+ pub negate: bool,
467+}
468+
469+/// `file_path_restriction`: pushes and pull requests may not change files
470+/// matching these patterns (fnmatch, `**` across directories).
471+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
472+#[serde(default)]
473+pub struct FilePathRule {
474+ pub restricted_file_paths: Vec<String>,
475+}
476+
477+/// `file_extension_restriction`: files with these extensions (`.exe`,
478+/// `.zip`) may not be added or changed.
479+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
480+#[serde(default)]
481+pub struct FileExtensionRule {
482+ pub restricted_file_extensions: Vec<String>,
483+}
484+
485+/// `max_file_size`: no file larger than this, in megabytes (1 to 100).
486+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
487+#[serde(default)]
488+pub struct MaxFileSizeRule {
489+ pub max_file_size_mb: u32,
490+}
491+
492+impl Default for MaxFileSizeRule {
493+ fn default() -> Self {
494+ MaxFileSizeRule { max_file_size_mb: 10 }
495+ }
496+}
497+
498+/// `max_file_path_length`: no path longer than this many characters.
499+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
500+#[serde(default)]
501+pub struct MaxFilePathLengthRule {
502+ pub max_file_path_length: u32,
503+}
504+
505+impl Default for MaxFilePathLengthRule {
506+ fn default() -> Self {
507+ MaxFilePathLengthRule { max_file_path_length: 255 }
508+ }
509+}
510+
511+/// `max_files_changed`: a push's commits, each, and a pull request as a
512+/// whole, change at most this many files.
513+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
514+#[serde(default)]
515+pub struct MaxFilesChangedRule {
516+ pub max_files: u32,
517+}
518+
519+impl Default for MaxFilesChangedRule {
520+ fn default() -> Self {
521+ MaxFilesChangedRule { max_files: 100 }
522+ }
523+}
524+
525+/// `confidence_threshold`: an agent's change g1t rates below `minimum`
526+/// needs approvals from people before it merges.
527+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
528+#[serde(default)]
529+pub struct ConfidenceRule {
530+ pub minimum: ConfidenceLevel,
531+ pub required_approvals: u32,
532+}
533+
534+impl Default for ConfidenceRule {
535+ fn default() -> Self {
536+ ConfidenceRule { minimum: ConfidenceLevel::Medium, required_approvals: 1 }
537+ }
538+}
539+
540+/// `cost_cap`: once agents have spent more than this on a pull request, in
541+/// US dollars, it neither merges nor is sent back to its agent until a
542+/// person approves it after that.
543+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
544+#[serde(default)]
545+pub struct CostCapRule {
546+ pub max_usd: f64,
547+}
548+
549+impl Default for CostCapRule {
550+ fn default() -> Self {
551+ CostCapRule { max_usd: 10.0 }
552+ }
553+}
554+
555+/// `path_review`: a pull request that changes a file matching `paths`
556+/// needs `required_approvals` from people, from `team` when one is named
557+/// (its slug, or `workspace/slug`).
558+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
559+#[serde(default)]
560+pub struct PathReviewRule {
561+ pub paths: Vec<String>,
562+ pub required_approvals: u32,
563+ #[serde(skip_serializing_if = "Option::is_none")]
564+ pub team: Option<String>,
565+}
566+
567+impl Default for PathReviewRule {
568+ fn default() -> Self {
569+ PathReviewRule { paths: Vec::new(), required_approvals: 1, team: None }
570+ }
571+}
572+
573+/// A day of the week.
574+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
575+#[serde(rename_all = "snake_case")]
576+pub enum Weekday {
577+ Mon,
578+ Tue,
579+ Wed,
580+ Thu,
581+ Fri,
582+ Sat,
583+ Sun,
584+}
585+
586+impl Weekday {
587+ pub const ALL: [Weekday; 7] = [
588+ Weekday::Mon,
589+ Weekday::Tue,
590+ Weekday::Wed,
591+ Weekday::Thu,
592+ Weekday::Fri,
593+ Weekday::Sat,
594+ Weekday::Sun,
595+ ];
596+
597+ pub fn as_str(self) -> &'static str {
598+ match self {
599+ Weekday::Mon => "mon",
600+ Weekday::Tue => "tue",
601+ Weekday::Wed => "wed",
602+ Weekday::Thu => "thu",
603+ Weekday::Fri => "fri",
604+ Weekday::Sat => "sat",
605+ Weekday::Sun => "sun",
606+ }
607+ }
608+}
609+
610+/// Hours on some days of the week when merging is allowed, `HH:MM` to
611+/// `HH:MM` in the rule's time zone. An `end` before `start` runs past
612+/// midnight.
613+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
614+pub struct WeeklyWindow {
615+ pub days: Vec<Weekday>,
616+ pub start: String,
617+ pub end: String,
618+}
619+
620+/// A stretch of time, RFC 3339 UTC. With no `end`, it lasts until removed:
621+/// an incident freeze.
622+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
623+pub struct Period {
624+ pub start: String,
625+ #[serde(default)]
626+ pub end: Option<String>,
627+ #[serde(default)]
628+ pub reason: String,
629+}
630+
631+/// `merge_window`: when pull requests may merge into the branch. Outside
632+/// every `windows` entry (when there are any), or during a `freezes` one,
633+/// merging waits, unless an `exceptions` entry covers the moment.
634+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
635+#[serde(default)]
636+pub struct MergeWindowRule {
637+ /// A fixed offset from UTC, `+02:00` or `-05:00`; `UTC` or empty is UTC.
638+ /// Daylight saving time is not applied.
639+ pub time_zone: String,
640+ pub windows: Vec<WeeklyWindow>,
641+ pub freezes: Vec<Period>,
642+ pub exceptions: Vec<Period>,
643+}
644+
645+impl Default for MergeWindowRule {
646+ fn default() -> Self {
647+ MergeWindowRule {
648+ time_zone: "UTC".to_owned(),
649+ windows: Vec::new(),
650+ freezes: Vec::new(),
651+ exceptions: Vec::new(),
652+ }
653+ }
654+}
655+
656+/// `agent_auto_merge`: whether g1t lands an agent's ready pull request into
657+/// the branch without a person pressing merge, and how sure of it g1t must
658+/// be. The repository's auto-merge setting must be on as well.
659+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
660+#[serde(default)]
661+pub struct AgentAutoMergeRule {
662+ pub allowed: bool,
663+ #[serde(skip_serializing_if = "Option::is_none")]
664+ pub minimum_confidence: Option<ConfidenceLevel>,
665+}
666+
667+impl Default for AgentAutoMergeRule {
668+ fn default() -> Self {
669+ AgentAutoMergeRule { allowed: true, minimum_confidence: None }
670+ }
671+}
672+
673+/// One rule and its parameters, tagged by `type`.
674+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
675+#[serde(tag = "type", content = "parameters", rename_all = "snake_case")]
676+pub enum Rule {
677+ /// Only bypass actors may create a matching branch or tag.
678+ Creation(NoParameters),
679+ /// Only bypass actors may push to (move) a matching branch or tag.
680+ Update(NoParameters),
681+ /// Only bypass actors may delete a matching branch or tag.
682+ Deletion(NoParameters),
683+ /// Nobody force pushes: a push must only add to its history.
684+ NonFastForward(NoParameters),
685+ /// No merge commits: history stays a straight line.
686+ RequiredLinearHistory(NoParameters),
687+ /// Every commit carries a signature g1t verifies.
688+ RequiredSignatures(NoParameters),
689+ PullRequest(PullRequestRule),
690+ RequiredStatusChecks(StatusChecksRule),
691+ MergeQueue(MergeQueueRule),
692+ RequiredDeployments(DeploymentsRule),
693+ CommitMessagePattern(PatternRule),
694+ CommitAuthorEmailPattern(PatternRule),
695+ CommitterEmailPattern(PatternRule),
696+ BranchNamePattern(PatternRule),
697+ TagNamePattern(PatternRule),
698+ FilePathRestriction(FilePathRule),
699+ FileExtensionRestriction(FileExtensionRule),
700+ MaxFileSize(MaxFileSizeRule),
701+ MaxFilePathLength(MaxFilePathLengthRule),
702+ MaxFilesChanged(MaxFilesChangedRule),
703+ /// Pushes that add a secret are refused, whatever the repository's own
704+ /// push protection setting says.
705+ SecretScanning(NoParameters),
706+ ConfidenceThreshold(ConfidenceRule),
707+ CostCap(CostCapRule),
708+ PathReview(PathReviewRule),
709+ MergeWindow(MergeWindowRule),
710+ AgentAutoMerge(AgentAutoMergeRule),
711+}
712+
713+impl Rule {
714+ /// Its `type`, as the API names it.
715+ pub fn kind(&self) -> &'static str {
716+ match self {
717+ Rule::Creation(_) => "creation",
718+ Rule::Update(_) => "update",
719+ Rule::Deletion(_) => "deletion",
720+ Rule::NonFastForward(_) => "non_fast_forward",
721+ Rule::RequiredLinearHistory(_) => "required_linear_history",
722+ Rule::RequiredSignatures(_) => "required_signatures",
723+ Rule::PullRequest(_) => "pull_request",
724+ Rule::RequiredStatusChecks(_) => "required_status_checks",
725+ Rule::MergeQueue(_) => "merge_queue",
726+ Rule::RequiredDeployments(_) => "required_deployments",
727+ Rule::CommitMessagePattern(_) => "commit_message_pattern",
728+ Rule::CommitAuthorEmailPattern(_) => "commit_author_email_pattern",
729+ Rule::CommitterEmailPattern(_) => "committer_email_pattern",
730+ Rule::BranchNamePattern(_) => "branch_name_pattern",
731+ Rule::TagNamePattern(_) => "tag_name_pattern",
732+ Rule::FilePathRestriction(_) => "file_path_restriction",
733+ Rule::FileExtensionRestriction(_) => "file_extension_restriction",
734+ Rule::MaxFileSize(_) => "max_file_size",
735+ Rule::MaxFilePathLength(_) => "max_file_path_length",
736+ Rule::MaxFilesChanged(_) => "max_files_changed",
737+ Rule::SecretScanning(_) => "secret_scanning",
738+ Rule::ConfidenceThreshold(_) => "confidence_threshold",
739+ Rule::CostCap(_) => "cost_cap",
740+ Rule::PathReview(_) => "path_review",
741+ Rule::MergeWindow(_) => "merge_window",
742+ Rule::AgentAutoMerge(_) => "agent_auto_merge",
743+ }
744+ }
745+
746+ /// How people are shown it.
747+ pub fn label(&self) -> &'static str {
748+ match self {
749+ Rule::Creation(_) => "Restrict creations",
750+ Rule::Update(_) => "Restrict updates",
751+ Rule::Deletion(_) => "Restrict deletions",
752+ Rule::NonFastForward(_) => "Block force pushes",
753+ Rule::RequiredLinearHistory(_) => "Require linear history",
754+ Rule::RequiredSignatures(_) => "Require signed commits",
755+ Rule::PullRequest(_) => "Require a pull request before merging",
756+ Rule::RequiredStatusChecks(_) => "Require status checks to pass",
757+ Rule::MergeQueue(_) => "Require the merge queue",
758+ Rule::RequiredDeployments(_) => "Require deployments to succeed",
759+ Rule::CommitMessagePattern(_) => "Commit message pattern",
760+ Rule::CommitAuthorEmailPattern(_) => "Commit author email pattern",
761+ Rule::CommitterEmailPattern(_) => "Committer email pattern",
762+ Rule::BranchNamePattern(_) => "Branch name pattern",
763+ Rule::TagNamePattern(_) => "Tag name pattern",
764+ Rule::FilePathRestriction(_) => "Restrict file paths",
765+ Rule::FileExtensionRestriction(_) => "Restrict file extensions",
766+ Rule::MaxFileSize(_) => "Restrict file size",
767+ Rule::MaxFilePathLength(_) => "Restrict file path length",
768+ Rule::MaxFilesChanged(_) => "Restrict files changed",
769+ Rule::SecretScanning(_) => "Block pushes that add secrets",
770+ Rule::ConfidenceThreshold(_) => "Confidence threshold",
771+ Rule::CostCap(_) => "Cost cap",
772+ Rule::PathReview(_) => "Review for sensitive paths",
773+ Rule::MergeWindow(_) => "Merge window",
774+ Rule::AgentAutoMerge(_) => "Agent auto-merge",
775+ }
776+ }
777+
778+ /// Whether the rule is about pushes: what a push may do or bring.
779+ /// Pull request rules hold on merge.
780+ pub fn on_push(&self) -> bool {
781+ matches!(
782+ self,
783+ Rule::Creation(_)
784+ | Rule::Update(_)
785+ | Rule::Deletion(_)
786+ | Rule::NonFastForward(_)
787+ | Rule::RequiredLinearHistory(_)
788+ | Rule::RequiredSignatures(_)
789+ | Rule::PullRequest(_)
790+ | Rule::MergeQueue(_)
791+ | Rule::CommitMessagePattern(_)
792+ | Rule::CommitAuthorEmailPattern(_)
793+ | Rule::CommitterEmailPattern(_)
794+ | Rule::BranchNamePattern(_)
795+ | Rule::TagNamePattern(_)
796+ | Rule::FilePathRestriction(_)
797+ | Rule::FileExtensionRestriction(_)
798+ | Rule::MaxFileSize(_)
799+ | Rule::MaxFilePathLength(_)
800+ | Rule::MaxFilesChanged(_)
801+ | Rule::SecretScanning(_)
802+ )
803+ }
804+
805+ /// Whether it says anything only tags can break (or only branches).
806+ pub fn for_branches_only(&self) -> bool {
807+ matches!(
808+ self,
809+ Rule::PullRequest(_)
810+ | Rule::RequiredStatusChecks(_)
811+ | Rule::MergeQueue(_)
812+ | Rule::RequiredDeployments(_)
813+ | Rule::BranchNamePattern(_)
814+ | Rule::ConfidenceThreshold(_)
815+ | Rule::CostCap(_)
816+ | Rule::PathReview(_)
817+ | Rule::MergeWindow(_)
818+ | Rule::AgentAutoMerge(_)
819+ )
820+ }
821+
822+ pub fn for_tags_only(&self) -> bool {
823+ matches!(self, Rule::TagNamePattern(_))
824+ }
825+}
826+
827+/// One rule of a ruleset, and whose changes it holds for. `parameters`
828+/// may be left out, or left partly out: what is missing takes its default.
829+#[derive(Clone, Debug, PartialEq, Serialize)]
830+pub struct RuleEntry {
831+ #[serde(flatten)]
832+ pub rule: Rule,
833+ pub applies_to: AppliesTo,
834+}
835+
836+impl<'de> Deserialize<'de> for RuleEntry {
837+ fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
838+ #[derive(Deserialize)]
839+ struct Written {
840+ #[serde(rename = "type")]
841+ kind: String,
842+ #[serde(default)]
843+ parameters: serde_json::Value,
844+ #[serde(default)]
845+ applies_to: AppliesTo,
846+ }
847+ let written = Written::deserialize(deserializer)?;
848+ let parameters = match written.parameters {
849+ serde_json::Value::Null => serde_json::json!({}),
850+ other => other,
851+ };
852+ let rule = serde_json::from_value(serde_json::json!({ "type": written.kind, "parameters": parameters }))
853+ .map_err(serde::de::Error::custom)?;
854+ Ok(RuleEntry { rule, applies_to: written.applies_to })
855+ }
856+}
857+
858+impl RuleEntry {
859+ pub fn everyone(rule: Rule) -> RuleEntry {
860+ RuleEntry { rule, applies_to: AppliesTo::Everyone }
861+ }
862+}
863+
864+/// What a ruleset says, as it is created, changed, exported and imported.
865+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
866+#[serde(default)]
867+pub struct RulesetSpec {
868+ pub name: String,
869+ pub enforcement: Enforcement,
870+ pub target: Target,
871+ pub conditions: Conditions,
872+ pub bypass_actors: Vec<BypassActor>,
873+ pub rules: Vec<RuleEntry>,
874+}
875+
876+/// A ruleset, as it is kept.
877+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
878+pub struct Ruleset {
879+ pub id: String,
880+ pub level: Level,
881+ /// The workspace it belongs to, or its repository's.
882+ pub workspace: String,
883+ /// A repository ruleset's repository: its id and `owner/name`.
884+ #[serde(default, skip_serializing_if = "Option::is_none")]
885+ pub repo_id: Option<String>,
886+ #[serde(default, skip_serializing_if = "Option::is_none")]
887+ pub repository: Option<String>,
888+ #[serde(flatten)]
889+ pub spec: RulesetSpec,
890+ /// `branch_protection` for the ruleset made from a repository's branch
891+ /// protection settings when rulesets arrived.
892+ #[serde(default, skip_serializing_if = "Option::is_none")]
893+ pub source: Option<String>,
894+ pub created_by: String,
895+ /// RFC 3339.
896+ pub created_at: String,
897+ pub updated_by: String,
898+ pub updated_at: String,
899+}
900+
901+/// Whose rulesets: a repository's (`repo`) or a workspace's (`workspace`).
902+/// Exactly one is set.
903+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
904+#[serde(default)]
905+pub struct Owner {
906+ #[serde(skip_serializing_if = "Option::is_none")]
907+ pub repo: Option<RepoPath>,
908+ #[serde(skip_serializing_if = "Option::is_none")]
909+ pub workspace: Option<String>,
910+}
911+
912+impl Owner {
913+ pub fn repo(path: RepoPath) -> Owner {
914+ Owner { repo: Some(path), workspace: None }
915+ }
916+
917+ pub fn workspace(slug: &str) -> Owner {
918+ Owner { repo: None, workspace: Some(slug.to_lowercase()) }
919+ }
920+}
921+
922+/// `list_rulesets`: a repository's or a workspace's rulesets. With
923+/// `include_parents`, a repository's list also has its workspace's
924+/// rulesets that hold in it. Anyone who may see the repository (members,
925+/// for a workspace). Returns `Outcome<Vec<Ruleset>>`.
926+#[derive(Clone, Debug, Serialize, Deserialize)]
927+pub struct ListRulesetsArgs {
928+ pub viewer: Viewer,
929+ #[serde(flatten)]
930+ pub owner: Owner,
931+ #[serde(default)]
932+ pub include_parents: bool,
933+}
934+
935+/// `get_ruleset`. Returns `Outcome<Ruleset>`.
936+#[derive(Clone, Debug, Serialize, Deserialize)]
937+pub struct GetRulesetArgs {
938+ pub viewer: Viewer,
939+ #[serde(flatten)]
940+ pub owner: Owner,
941+ pub id: String,
942+}
943+
944+/// `save_ruleset`: creates one (no `id`) or replaces one. The Maintain
945+/// role on a repository (`ManageProtection`); a workspace's owners for its
946+/// own. Returns `Outcome<Ruleset>`.
947+#[derive(Clone, Debug, Serialize, Deserialize)]
948+pub struct SaveRulesetArgs {
949+ pub actor: User,
950+ #[serde(flatten)]
951+ pub owner: Owner,
952+ #[serde(default)]
953+ pub id: Option<String>,
954+ pub ruleset: RulesetSpec,
955+}
956+
957+/// `delete_ruleset`. Returns `Outcome<bool>`.
958+#[derive(Clone, Debug, Serialize, Deserialize)]
959+pub struct DeleteRulesetArgs {
960+ pub actor: User,
961+ #[serde(flatten)]
962+ pub owner: Owner,
963+ pub id: String,
964+}
965+
966+/// `effective_rules`: every rule that holds for a branch (or a tag, with
967+/// `target` `tag`) of a repository, with the ruleset each comes from.
968+/// Returns `Outcome<EffectiveRules>`.
969+#[derive(Clone, Debug, Serialize, Deserialize)]
970+pub struct EffectiveRulesArgs {
971+ pub viewer: Viewer,
972+ pub repo: RepoPath,
973+ pub name: String,
974+ #[serde(default)]
975+ pub target: Target,
976+}
977+
978+/// A rule that holds for a branch, and where it comes from.
979+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
980+pub struct EffectiveRule {
981+ #[serde(flatten)]
982+ pub entry: RuleEntry,
983+ pub ruleset_id: String,
984+ pub ruleset_name: String,
985+ pub level: Level,
986+ pub enforcement: Enforcement,
987+}
988+
989+/// What holds for one branch or tag.
990+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
991+pub struct EffectiveRules {
992+ pub name: String,
993+ pub target: Target,
994+ /// Whether it is the repository's default branch.
995+ pub default_branch: bool,
996+ /// Active rules first, then those being evaluated.
997+ pub rules: Vec<EffectiveRule>,
998+ /// The rulesets that hold, by id: their names and who may bypass them.
999+ pub rulesets: Vec<RulesetSummary>,
1000+}
1001+
1002+/// A ruleset in brief.
1003+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1004+pub struct RulesetSummary {
1005+ pub id: String,
1006+ pub name: String,
1007+ pub level: Level,
1008+ pub enforcement: Enforcement,
1009+ pub bypass_actors: Vec<BypassActor>,
1010+}
1011+
1012+/// What a change was: a push, a merge, or a change made through g1t.
1013+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1014+#[serde(rename_all = "snake_case")]
1015+pub enum Action {
1016+ Push,
1017+ Merge,
1018+ CreateRef,
1019+ DeleteRef,
1020+ RenameRef,
1021+ /// A commit made through g1t, such as a web edit.
1022+ Commit,
1023+}
1024+
1025+impl Action {
1026+ pub fn as_str(self) -> &'static str {
1027+ match self {
1028+ Action::Push => "push",
1029+ Action::Merge => "merge",
1030+ Action::CreateRef => "create_ref",
1031+ Action::DeleteRef => "delete_ref",
1032+ Action::RenameRef => "rename_ref",
1033+ Action::Commit => "commit",
1034+ }
1035+ }
1036+}
1037+
1038+/// How an evaluation came out.
1039+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1040+#[serde(rename_all = "snake_case")]
1041+pub enum Verdict {
1042+ /// Every rule was met.
1043+ Pass,
1044+ /// A rule was broken and the change refused (an active ruleset), or
1045+ /// would have been (`evaluate`).
1046+ Fail,
1047+ /// A rule was broken by a bypass actor, who was let through.
1048+ Bypass,
1049+}
1050+
1051+impl Verdict {
1052+ pub fn as_str(self) -> &'static str {
1053+ match self {
1054+ Verdict::Pass => "pass",
1055+ Verdict::Fail => "fail",
1056+ Verdict::Bypass => "bypass",
1057+ }
1058+ }
1059+}
1060+
1061+/// One rule that a change breaks, and how to meet it.
1062+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1063+pub struct Violation {
1064+ /// The rule's `type`.
1065+ pub rule: String,
1066+ pub ruleset_id: String,
1067+ pub ruleset_name: String,
1068+ pub enforcement: Enforcement,
1069+ /// What is wrong, in a sentence.
1070+ pub message: String,
1071+ /// How to satisfy it, in a sentence. May be empty.
1072+ #[serde(default)]
1073+ pub remedy: String,
1074+}
1075+
1076+/// One ruleset's evaluation of one change, as it is recorded.
1077+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1078+pub struct NewEvaluation {
1079+ pub repo_id: String,
1080+ pub workspace: String,
1081+ pub ruleset_id: String,
1082+ pub ruleset_name: String,
1083+ pub enforcement: Enforcement,
1084+ pub action: Action,
1085+ /// The full ref: `refs/heads/main`.
1086+ pub git_ref: String,
1087+ pub actor: String,
1088+ /// `person`, `agent` or `g1t`.
1089+ pub actor_kind: String,
1090+ pub verdict: Verdict,
1091+ #[serde(default)]
1092+ pub violations: Vec<Violation>,
1093+ /// The pull request merged, for a merge.
1094+ #[serde(default)]
1095+ pub number: Option<u32>,
1096+ /// The commit it would have moved the ref to.
1097+ #[serde(default)]
1098+ pub sha: Option<String>,
1099+}
1100+
1101+/// A recorded evaluation.
1102+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1103+pub struct Evaluation {
1104+ pub id: String,
1105+ #[serde(flatten)]
1106+ pub evaluation: NewEvaluation,
1107+ /// `owner/name`, as it was.
1108+ #[serde(default)]
1109+ pub repository: String,
1110+ /// RFC 3339.
1111+ pub created_at: String,
1112+}
1113+
1114+/// `record_evaluations`: services only. Returns how many were kept.
1115+#[derive(Clone, Debug, Serialize, Deserialize)]
1116+pub struct RecordEvaluationsArgs {
1117+ pub evaluations: Vec<NewEvaluation>,
1118+}
1119+
1120+/// `rule_evaluations`: the latest evaluations of a repository's or a
1121+/// workspace's rulesets, newest first, filtered. Returns
1122+/// `Outcome<EvaluationPage>`.
1123+#[derive(Clone, Debug, Serialize, Deserialize)]
1124+pub struct EvaluationsArgs {
1125+ pub viewer: Viewer,
1126+ #[serde(flatten)]
1127+ pub owner: Owner,
1128+ #[serde(default)]
1129+ pub ruleset_id: Option<String>,
1130+ #[serde(default)]
1131+ pub verdict: Option<Verdict>,
1132+ /// Only those that broke a rule (failed, would have failed, bypassed).
1133+ #[serde(default)]
1134+ pub problems_only: bool,
1135+ /// An evaluation's id: only older ones.
1136+ #[serde(default)]
1137+ pub before: Option<String>,
1138+ #[serde(default)]
1139+ pub limit: Option<u32>,
1140+}
1141+
1142+/// A page of evaluations, and how they came out over the last 30 days.
1143+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1144+pub struct EvaluationPage {
1145+ pub evaluations: Vec<Evaluation>,
1146+ /// The `before` for the next page, when there is one.
1147+ #[serde(default)]
1148+ pub next: Option<String>,
1149+ pub insights: Insights,
1150+}
1151+
1152+/// How a ruleset's evaluations came out.
1153+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1154+pub struct Insights {
1155+ pub days: u32,
1156+ pub total: u32,
1157+ pub passed: u32,
1158+ /// Refused by an active ruleset.
1159+ pub blocked: u32,
1160+ /// Would have been refused by a ruleset in `evaluate`.
1161+ pub would_block: u32,
1162+ pub bypassed: u32,
1163+ /// Per ruleset, most problems first.
1164+ pub by_ruleset: Vec<RulesetInsight>,
1165+ /// Per rule type, most problems first.
1166+ pub by_rule: Vec<RuleInsight>,
1167+}
1168+
1169+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1170+pub struct RulesetInsight {
1171+ pub ruleset_id: String,
1172+ pub ruleset_name: String,
1173+ pub enforcement: Enforcement,
1174+ pub total: u32,
1175+ pub blocked: u32,
1176+ pub would_block: u32,
1177+ pub bypassed: u32,
1178+}
1179+
1180+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1181+pub struct RuleInsight {
1182+ pub rule: String,
1183+ pub count: u32,
1184+}
1185+
1186+/// A ruleset that holds for refs a service is about to change, with
1187+/// whether the actor may bypass it and how. What `ref_rules` returns.
1188+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1189+pub struct Applicable {
1190+ pub id: String,
1191+ pub name: String,
1192+ pub level: Level,
1193+ pub enforcement: Enforcement,
1194+ pub target: Target,
1195+ pub conditions: RefCondition,
1196+ pub rules: Vec<RuleEntry>,
1197+ /// How the actor may bypass it, if they may.
1198+ #[serde(default)]
1199+ pub bypass: Option<BypassMode>,
1200+}
1201+
1202+/// `ref_rules`: services only. The rulesets of a repository (its own and
1203+/// its workspace's) that are not disabled and hold for any of `refs` (full
1204+/// refs), with whether `actor` may bypass each. Returns
1205+/// `Outcome<RefRules>`.
1206+#[derive(Clone, Debug, Serialize, Deserialize)]
1207+pub struct RefRulesArgs {
1208+ pub repo_id: String,
1209+ pub actor: Option<User>,
1210+ pub refs: Vec<String>,
1211+}
1212+
1213+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1214+pub struct RefRules {
1215+ pub default_branch: String,
1216+ pub workspace: String,
1217+ pub rulesets: Vec<Applicable>,
1218+}
1219+
1220+/// What g1t made of a commit's signature.
1221+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1222+#[serde(tag = "state", rename_all = "snake_case")]
1223+pub enum Signature {
1224+ #[default]
1225+ Unsigned,
1226+ /// Valid, made with a key the account owning the committer's verified
1227+ /// address registered: that account's username.
1228+ Verified { signer: String },
1229+ /// Signed, but not verified: why.
1230+ Unverified { reason: String },
1231+}
1232+
1233+/// One file a commit adds, changes or deletes.
1234+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1235+pub struct FileChange {
1236+ pub path: String,
1237+ /// Its size in bytes, when its content is new and was read.
1238+ #[serde(default)]
1239+ pub size: Option<u64>,
1240+ #[serde(default)]
1241+ pub deleted: bool,
1242+}
1243+
1244+/// What rules about commits look at, for one commit.
1245+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1246+pub struct CommitFacts {
1247+ pub sha: String,
1248+ /// At most 4 KiB of it.
1249+ pub message: String,
1250+ #[serde(default)]
1251+ pub author_email: Option<String>,
1252+ #[serde(default)]
1253+ pub committer_email: Option<String>,
1254+ pub parents: u32,
1255+ #[serde(default)]
1256+ pub signature: Signature,
1257+ #[serde(default)]
1258+ pub files: Vec<FileChange>,
1259+ /// Whether `files` is every file it changes.
1260+ #[serde(default)]
1261+ pub files_complete: bool,
1262+}
1263+
1264+/// `inspect_commits`: services only. The commits a branch of `source_id`
1265+/// adds on top of `base_branch` of `target_id`, read as rules look at
1266+/// them, at most `limit`. Returns `Outcome<InspectedCommits>`.
1267+#[derive(Clone, Debug, Serialize, Deserialize)]
1268+pub struct InspectCommitsArgs {
1269+ pub source_id: String,
1270+ pub head: String,
1271+ pub target_id: String,
1272+ pub base_branch: String,
1273+ #[serde(default)]
1274+ pub limit: Option<u32>,
1275+}
1276+
1277+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1278+pub struct InspectedCommits {
1279+ pub commits: Vec<CommitFacts>,
1280+ /// Whether `commits` holds every commit the branch adds, each read in
1281+ /// full. A change too large to read is not.
1282+ pub complete: bool,
1283+}
1284+
1285+/// What kind of actor a change is by, for rules that hold only for agents'
1286+/// or people's changes and for the evaluation log.
1287+pub fn actor_kind(actor: &User) -> &'static str {
1288+ use crate::PrincipalKind;
1289+ match actor.kind {
1290+ PrincipalKind::System => "g1t",
1291+ PrincipalKind::Agent => "agent",
1292+ _ if actor.acting.is_some() => "agent",
1293+ PrincipalKind::Workspace => "token",
1294+ PrincipalKind::User => "person",
1295+ }
1296+}
1297+
1298+/// Whether the actor is an agent: g1t's, or another acting through an
1299+/// agent token. g1t acting on its own counts as an agent.
1300+pub fn is_agent(actor: &User) -> bool {
1301+ matches!(actor_kind(actor), "agent" | "g1t")
1302+}
1303+
1304+#[cfg(test)]
1305+mod tests {
1306+ use super::*;
1307+ use serde_json::json;
1308+
1309+ #[test]
1310+ fn a_rule_is_its_type_and_parameters() {
1311+ let entry = RuleEntry {
1312+ rule: Rule::PullRequest(PullRequestRule { required_approvals: 2, ..PullRequestRule::default() }),
1313+ applies_to: AppliesTo::Agents,
1314+ };
1315+ let value = serde_json::to_value(&entry).unwrap();
1316+ assert_eq!(value["type"], "pull_request");
1317+ assert_eq!(value["parameters"]["required_approvals"], 2);
1318+ assert_eq!(value["applies_to"], "agents");
1319+ let back: RuleEntry = serde_json::from_value(value).unwrap();
1320+ assert_eq!(back, entry);
1321+ }
1322+
1323+ #[test]
1324+ fn parameters_left_out_take_their_defaults() {
1325+ let entry: RuleEntry = serde_json::from_value(json!({ "type": "deletion" })).unwrap();
1326+ assert_eq!(entry.rule, Rule::Deletion(NoParameters {}));
1327+ assert_eq!(entry.applies_to, AppliesTo::Everyone);
1328+ let entry: RuleEntry = serde_json::from_value(json!({ "type": "deletion", "parameters": {} })).unwrap();
1329+ assert_eq!(entry.rule.kind(), "deletion");
1330+ let entry: RuleEntry =
1331+ serde_json::from_value(json!({ "type": "merge_queue", "parameters": { "max_entries_to_build": 8 } })).unwrap();
1332+ let Rule::MergeQueue(queue) = entry.rule else { panic!() };
1333+ assert_eq!((queue.max_entries_to_build, queue.check_response_timeout_minutes), (8, 45));
1334+ }
1335+
1336+ #[test]
1337+ fn every_rule_type_reads_back_as_it_is_named() {
1338+ let rules = [
1339+ Rule::Creation(NoParameters {}),
1340+ Rule::Update(NoParameters {}),
1341+ Rule::Deletion(NoParameters {}),
1342+ Rule::NonFastForward(NoParameters {}),
1343+ Rule::RequiredLinearHistory(NoParameters {}),
1344+ Rule::RequiredSignatures(NoParameters {}),
1345+ Rule::PullRequest(PullRequestRule::default()),
1346+ Rule::RequiredStatusChecks(StatusChecksRule::default()),
1347+ Rule::MergeQueue(MergeQueueRule::default()),
1348+ Rule::RequiredDeployments(DeploymentsRule::default()),
1349+ Rule::CommitMessagePattern(PatternRule::default()),
1350+ Rule::CommitAuthorEmailPattern(PatternRule::default()),
1351+ Rule::CommitterEmailPattern(PatternRule::default()),
1352+ Rule::BranchNamePattern(PatternRule::default()),
1353+ Rule::TagNamePattern(PatternRule::default()),
1354+ Rule::FilePathRestriction(FilePathRule::default()),
1355+ Rule::FileExtensionRestriction(FileExtensionRule::default()),
1356+ Rule::MaxFileSize(MaxFileSizeRule::default()),
1357+ Rule::MaxFilePathLength(MaxFilePathLengthRule::default()),
1358+ Rule::MaxFilesChanged(MaxFilesChangedRule::default()),
1359+ Rule::SecretScanning(NoParameters {}),
1360+ Rule::ConfidenceThreshold(ConfidenceRule::default()),
1361+ Rule::CostCap(CostCapRule::default()),
1362+ Rule::PathReview(PathReviewRule::default()),
1363+ Rule::MergeWindow(MergeWindowRule::default()),
1364+ Rule::AgentAutoMerge(AgentAutoMergeRule::default()),
1365+ ];
1366+ for rule in rules {
1367+ let value = serde_json::to_value(RuleEntry::everyone(rule.clone())).unwrap();
1368+ assert_eq!(value["type"], rule.kind());
1369+ let back: RuleEntry = serde_json::from_value(value).unwrap();
1370+ assert_eq!(back.rule, rule);
1371+ assert!(!rule.label().is_empty());
1372+ }
1373+ }
1374+
1375+ #[test]
1376+ fn a_ruleset_reads_as_the_api_shows_it() {
1377+ let ruleset: RulesetSpec = serde_json::from_value(json!({
1378+ "name": "Protect main",
1379+ "enforcement": "evaluate",
1380+ "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH", "release/**"], "exclude": [] } },
1381+ "bypass_actors": [{ "kind": "role", "value": "admin", "mode": "pull_requests" }, { "kind": "g1t" }],
1382+ "rules": [{ "type": "non_fast_forward" }, { "type": "required_status_checks", "parameters": { "checks": [{ "context": "CI", "integration": "actions" }], "strict": true } }]
1383+ }))
1384+ .unwrap();
1385+ assert_eq!(ruleset.enforcement, Enforcement::Evaluate);
1386+ assert_eq!(ruleset.target, Target::Branch);
1387+ assert_eq!(ruleset.bypass_actors[1], BypassActor { kind: ActorKind::G1t, value: String::new(), mode: BypassMode::Always });
1388+ let Rule::RequiredStatusChecks(checks) = &ruleset.rules[1].rule else { panic!() };
1389+ assert_eq!(checks.checks[0].integration, Some(Integration::Actions));
1390+ assert!(checks.strict);
1391+ }
1392+
1393+ #[test]
1394+ fn refs_split_into_their_target_and_name() {
1395+ assert_eq!(Target::of_ref("refs/heads/release/1.x"), Some((Target::Branch, "release/1.x")));
1396+ assert_eq!(Target::of_ref("refs/tags/v1"), Some((Target::Tag, "v1")));
1397+ assert_eq!(Target::of_ref("refs/notes/x"), None);
1398+ assert_eq!(Target::Tag.full_ref("v2"), "refs/tags/v2");
1399+ }
1400+
1401+ #[test]
1402+ fn whose_change_it_is() {
1403+ assert!(AppliesTo::Everyone.covers(true) && AppliesTo::Everyone.covers(false));
1404+ assert!(AppliesTo::Agents.covers(true) && !AppliesTo::Agents.covers(false));
1405+ assert!(AppliesTo::People.covers(false) && !AppliesTo::People.covers(true));
1406+ let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
1407+ assert_eq!(actor_kind(&person), "person");
1408+ assert!(!is_agent(&person));
1409+ assert!(is_agent(&User::system("acme")));
1410+ }
1411+}
+10−0
11341134 /// Where to see more, such as the run's page.
11351135 pub target_url: Option<String>,
11361136 pub updated_at: String,
1137+ /// The integration that reported it (`actions`, `deployments`,
1138+ /// `security`, `g1t`), when recorded. A required check can insist on
1139+ /// one (`rules::RequiredCheck::integration`).
1140+ #[serde(default, skip_serializing_if = "Option::is_none")]
1141+ pub source: Option<String>,
11371142 }
11381143
11391144 /// `set_commit_status`: for services only. Returns `Outcome<bool>`.
11481153 pub description: Option<String>,
11491154 #[serde(default)]
11501155 pub target_url: Option<String>,
1156+ /// The integration reporting it: `actions`, `deployments`, `security`
1157+ /// or `g1t`.
1158+ #[serde(default)]
1159+ pub source: Option<String>,
11511160 }
11521161
11531162 /// A message a person sent an agent at work on a pull request. The agent
24892498 description: Some(format!("{context} {state}")),
24902499 target_url: None,
24912500 updated_at: String::new(),
2501+ source: None,
24922502 }
24932503 }
24942504
+13−0
1+[package]
2+name = "g1t-rules"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "The rules engine: which rulesets hold for a branch or tag, and whether a push or a merge meets them."
7+
8+[dependencies]
9+g1t-contracts.workspace = true
10+serde.workspace = true
11+serde_json.workspace = true
12+# Linear-time regular expressions only: pattern rules run on every push.
13+regex = { version = "1.13", default-features = false, features = ["std", "perf", "unicode-perl", "unicode-case"] }
+335−0
1+//! Rules about what commits bring: their messages and addresses, their
2+//! signatures and parents, and the files they change. The same checks hold
3+//! for a push and for the commits a pull request lands.
4+
5+use g1t_contracts::rules::{CommitFacts, PatternRule, Rule, Signature};
6+
7+use crate::{glob, text};
8+
9+/// One problem a rule found, and how to fix it.
10+#[derive(Clone, Debug, PartialEq, Eq)]
11+pub struct Problem {
12+ pub message: String,
13+ pub remedy: String,
14+}
15+
16+impl Problem {
17+ pub fn new(message: impl Into<String>, remedy: impl Into<String>) -> Problem {
18+ Problem { message: message.into(), remedy: remedy.into() }
19+ }
20+}
21+
22+/// The most problems one rule reports; the rest are counted.
23+const MAX_PROBLEMS: usize = 5;
24+
25+fn short(sha: &str) -> &str {
26+ &sha[..sha.len().min(7)]
27+}
28+
29+/// Whether a rule is about what commits bring, and so needs them read.
30+pub fn about_content(rule: &Rule) -> bool {
31+ matches!(
32+ rule,
33+ Rule::RequiredLinearHistory(_)
34+ | Rule::RequiredSignatures(_)
35+ | Rule::CommitMessagePattern(_)
36+ | Rule::CommitAuthorEmailPattern(_)
37+ | Rule::CommitterEmailPattern(_)
38+ | Rule::FilePathRestriction(_)
39+ | Rule::FileExtensionRestriction(_)
40+ | Rule::MaxFileSize(_)
41+ | Rule::MaxFilePathLength(_)
42+ | Rule::MaxFilesChanged(_)
43+ | Rule::SecretScanning(_)
44+ )
45+}
46+
47+fn capped(mut problems: Vec<Problem>) -> Vec<Problem> {
48+ if problems.len() > MAX_PROBLEMS {
49+ let more = problems.len() - (MAX_PROBLEMS - 1);
50+ problems.truncate(MAX_PROBLEMS - 1);
51+ let remedy = problems[0].remedy.clone();
52+ problems.push(Problem::new(format!("…and {more} more like it."), remedy));
53+ }
54+ problems
55+}
56+
57+fn pattern_problems(
58+ rule: &PatternRule,
59+ commits: &[CommitFacts],
60+ what: &str,
61+ read: impl Fn(&CommitFacts) -> Option<&str>,
62+) -> Vec<Problem> {
63+ let compiled = match text::compile(rule) {
64+ Ok(compiled) => compiled,
65+ // Saved rules compile; one that no longer does refuses nothing.
66+ Err(_) => return Vec::new(),
67+ };
68+ let remedy = format!("Rewrite the commits so each {what} {}, then push again.", compiled.wants());
69+ commits
70+ .iter()
71+ .filter(|commit| !compiled.allows(read(commit).unwrap_or_default()))
72+ .map(|commit| Problem::new(format!("Commit {} has a {what} that does not {}.", short(&commit.sha), compiled.wants()), remedy.clone()))
73+ .collect()
74+}
75+
76+/// The extension of a path, lowercase with its dot: `.exe`. Empty without.
77+fn extension(path: &str) -> String {
78+ let name = path.rsplit('/').next().unwrap_or(path);
79+ match name.rfind('.') {
80+ Some(0) | None => String::new(),
81+ Some(at) => name[at..].to_lowercase(),
82+ }
83+}
84+
85+/// The problems `rule` finds in `commits`. `complete` says whether they
86+/// are everything the change brings, each read in full: content rules
87+/// cannot be met by a change too large to read.
88+pub fn problems(rule: &Rule, commits: &[CommitFacts], complete: bool) -> Vec<Problem> {
89+ if !about_content(rule) {
90+ return Vec::new();
91+ }
92+ if !complete {
93+ return vec![Problem::new(
94+ "The change is too large for g1t to check against this rule.",
95+ "Split it into smaller pushes or pull requests.",
96+ )];
97+ }
98+ let found = match rule {
99+ Rule::RequiredLinearHistory(_) => commits
100+ .iter()
101+ .filter(|commit| commit.parents > 1)
102+ .map(|commit| {
103+ Problem::new(
104+ format!("Commit {} is a merge commit; this branch keeps a linear history.", short(&commit.sha)),
105+ "Rebase onto the branch instead of merging it in, then push again.",
106+ )
107+ })
108+ .collect(),
109+ Rule::RequiredSignatures(_) => commits
110+ .iter()
111+ .filter_map(|commit| match &commit.signature {
112+ Signature::Verified { .. } => None,
113+ Signature::Unsigned => Some(Problem::new(
114+ format!("Commit {} is not signed.", short(&commit.sha)),
115+ "Sign your commits with an SSH key registered on your g1t account (git config gpg.format ssh; git commit -S), then push again.",
116+ )),
117+ Signature::Unverified { reason } => Some(Problem::new(
118+ format!("Commit {}'s signature could not be verified: {reason}", short(&commit.sha)),
119+ "Sign with an SSH key registered on the g1t account that owns the committer's verified email address.",
120+ )),
121+ })
122+ .collect(),
123+ Rule::CommitMessagePattern(pattern) => pattern_problems(pattern, commits, "message", |commit| Some(&commit.message)),
124+ Rule::CommitAuthorEmailPattern(pattern) => {
125+ pattern_problems(pattern, commits, "author email", |commit| commit.author_email.as_deref())
126+ }
127+ Rule::CommitterEmailPattern(pattern) => {
128+ pattern_problems(pattern, commits, "committer email", |commit| commit.committer_email.as_deref())
129+ }
130+ Rule::FilePathRestriction(paths) => commits
131+ .iter()
132+ .flat_map(|commit| {
133+ commit.files.iter().filter_map(move |file| {
134+ paths
135+ .restricted_file_paths
136+ .iter()
137+ .find(|pattern| glob::path_matches(pattern, &file.path))
138+ .map(|pattern| {
139+ Problem::new(
140+ format!("Commit {} changes {}, which matches the restricted path {pattern}.", short(&commit.sha), file.path),
141+ "Leave restricted paths unchanged, or ask someone who may bypass this ruleset.",
142+ )
143+ })
144+ })
145+ })
146+ .collect(),
147+ Rule::FileExtensionRestriction(extensions) => {
148+ let restricted: Vec<String> = extensions
149+ .restricted_file_extensions
150+ .iter()
151+ .map(|extension| {
152+ let extension = extension.trim().to_lowercase();
153+ if extension.starts_with('.') { extension } else { format!(".{extension}") }
154+ })
155+ .collect();
156+ commits
157+ .iter()
158+ .flat_map(|commit| {
159+ let restricted = &restricted;
160+ commit.files.iter().filter(|file| !file.deleted).filter_map(move |file| {
161+ let found = extension(&file.path);
162+ (!found.is_empty() && restricted.contains(&found)).then(|| {
163+ Problem::new(
164+ format!("Commit {} adds {}, and {found} files are not allowed.", short(&commit.sha), file.path),
165+ "Remove the file from the commits (git rm --cached, then amend or rebase).",
166+ )
167+ })
168+ })
169+ })
170+ .collect()
171+ }
172+ Rule::MaxFileSize(limit) => {
173+ let max = u64::from(limit.max_file_size_mb) * 1024 * 1024;
174+ commits
175+ .iter()
176+ .flat_map(|commit| {
177+ commit.files.iter().filter(|file| file.size.is_some_and(|size| size > max)).map(move |file| {
178+ Problem::new(
179+ format!(
180+ "Commit {} adds {} at {:.1} MB; files may be at most {} MB.",
181+ short(&commit.sha),
182+ file.path,
183+ file.size.unwrap_or(0) as f64 / (1024.0 * 1024.0),
184+ limit.max_file_size_mb
185+ ),
186+ "Take the file out of the commits, and keep large files in a package or release instead.",
187+ )
188+ })
189+ })
190+ .collect()
191+ }
192+ Rule::MaxFilePathLength(limit) => commits
193+ .iter()
194+ .flat_map(|commit| {
195+ commit
196+ .files
197+ .iter()
198+ .filter(|file| !file.deleted && file.path.chars().count() > limit.max_file_path_length as usize)
199+ .map(move |file| {
200+ Problem::new(
201+ format!(
202+ "Commit {} adds a path {} characters long; paths may be at most {}.",
203+ short(&commit.sha),
204+ file.path.chars().count(),
205+ limit.max_file_path_length
206+ ),
207+ "Use a shorter path.",
208+ )
209+ })
210+ })
211+ .collect(),
212+ Rule::MaxFilesChanged(limit) => commits
213+ .iter()
214+ .filter(|commit| !commit.files_complete || commit.files.len() > limit.max_files as usize)
215+ .map(|commit| {
216+ Problem::new(
217+ format!("Commit {} changes more than {} files.", short(&commit.sha), limit.max_files),
218+ "Split the change into smaller commits.",
219+ )
220+ })
221+ .collect(),
222+ // Secrets are push protection's to find; this rule only asks that
223+ // every push be read whole, which `complete` said it was.
224+ _ => Vec::new(),
225+ };
226+ capped(found)
227+}
228+
229+#[cfg(test)]
230+pub(crate) mod tests_support {
231+ use g1t_contracts::rules::{CommitFacts, FileChange, Signature};
232+
233+ /// A commit by ada@acme.com changing `files`, 10 bytes each.
234+ pub(crate) fn commit(sha: &str, message: &str, files: &[&str]) -> CommitFacts {
235+ CommitFacts {
236+ sha: sha.into(),
237+ message: message.into(),
238+ author_email: Some("ada@acme.com".into()),
239+ committer_email: Some("ada@acme.com".into()),
240+ parents: 1,
241+ signature: Signature::Unsigned,
242+ files: files.iter().map(|path| FileChange { path: (*path).into(), size: Some(10), deleted: false }).collect(),
243+ files_complete: true,
244+ }
245+ }
246+}
247+
248+#[cfg(test)]
249+mod tests {
250+ use super::tests_support::commit;
251+ use super::*;
252+ use g1t_contracts::rules::{
253+ FileExtensionRule, FilePathRule, MaxFilePathLengthRule, MaxFileSizeRule, MaxFilesChangedRule, NoParameters,
254+ PatternOperator,
255+ };
256+
257+ #[test]
258+ fn merge_commits_break_linear_history() {
259+ let mut merge = commit("aaaaaaaaaa", "Merge main", &[]);
260+ merge.parents = 2;
261+ let found = problems(&Rule::RequiredLinearHistory(NoParameters {}), &[commit("b", "x", &[]), merge], true);
262+ assert_eq!(found.len(), 1);
263+ assert_eq!(found[0].message, "Commit aaaaaaa is a merge commit; this branch keeps a linear history.");
264+ }
265+
266+ #[test]
267+ fn only_verified_signatures_meet_the_signature_rule() {
268+ let mut signed = commit("s", "x", &[]);
269+ signed.signature = Signature::Verified { signer: "ada".into() };
270+ let mut bad = commit("u", "x", &[]);
271+ bad.signature = Signature::Unverified { reason: "GPG signatures are not verified yet.".into() };
272+ let found = problems(&Rule::RequiredSignatures(NoParameters {}), &[signed, bad, commit("n", "x", &[])], true);
273+ assert_eq!(found.len(), 2);
274+ assert!(found[0].message.contains("could not be verified: GPG"));
275+ assert!(found[1].message.contains("is not signed"));
276+ }
277+
278+ #[test]
279+ fn message_and_address_patterns_check_every_commit() {
280+ let conventional = PatternRule { name: String::new(), operator: PatternOperator::Regex, pattern: "^(feat|fix): ".into(), negate: false };
281+ let found = problems(&Rule::CommitMessagePattern(conventional), &[commit("a1", "feat: x", &[]), commit("b2", "stuff", &[])], true);
282+ assert_eq!(found.len(), 1);
283+ assert!(found[0].message.starts_with("Commit b2 has a message that does not match"));
284+ let domain = PatternRule { name: String::new(), operator: PatternOperator::EndsWith, pattern: "@acme.com".into(), negate: false };
285+ let mut outsider = commit("c3", "x", &[]);
286+ outsider.author_email = Some("eve@example.com".into());
287+ assert_eq!(problems(&Rule::CommitAuthorEmailPattern(domain.clone()), &[outsider.clone()], true).len(), 1);
288+ assert!(problems(&Rule::CommitterEmailPattern(domain), &[outsider], true).is_empty());
289+ }
290+
291+ #[test]
292+ fn restricted_paths_extensions_sizes_and_lengths() {
293+ let paths = Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] });
294+ let change = commit("a", "x", &["src/a.rs", ".g1t/workflows/ci.yml", "docs/CODEOWNERS"]);
295+ assert_eq!(problems(&paths, &[change.clone()], true).len(), 2);
296+ let extensions = Rule::FileExtensionRestriction(FileExtensionRule { restricted_file_extensions: vec!["exe".into(), ".ZIP".into()] });
297+ let binaries = commit("b", "x", &["tool.exe", "a.zip", "README", ".env"]);
298+ assert_eq!(problems(&extensions, &[binaries], true).len(), 2);
299+ let mut big = commit("c", "x", &["video.mp4"]);
300+ big.files[0].size = Some(30 * 1024 * 1024);
301+ let found = problems(&Rule::MaxFileSize(MaxFileSizeRule { max_file_size_mb: 10 }), &[big], true);
302+ assert_eq!(found[0].message, "Commit c adds video.mp4 at 30.0 MB; files may be at most 10 MB.");
303+ let long = commit("d", "x", &[&"a/".repeat(200)]);
304+ assert_eq!(problems(&Rule::MaxFilePathLength(MaxFilePathLengthRule { max_file_path_length: 255 }), &[long], true).len(), 1);
305+ let many = commit("e", "x", &["1", "2", "3"]);
306+ assert_eq!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 2 }), &[many.clone()], true).len(), 1);
307+ assert!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 3 }), &[many], true).is_empty());
308+ }
309+
310+ #[test]
311+ fn deleting_a_file_still_changes_a_restricted_path_but_adds_no_extension() {
312+ let mut gone = commit("a", "x", &["CODEOWNERS", "tool.exe"]);
313+ for file in &mut gone.files {
314+ file.deleted = true;
315+ }
316+ assert_eq!(problems(&Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec!["CODEOWNERS".into()] }), &[gone.clone()], true).len(), 1);
317+ assert!(problems(&Rule::FileExtensionRestriction(FileExtensionRule { restricted_file_extensions: vec!["exe".into()] }), &[gone], true).is_empty());
318+ }
319+
320+ #[test]
321+ fn a_change_too_large_to_read_cannot_meet_a_content_rule() {
322+ let found = problems(&Rule::SecretScanning(NoParameters {}), &[], false);
323+ assert_eq!(found[0].message, "The change is too large for g1t to check against this rule.");
324+ assert!(problems(&Rule::SecretScanning(NoParameters {}), &[], true).is_empty());
325+ assert!(problems(&Rule::Deletion(NoParameters {}), &[], false).is_empty(), "not a content rule");
326+ }
327+
328+ #[test]
329+ fn many_problems_are_summed_up() {
330+ let commits: Vec<CommitFacts> = (0..20).map(|n| commit(&format!("c{n}"), "x", &[])).collect();
331+ let found = problems(&Rule::RequiredSignatures(NoParameters {}), &commits, true);
332+ assert_eq!(found.len(), 5);
333+ assert_eq!(found[4].message, "…and 16 more like it.");
334+ }
335+}
+270−0
1+//! fnmatch patterns, for branch and tag names, repository names and file
2+//! paths.
3+//!
4+//! - `*` matches any run of characters but `/`.
5+//! - `**` matches any run of characters, `/` included; `**/` also matches
6+//! no directory at all, so `docs/**/*.md` matches `docs/a.md`.
7+//! - `?` matches one character but `/`.
8+//! - `[abc]`, `[a-z]` match one character of the set; `[!abc]` or `[^abc]`
9+//! one not in it.
10+//! - `\` makes the next character literal.
11+//!
12+//! Matching is a table over pattern and text positions, so it takes time in
13+//! proportion to their lengths multiplied, never more.
14+
15+/// One piece of a pattern.
16+#[derive(Clone, Debug, PartialEq, Eq)]
17+enum Token {
18+ Literal(char),
19+ /// `?`
20+ One,
21+ /// `*`
22+ Star,
23+ /// `**`, and whether a `/` follows it (`**/`), which it may skip.
24+ Globstar { slash: bool },
25+ Class { negated: bool, items: Vec<(char, char)> },
26+}
27+
28+fn tokens(pattern: &str) -> Vec<Token> {
29+ let chars: Vec<char> = pattern.chars().collect();
30+ let mut out = Vec::new();
31+ let mut at = 0;
32+ while at < chars.len() {
33+ match chars[at] {
34+ '\\' if at + 1 < chars.len() => {
35+ out.push(Token::Literal(chars[at + 1]));
36+ at += 2;
37+ }
38+ '*' if chars.get(at + 1) == Some(&'*') => {
39+ let slash = chars.get(at + 2) == Some(&'/');
40+ out.push(Token::Globstar { slash });
41+ at += if slash { 3 } else { 2 };
42+ // More stars right after `**` say no more.
43+ while !slash && chars.get(at) == Some(&'*') {
44+ at += 1;
45+ }
46+ }
47+ '*' => {
48+ out.push(Token::Star);
49+ at += 1;
50+ }
51+ '?' => {
52+ out.push(Token::One);
53+ at += 1;
54+ }
55+ '[' => match class(&chars, at) {
56+ Some((token, next)) => {
57+ out.push(token);
58+ at = next;
59+ }
60+ None => {
61+ out.push(Token::Literal('['));
62+ at += 1;
63+ }
64+ },
65+ c => {
66+ out.push(Token::Literal(c));
67+ at += 1;
68+ }
69+ }
70+ }
71+ out
72+}
73+
74+/// A `[...]` class starting at `start`, and where the pattern goes on.
75+fn class(chars: &[char], start: usize) -> Option<(Token, usize)> {
76+ let mut at = start + 1;
77+ let negated = matches!(chars.get(at), Some('!' | '^'));
78+ if negated {
79+ at += 1;
80+ }
81+ let mut items = Vec::new();
82+ let mut first = true;
83+ loop {
84+ let c = *chars.get(at)?;
85+ if c == ']' && !first {
86+ return Some((Token::Class { negated, items }, at + 1));
87+ }
88+ first = false;
89+ let c = if c == '\\' {
90+ at += 1;
91+ *chars.get(at)?
92+ } else {
93+ c
94+ };
95+ if chars.get(at + 1) == Some(&'-') && chars.get(at + 2).is_some_and(|end| *end != ']') {
96+ items.push((c, chars[at + 2]));
97+ at += 3;
98+ } else {
99+ items.push((c, c));
100+ at += 1;
101+ }
102+ }
103+}
104+
105+fn in_class(c: char, negated: bool, items: &[(char, char)]) -> bool {
106+ let found = items.iter().any(|(low, high)| (*low..=*high).contains(&c));
107+ found != negated && c != '/'
108+}
109+
110+/// Whether `text` matches `pattern`, exactly.
111+pub fn matches(pattern: &str, text: &str) -> bool {
112+ let tokens = tokens(pattern);
113+ let text: Vec<char> = text.chars().collect();
114+ // done[t][p]: whether text[t..] matches tokens[p..].
115+ let (rows, cols) = (text.len() + 1, tokens.len() + 1);
116+ let mut done = vec![false; rows * cols];
117+ done[text.len() * cols + tokens.len()] = true;
118+ for t in (0..rows).rev() {
119+ for p in (0..tokens.len()).rev() {
120+ let next = |t: usize, p: usize| done[t * cols + p];
121+ let here = match &tokens[p] {
122+ Token::Literal(c) => t < text.len() && text[t] == *c && next(t + 1, p + 1),
123+ Token::One => t < text.len() && text[t] != '/' && next(t + 1, p + 1),
124+ Token::Class { negated, items } => {
125+ t < text.len() && in_class(text[t], *negated, items) && next(t + 1, p + 1)
126+ }
127+ // Nothing more, or one more character (not `/`) and the star again.
128+ Token::Star => next(t, p + 1) || (t < text.len() && text[t] != '/' && next(t + 1, p)),
129+ Token::Globstar { slash: false } => next(t, p + 1) || (t < text.len() && next(t + 1, p)),
130+ // `**/`: no directory at all, or any run ending in `/`.
131+ Token::Globstar { slash: true } => {
132+ next(t, p + 1)
133+ || (t < text.len() && {
134+ // Consume up to and including a `/`.
135+ let mut end = t;
136+ let mut found = false;
137+ while end < text.len() {
138+ if text[end] == '/' && next(end + 1, p + 1) {
139+ found = true;
140+ break;
141+ }
142+ end += 1;
143+ }
144+ found
145+ })
146+ }
147+ };
148+ done[t * cols + p] = here;
149+ }
150+ }
151+ done[0]
152+}
153+
154+/// Whether a file path matches a path pattern. A pattern with no `/` in it
155+/// matches a file of that name in any directory (`*.exe`, `CODEOWNERS`);
156+/// one with a `/` matches from the repository's root, a leading `/`
157+/// optional. A pattern ending in `/` matches everything under it.
158+pub fn path_matches(pattern: &str, path: &str) -> bool {
159+ let pattern = pattern.trim();
160+ if pattern.is_empty() {
161+ return false;
162+ }
163+ let path = path.trim_start_matches('/');
164+ if let Some(directory) = pattern.strip_suffix('/') {
165+ let directory = directory.trim_start_matches('/');
166+ return matches(&format!("{directory}/**"), path);
167+ }
168+ if !pattern.contains('/') {
169+ let name = path.rsplit('/').next().unwrap_or(path);
170+ return matches(pattern, name) || matches(pattern, path);
171+ }
172+ matches(pattern.trim_start_matches('/'), path)
173+}
174+
175+/// Whether a pattern is one [`matches`] reads as written: its classes are
176+/// closed. Anything else is still matched, as literal text.
177+pub fn well_formed(pattern: &str) -> bool {
178+ let chars: Vec<char> = pattern.chars().collect();
179+ let mut at = 0;
180+ while at < chars.len() {
181+ match chars[at] {
182+ '\\' => at += 2,
183+ '[' => match class(&chars, at) {
184+ Some((_, next)) => at = next,
185+ None => return false,
186+ },
187+ _ => at += 1,
188+ }
189+ }
190+ true
191+}
192+
193+#[cfg(test)]
194+mod tests {
195+ use super::*;
196+
197+ #[test]
198+ fn a_star_stays_within_a_segment() {
199+ assert!(matches("release/*", "release/1.x"));
200+ assert!(!matches("release/*", "release/1.x/hotfix"));
201+ assert!(!matches("release/*", "release"));
202+ assert!(matches("*", "main"));
203+ assert!(!matches("*", "feature/x"));
204+ assert!(matches("feat*", "feature"));
205+ assert!(matches("*-rc", "v1-rc"));
206+ }
207+
208+ #[test]
209+ fn a_globstar_crosses_segments() {
210+ assert!(matches("release/**", "release/1.x/hotfix"));
211+ assert!(matches("**", "a/b/c"));
212+ assert!(matches("docs/**/*.md", "docs/a.md"));
213+ assert!(matches("docs/**/*.md", "docs/guides/deep/a.md"));
214+ assert!(!matches("docs/**/*.md", "src/a.md"));
215+ assert!(matches(".g1t/workflows/**", ".g1t/workflows/ci.yml"));
216+ assert!(matches("**/secrets.json", "secrets.json"));
217+ assert!(matches("**/secrets.json", "config/prod/secrets.json"));
218+ }
219+
220+ #[test]
221+ fn single_characters_and_classes() {
222+ assert!(matches("v?", "v1"));
223+ assert!(!matches("v?", "v10"));
224+ assert!(!matches("a?b", "a/b"));
225+ assert!(matches("v[0-9]*", "v1.2"));
226+ assert!(!matches("v[0-9]*", "va"));
227+ assert!(matches("[!m]*", "dev"));
228+ assert!(!matches("[!m]*", "main"));
229+ assert!(matches("[^m]*", "dev"));
230+ assert!(matches("[]]", "]"));
231+ }
232+
233+ #[test]
234+ fn escapes_and_unclosed_classes_are_literal() {
235+ assert!(matches(r"a\*b", "a*b"));
236+ assert!(!matches(r"a\*b", "axb"));
237+ assert!(matches("a[b", "a[b"));
238+ assert!(!well_formed("a[b"));
239+ assert!(well_formed("release/[0-9]*"));
240+ }
241+
242+ #[test]
243+ fn exact_names_match_only_themselves() {
244+ assert!(matches("main", "main"));
245+ assert!(!matches("main", "mainline"));
246+ assert!(!matches("main", "Main"));
247+ assert!(matches("", ""));
248+ assert!(!matches("", "x"));
249+ }
250+
251+ #[test]
252+ fn long_texts_do_not_take_long() {
253+ let text = "a".repeat(2000);
254+ let pattern = "*a*a*a*a*a*a*a*a*b";
255+ assert!(!matches(pattern, &text));
256+ }
257+
258+ #[test]
259+ fn paths_without_a_slash_match_a_name_anywhere() {
260+ assert!(path_matches("CODEOWNERS", "CODEOWNERS"));
261+ assert!(path_matches("CODEOWNERS", ".g1t/CODEOWNERS"));
262+ assert!(path_matches("*.exe", "bin/tool.exe"));
263+ assert!(!path_matches("*.exe", "bin/tool.exe.txt"));
264+ assert!(path_matches("/infra/**", "infra/main.tf"));
265+ assert!(path_matches("infra/", "infra/modules/a.tf"));
266+ assert!(!path_matches("infra/", "src/infra.rs"));
267+ assert!(!path_matches("src/*.rs", "lib/src/a.rs"));
268+ assert!(!path_matches(" ", "a"));
269+ }
270+}
+233−0
1+//! Branch protection as it was before rulesets: one set of settings for the
2+//! default branch. A repository's settings become its "Default branch
3+//! protection" ruleset, holding exactly what they held, and the settings
4+//! the API still takes for that branch are read from and written to it.
5+
6+use g1t_contracts::rules::{
7+ Conditions, DEFAULT_BRANCH, Enforcement, MergeQueueRule, PullRequestRule, RefCondition, RequiredCheck, Rule,
8+ RuleEntry, RulesetSpec, StatusChecksRule, Target,
9+};
10+use g1t_contracts::work::RepoSettings;
11+
12+/// The name the ruleset made from branch protection is given.
13+pub const NAME: &str = "Default branch protection";
14+
15+/// What branch protection held, as rules. `protected`: pushes to the
16+/// default branch were refused.
17+pub fn rules_of(settings: &RepoSettings, protected: bool) -> Vec<RuleEntry> {
18+ let mut rules = Vec::new();
19+ if protected || settings.required_approvals > 0 || settings.require_code_owner_review {
20+ rules.push(RuleEntry::everyone(Rule::PullRequest(PullRequestRule {
21+ required_approvals: settings.required_approvals,
22+ count_agent_approvals: settings.count_agent_approvals,
23+ require_code_owner_review: settings.require_code_owner_review,
24+ allow_direct_pushes: !protected,
25+ ..PullRequestRule::default()
26+ })));
27+ }
28+ if !settings.required_checks.is_empty() || settings.require_up_to_date {
29+ rules.push(RuleEntry::everyone(Rule::RequiredStatusChecks(StatusChecksRule {
30+ checks: settings
31+ .required_checks
32+ .iter()
33+ .map(|name| RequiredCheck { context: name.clone(), integration: None })
34+ .collect(),
35+ strict: settings.require_up_to_date,
36+ paths: Vec::new(),
37+ allow_bypass_on_merge: settings.allow_ignoring_checks,
38+ })));
39+ }
40+ if settings.merge_queue {
41+ rules.push(RuleEntry::everyone(Rule::MergeQueue(MergeQueueRule::default())));
42+ }
43+ rules
44+}
45+
46+/// The ruleset branch protection becomes, or `None` when it held nothing.
47+pub fn ruleset_of(settings: &RepoSettings, protected: bool) -> Option<RulesetSpec> {
48+ let rules = rules_of(settings, protected);
49+ (!rules.is_empty()).then(|| RulesetSpec {
50+ name: NAME.to_owned(),
51+ enforcement: Enforcement::Active,
52+ target: Target::Branch,
53+ conditions: Conditions {
54+ ref_name: RefCondition { include: vec![DEFAULT_BRANCH.to_owned()], exclude: Vec::new() },
55+ repository: None,
56+ },
57+ bypass_actors: Vec::new(),
58+ rules,
59+ })
60+}
61+
62+/// A ruleset's rules with the branch protection kinds (pull request,
63+/// status checks, merge queue) replaced by what `settings` say, the others
64+/// kept as they were.
65+pub fn replace(existing: &[RuleEntry], settings: &RepoSettings, protected: bool) -> Vec<RuleEntry> {
66+ let mut rules: Vec<RuleEntry> = existing
67+ .iter()
68+ .filter(|entry| {
69+ !matches!(entry.rule, Rule::PullRequest(_) | Rule::RequiredStatusChecks(_) | Rule::MergeQueue(_))
70+ || entry.applies_to != g1t_contracts::rules::AppliesTo::Everyone
71+ })
72+ .cloned()
73+ .collect();
74+ let mut fresh = rules_of(settings, protected);
75+ // Keep what the old settings did not have a say in.
76+ for entry in &mut fresh {
77+ if let (Rule::PullRequest(new), Some(Rule::PullRequest(old))) = (
78+ &mut entry.rule,
79+ existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::PullRequest(_))),
80+ ) {
81+ new.dismiss_stale_reviews_on_push = old.dismiss_stale_reviews_on_push;
82+ new.require_last_push_approval = old.require_last_push_approval;
83+ new.allowed_merge_methods = old.allowed_merge_methods.clone();
84+ }
85+ if let (Rule::RequiredStatusChecks(new), Some(Rule::RequiredStatusChecks(old))) = (
86+ &mut entry.rule,
87+ existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::RequiredStatusChecks(_))),
88+ ) {
89+ for check in &mut new.checks {
90+ check.integration = old
91+ .checks
92+ .iter()
93+ .find(|was| was.context.eq_ignore_ascii_case(&check.context))
94+ .and_then(|was| was.integration);
95+ }
96+ }
97+ if let (Rule::MergeQueue(new), Some(Rule::MergeQueue(old))) = (
98+ &mut entry.rule,
99+ existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::MergeQueue(_))),
100+ ) {
101+ *new = old.clone();
102+ }
103+ }
104+ let mut out = fresh;
105+ out.append(&mut rules);
106+ out
107+}
108+
109+/// Whether the ruleset's pull request rule refuses pushes: what the
110+/// repository's old `protected` flag said.
111+pub fn requires_pull_requests(rules: &[RuleEntry]) -> bool {
112+ rules.iter().any(|entry| {
113+ entry.applies_to == g1t_contracts::rules::AppliesTo::Everyone
114+ && matches!(&entry.rule, Rule::PullRequest(rule) if !rule.allow_direct_pushes)
115+ })
116+}
117+
118+#[cfg(test)]
119+mod tests {
120+ use super::*;
121+ use crate::merge::requirements;
122+ use crate::push::{RefChange, judge};
123+ use crate::select::Who;
124+ use g1t_contracts::rules::{Applicable, Level, NoParameters};
125+
126+ fn applicable(spec: &RulesetSpec) -> Applicable {
127+ Applicable {
128+ id: "rs_bp".into(),
129+ name: spec.name.clone(),
130+ level: Level::Repository,
131+ enforcement: spec.enforcement,
132+ target: spec.target,
133+ conditions: spec.conditions.ref_name.clone(),
134+ rules: spec.rules.clone(),
135+ bypass: None,
136+ }
137+ }
138+
139+ fn push_to(branch: &str) -> RefChange {
140+ RefChange {
141+ git_ref: format!("refs/heads/{branch}"),
142+ old: Some("a".repeat(40)),
143+ new: Some("b".repeat(40)),
144+ fast_forward: Some(true),
145+ commits: Vec::new(),
146+ complete: true,
147+ }
148+ }
149+
150+ #[test]
151+ fn nothing_protected_makes_no_ruleset() {
152+ assert_eq!(ruleset_of(&RepoSettings::default(), false), None);
153+ }
154+
155+ #[test]
156+ fn protection_becomes_a_ruleset_that_behaves_as_it_did() {
157+ let settings = RepoSettings {
158+ required_checks: vec!["CI".into()],
159+ require_up_to_date: true,
160+ required_approvals: 2,
161+ count_agent_approvals: false,
162+ allow_ignoring_checks: false,
163+ merge_queue: true,
164+ require_code_owner_review: true,
165+ ..RepoSettings::default()
166+ };
167+ let spec = ruleset_of(&settings, true).unwrap();
168+ assert_eq!(spec.name, "Default branch protection");
169+ assert_eq!(spec.conditions.ref_name.include, vec!["~DEFAULT_BRANCH"]);
170+ let rules = [applicable(&spec)];
171+ // The same requirements on the default branch...
172+ let found = requirements(&rules, "refs/heads/main", "main", false, &[]);
173+ assert_eq!(found.required_checks, vec!["CI"]);
174+ assert!(found.strict && !found.allow_bypass_on_merge && found.require_code_owner_review && !found.count_agent_approvals);
175+ assert_eq!(found.required_approvals, 2);
176+ assert!(found.merge_queue.is_some());
177+ // ...none on another branch...
178+ assert_eq!(requirements(&rules, "refs/heads/release", "main", false, &[]).required_approvals, 0);
179+ // ...and pushes to it refused, as protection refused them.
180+ assert!(crate::outcome::refused(&judge(&rules, "main", Who::Person, &push_to("main"))));
181+ assert!(judge(&rules, "main", Who::Person, &push_to("release")).is_empty());
182+ assert!(requires_pull_requests(&spec.rules));
183+ }
184+
185+ #[test]
186+ fn approvals_without_protection_still_let_pushes_through() {
187+ let settings = RepoSettings { required_approvals: 1, ..RepoSettings::default() };
188+ let spec = ruleset_of(&settings, false).unwrap();
189+ let rules = [applicable(&spec)];
190+ assert!(!crate::outcome::refused(&judge(&rules, "main", Who::Person, &push_to("main"))));
191+ assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &[]).required_approvals, 1);
192+ assert!(!requires_pull_requests(&spec.rules));
193+ // Protection alone is a pull request rule with nothing else asked.
194+ let only = ruleset_of(&RepoSettings::default(), true).unwrap();
195+ assert_eq!(only.rules.len(), 1);
196+ assert!(requires_pull_requests(&only.rules));
197+ }
198+
199+ /// What services/work/migrations/0028_rulesets.sql writes for a
200+ /// repository with every setting on (run against SQLite), read back:
201+ /// the same ruleset this module makes.
202+ #[test]
203+ fn the_migration_writes_what_this_module_makes() {
204+ let migrated: RulesetSpec = serde_json::from_str(r#"{"bypass_actors": [], "conditions": {"ref_name": {"exclude": [], "include": ["~DEFAULT_BRANCH"]}}, "enforcement": "active", "name": "Default branch protection", "rules": [{"applies_to": "everyone", "parameters": {"allow_direct_pushes": true, "allowed_merge_methods": [], "count_agent_approvals": false, "dismiss_stale_reviews_on_push": false, "require_code_owner_review": true, "require_last_push_approval": false, "required_approvals": 2}, "type": "pull_request"}, {"applies_to": "everyone", "parameters": {"allow_bypass_on_merge": false, "checks": [{"context": "CI"}, {"context": "Lint"}], "paths": [], "strict": true}, "type": "required_status_checks"}, {"applies_to": "everyone", "parameters": {"check_response_timeout_minutes": 45, "max_entries_to_build": 4, "merge_method": "merge", "min_entries_to_merge": 1, "min_entries_wait_minutes": 0}, "type": "merge_queue"}], "target": "branch"}"#).unwrap();
205+ let settings = RepoSettings {
206+ required_approvals: 2,
207+ count_agent_approvals: false,
208+ require_code_owner_review: true,
209+ required_checks: vec!["CI".into(), "Lint".into()],
210+ require_up_to_date: true,
211+ allow_ignoring_checks: false,
212+ merge_queue: true,
213+ ..RepoSettings::default()
214+ };
215+ assert_eq!(Some(migrated), ruleset_of(&settings, false));
216+ }
217+
218+ #[test]
219+ fn settings_written_later_replace_only_their_own_rules() {
220+ let spec = ruleset_of(&RepoSettings { required_approvals: 1, ..RepoSettings::default() }, true).unwrap();
221+ let mut rules = spec.rules.clone();
222+ rules.push(RuleEntry::everyone(Rule::NonFastForward(NoParameters {})));
223+ if let Rule::PullRequest(rule) = &mut rules[0].rule {
224+ rule.dismiss_stale_reviews_on_push = true;
225+ }
226+ let changed = replace(&rules, &RepoSettings { required_approvals: 3, required_checks: vec!["CI".into()], ..RepoSettings::default() }, true);
227+ let kinds: Vec<&str> = changed.iter().map(|entry| entry.rule.kind()).collect();
228+ assert_eq!(kinds, vec!["pull_request", "required_status_checks", "non_fast_forward"]);
229+ let Rule::PullRequest(pull) = &changed[0].rule else { panic!() };
230+ assert_eq!(pull.required_approvals, 3);
231+ assert!(pull.dismiss_stale_reviews_on_push, "what the settings never had stays");
232+ }
233+}
+64−0
1+//! The rules engine: which rulesets hold for a branch or tag, and whether a
2+//! change to it meets them.
3+//!
4+//! Everything here is pure: the work service (merges, and where rulesets
5+//! are kept) and the repos service (pushes, and every other change to a
6+//! branch or tag) gather the facts and ask. The types are in
7+//! `g1t_contracts::rules`.
8+//!
9+//! - [`select`]: which rulesets hold in a repository and for a name, who
10+//! may bypass them, and the effective rules of one branch.
11+//! - [`push`]: judging a push, or a branch created, deleted or renamed.
12+//! - [`merge`]: judging a pull request's merge, and what the active rules
13+//! ask of g1t's lifecycle and merge queue ([`merge::requirements`]).
14+//! - [`content`]: rules about what commits bring, for both.
15+//! - [`validate`]: whether a ruleset can be saved.
16+//! - [`legacy`]: branch protection as it was, as a ruleset.
17+//! - [`report`]: what git and people are told.
18+
19+pub mod content;
20+pub mod glob;
21+pub mod legacy;
22+pub mod merge;
23+pub mod outcome;
24+pub mod push;
25+pub mod report;
26+pub mod select;
27+pub mod text;
28+pub mod validate;
29+pub mod window;
30+
31+pub use outcome::Judged;
32+pub use select::{ActorFacts, RepoFacts, Who};
33+
34+use g1t_contracts::rules::{Action, NewEvaluation};
35+
36+/// The evaluations to record for a change: one per ruleset that holds.
37+pub fn evaluations(
38+ judged: &[Judged],
39+ repo_id: &str,
40+ workspace: &str,
41+ action: Action,
42+ actor: &ActorFacts,
43+ number: Option<u32>,
44+ sha: Option<&str>,
45+) -> Vec<NewEvaluation> {
46+ judged
47+ .iter()
48+ .map(|one| NewEvaluation {
49+ repo_id: repo_id.to_owned(),
50+ workspace: workspace.to_lowercase(),
51+ ruleset_id: one.id.clone(),
52+ ruleset_name: one.name.clone(),
53+ enforcement: one.enforcement,
54+ action,
55+ git_ref: one.git_ref.clone(),
56+ actor: actor.username.clone(),
57+ actor_kind: actor.kind.as_str().to_owned(),
58+ verdict: one.verdict(),
59+ violations: one.violations.clone(),
60+ number,
61+ sha: sha.map(str::to_owned),
62+ })
63+ .collect()
64+}
+879−0
1+//! Judging a pull request's merge into a branch: approvals, checks,
2+//! deployments, the commits it lands, and the agent-first rules (how sure
3+//! g1t is of an agent's change, what it cost, who must look at sensitive
4+//! paths, and when merging is allowed at all).
5+//!
6+//! The merge button, the API, MCP, auto-merge, g1t's lifecycle and the
7+//! merge queue all ask this one question, so a pull request merges the
8+//! same way whoever merges it.
9+
10+use std::collections::HashMap;
11+
12+use g1t_contracts::rules::{
13+ Applicable, ConfidenceLevel, Enforcement, InspectedCommits, Integration, MergeMethod, MergeQueueRule, Rule,
14+ StatusChecksRule,
15+};
16+use g1t_contracts::work::{CommitStatus, RequiredState, Verdict, check_name, required_checks};
17+
18+use crate::content::{self, Problem};
19+use crate::glob;
20+use crate::outcome::Judged;
21+use crate::select::applies_to_ref;
22+use crate::window;
23+
24+/// One reviewer's latest verdict.
25+#[derive(Clone, Debug, PartialEq, Eq)]
26+pub struct Review {
27+ pub reviewer_id: String,
28+ pub username: String,
29+ pub verdict: Verdict,
30+ /// RFC 3339.
31+ pub at: String,
32+ /// g1t's reviewer agent.
33+ pub agent: bool,
34+}
35+
36+/// Everything a merge is judged on.
37+#[derive(Clone, Debug, Default)]
38+pub struct MergeFacts<'a> {
39+ /// The branch it merges into, as a full ref.
40+ pub git_ref: String,
41+ /// Whether an agent made the change.
42+ pub agent_change: bool,
43+ /// Who answers for it (whoever asked g1t for it, or its author).
44+ pub owner_id: &'a str,
45+ /// Each reviewer's latest verdict.
46+ pub reviews: &'a [Review],
47+ /// When its head last moved, and by whom (a user id), if known.
48+ pub head_pushed_at: Option<&'a str>,
49+ pub head_pushed_by: Option<&'a str>,
50+ /// What its code owners have still to approve, when that was asked.
51+ pub code_owners_missing: Option<&'a str>,
52+ /// The statuses on its head.
53+ pub statuses: &'a [CommitStatus],
54+ /// Whether the branch it merges into has moved without it.
55+ pub behind: bool,
56+ /// The files it changes.
57+ pub files: &'a [String],
58+ /// Its commits, read, when a rule about commits holds.
59+ pub commits: Option<&'a InspectedCommits>,
60+ /// How sure g1t is of an agent's change, once rated.
61+ pub confidence: Option<ConfidenceLevel>,
62+ /// What agents have spent on it, in US dollars.
63+ pub spent_usd: f64,
64+ /// Milliseconds since the epoch.
65+ pub now_ms: u64,
66+ pub method: Option<MergeMethod>,
67+ /// The people of each team a rule names, by `workspace/slug`,
68+ /// usernames lowercase.
69+ pub team_members: Option<&'a HashMap<String, Vec<String>>>,
70+ /// The merger asked to merge past required checks.
71+ pub ignore_checks: bool,
72+}
73+
74+/// Where a status came from: as recorded, or from its name.
75+pub fn integration_of(status: &CommitStatus) -> Integration {
76+ if let Some(found) = status.source.as_deref().and_then(Integration::parse) {
77+ return found;
78+ }
79+ let context = status.context.as_str();
80+ if context.starts_with("g1t / deploy") {
81+ Integration::Deployments
82+ } else if matches!(context, "Code scanning" | "Dependency review") {
83+ Integration::Security
84+ } else if context.starts_with("g1t / ") || context == "Code owners" {
85+ Integration::G1t
86+ } else {
87+ Integration::Actions
88+ }
89+}
90+
91+/// The statuses that may meet `rule`'s checks: a check pinned to an
92+/// integration is met only by statuses that integration reported.
93+pub fn statuses_for(rule: &StatusChecksRule, statuses: &[CommitStatus]) -> Vec<CommitStatus> {
94+ statuses
95+ .iter()
96+ .filter(|status| {
97+ let name = check_name(&status.context).0;
98+ rule.checks.iter().all(|check| {
99+ !check.context.trim().eq_ignore_ascii_case(name)
100+ || check.integration.is_none_or(|wanted| integration_of(status) == wanted)
101+ })
102+ })
103+ .cloned()
104+ .collect()
105+}
106+
107+/// Whether a status checks rule holds for a pull request changing `files`.
108+pub fn checks_hold(rule: &StatusChecksRule, files: &[String]) -> bool {
109+ rule.paths.is_empty() || files.iter().any(|file| rule.paths.iter().any(|pattern| glob::path_matches(pattern, file)))
110+}
111+
112+fn plural(count: u32, one: &str, many: &str) -> String {
113+ format!("{count} {}", if count == 1 { one } else { many })
114+}
115+
116+/// The people (not g1t, not its owner) who approve it now; with `fresh`,
117+/// only approvals given since its head last moved.
118+fn people_approving<'a>(facts: &'a MergeFacts<'_>, fresh: bool) -> impl Iterator<Item = &'a Review> {
119+ facts.reviews.iter().filter(move |review| {
120+ review.verdict == Verdict::Approve
121+ && !review.agent
122+ && review.reviewer_id != facts.owner_id
123+ && (!fresh || facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed))
124+ })
125+}
126+
127+fn pull_request_problems(rule: &g1t_contracts::rules::PullRequestRule, facts: &MergeFacts<'_>) -> Vec<Problem> {
128+ let mut problems = Vec::new();
129+ if rule.required_approvals > 0 {
130+ let others = || facts.reviews.iter().filter(|review| review.reviewer_id != facts.owner_id);
131+ if others().any(|review| review.verdict == Verdict::RequestChanges) {
132+ problems.push(Problem::new(
133+ "A reviewer has asked for changes.",
134+ "Address the review and ask them to review again.",
135+ ));
136+ } else {
137+ let counted = others()
138+ .filter(|review| review.verdict == Verdict::Approve)
139+ .filter(|review| rule.count_agent_approvals || !review.agent)
140+ .filter(|review| {
141+ !rule.dismiss_stale_reviews_on_push || facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed)
142+ })
143+ .count() as u32;
144+ if counted < rule.required_approvals {
145+ let from = if rule.count_agent_approvals { "" } else { " from people" };
146+ let since = if rule.dismiss_stale_reviews_on_push { " since its latest push" } else { "" };
147+ problems.push(Problem::new(
148+ format!(
149+ "It needs {}{from}{since}; it has {counted}.",
150+ plural(rule.required_approvals, "approving review", "approving reviews")
151+ ),
152+ "Ask for a review.",
153+ ));
154+ }
155+ }
156+ }
157+ if rule.require_code_owner_review
158+ && let Some(missing) = facts.code_owners_missing
159+ {
160+ problems.push(Problem::new(missing.to_owned(), "Ask its code owners to review it."));
161+ }
162+ if rule.require_last_push_approval {
163+ let pusher = facts.head_pushed_by.unwrap_or(facts.owner_id);
164+ let approved = facts.reviews.iter().any(|review| {
165+ review.verdict == Verdict::Approve
166+ && review.reviewer_id != pusher
167+ && (rule.count_agent_approvals || !review.agent)
168+ && facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed)
169+ });
170+ if !approved {
171+ problems.push(Problem::new(
172+ "Its latest push has not been approved by someone other than whoever pushed it.",
173+ "Ask someone else to review the latest changes.",
174+ ));
175+ }
176+ }
177+ if let Some(method) = facts.method
178+ && !rule.allowed_merge_methods.is_empty()
179+ && !rule.allowed_merge_methods.contains(&method)
180+ {
181+ let allowed: Vec<&str> = rule.allowed_merge_methods.iter().map(|method| method.as_str()).collect();
182+ problems.push(Problem::new(
183+ format!("This branch allows only {} merges, and this one would be a {} merge.", allowed.join(" or "), method.as_str()),
184+ "Merge it another way allowed here.",
185+ ));
186+ }
187+ problems
188+}
189+
190+fn checks_problems(rule: &StatusChecksRule, facts: &MergeFacts<'_>) -> Vec<Problem> {
191+ if !checks_hold(rule, facts.files) {
192+ return Vec::new();
193+ }
194+ let mut problems = Vec::new();
195+ if !(facts.ignore_checks && rule.allow_bypass_on_merge) {
196+ let names: Vec<String> = rule.checks.iter().map(|check| check.context.trim().to_owned()).collect();
197+ let statuses = statuses_for(rule, facts.statuses);
198+ for check in required_checks(&names, &statuses) {
199+ let pinned = rule
200+ .checks
201+ .iter()
202+ .find(|wanted| wanted.context.trim().eq_ignore_ascii_case(&check.name))
203+ .and_then(|wanted| wanted.integration)
204+ .map(|integration| format!(" from {}", integration.as_str()))
205+ .unwrap_or_default();
206+ let message = match check.state {
207+ RequiredState::Success => continue,
208+ RequiredState::Failure => format!("The required check {}{pinned} failed.", check.name),
209+ RequiredState::Pending => format!("The required check {}{pinned} has not finished.", check.name),
210+ RequiredState::Expected => format!("The required check {}{pinned} has not reported on its latest commit.", check.name),
211+ };
212+ let remedy = if rule.allow_bypass_on_merge {
213+ "Wait or fix it, or bypass the required checks as you merge."
214+ } else {
215+ "Wait for it to pass, or push a fix."
216+ };
217+ problems.push(Problem::new(message, remedy));
218+ }
219+ }
220+ if rule.strict && facts.behind {
221+ problems.push(Problem::new(
222+ "It is behind the branch it merges into, which requires pull requests to be up to date.",
223+ "Catch up with the branch first; its required checks then run again.",
224+ ));
225+ }
226+ problems
227+}
228+
229+/// The status a deployment to `environment` reports.
230+pub fn deployment_context(environment: &str) -> String {
231+ let environment = environment.trim();
232+ if environment.is_empty() || environment.eq_ignore_ascii_case("preview") {
233+ "g1t / deploy".to_owned()
234+ } else {
235+ format!("g1t / deploy ({environment})")
236+ }
237+}
238+
239+fn level_rank(level: ConfidenceLevel) -> u8 {
240+ match level {
241+ ConfidenceLevel::Low => 0,
242+ ConfidenceLevel::Medium => 1,
243+ ConfidenceLevel::High => 2,
244+ }
245+}
246+
247+/// The problems one rule finds in a merge.
248+fn rule_problems(rule: &Rule, facts: &MergeFacts<'_>) -> Vec<Problem> {
249+ match rule {
250+ Rule::PullRequest(rule) => pull_request_problems(rule, facts),
251+ Rule::RequiredStatusChecks(rule) => checks_problems(rule, facts),
252+ Rule::RequiredDeployments(rule) => rule
253+ .environments
254+ .iter()
255+ .filter(|environment| !environment.trim().is_empty())
256+ .filter_map(|environment| {
257+ let context = deployment_context(environment);
258+ let state = facts.statuses.iter().find(|status| status.context == context).map(|status| status.state.as_str());
259+ (state != Some("success")).then(|| {
260+ Problem::new(
261+ format!(
262+ "It has not deployed to {} successfully{}.",
263+ environment.trim(),
264+ match state {
265+ Some("pending") => " yet: the deployment is running",
266+ Some(_) => ": the deployment failed",
267+ None => "",
268+ }
269+ ),
270+ "Wait for its deployment, or fix what made it fail and push.",
271+ )
272+ })
273+ })
274+ .collect(),
275+ rule if content::about_content(rule) => match facts.commits {
276+ Some(inspected) => content::problems(rule, &inspected.commits, inspected.complete),
277+ None => Vec::new(),
278+ },
279+ Rule::ConfidenceThreshold(rule) if facts.agent_change => {
280+ let below = facts.confidence.is_none_or(|level| level_rank(level) < level_rank(rule.minimum));
281+ let approvals = people_approving(facts, false).count() as u32;
282+ if below && approvals < rule.required_approvals.max(1) {
283+ let rated = match facts.confidence {
284+ Some(level) => format!("g1t rates this agent's change {} confidence", level.as_str()),
285+ None => "g1t has not rated this agent's change yet".to_owned(),
286+ };
287+ vec![Problem::new(
288+ format!(
289+ "{rated}; below {} it needs {}.",
290+ rule.minimum.as_str(),
291+ plural(rule.required_approvals.max(1), "approval from a person", "approvals from people")
292+ ),
293+ "Review the change and approve it if it is right.",
294+ )]
295+ } else {
296+ Vec::new()
297+ }
298+ }
299+ Rule::CostCap(rule) if facts.spent_usd > rule.max_usd => {
300+ if people_approving(facts, false).next().is_some() {
301+ Vec::new()
302+ } else {
303+ vec![Problem::new(
304+ format!(
305+ "Agents have spent ${:.2} on this pull request, over its ${:.2} cap.",
306+ facts.spent_usd, rule.max_usd
307+ ),
308+ "A person must approve it before it merges or its agent continues.",
309+ )]
310+ }
311+ }
312+ Rule::PathReview(rule) => {
313+ let touched: Vec<&String> = facts
314+ .files
315+ .iter()
316+ .filter(|file| rule.paths.iter().any(|pattern| glob::path_matches(pattern, file)))
317+ .collect();
318+ if touched.is_empty() || rule.required_approvals == 0 {
319+ return Vec::new();
320+ }
321+ let members = rule.team.as_ref().map(|team| {
322+ facts
323+ .team_members
324+ .and_then(|teams| teams.get(&team.trim().trim_start_matches('@').to_lowercase()).cloned())
325+ .unwrap_or_default()
326+ });
327+ let approvals = people_approving(facts, true)
328+ .filter(|review| members.as_ref().is_none_or(|members| members.contains(&review.username.to_lowercase())))
329+ .count() as u32;
330+ if approvals >= rule.required_approvals {
331+ return Vec::new();
332+ }
333+ let from = rule.team.as_ref().map(|team| format!(" from @{}", team.trim().trim_start_matches('@'))).unwrap_or_default();
334+ let shown: Vec<&str> = touched.iter().take(3).map(|file| file.as_str()).collect();
335+ let more = if touched.len() > 3 { format!(" and {} more", touched.len() - 3) } else { String::new() };
336+ vec![Problem::new(
337+ format!(
338+ "It changes sensitive paths ({}{more}), which need {}{from} since its latest push; it has {approvals}.",
339+ shown.join(", "),
340+ plural(rule.required_approvals, "approval", "approvals")
341+ ),
342+ format!("Ask{} for a review.", if from.is_empty() { String::new() } else { from.replacen(" from", "", 1) }),
343+ )]
344+ }
345+ Rule::MergeWindow(rule) => match window::closed(rule, facts.now_ms) {
346+ Some(closed) => vec![Problem::new(window::explain(&closed), "Merge when the window opens, or ask someone who may bypass this ruleset.")],
347+ None => Vec::new(),
348+ },
349+ _ => Vec::new(),
350+ }
351+}
352+
353+/// How every applicable ruleset judges merging a pull request.
354+pub fn judge(rulesets: &[Applicable], default_branch: &str, facts: &MergeFacts<'_>) -> Vec<Judged> {
355+ rulesets
356+ .iter()
357+ .filter(|ruleset| applies_to_ref(ruleset, &facts.git_ref, default_branch))
358+ .map(|ruleset| {
359+ let mut judged = Judged::of(ruleset, &facts.git_ref, true);
360+ for entry in &ruleset.rules {
361+ if !entry.applies_to.covers(facts.agent_change) {
362+ continue;
363+ }
364+ let kind = entry.rule.kind();
365+ for problem in rule_problems(&entry.rule, facts) {
366+ judged.add(kind, problem);
367+ }
368+ }
369+ judged
370+ })
371+ .collect()
372+}
373+
374+/// Whether merging needs the pull request's commits read: a rule about
375+/// commits holds, for whoever made the change.
376+pub fn needs_commits(rulesets: &[Applicable], agent_change: bool) -> bool {
377+ rulesets.iter().any(|ruleset| {
378+ ruleset
379+ .rules
380+ .iter()
381+ .any(|entry| entry.applies_to.covers(agent_change) && content::about_content(&entry.rule) && !matches!(entry.rule, Rule::SecretScanning(_)))
382+ })
383+}
384+
385+/// The teams rules name, for their people to be looked up.
386+pub fn named_teams(rulesets: &[Applicable]) -> Vec<String> {
387+ let mut teams: Vec<String> = rulesets
388+ .iter()
389+ .flat_map(|ruleset| ruleset.rules.iter())
390+ .filter_map(|entry| match &entry.rule {
391+ Rule::PathReview(rule) => rule.team.clone(),
392+ _ => None,
393+ })
394+ .collect();
395+ teams.sort();
396+ teams.dedup();
397+ teams
398+}
399+
400+/// What the active rules ask of a branch, in the terms g1t's lifecycle,
401+/// pull request page and merge queue use. Evaluate-mode rulesets add
402+/// nothing here: they never hold a pull request up.
403+#[derive(Clone, Debug, Default, PartialEq)]
404+pub struct Requirements {
405+ /// Whether changes reach the branch only through pull requests.
406+ pub pull_request: bool,
407+ /// Every required check, by name, that holds for the files changed.
408+ pub required_checks: Vec<String>,
409+ pub strict: bool,
410+ /// Whether every status checks rule lets a merger bypass its checks.
411+ pub allow_bypass_on_merge: bool,
412+ pub required_approvals: u32,
413+ /// Whether every pull request rule counts agents' approvals.
414+ pub count_agent_approvals: bool,
415+ pub require_code_owner_review: bool,
416+ /// The merge queue, if a rule requires it.
417+ pub merge_queue: Option<MergeQueueRule>,
418+ /// Whether g1t may land an agent's change here by itself, and the
419+ /// confidence it needs to.
420+ pub agent_auto_merge: bool,
421+ pub auto_merge_confidence: Option<ConfidenceLevel>,
422+ /// The highest cost cap below which an agent continues on its own.
423+ pub cost_cap: Option<f64>,
424+}
425+
426+/// What the active rules hold for, stacked: the most restrictive wins.
427+pub fn requirements(rulesets: &[Applicable], git_ref: &str, default_branch: &str, agent_change: bool, files: &[String]) -> Requirements {
428+ let mut found = Requirements {
429+ count_agent_approvals: true,
430+ allow_bypass_on_merge: true,
431+ agent_auto_merge: true,
432+ ..Requirements::default()
433+ };
434+ let mut any_checks = false;
435+ for ruleset in rulesets
436+ .iter()
437+ .filter(|ruleset| ruleset.enforcement == Enforcement::Active)
438+ .filter(|ruleset| applies_to_ref(ruleset, git_ref, default_branch))
439+ {
440+ for entry in ruleset.rules.iter().filter(|entry| entry.applies_to.covers(agent_change)) {
441+ match &entry.rule {
442+ Rule::PullRequest(rule) => {
443+ found.pull_request = true;
444+ found.required_approvals = found.required_approvals.max(rule.required_approvals);
445+ found.count_agent_approvals &= rule.count_agent_approvals;
446+ found.require_code_owner_review |= rule.require_code_owner_review;
447+ }
448+ Rule::RequiredStatusChecks(rule) if checks_hold(rule, files) => {
449+ any_checks = true;
450+ found.strict |= rule.strict;
451+ found.allow_bypass_on_merge &= rule.allow_bypass_on_merge;
452+ for check in &rule.checks {
453+ let name = check.context.trim().to_owned();
454+ if !name.is_empty() && !found.required_checks.iter().any(|have| have.eq_ignore_ascii_case(&name)) {
455+ found.required_checks.push(name);
456+ }
457+ }
458+ }
459+ Rule::MergeQueue(rule) => {
460+ found.pull_request = true;
461+ found.merge_queue = Some(match found.merge_queue.take() {
462+ // Two queue rules: the smaller batches and the
463+ // longer waits of either.
464+ Some(have) => MergeQueueRule {
465+ merge_method: have.merge_method,
466+ max_entries_to_build: have.max_entries_to_build.min(rule.max_entries_to_build),
467+ min_entries_to_merge: have.min_entries_to_merge.max(rule.min_entries_to_merge),
468+ min_entries_wait_minutes: have.min_entries_wait_minutes.max(rule.min_entries_wait_minutes),
469+ check_response_timeout_minutes: have.check_response_timeout_minutes.min(rule.check_response_timeout_minutes),
470+ },
471+ None => rule.clone(),
472+ });
473+ }
474+ Rule::AgentAutoMerge(rule) => {
475+ found.agent_auto_merge &= rule.allowed;
476+ if let Some(minimum) = rule.minimum_confidence {
477+ found.auto_merge_confidence = Some(match found.auto_merge_confidence {
478+ Some(have) if level_rank(have) >= level_rank(minimum) => have,
479+ _ => minimum,
480+ });
481+ }
482+ }
483+ Rule::CostCap(rule) => {
484+ found.cost_cap = Some(found.cost_cap.map_or(rule.max_usd, |have| have.min(rule.max_usd)));
485+ }
486+ _ => {}
487+ }
488+ }
489+ }
490+ if !any_checks {
491+ // Nothing to bypass: the setting means nothing without checks.
492+ found.allow_bypass_on_merge = true;
493+ }
494+ found
495+}
496+
497+/// Whether g1t may land an agent's change into the branch by itself, given
498+/// how sure of it g1t is; why not, if it may not.
499+pub fn auto_merge_refusal(requirements: &Requirements, confidence: Option<ConfidenceLevel>) -> Option<String> {
500+ if !requirements.agent_auto_merge {
501+ return Some("Rules for this branch do not let agents' changes merge by themselves.".to_owned());
502+ }
503+ let minimum = requirements.auto_merge_confidence?;
504+ if confidence.is_some_and(|level| level_rank(level) >= level_rank(minimum)) {
505+ return None;
506+ }
507+ Some(format!("Rules for this branch let an agent's change merge by itself only at {} confidence or higher.", minimum.as_str()))
508+}
509+
510+/// Whether a violation is about checks or being up to date, which g1t's
511+/// lifecycle waits for on its own, rather than something people must do.
512+pub fn about_checks(rule: &str) -> bool {
513+ matches!(rule, "required_status_checks" | "required_deployments")
514+}
515+
516+#[cfg(test)]
517+mod tests {
518+ use super::*;
519+ use crate::content::tests_support::commit;
520+ use crate::outcome::{blocking, refused};
521+ use g1t_contracts::rules::{
522+ AppliesTo, BypassMode, ConfidenceRule, CostCapRule, DeploymentsRule, Level, MergeWindowRule, NoParameters,
523+ PathReviewRule, Period, PullRequestRule, RefCondition, RequiredCheck, RuleEntry, Verdict as Outcome,
524+ };
525+
526+ fn ruleset(rules: Vec<RuleEntry>) -> Applicable {
527+ Applicable {
528+ id: "rs_1".into(),
529+ name: "Protect main".into(),
530+ level: Level::Repository,
531+ enforcement: Enforcement::Active,
532+ target: g1t_contracts::rules::Target::Branch,
533+ conditions: RefCondition { include: vec!["~DEFAULT_BRANCH".into()], exclude: Vec::new() },
534+ rules,
535+ bypass: None,
536+ }
537+ }
538+
539+ fn all(rule: Rule) -> RuleEntry {
540+ RuleEntry::everyone(rule)
541+ }
542+
543+ fn review(id: &str, verdict: Verdict, at: &str) -> Review {
544+ Review { reviewer_id: id.into(), username: id.into(), verdict, at: at.into(), agent: id == "g1t" }
545+ }
546+
547+ fn status(context: &str, state: &str) -> CommitStatus {
548+ CommitStatus { context: context.into(), state: state.into(), description: None, target_url: None, updated_at: String::new(), source: None }
549+ }
550+
551+ fn facts<'a>(reviews: &'a [Review], statuses: &'a [CommitStatus], files: &'a [String]) -> MergeFacts<'a> {
552+ MergeFacts {
553+ git_ref: "refs/heads/main".into(),
554+ owner_id: "ada",
555+ reviews,
556+ statuses,
557+ files,
558+ now_ms: 1_000,
559+ method: Some(MergeMethod::Merge),
560+ ..MergeFacts::default()
561+ }
562+ }
563+
564+ fn messages(judged: &[Judged]) -> Vec<String> {
565+ blocking(judged).iter().map(|violation| violation.message.clone()).collect()
566+ }
567+
568+ #[test]
569+ fn approvals_are_counted_as_the_rule_says() {
570+ let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 2, ..PullRequestRule::default() }))])];
571+ let one = [review("bob", Verdict::Approve, "2026-10-07T10:00:00Z"), review("ada", Verdict::Approve, "2026-10-07T10:00:00Z")];
572+ assert_eq!(messages(&judge(&rules, "main", &facts(&one, &[], &[]))), vec!["It needs 2 approving reviews; it has 1."]);
573+ let two = [review("bob", Verdict::Approve, "x"), review("g1t", Verdict::Approve, "x")];
574+ assert!(!refused(&judge(&rules, "main", &facts(&two, &[], &[]))));
575+ let people_only = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
576+ required_approvals: 2,
577+ count_agent_approvals: false,
578+ ..PullRequestRule::default()
579+ }))])];
580+ assert_eq!(
581+ messages(&judge(&people_only, "main", &facts(&two, &[], &[]))),
582+ vec!["It needs 2 approving reviews from people; it has 1."]
583+ );
584+ let blocked = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::RequestChanges, "x")];
585+ assert_eq!(messages(&judge(&rules, "main", &facts(&blocked, &[], &[]))), vec!["A reviewer has asked for changes."]);
586+ }
587+
588+ #[test]
589+ fn stale_approvals_and_the_last_push() {
590+ let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
591+ required_approvals: 1,
592+ dismiss_stale_reviews_on_push: true,
593+ require_last_push_approval: true,
594+ ..PullRequestRule::default()
595+ }))])];
596+ let before = [review("bob", Verdict::Approve, "2026-10-07T09:00:00Z")];
597+ let mut pushed = facts(&before, &[], &[]);
598+ pushed.head_pushed_at = Some("2026-10-07T10:00:00Z");
599+ pushed.head_pushed_by = Some("bob");
600+ let found = messages(&judge(&rules, "main", &pushed));
601+ assert_eq!(found.len(), 2);
602+ assert_eq!(found[0], "It needs 1 approving review since its latest push; it has 0.");
603+ // Bob approves again, but he pushed last: someone else must.
604+ let after = [review("bob", Verdict::Approve, "2026-10-07T11:00:00Z")];
605+ let mut again = facts(&after, &[], &[]);
606+ again.head_pushed_at = Some("2026-10-07T10:00:00Z");
607+ again.head_pushed_by = Some("bob");
608+ assert_eq!(
609+ messages(&judge(&rules, "main", &again)),
610+ vec!["Its latest push has not been approved by someone other than whoever pushed it."]
611+ );
612+ let other = [review("cy", Verdict::Approve, "2026-10-07T11:00:00Z")];
613+ let mut fine = facts(&other, &[], &[]);
614+ fine.head_pushed_at = Some("2026-10-07T10:00:00Z");
615+ fine.head_pushed_by = Some("bob");
616+ assert!(!refused(&judge(&rules, "main", &fine)));
617+ }
618+
619+ #[test]
620+ fn code_owners_and_merge_methods() {
621+ let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
622+ require_code_owner_review: true,
623+ allowed_merge_methods: vec![MergeMethod::Squash],
624+ ..PullRequestRule::default()
625+ }))])];
626+ let mut waiting = facts(&[], &[], &[]);
627+ waiting.code_owners_missing = Some("@acme/docs must approve changes to docs/.");
628+ let found = messages(&judge(&rules, "main", &waiting));
629+ assert_eq!(found[0], "@acme/docs must approve changes to docs/.");
630+ assert_eq!(found[1], "This branch allows only squash merges, and this one would be a merge merge.");
631+ }
632+
633+ #[test]
634+ fn required_checks_pass_fail_wait_and_can_be_bypassed() {
635+ let checks = |allow: bool| {
636+ [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
637+ checks: vec![RequiredCheck { context: "CI".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
638+ allow_bypass_on_merge: allow,
639+ ..StatusChecksRule::default()
640+ }))])]
641+ };
642+ let statuses = [status("CI / pull_request", "failure")];
643+ let found = messages(&judge(&checks(false), "main", &facts(&[], &statuses, &[])));
644+ assert_eq!(found, vec!["The required check CI failed.", "The required check Lint has not reported on its latest commit."]);
645+ let mut ignoring = facts(&[], &statuses, &[]);
646+ ignoring.ignore_checks = true;
647+ assert!(refused(&judge(&checks(false), "main", &ignoring)), "not where the rule forbids it");
648+ assert!(!refused(&judge(&checks(true), "main", &ignoring)));
649+ let green = [status("CI / pull_request", "success"), status("Lint / pull_request", "success")];
650+ assert!(!refused(&judge(&checks(false), "main", &facts(&[], &green, &[]))));
651+ }
652+
653+ #[test]
654+ fn a_check_pinned_to_an_integration_counts_only_its_statuses() {
655+ let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
656+ checks: vec![RequiredCheck { context: "g1t / deploy".into(), integration: Some(Integration::Deployments) }],
657+ ..StatusChecksRule::default()
658+ }))])];
659+ // A workflow named "g1t" on a "deploy" event is not the deployment.
660+ let mut forged = status("g1t / deploy", "success");
661+ forged.source = Some("actions".into());
662+ assert_eq!(
663+ messages(&judge(&rules, "main", &facts(&[], &[forged], &[]))),
664+ vec!["The required check g1t / deploy from deployments has not reported on its latest commit."]
665+ );
666+ let real = status("g1t / deploy", "success");
667+ assert!(!refused(&judge(&rules, "main", &facts(&[], &[real], &[]))));
668+ }
669+
670+ #[test]
671+ fn checks_required_only_for_some_paths() {
672+ let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
673+ checks: vec![RequiredCheck { context: "Terraform".into(), integration: None }],
674+ paths: vec!["infra/**".into()],
675+ ..StatusChecksRule::default()
676+ }))])];
677+ let docs = vec!["docs/a.md".to_owned()];
678+ assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
679+ let infra = vec!["infra/main.tf".to_owned()];
680+ assert!(refused(&judge(&rules, "main", &facts(&[], &[], &infra))));
681+ assert!(requirements(&rules, "refs/heads/main", "main", false, &docs).required_checks.is_empty());
682+ assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &infra).required_checks, vec!["Terraform"]);
683+ }
684+
685+ #[test]
686+ fn being_up_to_date_and_deployments() {
687+ let rules = [ruleset(vec![
688+ all(Rule::RequiredStatusChecks(StatusChecksRule { strict: true, ..StatusChecksRule::default() })),
689+ all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["preview".into(), "docs".into()] })),
690+ ])];
691+ let statuses = [status("g1t / deploy", "success"), status("g1t / deploy (docs)", "pending")];
692+ let mut behind = facts(&[], &statuses, &[]);
693+ behind.behind = true;
694+ assert_eq!(
695+ messages(&judge(&rules, "main", &behind)),
696+ vec![
697+ "It is behind the branch it merges into, which requires pull requests to be up to date.",
698+ "It has not deployed to docs successfully yet: the deployment is running."
699+ ]
700+ );
701+ }
702+
703+ #[test]
704+ fn commits_it_lands_are_checked_when_read() {
705+ let rules = [ruleset(vec![all(Rule::RequiredLinearHistory(NoParameters {}))])];
706+ assert!(needs_commits(&rules, false));
707+ let mut merge = commit("abcdef12", "Merge main", &[]);
708+ merge.parents = 2;
709+ let inspected = InspectedCommits { commits: vec![merge], complete: true };
710+ let mut read = facts(&[], &[], &[]);
711+ read.commits = Some(&inspected);
712+ assert_eq!(blocking(&judge(&rules, "main", &read))[0].rule, "required_linear_history");
713+ let unread = InspectedCommits { commits: Vec::new(), complete: false };
714+ read.commits = Some(&unread);
715+ assert!(refused(&judge(&rules, "main", &read)));
716+ }
717+
718+ #[test]
719+ fn agent_changes_below_the_confidence_threshold_need_a_person() {
720+ let rules = [ruleset(vec![all(Rule::ConfidenceThreshold(ConfidenceRule { minimum: ConfidenceLevel::High, required_approvals: 1 }))])];
721+ let mut agent = facts(&[], &[], &[]);
722+ agent.agent_change = true;
723+ agent.confidence = Some(ConfidenceLevel::Medium);
724+ assert_eq!(
725+ messages(&judge(&rules, "main", &agent)),
726+ vec!["g1t rates this agent's change medium confidence; below high it needs 1 approval from a person."]
727+ );
728+ agent.confidence = Some(ConfidenceLevel::High);
729+ assert!(!refused(&judge(&rules, "main", &agent)));
730+ agent.confidence = None;
731+ assert!(refused(&judge(&rules, "main", &agent)));
732+ let approved = [review("bob", Verdict::Approve, "x")];
733+ let mut seen = facts(&approved, &[], &[]);
734+ seen.agent_change = true;
735+ assert!(!refused(&judge(&rules, "main", &seen)));
736+ // A g1t approval is not a person's.
737+ let robot = [review("g1t", Verdict::Approve, "x")];
738+ let mut unseen = facts(&robot, &[], &[]);
739+ unseen.agent_change = true;
740+ assert!(refused(&judge(&rules, "main", &unseen)));
741+ // A person's change is not rated.
742+ assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &[]))));
743+ }
744+
745+ #[test]
746+ fn rules_for_agents_and_for_people() {
747+ let agents_need_a_human = RuleEntry {
748+ rule: Rule::PullRequest(PullRequestRule { required_approvals: 1, count_agent_approvals: false, ..PullRequestRule::default() }),
749+ applies_to: AppliesTo::Agents,
750+ };
751+ let rules = [ruleset(vec![agents_need_a_human])];
752+ let robot = [review("g1t", Verdict::Approve, "x")];
753+ let mut agent = facts(&robot, &[], &[]);
754+ agent.agent_change = true;
755+ assert_eq!(messages(&judge(&rules, "main", &agent)), vec!["It needs 1 approving review from people; it has 0."]);
756+ assert!(!refused(&judge(&rules, "main", &facts(&robot, &[], &[]))), "people's changes are not held");
757+ }
758+
759+ #[test]
760+ fn a_cost_cap_holds_until_a_person_approves() {
761+ let rules = [ruleset(vec![all(Rule::CostCap(CostCapRule { max_usd: 5.0 }))])];
762+ let mut costly = facts(&[], &[], &[]);
763+ costly.spent_usd = 7.5;
764+ assert_eq!(
765+ messages(&judge(&rules, "main", &costly)),
766+ vec!["Agents have spent $7.50 on this pull request, over its $5.00 cap."]
767+ );
768+ costly.spent_usd = 4.0;
769+ assert!(!refused(&judge(&rules, "main", &costly)));
770+ let approved = [review("bob", Verdict::Approve, "x")];
771+ let mut seen = facts(&approved, &[], &[]);
772+ seen.spent_usd = 7.5;
773+ assert!(!refused(&judge(&rules, "main", &seen)));
774+ assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &[]).cost_cap, Some(5.0));
775+ }
776+
777+ #[test]
778+ fn sensitive_paths_need_their_teams_approval() {
779+ let rules = [ruleset(vec![all(Rule::PathReview(PathReviewRule {
780+ paths: vec!["infra/**".into(), "*.tf".into()],
781+ required_approvals: 2,
782+ team: Some("acme/platform".into()),
783+ }))])];
784+ let files = vec!["infra/main.tf".to_owned(), "src/lib.rs".to_owned()];
785+ let mut teams = HashMap::new();
786+ teams.insert("acme/platform".to_owned(), vec!["bob".to_owned(), "cy".to_owned()]);
787+ let reviews = [review("bob", Verdict::Approve, "x"), review("dee", Verdict::Approve, "x")];
788+ let mut one = facts(&reviews, &[], &files);
789+ one.team_members = Some(&teams);
790+ assert_eq!(
791+ messages(&judge(&rules, "main", &one)),
792+ vec!["It changes sensitive paths (infra/main.tf), which need 2 approvals from @acme/platform since its latest push; it has 1."]
793+ );
794+ let both = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::Approve, "x")];
795+ let mut two = facts(&both, &[], &files);
796+ two.team_members = Some(&teams);
797+ assert!(!refused(&judge(&rules, "main", &two)));
798+ let docs = vec!["docs/a.md".to_owned()];
799+ assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
800+ assert_eq!(named_teams(&rules), vec!["acme/platform"]);
801+ }
802+
803+ #[test]
804+ fn a_merge_freeze_holds_merges() {
805+ let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule {
806+ freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }],
807+ ..MergeWindowRule::default()
808+ }))])];
809+ assert_eq!(messages(&judge(&rules, "main", &facts(&[], &[], &[]))), vec!["Merging is frozen (Incident) until the freeze is lifted."]);
810+ }
811+
812+ #[test]
813+ fn bypassing_for_pull_requests_covers_merges() {
814+ let mut rules = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() }))]);
815+ rules.bypass = Some(BypassMode::PullRequests);
816+ let judged = judge(&[rules], "main", &facts(&[], &[], &[]));
817+ assert!(!refused(&judged));
818+ assert_eq!(judged[0].verdict(), Outcome::Bypass);
819+ }
820+
821+ #[test]
822+ fn requirements_stack_to_the_most_restrictive() {
823+ let a = ruleset(vec![
824+ all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() })),
825+ all(Rule::RequiredStatusChecks(StatusChecksRule {
826+ checks: vec![RequiredCheck { context: "CI".into(), integration: None }],
827+ allow_bypass_on_merge: true,
828+ ..StatusChecksRule::default()
829+ })),
830+ all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 8, ..MergeQueueRule::default() })),
831+ ]);
832+ let mut b = ruleset(vec![
833+ all(Rule::PullRequest(PullRequestRule { required_approvals: 3, count_agent_approvals: false, require_code_owner_review: true, ..PullRequestRule::default() })),
834+ all(Rule::RequiredStatusChecks(StatusChecksRule {
835+ checks: vec![RequiredCheck { context: "ci".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
836+ strict: true,
837+ ..StatusChecksRule::default()
838+ })),
839+ all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 2, ..MergeQueueRule::default() })),
840+ ]);
841+ b.id = "rs_2".into();
842+ let mut dry = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 6, ..PullRequestRule::default() }))]);
843+ dry.enforcement = Enforcement::Evaluate;
844+ let found = requirements(&[a, b, dry], "refs/heads/main", "main", false, &[]);
845+ assert!(found.pull_request);
846+ assert_eq!(found.required_approvals, 3, "evaluate mode adds nothing");
847+ assert!(!found.count_agent_approvals && found.require_code_owner_review && found.strict);
848+ assert!(!found.allow_bypass_on_merge);
849+ assert_eq!(found.required_checks, vec!["CI", "Lint"]);
850+ assert_eq!(found.merge_queue.unwrap().max_entries_to_build, 2);
851+ assert!(requirements(&[], "refs/heads/main", "main", false, &[]).allow_bypass_on_merge);
852+ }
853+
854+ #[test]
855+ fn agent_auto_merge_by_branch_and_confidence() {
856+ let rules = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule {
857+ allowed: true,
858+ minimum_confidence: Some(ConfidenceLevel::High),
859+ }))])];
860+ let found = requirements(&rules, "refs/heads/main", "main", true, &[]);
861+ assert!(auto_merge_refusal(&found, Some(ConfidenceLevel::Medium)).is_some());
862+ assert_eq!(auto_merge_refusal(&found, Some(ConfidenceLevel::High)), None);
863+ let off = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { allowed: false, minimum_confidence: None }))])];
864+ assert!(auto_merge_refusal(&requirements(&off, "refs/heads/main", "main", true, &[]), Some(ConfidenceLevel::High)).is_some());
865+ assert_eq!(auto_merge_refusal(&requirements(&[], "refs/heads/main", "main", true, &[]), None), None);
866+ }
867+
868+ #[test]
869+ fn statuses_come_from_their_integration() {
870+ assert_eq!(integration_of(&status("CI / pull_request", "success")), Integration::Actions);
871+ assert_eq!(integration_of(&status("g1t / deploy (docs)", "success")), Integration::Deployments);
872+ assert_eq!(integration_of(&status("Code scanning", "success")), Integration::Security);
873+ let mut recorded = status("CI / push", "success");
874+ recorded.source = Some("security".into());
875+ assert_eq!(integration_of(&recorded), Integration::Security);
876+ assert_eq!(deployment_context("preview"), "g1t / deploy");
877+ assert_eq!(deployment_context("docs"), "g1t / deploy (docs)");
878+ }
879+}
+91−0
1+//! How one ruleset judged one change, and how several stack.
2+
3+use g1t_contracts::rules::{Applicable, BypassMode, Enforcement, Level, Verdict, Violation};
4+
5+use crate::content::Problem;
6+
7+/// One ruleset's judgement of one change to one ref.
8+#[derive(Clone, Debug, PartialEq)]
9+pub struct Judged {
10+ pub id: String,
11+ pub name: String,
12+ pub level: Level,
13+ pub enforcement: Enforcement,
14+ pub bypass: Option<BypassMode>,
15+ /// The full ref.
16+ pub git_ref: String,
17+ pub violations: Vec<Violation>,
18+ /// Whether the change is a pull request merging (a bypass "for pull
19+ /// requests" holds) rather than a push.
20+ pub merging: bool,
21+}
22+
23+impl Judged {
24+ pub(crate) fn of(ruleset: &Applicable, git_ref: &str, merging: bool) -> Judged {
25+ Judged {
26+ id: ruleset.id.clone(),
27+ name: ruleset.name.clone(),
28+ level: ruleset.level,
29+ enforcement: ruleset.enforcement,
30+ bypass: ruleset.bypass,
31+ git_ref: git_ref.to_owned(),
32+ violations: Vec::new(),
33+ merging,
34+ }
35+ }
36+
37+ pub(crate) fn add(&mut self, rule: &str, problem: Problem) {
38+ self.violations.push(Violation {
39+ rule: rule.to_owned(),
40+ ruleset_id: self.id.clone(),
41+ ruleset_name: self.name.clone(),
42+ enforcement: self.enforcement,
43+ message: problem.message,
44+ remedy: problem.remedy,
45+ });
46+ }
47+
48+ /// Whether the actor's bypass holds for this change.
49+ pub fn bypassed(&self) -> bool {
50+ match self.bypass {
51+ Some(BypassMode::Always) => true,
52+ Some(BypassMode::PullRequests) => self.merging,
53+ None => false,
54+ }
55+ }
56+
57+ pub fn verdict(&self) -> Verdict {
58+ if self.violations.is_empty() {
59+ Verdict::Pass
60+ } else if self.bypassed() {
61+ Verdict::Bypass
62+ } else {
63+ Verdict::Fail
64+ }
65+ }
66+
67+ /// Whether it refuses the change: an active ruleset that failed.
68+ pub fn blocks(&self) -> bool {
69+ self.enforcement == Enforcement::Active && self.verdict() == Verdict::Fail
70+ }
71+
72+ /// Whether it would refuse it, were it active.
73+ pub fn would_block(&self) -> bool {
74+ self.enforcement == Enforcement::Evaluate && self.verdict() == Verdict::Fail
75+ }
76+}
77+
78+/// The violations that refuse the change, across rulesets.
79+pub fn blocking(judged: &[Judged]) -> Vec<&Violation> {
80+ judged.iter().filter(|one| one.blocks()).flat_map(|one| one.violations.iter()).collect()
81+}
82+
83+/// The violations rulesets in `evaluate` would have refused it for.
84+pub fn would_block(judged: &[Judged]) -> Vec<&Violation> {
85+ judged.iter().filter(|one| one.would_block()).flat_map(|one| one.violations.iter()).collect()
86+}
87+
88+/// Whether anything refuses the change.
89+pub fn refused(judged: &[Judged]) -> bool {
90+ judged.iter().any(Judged::blocks)
91+}
+312−0
1+//! Judging a change to a branch or tag that does not come from merging a
2+//! pull request: a push, a branch created, deleted or renamed through g1t,
3+//! or a commit made on the site.
4+
5+use g1t_contracts::rules::{Applicable, CommitFacts, Rule, Target};
6+
7+use crate::content::{self, Problem};
8+use crate::outcome::Judged;
9+use crate::select::{Who, applies_to_ref};
10+use crate::text;
11+
12+/// One ref a change moves.
13+#[derive(Clone, Debug, Default, PartialEq, Eq)]
14+pub struct RefChange {
15+ /// The full ref.
16+ pub git_ref: String,
17+ /// Where it pointed; `None` when it is created.
18+ pub old: Option<String>,
19+ /// Where it will; `None` when it is deleted.
20+ pub new: Option<String>,
21+ /// For an update: whether `new` contains `old`. `None` if unknown.
22+ pub fast_forward: Option<bool>,
23+ /// The commits the change adds to the ref, newest first.
24+ pub commits: Vec<CommitFacts>,
25+ /// Whether `commits` is every commit it adds, each read in full.
26+ pub complete: bool,
27+}
28+
29+impl RefChange {
30+ fn created(&self) -> bool {
31+ self.old.is_none() && self.new.is_some()
32+ }
33+
34+ fn deleted(&self) -> bool {
35+ self.new.is_none()
36+ }
37+
38+ fn updated(&self) -> bool {
39+ self.old.is_some() && self.new.is_some()
40+ }
41+}
42+
43+fn short_name(git_ref: &str) -> &str {
44+ Target::of_ref(git_ref).map_or(git_ref, |(_, name)| name)
45+}
46+
47+/// The problems one rule finds in a change, for an actor of kind `who`.
48+fn rule_problems(rule: &Rule, change: &RefChange) -> Vec<Problem> {
49+ let name = short_name(&change.git_ref);
50+ let tag = change.git_ref.starts_with("refs/tags/");
51+ let what = if tag { "tag" } else { "branch" };
52+ match rule {
53+ Rule::Creation(_) if change.created() => vec![Problem::new(
54+ format!("Only people this ruleset lets bypass it may create the {what} {name}."),
55+ format!("Use a {what} name the ruleset does not cover, or ask someone who may bypass it."),
56+ )],
57+ Rule::Update(_) if change.updated() => vec![Problem::new(
58+ format!("Only people this ruleset lets bypass it may push to {name}."),
59+ "Ask someone who may bypass it, or change it through a pull request.",
60+ )],
61+ Rule::Deletion(_) if change.deleted() => vec![Problem::new(
62+ format!("The {what} {name} cannot be deleted."),
63+ "Ask someone who may bypass this ruleset.",
64+ )],
65+ Rule::NonFastForward(_) if change.updated() && change.fast_forward == Some(false) => vec![Problem::new(
66+ format!("Force pushes to {name} are blocked: the push would rewrite its history."),
67+ format!("Pull {name}, put your commits on top of it, and push without --force."),
68+ )],
69+ Rule::PullRequest(rule) if change.updated() && !tag && !rule.allow_direct_pushes => vec![Problem::new(
70+ format!("Changes to {name} must be made through a pull request."),
71+ format!("Push a branch, open a pull request into {name}, and merge it."),
72+ )],
73+ Rule::BranchNamePattern(pattern) | Rule::TagNamePattern(pattern) if change.created() => {
74+ match text::compile(pattern) {
75+ Ok(compiled) if !compiled.allows(name) => vec![Problem::new(
76+ format!("The {what} name {name} does not {}.", compiled.wants()),
77+ format!("Name the {what} so it does {}.", compiled.wants().trim_start_matches("not ")),
78+ )],
79+ _ => Vec::new(),
80+ }
81+ }
82+ rule if content::about_content(rule) && !change.deleted() => {
83+ content::problems(rule, &change.commits, change.complete)
84+ }
85+ _ => Vec::new(),
86+ }
87+}
88+
89+/// How every applicable ruleset judges one ref change by an actor of kind
90+/// `who`. Rulesets that do not hold for its ref are left out.
91+pub fn judge(rulesets: &[Applicable], default_branch: &str, who: Who, change: &RefChange) -> Vec<Judged> {
92+ rulesets
93+ .iter()
94+ .filter(|ruleset| applies_to_ref(ruleset, &change.git_ref, default_branch))
95+ .map(|ruleset| {
96+ let mut judged = Judged::of(ruleset, &change.git_ref, false);
97+ for entry in &ruleset.rules {
98+ if !entry.applies_to.covers(who.is_agent()) {
99+ continue;
100+ }
101+ let kind = entry.rule.kind();
102+ for problem in rule_problems(&entry.rule, change) {
103+ judged.add(kind, problem);
104+ }
105+ }
106+ judged
107+ })
108+ .collect()
109+}
110+
111+/// Whether any ruleset that is not bypassed has a rule that needs a
112+/// change's commits read: if none, a push need not be parsed for rules.
113+pub fn needs_content(rulesets: &[Applicable], who: Who) -> bool {
114+ rulesets.iter().filter(|ruleset| ruleset.bypass.is_none()).any(|ruleset| {
115+ ruleset
116+ .rules
117+ .iter()
118+ .any(|entry| entry.applies_to.covers(who.is_agent()) && content::about_content(&entry.rule))
119+ })
120+}
121+
122+/// Whether any ruleset asks for every push to be read whole.
123+pub fn requires_scanning(rulesets: &[Applicable], who: Who) -> bool {
124+ rulesets.iter().any(|ruleset| {
125+ ruleset
126+ .rules
127+ .iter()
128+ .any(|entry| entry.applies_to.covers(who.is_agent()) && matches!(entry.rule, Rule::SecretScanning(_)))
129+ })
130+}
131+
132+/// Whether any rule asks for signatures to be verified.
133+pub fn needs_signatures(rulesets: &[Applicable]) -> bool {
134+ rulesets
135+ .iter()
136+ .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_))))
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+ use crate::outcome::{blocking, refused, would_block};
143+ use g1t_contracts::rules::{
144+ AppliesTo, BypassMode, Enforcement, FilePathRule, Level, NoParameters, PatternOperator, PatternRule,
145+ PullRequestRule, RefCondition, RuleEntry, Verdict,
146+ };
147+
148+ fn ruleset(id: &str, include: &[&str], rules: Vec<RuleEntry>) -> Applicable {
149+ Applicable {
150+ id: id.into(),
151+ name: format!("Ruleset {id}"),
152+ level: Level::Repository,
153+ enforcement: Enforcement::Active,
154+ target: if include.iter().any(|p| p.starts_with("v")) { Target::Tag } else { Target::Branch },
155+ conditions: RefCondition { include: include.iter().map(|p| (*p).to_owned()).collect(), exclude: Vec::new() },
156+ rules,
157+ bypass: None,
158+ }
159+ }
160+
161+ fn all(rule: Rule) -> RuleEntry {
162+ RuleEntry::everyone(rule)
163+ }
164+
165+ fn update(git_ref: &str) -> RefChange {
166+ RefChange {
167+ git_ref: git_ref.into(),
168+ old: Some("a".repeat(40)),
169+ new: Some("b".repeat(40)),
170+ fast_forward: Some(true),
171+ commits: Vec::new(),
172+ complete: true,
173+ }
174+ }
175+
176+ #[test]
177+ fn a_protected_branch_takes_changes_only_through_pull_requests() {
178+ let protect = ruleset("main", &["~DEFAULT_BRANCH"], vec![all(Rule::PullRequest(PullRequestRule::default()))]);
179+ let judged = judge(&[protect.clone()], "main", Who::Person, &update("refs/heads/main"));
180+ assert!(refused(&judged));
181+ assert_eq!(blocking(&judged)[0].message, "Changes to main must be made through a pull request.");
182+ assert_eq!(blocking(&judged)[0].rule, "pull_request");
183+ // Creating it, as the first push to an empty repository does, is allowed.
184+ let created = RefChange { old: None, ..update("refs/heads/main") };
185+ assert!(!refused(&judge(&[protect.clone()], "main", Who::Person, &created)));
186+ // Another branch is not covered.
187+ assert!(judge(&[protect], "main", Who::Person, &update("refs/heads/feature")).is_empty());
188+ }
189+
190+ #[test]
191+ fn creations_deletions_and_force_pushes() {
192+ let guard = ruleset(
193+ "r",
194+ &["release/*"],
195+ vec![all(Rule::Creation(NoParameters {})), all(Rule::Deletion(NoParameters {})), all(Rule::NonFastForward(NoParameters {}))],
196+ );
197+ let created = RefChange { old: None, ..update("refs/heads/release/2") };
198+ assert_eq!(blocking(&judge(&[guard.clone()], "main", Who::Person, &created))[0].rule, "creation");
199+ let deleted = RefChange { new: None, ..update("refs/heads/release/2") };
200+ assert_eq!(blocking(&judge(&[guard.clone()], "main", Who::Person, &deleted))[0].rule, "deletion");
201+ let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/release/2") };
202+ let judged = judge(&[guard.clone()], "main", Who::Person, &forced);
203+ assert_eq!(blocking(&judged)[0].message, "Force pushes to release/2 are blocked: the push would rewrite its history.");
204+ assert!(!refused(&judge(&[guard], "main", Who::Person, &update("refs/heads/release/2"))));
205+ }
206+
207+ #[test]
208+ fn a_bypass_lets_the_push_through_and_is_recorded_as_one() {
209+ let mut protect = ruleset("main", &["main"], vec![all(Rule::Update(NoParameters {}))]);
210+ protect.bypass = Some(BypassMode::Always);
211+ let judged = judge(&[protect.clone()], "main", Who::Person, &update("refs/heads/main"));
212+ assert!(!refused(&judged));
213+ assert_eq!(judged[0].verdict(), Verdict::Bypass);
214+ // A bypass for pull requests only does not cover a push.
215+ protect.bypass = Some(BypassMode::PullRequests);
216+ assert!(refused(&judge(&[protect], "main", Who::Person, &update("refs/heads/main"))));
217+ }
218+
219+ #[test]
220+ fn evaluate_mode_records_without_refusing() {
221+ let mut dry = ruleset("dry", &["~ALL"], vec![all(Rule::NonFastForward(NoParameters {}))]);
222+ dry.enforcement = Enforcement::Evaluate;
223+ let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/feature") };
224+ let judged = judge(&[dry], "main", Who::Person, &forced);
225+ assert!(!refused(&judged));
226+ assert_eq!(would_block(&judged).len(), 1);
227+ assert_eq!(judged[0].verdict(), Verdict::Fail);
228+ }
229+
230+ #[test]
231+ fn rules_for_agents_hold_only_for_agents() {
232+ let agents_only = RuleEntry {
233+ rule: Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] }),
234+ applies_to: AppliesTo::Agents,
235+ };
236+ let workflows = ruleset("w", &["~ALL"], vec![agents_only]);
237+ let mut change = update("refs/heads/feature");
238+ change.commits = vec![crate::content::tests_support::commit("c1", "x", &[".g1t/workflows/deploy.yml"])];
239+ assert!(refused(&judge(&[workflows.clone()], "main", Who::Agent, &change)));
240+ assert!(refused(&judge(&[workflows.clone()], "main", Who::G1t, &change)));
241+ assert!(!refused(&judge(&[workflows], "main", Who::Person, &change)));
242+ }
243+
244+ #[test]
245+ fn names_of_new_branches_and_tags_follow_their_patterns() {
246+ let branches = ruleset(
247+ "n",
248+ &["~ALL"],
249+ vec![all(Rule::BranchNamePattern(PatternRule {
250+ name: String::new(),
251+ operator: PatternOperator::Regex,
252+ pattern: "^(main|(feature|fix)/.+)$".into(),
253+ negate: false,
254+ }))],
255+ );
256+ let bad = RefChange { old: None, ..update("refs/heads/stuff") };
257+ assert_eq!(
258+ blocking(&judge(&[branches.clone()], "main", Who::Person, &bad))[0].message,
259+ "The branch name stuff does not match /^(main|(feature|fix)/.+)$/."
260+ );
261+ let good = RefChange { old: None, ..update("refs/heads/feature/rules") };
262+ assert!(!refused(&judge(&[branches.clone()], "main", Who::Person, &good)));
263+ // Pushing to an existing branch is not naming it.
264+ assert!(!refused(&judge(&[branches], "main", Who::Person, &update("refs/heads/stuff"))));
265+ let tags = ruleset(
266+ "t",
267+ &["v*", "~ALL"],
268+ vec![all(Rule::TagNamePattern(PatternRule {
269+ name: "Semantic versions".into(),
270+ operator: PatternOperator::Regex,
271+ pattern: r"^v\d+\.\d+\.\d+$".into(),
272+ negate: false,
273+ }))],
274+ );
275+ let tag = RefChange { old: None, ..update("refs/tags/v1") };
276+ assert!(refused(&judge(&[tags], "main", Who::Person, &tag)));
277+ }
278+
279+ #[test]
280+ fn content_rules_need_the_change_read_whole() {
281+ let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]);
282+ assert!(needs_content(&[signed.clone()], Who::Person));
283+ assert!(needs_signatures(&[signed.clone()]));
284+ let unread = RefChange { complete: false, ..update("refs/heads/feature") };
285+ assert_eq!(
286+ blocking(&judge(&[signed.clone()], "main", Who::Person, &unread))[0].message,
287+ "The change is too large for g1t to check against this rule."
288+ );
289+ let mut bypassed = signed;
290+ bypassed.bypass = Some(BypassMode::Always);
291+ assert!(!needs_content(&[bypassed], Who::Person), "a bypass actor's push need not be read");
292+ let scan = ruleset("x", &["~ALL"], vec![all(Rule::SecretScanning(NoParameters {}))]);
293+ assert!(requires_scanning(&[scan], Who::Agent));
294+ }
295+
296+ #[test]
297+ fn deleting_a_branch_checks_no_commits() {
298+ let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]);
299+ let deleted = RefChange { new: None, complete: false, ..update("refs/heads/feature") };
300+ assert!(!refused(&judge(&[signed], "main", Who::Person, &deleted)));
301+ }
302+
303+ #[test]
304+ fn several_rulesets_stack() {
305+ let a = ruleset("a", &["main"], vec![all(Rule::NonFastForward(NoParameters {}))]);
306+ let b = ruleset("b", &["~ALL"], vec![all(Rule::PullRequest(PullRequestRule::default()))]);
307+ let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/main") };
308+ let judged = judge(&[a, b], "main", Who::Person, &forced);
309+ let rules: Vec<&str> = blocking(&judged).iter().map(|violation| violation.rule.as_str()).collect();
310+ assert_eq!(rules, vec!["non_fast_forward", "pull_request"]);
311+ }
312+}
+107−0
1+//! What people are told when rules refuse a change: `remote:` lines for
2+//! git, and one sentence for the API and the merge box.
3+
4+use g1t_contracts::rules::Violation;
5+
6+use crate::outcome::{Judged, blocking};
7+
8+/// The `ng` reason git prints beside a refused ref: short, one line.
9+pub fn ng_reason(judged: &[Judged]) -> String {
10+ match blocking(judged).first() {
11+ Some(violation) => format!("declined by ruleset \"{}\" ({})", violation.ruleset_name, violation.rule),
12+ None => "declined by rules".to_owned(),
13+ }
14+}
15+
16+/// The lines git prints as `remote:` for a push the rules refuse: each
17+/// broken rule with its ruleset, and how to meet it. `rules_url` is where
18+/// the branch's rules are shown.
19+pub fn remote_lines(git_ref: &str, judged: &[Judged], rules_url: &str) -> Vec<String> {
20+ let violations = blocking(judged);
21+ let mut lines = vec![
22+ String::new(),
23+ format!("error: rules for {git_ref} declined this push:"),
24+ ];
25+ let mut last_remedy = String::new();
26+ for violation in &violations {
27+ lines.push(format!("- {} [ruleset \"{}\", {}]", violation.message, violation.ruleset_name, violation.rule));
28+ if !violation.remedy.is_empty() && violation.remedy != last_remedy {
29+ lines.push(format!(" {}", violation.remedy));
30+ last_remedy = violation.remedy.clone();
31+ }
32+ }
33+ lines.push(format!("See the rules that hold for it: {rules_url}"));
34+ lines.push(String::new());
35+ lines
36+}
37+
38+/// One sentence for a refused merge: the first problem, and how many more.
39+pub fn summary(violations: &[&Violation]) -> Option<String> {
40+ let first = violations.first()?;
41+ let more = violations.len() - 1;
42+ let tail = match more {
43+ 0 => String::new(),
44+ 1 => " One more rule is not met.".to_owned(),
45+ more => format!(" {more} more rules are not met."),
46+ };
47+ Some(format!("{}{tail}", first.message))
48+}
49+
50+#[cfg(test)]
51+mod tests {
52+ use super::*;
53+ use g1t_contracts::rules::{Enforcement, Level};
54+
55+ fn judged(violations: &[(&str, &str, &str)]) -> Judged {
56+ Judged {
57+ id: "rs_1".into(),
58+ name: "Protect main".into(),
59+ level: Level::Repository,
60+ enforcement: Enforcement::Active,
61+ bypass: None,
62+ git_ref: "refs/heads/main".into(),
63+ violations: violations
64+ .iter()
65+ .map(|(rule, message, remedy)| Violation {
66+ rule: (*rule).into(),
67+ ruleset_id: "rs_1".into(),
68+ ruleset_name: "Protect main".into(),
69+ enforcement: Enforcement::Active,
70+ message: (*message).into(),
71+ remedy: (*remedy).into(),
72+ })
73+ .collect(),
74+ merging: false,
75+ }
76+ }
77+
78+ #[test]
79+ fn git_is_told_which_ruleset_and_rule_and_what_to_do() {
80+ let refused = [judged(&[
81+ ("pull_request", "Changes to main must be made through a pull request.", "Push a branch and open a pull request."),
82+ ("non_fast_forward", "Force pushes to main are blocked.", "Pull, then push without --force."),
83+ ])];
84+ assert_eq!(ng_reason(&refused), "declined by ruleset \"Protect main\" (pull_request)");
85+ let lines = remote_lines("refs/heads/main", &refused, "https://g1t.sh/acme/web/settings/rules?branch=main");
86+ assert_eq!(
87+ lines,
88+ vec![
89+ "",
90+ "error: rules for refs/heads/main declined this push:",
91+ "- Changes to main must be made through a pull request. [ruleset \"Protect main\", pull_request]",
92+ " Push a branch and open a pull request.",
93+ "- Force pushes to main are blocked. [ruleset \"Protect main\", non_fast_forward]",
94+ " Pull, then push without --force.",
95+ "See the rules that hold for it: https://g1t.sh/acme/web/settings/rules?branch=main",
96+ "",
97+ ]
98+ );
99+ }
100+
101+ #[test]
102+ fn a_merge_refusal_is_one_sentence() {
103+ let refused = judged(&[("pull_request", "It needs 2 approving reviews; it has 0.", ""), ("merge_window", "Merging is frozen.", "")]);
104+ assert_eq!(summary(&blocking(&[refused]).to_vec()).as_deref(), Some("It needs 2 approving reviews; it has 0. One more rule is not met."));
105+ assert_eq!(summary(&[]), None);
106+ }
107+}
+476−0
1+//! Which rulesets hold where, who may bypass them, and what holds for one
2+//! branch once they are stacked.
3+
4+use g1t_contracts::access::{self, RepoRole};
5+use g1t_contracts::repos::Repo;
6+use g1t_contracts::rules::{
7+ ALL, ActorKind, Applicable, BypassActor, BypassMode, DEFAULT_BRANCH, EffectiveRule, EffectiveRules, Enforcement,
8+ Level, RefCondition, RepositoryCondition, Ruleset, RulesetSummary, Target, VisibilityCondition,
9+};
10+use g1t_contracts::{PrincipalKind, Role, User};
11+
12+use crate::glob;
13+
14+/// What a ruleset needs to know about a repository to say whether it holds.
15+#[derive(Clone, Copy, Debug)]
16+pub struct RepoFacts<'a> {
17+ pub id: &'a str,
18+ pub name: &'a str,
19+ pub private: bool,
20+ pub topics: &'a [String],
21+ pub default_branch: &'a str,
22+}
23+
24+impl<'a> From<&'a Repo> for RepoFacts<'a> {
25+ fn from(repo: &'a Repo) -> Self {
26+ RepoFacts {
27+ id: &repo.id,
28+ name: &repo.name,
29+ private: repo.is_private,
30+ topics: &repo.topics,
31+ default_branch: &repo.default_branch,
32+ }
33+ }
34+}
35+
36+/// A name written as a full ref, shortened: `refs/heads/main` is `main`.
37+fn short(pattern: &str, target: Target) -> &str {
38+ let prefix = match target {
39+ Target::Branch => "refs/heads/",
40+ Target::Tag => "refs/tags/",
41+ };
42+ pattern.strip_prefix(prefix).unwrap_or(pattern)
43+}
44+
45+fn ref_pattern_matches(pattern: &str, target: Target, name: &str, default_branch: &str) -> bool {
46+ let pattern = pattern.trim();
47+ match pattern {
48+ ALL => true,
49+ DEFAULT_BRANCH => target == Target::Branch && name == default_branch,
50+ _ => glob::matches(short(pattern, target), name),
51+ }
52+}
53+
54+/// Whether a branch or tag named `name` is one `condition` selects.
55+pub fn ref_matches(condition: &RefCondition, target: Target, name: &str, default_branch: &str) -> bool {
56+ condition.include.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch))
57+ && !condition.exclude.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch))
58+}
59+
60+/// Whether a workspace ruleset's repository condition selects `repo`.
61+pub fn repo_matches(condition: &RepositoryCondition, repo: RepoFacts<'_>) -> bool {
62+ let name = repo.name.to_lowercase();
63+ let named = |pattern: &String| {
64+ let pattern = pattern.trim();
65+ pattern == ALL || glob::matches(&pattern.to_lowercase(), &name)
66+ };
67+ let visible = match condition.visibility {
68+ VisibilityCondition::Any => true,
69+ VisibilityCondition::Public => !repo.private,
70+ VisibilityCondition::Private => repo.private,
71+ };
72+ let topical = condition.topics.is_empty()
73+ || condition
74+ .topics
75+ .iter()
76+ .any(|topic| repo.topics.iter().any(|has| has.eq_ignore_ascii_case(topic.trim())));
77+ condition.include.iter().any(named) && !condition.exclude.iter().any(named) && visible && topical
78+}
79+
80+/// Whether a ruleset holds in `repo` at all, whatever the branch: a
81+/// repository's own, or a workspace's that selects it. Disabled ones never.
82+pub fn holds_in(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
83+ if ruleset.spec.enforcement == Enforcement::Disabled {
84+ return false;
85+ }
86+ match ruleset.level {
87+ Level::Repository => ruleset.repo_id.as_deref() == Some(repo.id),
88+ Level::Workspace => {
89+ let condition = ruleset.spec.conditions.repository.clone().unwrap_or_default();
90+ repo_matches(&condition, repo)
91+ }
92+ }
93+}
94+
95+/// Who is changing something, as bypass lists name people.
96+#[derive(Clone, Debug, Default, PartialEq, Eq)]
97+pub struct ActorFacts {
98+ pub username: String,
99+ pub kind: Who,
100+ /// Their role on the repository.
101+ pub role: Option<RepoRole>,
102+ /// Whether they own its workspace.
103+ pub owner: bool,
104+ /// The teams they are in, as `workspace/slug`, lowercase.
105+ pub teams: Vec<String>,
106+ /// The token they act through, if any.
107+ pub token_id: Option<String>,
108+}
109+
110+/// What kind of actor.
111+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112+pub enum Who {
113+ #[default]
114+ Person,
115+ /// An agent acting through a token, g1t's or another.
116+ Agent,
117+ /// g1t acting on its own: the merge queue, security updates.
118+ G1t,
119+ /// A workspace's own token.
120+ Token,
121+}
122+
123+impl Who {
124+ pub fn as_str(self) -> &'static str {
125+ match self {
126+ Who::Person => "person",
127+ Who::Agent => "agent",
128+ Who::G1t => "g1t",
129+ Who::Token => "token",
130+ }
131+ }
132+
133+ /// Rules for agents hold for agents and g1t; the rest are people's.
134+ pub fn is_agent(self) -> bool {
135+ matches!(self, Who::Agent | Who::G1t)
136+ }
137+}
138+
139+impl ActorFacts {
140+ /// What `user` is in `repo`. Their teams are the caller's to add.
141+ pub fn of(user: &User, repo: &Repo) -> ActorFacts {
142+ let kind = match user.kind {
143+ PrincipalKind::System => Who::G1t,
144+ PrincipalKind::Agent => Who::Agent,
145+ _ if user.acting.is_some() => Who::Agent,
146+ PrincipalKind::Workspace => Who::Token,
147+ PrincipalKind::User => Who::Person,
148+ };
149+ let token_id = user
150+ .acting
151+ .as_ref()
152+ .map(|acting| acting.credential_id.clone())
153+ .or_else(|| user.token.as_ref().map(|token| token.token_id.clone()))
154+ .filter(|id| !id.is_empty());
155+ ActorFacts {
156+ username: user.username.clone(),
157+ kind,
158+ role: access::permission(Some(user), repo),
159+ owner: user.role_in(&repo.namespace.to_lowercase()) == Some(Role::Owner),
160+ teams: Vec::new(),
161+ token_id,
162+ }
163+ }
164+}
165+
166+/// A team named in a bypass list or a rule, as `workspace/slug`.
167+pub fn team_key(name: &str, workspace: &str) -> String {
168+ let name = name.trim().trim_start_matches('@').to_lowercase();
169+ if name.contains('/') { name } else { format!("{}/{name}", workspace.to_lowercase()) }
170+}
171+
172+/// Whether one bypass entry names the actor. An agent or a token is never
173+/// named by a role, a team or a person: only by `g1t` or its token, so an
174+/// agent acting for an admin obeys the rules its admin may bypass.
175+fn names(entry: &BypassActor, who: &ActorFacts, workspace: &str) -> bool {
176+ let value = entry.value.trim();
177+ match entry.kind {
178+ // g1t's agents act through run tokens (`Agent`), and g1t on its own
179+ // as `System`.
180+ ActorKind::G1t => who.kind.is_agent(),
181+ ActorKind::Token => {
182+ (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token)
183+ || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value)
184+ }
185+ _ if who.kind != Who::Person => false,
186+ ActorKind::User => !value.is_empty() && who.username.eq_ignore_ascii_case(value.trim_start_matches('@')),
187+ ActorKind::Team => !value.is_empty() && who.teams.contains(&team_key(value, workspace)),
188+ ActorKind::Role => match value.to_ascii_lowercase().as_str() {
189+ "owner" => who.owner,
190+ role => RepoRole::parse(role).is_some_and(|wanted| who.role.is_some_and(|has| has >= wanted)),
191+ },
192+ }
193+}
194+
195+/// How the actor may bypass a ruleset with these bypass actors, if at all.
196+/// `always` wins over `pull_requests` when both name them.
197+pub fn bypass(actors: &[BypassActor], who: &ActorFacts, workspace: &str) -> Option<BypassMode> {
198+ let modes: Vec<BypassMode> = actors.iter().filter(|entry| names(entry, who, workspace)).map(|entry| entry.mode).collect();
199+ if modes.contains(&BypassMode::Always) {
200+ Some(BypassMode::Always)
201+ } else {
202+ modes.first().copied()
203+ }
204+}
205+
206+/// Whether any bypass list names a team: only then are the actor's teams
207+/// worth looking up.
208+pub fn names_teams(rulesets: &[Ruleset]) -> bool {
209+ rulesets
210+ .iter()
211+ .any(|ruleset| ruleset.spec.bypass_actors.iter().any(|entry| entry.kind == ActorKind::Team))
212+}
213+
214+/// The rulesets that hold in `repo` for any of `refs` (full refs), as a
215+/// service applies them, with how the actor may bypass each.
216+pub fn applicable(rulesets: &[Ruleset], repo: RepoFacts<'_>, refs: &[String], who: Option<&ActorFacts>, workspace: &str) -> Vec<Applicable> {
217+ rulesets
218+ .iter()
219+ .filter(|ruleset| holds_in(ruleset, repo))
220+ .filter(|ruleset| {
221+ refs.iter().any(|git_ref| match Target::of_ref(git_ref) {
222+ Some((target, name)) => {
223+ target == ruleset.spec.target
224+ && ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch)
225+ }
226+ None => false,
227+ })
228+ })
229+ .map(|ruleset| Applicable {
230+ id: ruleset.id.clone(),
231+ name: ruleset.spec.name.clone(),
232+ level: ruleset.level,
233+ enforcement: ruleset.spec.enforcement,
234+ target: ruleset.spec.target,
235+ conditions: ruleset.spec.conditions.ref_name.clone(),
236+ rules: ruleset.spec.rules.clone(),
237+ bypass: who.and_then(|who| bypass(&ruleset.spec.bypass_actors, who, workspace)),
238+ })
239+ .collect()
240+}
241+
242+/// Whether an applicable ruleset holds for a full ref.
243+pub fn applies_to_ref(ruleset: &Applicable, git_ref: &str, default_branch: &str) -> bool {
244+ match Target::of_ref(git_ref) {
245+ Some((target, name)) => target == ruleset.target && ref_matches(&ruleset.conditions, target, name, default_branch),
246+ None => false,
247+ }
248+}
249+
250+/// Every rule that holds for one branch or tag: active rulesets' first,
251+/// then those being evaluated, each with where it comes from.
252+pub fn effective(rulesets: &[Ruleset], repo: RepoFacts<'_>, target: Target, name: &str) -> EffectiveRules {
253+ let mut holding: Vec<&Ruleset> = rulesets
254+ .iter()
255+ .filter(|ruleset| holds_in(ruleset, repo))
256+ .filter(|ruleset| ruleset.spec.target == target)
257+ .filter(|ruleset| ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch))
258+ .collect();
259+ // Active before evaluate; workspace before repository; then by name.
260+ holding.sort_by_key(|ruleset| {
261+ (
262+ ruleset.spec.enforcement != Enforcement::Active,
263+ ruleset.level != Level::Workspace,
264+ ruleset.spec.name.to_lowercase(),
265+ )
266+ });
267+ EffectiveRules {
268+ name: name.to_owned(),
269+ target,
270+ default_branch: target == Target::Branch && name == repo.default_branch,
271+ rules: holding
272+ .iter()
273+ .flat_map(|ruleset| {
274+ ruleset.spec.rules.iter().map(|entry| EffectiveRule {
275+ entry: entry.clone(),
276+ ruleset_id: ruleset.id.clone(),
277+ ruleset_name: ruleset.spec.name.clone(),
278+ level: ruleset.level,
279+ enforcement: ruleset.spec.enforcement,
280+ })
281+ })
282+ .collect(),
283+ rulesets: holding
284+ .iter()
285+ .map(|ruleset| RulesetSummary {
286+ id: ruleset.id.clone(),
287+ name: ruleset.spec.name.clone(),
288+ level: ruleset.level,
289+ enforcement: ruleset.spec.enforcement,
290+ bypass_actors: ruleset.spec.bypass_actors.clone(),
291+ })
292+ .collect(),
293+ }
294+}
295+
296+#[cfg(test)]
297+mod tests {
298+ use super::*;
299+ use g1t_contracts::rules::{BypassMode, Conditions, NoParameters, Rule, RuleEntry, RulesetSpec};
300+
301+ pub(crate) fn ruleset(id: &str, level: Level, include: &[&str], exclude: &[&str], rules: Vec<Rule>) -> Ruleset {
302+ Ruleset {
303+ id: id.into(),
304+ level,
305+ workspace: "acme".into(),
306+ repo_id: (level == Level::Repository).then(|| "rep_1".to_owned()),
307+ repository: None,
308+ spec: RulesetSpec {
309+ name: id.into(),
310+ conditions: Conditions {
311+ ref_name: RefCondition {
312+ include: include.iter().map(|p| (*p).to_owned()).collect(),
313+ exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
314+ },
315+ repository: None,
316+ },
317+ rules: rules.into_iter().map(RuleEntry::everyone).collect(),
318+ ..RulesetSpec::default()
319+ },
320+ source: None,
321+ created_by: "ada".into(),
322+ created_at: String::new(),
323+ updated_by: "ada".into(),
324+ updated_at: String::new(),
325+ }
326+ }
327+
328+ fn repo<'a>(topics: &'a [String]) -> RepoFacts<'a> {
329+ RepoFacts { id: "rep_1", name: "web", private: true, topics, default_branch: "main" }
330+ }
331+
332+ #[test]
333+ fn names_match_patterns_the_default_branch_and_all() {
334+ let condition = |include: &[&str], exclude: &[&str]| RefCondition {
335+ include: include.iter().map(|p| (*p).to_owned()).collect(),
336+ exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
337+ };
338+ assert!(ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "main", "main"));
339+ assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "dev", "main"));
340+ assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Tag, "main", "main"));
341+ assert!(ref_matches(&condition(&["~ALL"], &["dependabot/**"]), Target::Branch, "feature/x", "main"));
342+ assert!(!ref_matches(&condition(&["~ALL"], &["g1t-queue/**"]), Target::Branch, "g1t-queue/a", "main"));
343+ assert!(ref_matches(&condition(&["refs/heads/release/*"], &[]), Target::Branch, "release/2", "main"));
344+ assert!(ref_matches(&condition(&["v*"], &[]), Target::Tag, "v1.0.0", "main"));
345+ assert!(!ref_matches(&condition(&[], &[]), Target::Branch, "main", "main"), "an empty include selects nothing");
346+ }
347+
348+ #[test]
349+ fn workspace_rulesets_select_repositories_by_name_visibility_and_topic() {
350+ let topics = vec!["payments".to_owned()];
351+ let mut condition = RepositoryCondition::default();
352+ assert!(repo_matches(&condition, repo(&topics)));
353+ condition.include = vec!["api-*".into()];
354+ assert!(!repo_matches(&condition, repo(&topics)));
355+ condition.include = vec!["W*".into()];
356+ assert!(repo_matches(&condition, repo(&topics)), "names ignore case");
357+ condition.exclude = vec!["web".into()];
358+ assert!(!repo_matches(&condition, repo(&topics)));
359+ condition.exclude.clear();
360+ condition.visibility = VisibilityCondition::Public;
361+ assert!(!repo_matches(&condition, repo(&topics)));
362+ condition.visibility = VisibilityCondition::Private;
363+ condition.topics = vec!["Payments".into()];
364+ assert!(repo_matches(&condition, repo(&topics)));
365+ condition.topics = vec!["docs".into()];
366+ assert!(!repo_matches(&condition, repo(&topics)));
367+ }
368+
369+ #[test]
370+ fn a_repository_ruleset_holds_only_in_its_repository_and_disabled_ones_nowhere() {
371+ let topics = Vec::new();
372+ let own = ruleset("a", Level::Repository, &["~ALL"], &[], vec![]);
373+ assert!(holds_in(&own, repo(&topics)));
374+ let other = Ruleset { repo_id: Some("rep_2".into()), ..own.clone() };
375+ assert!(!holds_in(&other, repo(&topics)));
376+ let mut off = own.clone();
377+ off.spec.enforcement = Enforcement::Disabled;
378+ assert!(!holds_in(&off, repo(&topics)));
379+ }
380+
381+ fn person(role: RepoRole) -> ActorFacts {
382+ ActorFacts { username: "ada".into(), kind: Who::Person, role: Some(role), ..ActorFacts::default() }
383+ }
384+
385+ fn entry(kind: ActorKind, value: &str, mode: BypassMode) -> BypassActor {
386+ BypassActor { kind, value: value.into(), mode }
387+ }
388+
389+ #[test]
390+ fn nobody_bypasses_by_default() {
391+ assert_eq!(bypass(&[], &person(RepoRole::Admin), "acme"), None);
392+ let g1t = ActorFacts { kind: Who::G1t, username: "g1t".into(), ..ActorFacts::default() };
393+ assert_eq!(bypass(&[], &g1t, "acme"), None);
394+ }
395+
396+ #[test]
397+ fn roles_people_and_teams_bypass_as_listed() {
398+ let list = [entry(ActorKind::Role, "maintain", BypassMode::PullRequests)];
399+ assert_eq!(bypass(&list, &person(RepoRole::Admin), "acme"), Some(BypassMode::PullRequests));
400+ assert_eq!(bypass(&list, &person(RepoRole::Write), "acme"), None);
401+ let both = [
402+ entry(ActorKind::Role, "write", BypassMode::PullRequests),
403+ entry(ActorKind::User, "@Ada", BypassMode::Always),
404+ ];
405+ assert_eq!(bypass(&both, &person(RepoRole::Write), "acme"), Some(BypassMode::Always));
406+ let team = [entry(ActorKind::Team, "release", BypassMode::Always)];
407+ let mut ada = person(RepoRole::Read);
408+ assert_eq!(bypass(&team, &ada, "acme"), None);
409+ ada.teams.push("acme/release".into());
410+ assert_eq!(bypass(&team, &ada, "acme"), Some(BypassMode::Always));
411+ let owners = [entry(ActorKind::Role, "owner", BypassMode::Always)];
412+ assert_eq!(bypass(&owners, &ada, "acme"), None);
413+ ada.owner = true;
414+ assert_eq!(bypass(&owners, &ada, "acme"), Some(BypassMode::Always));
415+ }
416+
417+ #[test]
418+ fn agents_bypass_only_when_g1t_or_their_token_is_listed() {
419+ let agent = ActorFacts {
420+ username: "g1t".into(),
421+ kind: Who::Agent,
422+ role: Some(RepoRole::Admin),
423+ owner: true,
424+ token_id: Some("tok_1".into()),
425+ ..ActorFacts::default()
426+ };
427+ let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)];
428+ assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role");
429+ assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
430+ assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
431+ assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None);
432+ let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() };
433+ assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::PullRequests)], &system, "acme"), Some(BypassMode::PullRequests));
434+ let token = ActorFacts { kind: Who::Token, ..ActorFacts::default() };
435+ assert_eq!(bypass(&[entry(ActorKind::Token, "workspace", BypassMode::Always)], &token, "acme"), Some(BypassMode::Always));
436+ }
437+
438+ #[test]
439+ fn effective_rules_stack_every_ruleset_that_holds() {
440+ let topics = Vec::new();
441+ let mut evaluate = ruleset("b-dry", Level::Repository, &["main"], &[], vec![Rule::RequiredLinearHistory(NoParameters {})]);
442+ evaluate.spec.enforcement = Enforcement::Evaluate;
443+ let rulesets = vec![
444+ evaluate,
445+ ruleset("a-main", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Deletion(NoParameters {}), Rule::NonFastForward(NoParameters {})]),
446+ ruleset("org", Level::Workspace, &["~ALL"], &[], vec![Rule::Deletion(NoParameters {})]),
447+ ruleset("release", Level::Repository, &["release/*"], &[], vec![Rule::Creation(NoParameters {})]),
448+ ];
449+ let main = effective(&rulesets, repo(&topics), Target::Branch, "main");
450+ assert!(main.default_branch);
451+ let from: Vec<(&str, &str)> = main.rules.iter().map(|rule| (rule.ruleset_id.as_str(), rule.entry.rule.kind())).collect();
452+ assert_eq!(
453+ from,
454+ vec![("org", "deletion"), ("a-main", "deletion"), ("a-main", "non_fast_forward"), ("b-dry", "required_linear_history")]
455+ );
456+ assert_eq!(main.rulesets.len(), 3);
457+ let release = effective(&rulesets, repo(&topics), Target::Branch, "release/1");
458+ assert_eq!(release.rules.iter().map(|rule| rule.entry.rule.kind()).collect::<Vec<_>>(), vec!["deletion", "creation"]);
459+ assert!(effective(&rulesets, repo(&topics), Target::Tag, "main").rules.is_empty());
460+ }
461+
462+ #[test]
463+ fn applicable_rulesets_carry_the_actors_bypass() {
464+ let topics = Vec::new();
465+ let mut guarded = ruleset("a", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Update(NoParameters {})]);
466+ guarded.spec.bypass_actors = vec![entry(ActorKind::Role, "admin", BypassMode::Always)];
467+ let rulesets = vec![guarded, ruleset("b", Level::Repository, &["feature/*"], &[], vec![])];
468+ let found = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Admin)), "acme");
469+ assert_eq!(found.len(), 1);
470+ assert_eq!(found[0].bypass, Some(BypassMode::Always));
471+ assert!(applies_to_ref(&found[0], "refs/heads/main", "main"));
472+ assert!(!applies_to_ref(&found[0], "refs/tags/main", "main"));
473+ let none = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Write)), "acme");
474+ assert_eq!(none[0].bypass, None);
475+ }
476+}
+142−0
1+//! Pattern rules: whether a commit message, an email address, or a branch
2+//! or tag name is as a rule asks.
3+//!
4+//! Regular expressions run on the `regex` crate's engine, which takes time
5+//! in proportion to the text and never backtracks, so no pattern can make a
6+//! push slow. Look-around and back-references are not supported, and a
7+//! pattern that would compile to more than [`SIZE_LIMIT`] is refused.
8+
9+use g1t_contracts::rules::{PatternOperator, PatternRule};
10+use regex::{Regex, RegexBuilder};
11+
12+/// The most memory one compiled pattern may take.
13+pub const SIZE_LIMIT: usize = 1 << 20;
14+/// The most of a text a pattern is tested on.
15+pub const MAX_TEXT: usize = 64 * 1024;
16+
17+/// A pattern rule, ready to test texts.
18+pub struct Compiled {
19+ rule: PatternRule,
20+ regex: Option<Regex>,
21+}
22+
23+/// Compiles a rule's pattern, or says why it cannot be.
24+pub fn compile(rule: &PatternRule) -> Result<Compiled, String> {
25+ let regex = match rule.operator {
26+ PatternOperator::Regex => Some(
27+ RegexBuilder::new(&rule.pattern)
28+ .size_limit(SIZE_LIMIT)
29+ .dfa_size_limit(SIZE_LIMIT)
30+ .build()
31+ .map_err(|error| match error {
32+ regex::Error::CompiledTooBig(_) => "The regular expression is too large.".to_owned(),
33+ other => format!("The regular expression is not valid: {}", first_line(&other.to_string())),
34+ })?,
35+ ),
36+ _ => None,
37+ };
38+ Ok(Compiled { rule: rule.clone(), regex })
39+}
40+
41+fn first_line(text: &str) -> String {
42+ text.lines().last().unwrap_or(text).trim().trim_start_matches("error: ").to_owned()
43+}
44+
45+impl Compiled {
46+ /// Whether `text` is as the rule asks: it matches, or with `negate`
47+ /// does not.
48+ pub fn allows(&self, text: &str) -> bool {
49+ let text = cut(text);
50+ let pattern = self.rule.pattern.as_str();
51+ let found = match self.rule.operator {
52+ PatternOperator::StartsWith => text.starts_with(pattern),
53+ PatternOperator::EndsWith => text.ends_with(pattern),
54+ PatternOperator::Contains => text.contains(pattern),
55+ PatternOperator::Regex => self.regex.as_ref().is_some_and(|regex| regex.is_match(text)),
56+ };
57+ found != self.rule.negate
58+ }
59+
60+ /// What the rule asks, in words: `start with "feat: "`, `match /^v\d/`.
61+ pub fn wants(&self) -> String {
62+ describe(&self.rule)
63+ }
64+}
65+
66+/// What a pattern rule asks, in words.
67+pub fn describe(rule: &PatternRule) -> String {
68+ let not = if rule.negate { "not " } else { "" };
69+ let pattern = &rule.pattern;
70+ let what = match rule.operator {
71+ PatternOperator::StartsWith => format!("{not}start with \"{pattern}\""),
72+ PatternOperator::EndsWith => format!("{not}end with \"{pattern}\""),
73+ PatternOperator::Contains => format!("{not}contain \"{pattern}\""),
74+ PatternOperator::Regex => format!("{not}match /{pattern}/"),
75+ };
76+ if rule.name.trim().is_empty() {
77+ what
78+ } else {
79+ format!("{what} ({})", rule.name.trim())
80+ }
81+}
82+
83+/// At most [`MAX_TEXT`] bytes of `text`, cut on a character boundary.
84+fn cut(text: &str) -> &str {
85+ if text.len() <= MAX_TEXT {
86+ return text;
87+ }
88+ let mut end = MAX_TEXT;
89+ while !text.is_char_boundary(end) {
90+ end -= 1;
91+ }
92+ &text[..end]
93+}
94+
95+#[cfg(test)]
96+mod tests {
97+ use super::*;
98+
99+ fn rule(operator: PatternOperator, pattern: &str, negate: bool) -> PatternRule {
100+ PatternRule { name: String::new(), operator, pattern: pattern.to_owned(), negate }
101+ }
102+
103+ #[test]
104+ fn each_operator_compares_as_it_says() {
105+ let starts = compile(&rule(PatternOperator::StartsWith, "feat", false)).unwrap();
106+ assert!(starts.allows("feat: add rules"));
107+ assert!(!starts.allows("fix: rules"));
108+ let ends = compile(&rule(PatternOperator::EndsWith, "@acme.com", false)).unwrap();
109+ assert!(ends.allows("ada@acme.com"));
110+ assert!(!ends.allows("ada@example.com"));
111+ let contains = compile(&rule(PatternOperator::Contains, "WIP", true)).unwrap();
112+ assert!(contains.allows("Finish rules"));
113+ assert!(!contains.allows("WIP: rules"));
114+ let regex = compile(&rule(PatternOperator::Regex, r"^(feat|fix)(\(.+\))?: ", false)).unwrap();
115+ assert!(regex.allows("fix(api): snake case"));
116+ assert!(!regex.allows("Update things"));
117+ }
118+
119+ #[test]
120+ fn a_bad_or_huge_expression_is_refused() {
121+ assert!(compile(&rule(PatternOperator::Regex, "(unclosed", false)).is_err());
122+ // Back-references need backtracking, which the engine never does.
123+ assert!(compile(&rule(PatternOperator::Regex, r"(a)\1", false)).is_err());
124+ let huge = compile(&rule(PatternOperator::Regex, r"\w{1000}\w{1000}\w{1000}", false));
125+ assert_eq!(huge.err().as_deref(), Some("The regular expression is too large."));
126+ }
127+
128+ #[test]
129+ fn a_pathological_pattern_stays_linear() {
130+ let compiled = compile(&rule(PatternOperator::Regex, "(a+)+$", false)).unwrap();
131+ let text = format!("{}!", "a".repeat(50_000));
132+ assert!(!compiled.allows(&text));
133+ }
134+
135+ #[test]
136+ fn rules_are_described_in_words() {
137+ assert_eq!(describe(&rule(PatternOperator::StartsWith, "feat", false)), "start with \"feat\"");
138+ assert_eq!(describe(&rule(PatternOperator::Regex, "^v", true)), "not match /^v/");
139+ let named = PatternRule { name: "Conventional commits".into(), ..rule(PatternOperator::Contains, ":", false) };
140+ assert_eq!(describe(&named), "contain \":\" (Conventional commits)");
141+ }
142+}
+426−0
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+/// The largest cost cap, in US dollars.
17+pub const MAX_COST_USD: f64 = 10_000.0;
18+
19+fn 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+
40+fn 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+
52+fn 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+
63+fn 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.
227+fn 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.
242+pub 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)]
322+mod 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+}
+215−0
1+//! Merge windows: when a branch takes merges, by the week, with freezes and
2+//! exceptions on a calendar.
3+
4+use g1t_contracts::rules::{MergeWindowRule, Period, Weekday};
5+use g1t_contracts::time::{parse_rfc3339, rfc3339};
6+
7+const MINUTE: u64 = 60 * 1000;
8+const DAY_MINUTES: i64 = 24 * 60;
9+
10+/// A time zone's offset from UTC in minutes: `+02:00`, `-0530`, `UTC`.
11+pub fn offset_minutes(zone: &str) -> Option<i64> {
12+ let zone = zone.trim();
13+ if zone.is_empty() || zone.eq_ignore_ascii_case("utc") || zone == "Z" {
14+ return Some(0);
15+ }
16+ let zone = zone.strip_prefix("UTC").or_else(|| zone.strip_prefix("utc")).unwrap_or(zone);
17+ let (sign, rest) = match zone.chars().next()? {
18+ '+' => (1, &zone[1..]),
19+ '-' => (-1, &zone[1..]),
20+ _ => return None,
21+ };
22+ let (hours, minutes) = match rest.split_once(':') {
23+ Some((hours, minutes)) => (hours, minutes),
24+ None if rest.len() == 4 => rest.split_at(2),
25+ None => (rest, "0"),
26+ };
27+ let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
28+ if hours > 14 || minutes > 59 {
29+ return None;
30+ }
31+ Some(sign * (hours * 60 + minutes))
32+}
33+
34+/// Minutes into the day of `HH:MM`.
35+pub fn clock(text: &str) -> Option<i64> {
36+ let (hours, minutes) = text.trim().split_once(':')?;
37+ let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
38+ if hours > 24 || minutes > 59 || (hours == 24 && minutes > 0) {
39+ return None;
40+ }
41+ Some(hours * 60 + minutes)
42+}
43+
44+/// The day of the week of a count of days since 1970-01-01, a Thursday.
45+fn weekday(days: i64) -> Weekday {
46+ Weekday::ALL[(days + 3).rem_euclid(7) as usize]
47+}
48+
49+fn in_period(period: &Period, now: u64) -> bool {
50+ let Some(start) = parse_rfc3339(&period.start) else { return false };
51+ let end = period.end.as_deref().and_then(parse_rfc3339);
52+ start <= now && end.is_none_or(|end| now < end)
53+}
54+
55+/// Why merging is closed now, or `None` when it is open.
56+#[derive(Clone, Debug, PartialEq, Eq)]
57+pub enum Closed {
58+ /// A freeze covers now: its reason, and when it ends if it does.
59+ Frozen { reason: String, until: Option<String> },
60+ /// Outside every weekly window: when the next one opens, RFC 3339.
61+ Outside { opens: Option<String> },
62+}
63+
64+/// Whether merging is allowed at `now` (milliseconds since the epoch).
65+pub fn closed(rule: &MergeWindowRule, now: u64) -> Option<Closed> {
66+ if rule.exceptions.iter().any(|period| in_period(period, now)) {
67+ return None;
68+ }
69+ if let Some(freeze) = rule.freezes.iter().find(|period| in_period(period, now)) {
70+ return Some(Closed::Frozen { reason: freeze.reason.trim().to_owned(), until: freeze.end.clone() });
71+ }
72+ if rule.windows.is_empty() {
73+ return None;
74+ }
75+ let offset = offset_minutes(&rule.time_zone).unwrap_or(0);
76+ let local = (now / MINUTE) as i64 + offset;
77+ if open_at(rule, local) {
78+ return None;
79+ }
80+ // The next minute a window opens, within a week and a day.
81+ let opens = (1..=(8 * DAY_MINUTES))
82+ .map(|ahead| local + ahead)
83+ .find(|minute| open_at(rule, *minute) && !open_at(rule, minute - 1))
84+ .map(|minute| rfc3339(((minute - offset) as u64) * MINUTE));
85+ Some(Closed::Outside { opens })
86+}
87+
88+/// Whether a local minute (since the epoch) falls in a weekly window.
89+fn open_at(rule: &MergeWindowRule, local: i64) -> bool {
90+ let (day, minute) = (local.div_euclid(DAY_MINUTES), local.rem_euclid(DAY_MINUTES));
91+ rule.windows.iter().any(|window| {
92+ let (Some(start), Some(end)) = (clock(&window.start), clock(&window.end)) else {
93+ return false;
94+ };
95+ if start <= end {
96+ window.days.contains(&weekday(day)) && start <= minute && minute < end
97+ } else {
98+ // Past midnight: the evening of a listed day, or the morning after.
99+ (window.days.contains(&weekday(day)) && minute >= start)
100+ || (window.days.contains(&weekday(day - 1)) && minute < end)
101+ }
102+ })
103+}
104+
105+/// What a closed window tells people.
106+pub fn explain(closed: &Closed) -> String {
107+ match closed {
108+ Closed::Frozen { reason, until } => {
109+ let why = if reason.is_empty() { String::new() } else { format!(" ({reason})") };
110+ match until {
111+ Some(until) => format!("Merging is frozen{why} until {until}."),
112+ None => format!("Merging is frozen{why} until the freeze is lifted."),
113+ }
114+ }
115+ Closed::Outside { opens: Some(opens) } => format!("Merging is outside the merge window; it next opens at {opens}."),
116+ Closed::Outside { opens: None } => "Merging is outside the merge window.".to_owned(),
117+ }
118+}
119+
120+#[cfg(test)]
121+mod tests {
122+ use super::*;
123+ use g1t_contracts::rules::WeeklyWindow;
124+
125+ /// 2026-10-07 is a Wednesday.
126+ fn at(text: &str) -> u64 {
127+ parse_rfc3339(text).unwrap()
128+ }
129+
130+ fn weekdays(start: &str, end: &str) -> MergeWindowRule {
131+ MergeWindowRule {
132+ windows: vec![WeeklyWindow {
133+ days: vec![Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu],
134+ start: start.into(),
135+ end: end.into(),
136+ }],
137+ ..MergeWindowRule::default()
138+ }
139+ }
140+
141+ #[test]
142+ fn zones_and_clocks_parse() {
143+ assert_eq!(offset_minutes("UTC"), Some(0));
144+ assert_eq!(offset_minutes(""), Some(0));
145+ assert_eq!(offset_minutes("+02:00"), Some(120));
146+ assert_eq!(offset_minutes("-0530"), Some(-330));
147+ assert_eq!(offset_minutes("UTC+1"), Some(60));
148+ assert_eq!(offset_minutes("Europe/Paris"), None);
149+ assert_eq!(clock("09:30"), Some(570));
150+ assert_eq!(clock("24:00"), Some(1440));
151+ assert_eq!(clock("25:00"), None);
152+ }
153+
154+ #[test]
155+ fn days_of_the_week_are_counted_from_a_thursday() {
156+ assert_eq!(weekday(0), Weekday::Thu);
157+ assert_eq!(weekday(at("2026-10-07T12:00:00Z") as i64 / 86_400_000), Weekday::Wed);
158+ }
159+
160+ #[test]
161+ fn inside_the_window_merging_is_open() {
162+ let rule = weekdays("09:00", "17:00");
163+ assert_eq!(closed(&rule, at("2026-10-07T10:00:00Z")), None);
164+ assert_eq!(closed(&MergeWindowRule::default(), at("2026-10-10T03:00:00Z")), None);
165+ }
166+
167+ #[test]
168+ fn outside_it_says_when_it_opens() {
169+ let rule = weekdays("09:00", "17:00");
170+ // Wednesday evening: Thursday morning.
171+ assert_eq!(
172+ closed(&rule, at("2026-10-07T18:00:00Z")),
173+ Some(Closed::Outside { opens: Some("2026-10-08T09:00:00.000Z".into()) })
174+ );
175+ // Friday: the next Monday.
176+ assert_eq!(
177+ closed(&rule, at("2026-10-09T10:00:00Z")),
178+ Some(Closed::Outside { opens: Some("2026-10-12T09:00:00.000Z".into()) })
179+ );
180+ }
181+
182+ #[test]
183+ fn the_window_is_in_its_time_zone() {
184+ let rule = MergeWindowRule { time_zone: "-05:00".into(), ..weekdays("09:00", "17:00") };
185+ // 13:00 UTC is 08:00 there: not yet.
186+ assert!(closed(&rule, at("2026-10-07T13:00:00Z")).is_some());
187+ assert_eq!(closed(&rule, at("2026-10-07T15:00:00Z")), None);
188+ }
189+
190+ #[test]
191+ fn a_window_can_run_past_midnight() {
192+ let rule = MergeWindowRule {
193+ windows: vec![WeeklyWindow { days: vec![Weekday::Fri], start: "22:00".into(), end: "02:00".into() }],
194+ ..MergeWindowRule::default()
195+ };
196+ assert_eq!(closed(&rule, at("2026-10-09T23:00:00Z")), None);
197+ assert_eq!(closed(&rule, at("2026-10-10T01:00:00Z")), None);
198+ assert!(closed(&rule, at("2026-10-10T03:00:00Z")).is_some());
199+ }
200+
201+ #[test]
202+ fn freezes_close_it_and_exceptions_open_it() {
203+ let mut rule = weekdays("00:00", "24:00");
204+ rule.freezes.push(Period { start: "2026-10-07T00:00:00Z".into(), end: Some("2026-10-08T00:00:00Z".into()), reason: "Release".into() });
205+ rule.freezes.push(Period { start: "2026-10-12T00:00:00Z".into(), end: None, reason: String::new() });
206+ assert_eq!(
207+ explain(&closed(&rule, at("2026-10-07T10:00:00Z")).unwrap()),
208+ "Merging is frozen (Release) until 2026-10-08T00:00:00Z."
209+ );
210+ assert_eq!(closed(&rule, at("2026-10-08T10:00:00Z")), None);
211+ assert_eq!(explain(&closed(&rule, at("2026-10-13T10:00:00Z")).unwrap()), "Merging is frozen until the freeze is lifted.");
212+ rule.exceptions.push(Period { start: "2026-10-07T12:00:00Z".into(), end: Some("2026-10-07T13:00:00Z".into()), reason: "Hotfix".into() });
213+ assert_eq!(closed(&rule, at("2026-10-07T12:30:00Z")), None);
214+ }
215+}
+2−0
12331233 _ => "failed",
12341234 }),
12351235 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
1236+ "source": "actions",
12361237 }),
12371238 )
12381239 .await;
12511252 "state": "pending",
12521253 "description": format!("{} is running", run.name),
12531254 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
1255+ "source": "actions",
12541256 }),
12551257 )
12561258 .await;
+1−1
12851285 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
12861286 method: "POST",
12871287 headers: { "content-type": "application/json" },
1288− body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
1288+ body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
12891289 }).catch(() => undefined);
12901290 }
12911291
+1−0
419419 state: state.to_owned(),
420420 description: Some(description.chars().take(140).collect()),
421421 target_url: Some(format!("{SITE}/{}/{}/security/pulls/{number}", repo.namespace, repo.name)),
422+ source: Some("security".to_owned()),
422423 },
423424 )
424425 .await;
+2−1
14541454 state,
14551455 description: Some(description.chars().take(400).collect()),
14561456 target_url: Some(site),
1457+ source: Some("security".to_owned()),
14571458 },
14581459 )
14591460 .await?;
16611662 #[test]
16621663 fn checks_decide_failure_and_success() {
16631664 let check = |state| RequiredCheck { name: "CI".into(), state, description: None, target_url: None };
1664− let status = |state: &str| CommitStatus { context: "CI / push".into(), state: state.into(), description: None, target_url: None, updated_at: String::new() };
1665+ let status = |state: &str| CommitStatus { context: "CI / push".into(), state: state.into(), description: None, target_url: None, updated_at: String::new(), source: None };
16651666 let detail = |required: Vec<RequiredCheck>, statuses: Vec<CommitStatus>| {
16661667 let mut detail: PullDetail = serde_json::from_value(json!({
16671668 "pull": {"id": "pul_1", "repoId": "rep_1", "number": 1, "issue": null, "title": "t", "body": null, "agent": "", "runtime": "external",
+144−0
1+-- Rulesets: what may happen to a repository's branches and tags, and what
2+-- a pull request needs before it merges. See crates/rules and
3+-- src/rulesets.rs. Every timestamp is RFC 3339 UTC.
4+
5+-- A repository's rulesets (level 'repository', with repo_id) and a
6+-- workspace's (level 'workspace'). `spec` is the ruleset as the API shows
7+-- it (g1t_contracts::rules::RulesetSpec, JSON); name, enforcement and
8+-- target are copied out of it for listing.
9+CREATE TABLE rulesets (
10+ id TEXT PRIMARY KEY,
11+ level TEXT NOT NULL,
12+ -- The workspace's slug; for a repository's, its workspace when saved.
13+ workspace TEXT NOT NULL,
14+ repo_id TEXT,
15+ name TEXT NOT NULL,
16+ enforcement TEXT NOT NULL,
17+ target TEXT NOT NULL,
18+ spec TEXT NOT NULL,
19+ -- 'branch_protection' for the one made from branch protection settings.
20+ source TEXT,
21+ created_by TEXT NOT NULL,
22+ created_at TEXT NOT NULL,
23+ updated_by TEXT NOT NULL,
24+ updated_at TEXT NOT NULL
25+);
26+CREATE INDEX rulesets_by_repo ON rulesets (repo_id);
27+CREATE INDEX rulesets_by_workspace ON rulesets (workspace, level);
28+
29+-- Every evaluation of a ruleset on a push, a merge or another change to a
30+-- branch or tag: the audit trail, and what `evaluate` rulesets would have
31+-- refused. `violations` is JSON (Vec<Violation>). Ids sort by time.
32+CREATE TABLE rule_evaluations (
33+ id TEXT PRIMARY KEY,
34+ repo_id TEXT NOT NULL,
35+ workspace TEXT NOT NULL,
36+ ruleset_id TEXT NOT NULL,
37+ ruleset_name TEXT NOT NULL,
38+ enforcement TEXT NOT NULL,
39+ -- push, merge, create_ref, delete_ref, rename_ref or commit.
40+ action TEXT NOT NULL,
41+ git_ref TEXT NOT NULL,
42+ actor TEXT NOT NULL,
43+ -- person, agent, g1t or token.
44+ actor_kind TEXT NOT NULL,
45+ -- pass, fail or bypass.
46+ verdict TEXT NOT NULL,
47+ violations TEXT NOT NULL DEFAULT '[]',
48+ number INTEGER,
49+ sha TEXT,
50+ created_at TEXT NOT NULL
51+);
52+CREATE INDEX rule_evaluations_by_repo ON rule_evaluations (repo_id, id);
53+CREATE INDEX rule_evaluations_by_workspace ON rule_evaluations (workspace, id);
54+CREATE INDEX rule_evaluations_by_ruleset ON rule_evaluations (ruleset_id, id);
55+
56+-- The repos service kept whether pushes to the default branch were refused
57+-- (its `protected` flag). The first time g1t reads a repository's rulesets
58+-- after this, it folds that flag into its branch protection ruleset, once.
59+CREATE TABLE ruleset_adoptions (
60+ repo_id TEXT PRIMARY KEY,
61+ adopted_at TEXT NOT NULL
62+);
63+
64+-- Who moved a pull request's head last, and when: for approving the most
65+-- recent push and dismissing approvals that came before it.
66+ALTER TABLE pulls ADD COLUMN head_pushed_by TEXT;
67+ALTER TABLE pulls ADD COLUMN head_pushed_at TEXT;
68+
69+-- The integration that reported a status: actions, deployments, security
70+-- or g1t. A required check can insist on one.
71+ALTER TABLE commit_statuses ADD COLUMN source TEXT;
72+
73+-- Branch protection becomes a ruleset, holding exactly what it held: the
74+-- default branch's pull request rule (approvals, code owners), required
75+-- status checks and merge queue. Pushes stay allowed here; repositories
76+-- that refused them are adopted as described above. Only for repositories
77+-- whose settings protect anything.
78+INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at)
79+SELECT
80+ 'rs_' || lower(hex(randomblob(12))),
81+ 'repository',
82+ '',
83+ s.repo_id,
84+ 'Default branch protection',
85+ 'active',
86+ 'branch',
87+ json_object(
88+ 'name', 'Default branch protection',
89+ 'enforcement', 'active',
90+ 'target', 'branch',
91+ 'conditions', json_object('ref_name', json_object('include', json_array('~DEFAULT_BRANCH'), 'exclude', json_array())),
92+ 'bypass_actors', json_array(),
93+ 'rules', (
94+ SELECT json_group_array(json(rule.value))
95+ FROM json_each(json_array(
96+ CASE WHEN s.required_approvals > 0 OR s.require_code_owner_review != 0 THEN json_object(
97+ 'type', 'pull_request',
98+ 'parameters', json_object(
99+ 'required_approvals', s.required_approvals,
100+ 'count_agent_approvals', json(CASE WHEN s.count_agent_approvals != 0 THEN 'true' ELSE 'false' END),
101+ 'dismiss_stale_reviews_on_push', json('false'),
102+ 'require_code_owner_review', json(CASE WHEN s.require_code_owner_review != 0 THEN 'true' ELSE 'false' END),
103+ 'require_last_push_approval', json('false'),
104+ 'allowed_merge_methods', json_array(),
105+ 'allow_direct_pushes', json('true')
106+ ),
107+ 'applies_to', 'everyone'
108+ ) END,
109+ CASE WHEN s.required_checks != '[]' OR s.require_up_to_date != 0 THEN json_object(
110+ 'type', 'required_status_checks',
111+ 'parameters', json_object(
112+ 'checks', (SELECT json_group_array(json_object('context', checks.value)) FROM json_each(s.required_checks) AS checks),
113+ 'strict', json(CASE WHEN s.require_up_to_date != 0 THEN 'true' ELSE 'false' END),
114+ 'paths', json_array(),
115+ 'allow_bypass_on_merge', json(CASE WHEN s.allow_ignoring_checks != 0 THEN 'true' ELSE 'false' END)
116+ ),
117+ 'applies_to', 'everyone'
118+ ) END,
119+ CASE WHEN s.merge_queue != 0 THEN json_object(
120+ 'type', 'merge_queue',
121+ 'parameters', json_object(
122+ 'merge_method', 'merge',
123+ 'max_entries_to_build', 4,
124+ 'min_entries_to_merge', 1,
125+ 'min_entries_wait_minutes', 0,
126+ 'check_response_timeout_minutes', 45
127+ ),
128+ 'applies_to', 'everyone'
129+ ) END
130+ )) AS rule
131+ WHERE rule.type != 'null'
132+ )
133+ ),
134+ 'branch_protection',
135+ s.updated_by,
136+ s.updated_at,
137+ s.updated_by,
138+ s.updated_at
139+FROM repo_settings s
140+WHERE s.required_approvals > 0
141+ OR s.require_code_owner_review != 0
142+ OR s.required_checks != '[]'
143+ OR s.require_up_to_date != 0
144+ OR s.merge_queue != 0;
+1−0
466466 state: state.to_owned(),
467467 description: Some(description),
468468 target_url,
469+ source: Some("g1t".to_owned()),
469470 })
470471 .await?;
471472 Ok(())
+1−0
15001500 description: None,
15011501 target_url: None,
15021502 updated_at: String::new(),
1503+ source: None,
15031504 })
15041505 .collect();
15051506 let required: Vec<String> = required.iter().map(|name| (*name).to_owned()).collect();
+1−1
238238 )?,
239239 // Slot::Statuses: on its head (statuses.rs `statuses`).
240240 self.statement(
241− "SELECT context, state, description, target_url, updated_at FROM commit_statuses
241+ "SELECT context, state, description, target_url, updated_at, source FROM commit_statuses
242242 WHERE repo_id = ?1 AND sha = (SELECT head_commit FROM pulls WHERE repo_id = ?1 AND number = ?2)
243243 ORDER BY context",
244244 &key(),
+10−4
2929 description: Option<String>,
3030 target_url: Option<String>,
3131 updated_at: String,
32+ #[serde(default)]
33+ source: Option<String>,
3234 }
3335
3436 impl From<StatusRow> for CommitStatus {
3941 description: row.description,
4042 target_url: row.target_url,
4143 updated_at: row.updated_at,
44+ source: row.source,
4245 }
4346 }
4447 }
195198 }
196199 Ok(self
197200 .db
198− .prepare("SELECT context, state, description, target_url, updated_at FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context")
201+ .prepare("SELECT context, state, description, target_url, updated_at, source FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context")
199202 .bind(&[repo_id.into(), sha.into()])?
200203 .all()
201204 .await?
211214 }
212215 self.db
213216 .prepare(
214− "INSERT INTO commit_statuses (repo_id, sha, context, state, description, target_url, updated_at)
215− VALUES (?, ?, ?, ?, ?, ?, ?)
217+ "INSERT INTO commit_statuses (repo_id, sha, context, state, description, target_url, updated_at, source)
218+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
216219 ON CONFLICT (repo_id, sha, context) DO UPDATE SET
217220 state = excluded.state, description = excluded.description,
218− target_url = excluded.target_url, updated_at = excluded.updated_at",
221+ target_url = excluded.target_url, updated_at = excluded.updated_at,
222+ source = excluded.source",
219223 )
220224 .bind(&[
221225 a.repo_id.as_str().into(),
225229 a.description.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
226230 a.target_url.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
227231 rfc3339(now_ms()).into(),
232+ a.source.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
228233 ])?
229234 .run()
230235 .await?;
300305 description: None,
301306 target_url: None,
302307 updated_at: String::new(),
308+ source: None,
303309 }
304310 }
305311