Skip to content
1,435 linesCodeBlameRaw
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
29use serde::{Deserialize, Serialize};
30
31use crate::repos::RepoPath;
32use crate::{User, Viewer};
33
34/// The repository's default branch, whatever it is called at the time.
35pub const DEFAULT_BRANCH: &str = "~DEFAULT_BRANCH";
36/// Every branch or tag, or every repository.
37pub const ALL: &str = "~ALL";
38/// Rulesets a repository, or a workspace, may have.
39pub const MAX_RULESETS: usize = 75;
40/// Rules in one ruleset.
41pub const MAX_RULES: usize = 50;
42/// Patterns in one list (branches, paths, extensions, repositories).
43pub const MAX_PATTERNS: usize = 100;
44/// The longest pattern, regular expression or name kept.
45pub const MAX_PATTERN_CHARS: usize = 512;
46/// Bypass actors in one ruleset.
47pub const MAX_BYPASS_ACTORS: usize = 50;
48/// Required approvals a pull request rule may ask for.
49pub const MAX_APPROVALS: u32 = 10;
50/// The ruleset made from a repository's branch protection, as it was
51/// before rulesets: its `source`.
52pub const BRANCH_PROTECTION: &str = "branch_protection";
53
54/// Whether a ruleset's rules hold.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Enforcement {
58 /// Its rules hold, and what breaks them is refused.
59 #[default]
60 Active,
61 /// A dry run: nothing is refused, and every push or merge it would
62 /// have refused is recorded, for its insights.
63 Evaluate,
64 /// Kept, but not evaluated at all.
65 Disabled,
66}
67
68impl Enforcement {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Enforcement::Active => "active",
72 Enforcement::Evaluate => "evaluate",
73 Enforcement::Disabled => "disabled",
74 }
75 }
76}
77
78/// What a ruleset's name conditions match.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Target {
82 #[default]
83 Branch,
84 Tag,
85}
86
87impl Target {
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Target::Branch => "branch",
91 Target::Tag => "tag",
92 }
93 }
94
95 /// The full ref of `name`: `refs/heads/<name>` or `refs/tags/<name>`.
96 pub fn full_ref(self, name: &str) -> String {
97 match self {
98 Target::Branch => format!("refs/heads/{name}"),
99 Target::Tag => format!("refs/tags/{name}"),
100 }
101 }
102
103 /// The target and short name of a full ref, if it is a branch or a tag.
104 pub fn of_ref(git_ref: &str) -> Option<(Target, &str)> {
105 if let Some(name) = git_ref.strip_prefix("refs/heads/") {
106 return Some((Target::Branch, name));
107 }
108 git_ref.strip_prefix("refs/tags/").map(|name| (Target::Tag, name))
109 }
110}
111
112/// Whose ruleset it is.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum Level {
116 #[default]
117 Repository,
118 Workspace,
119}
120
121impl Level {
122 pub fn as_str(self) -> &'static str {
123 match self {
124 Level::Repository => "repository",
125 Level::Workspace => "workspace",
126 }
127 }
128}
129
130/// Which branches or tags a ruleset holds for, by name. A name matches when
131/// it matches an `include` pattern and no `exclude` pattern. Patterns are
132/// fnmatch: `*` matches within one path segment, `**` across them, `?` one
133/// character, `[abc]` one of a set. `~DEFAULT_BRANCH` is the default
134/// branch, `~ALL` everything. An empty `include` matches nothing.
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(default)]
137pub struct RefCondition {
138 pub include: Vec<String>,
139 pub exclude: Vec<String>,
140}
141
142/// Which visibility of repository a workspace ruleset selects.
143#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum VisibilityCondition {
146 #[default]
147 Any,
148 Public,
149 Private,
150}
151
152/// Which of a workspace's repositories its ruleset holds in: those whose
153/// name matches an `include` pattern (fnmatch, or `~ALL`) and no `exclude`
154/// one, of the `visibility` chosen, and, when `topics` is not empty,
155/// carrying at least one of them.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct RepositoryCondition {
159 pub include: Vec<String>,
160 pub exclude: Vec<String>,
161 pub visibility: VisibilityCondition,
162 pub topics: Vec<String>,
163}
164
165impl Default for RepositoryCondition {
166 fn default() -> Self {
167 RepositoryCondition {
168 include: vec![ALL.to_owned()],
169 exclude: Vec::new(),
170 visibility: VisibilityCondition::Any,
171 topics: Vec::new(),
172 }
173 }
174}
175
176/// Where a ruleset holds. `repository` is a workspace ruleset's only.
177#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(default)]
179pub struct Conditions {
180 pub ref_name: RefCondition,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub repository: Option<RepositoryCondition>,
183}
184
185/// Who a bypass actor is.
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum ActorKind {
189 /// Everyone with at least this repository role (`value`: `write`,
190 /// `maintain` or `admin`), or the workspace's owners (`owner`).
191 Role,
192 /// The people of a team (`value`: its slug, or `workspace/slug`), its
193 /// child teams' people included.
194 Team,
195 /// One person, by username.
196 User,
197 /// An access token, by its id; `value` `workspace` is any of the
198 /// workspace's own tokens.
199 Token,
200 /// g1t: its agent at work in a sandbox, and the platform acting on its
201 /// own (the merge queue, security updates). Never a bypass actor
202 /// unless listed.
203 G1t,
204}
205
206/// When a bypass actor may bypass.
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum BypassMode {
210 /// Always: pushes and merges alike.
211 #[default]
212 Always,
213 /// Only when merging a pull request; their pushes obey the rules.
214 PullRequests,
215}
216
217impl BypassMode {
218 pub fn as_str(self) -> &'static str {
219 match self {
220 BypassMode::Always => "always",
221 BypassMode::PullRequests => "pull_requests",
222 }
223 }
224}
225
226/// Someone a ruleset does not hold for.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub struct BypassActor {
229 pub kind: ActorKind,
230 /// Who, as [`ActorKind`] says. Empty for `g1t`.
231 #[serde(default)]
232 pub value: String,
233 #[serde(default)]
234 pub mode: BypassMode,
235}
236
237/// Whose changes a rule holds for.
238#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum AppliesTo {
241 #[default]
242 Everyone,
243 /// Only agents' changes: a push by an agent, a pull request an agent
244 /// made (g1t's or another's through a token).
245 Agents,
246 /// Only people's changes.
247 People,
248}
249
250impl AppliesTo {
251 pub fn as_str(self) -> &'static str {
252 match self {
253 AppliesTo::Everyone => "everyone",
254 AppliesTo::Agents => "agents",
255 AppliesTo::People => "people",
256 }
257 }
258
259 /// Whether it holds for a change by an agent (`agent`) or a person.
260 pub fn covers(self, agent: bool) -> bool {
261 match self {
262 AppliesTo::Everyone => true,
263 AppliesTo::Agents => agent,
264 AppliesTo::People => !agent,
265 }
266 }
267}
268
269/// A rule with no parameters.
270#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
271pub struct NoParameters {}
272
273/// How a pull request is merged.
274#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum MergeMethod {
277 /// The branch lands as it is, its commits included: how g1t merges.
278 Merge,
279 Squash,
280 Rebase,
281}
282
283impl MergeMethod {
284 pub fn as_str(self) -> &'static str {
285 match self {
286 MergeMethod::Merge => "merge",
287 MergeMethod::Squash => "squash",
288 MergeMethod::Rebase => "rebase",
289 }
290 }
291}
292
293/// `pull_request`: changes reach the branch only by merging a pull request,
294/// and the pull request needs what this says first.
295#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(default)]
297pub struct PullRequestRule {
298 /// Approving reviews needed. A reviewer who has since asked for
299 /// changes blocks it; nobody approves their own.
300 pub required_approvals: u32,
301 /// Whether an agent's approval (g1t's reviewer) counts towards
302 /// `required_approvals`. Off means only people's approvals count.
303 pub count_agent_approvals: bool,
304 /// Approvals given before the latest push no longer count.
305 pub dismiss_stale_reviews_on_push: bool,
306 /// The code owners of every file it changes must approve.
307 pub require_code_owner_review: bool,
308 /// Someone other than whoever pushed last must approve after that push.
309 pub require_last_push_approval: bool,
310 /// The ways it may be merged. Empty allows every one.
311 pub allowed_merge_methods: Vec<MergeMethod>,
312 /// Pull requests need what this rule says, but pushes straight to the
313 /// branch are still allowed. Off (the default) refuses them. Only the
314 /// ruleset made from branch protection that did not require pull
315 /// requests turns it on.
316 #[serde(skip_serializing_if = "std::ops::Not::not")]
317 pub allow_direct_pushes: bool,
318}
319
320impl Default for PullRequestRule {
321 fn default() -> Self {
322 PullRequestRule {
323 required_approvals: 0,
324 count_agent_approvals: true,
325 dismiss_stale_reviews_on_push: false,
326 require_code_owner_review: false,
327 require_last_push_approval: false,
328 allowed_merge_methods: Vec::new(),
329 allow_direct_pushes: false,
330 }
331 }
332}
333
334/// Where a required check's status must come from.
335#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum Integration {
338 /// Workflow runs (`.g1t/workflows`).
339 Actions,
340 /// Deployments: `g1t / deploy`.
341 Deployments,
342 /// The security suite: code scanning and dependency review.
343 Security,
344 /// g1t itself, such as code owners.
345 G1t,
346 /// Reported through the API, by a token or an integration: a status
347 /// or a check run.
348 Api,
349}
350
351impl Integration {
352 pub fn as_str(self) -> &'static str {
353 match self {
354 Integration::Actions => "actions",
355 Integration::Deployments => "deployments",
356 Integration::Security => "security",
357 Integration::G1t => "g1t",
358 Integration::Api => "api",
359 }
360 }
361
362 pub fn parse(text: &str) -> Option<Integration> {
363 match text {
364 "actions" => Some(Integration::Actions),
365 "deployments" => Some(Integration::Deployments),
366 "security" => Some(Integration::Security),
367 "g1t" => Some(Integration::G1t),
368 "api" => Some(Integration::Api),
369 _ => None,
370 }
371 }
372}
373
374/// One check that must pass: a workflow's name (`CI`) or another status's
375/// context, and, if set, the integration that must have reported it.
376#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
377pub struct RequiredCheck {
378 pub context: String,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub integration: Option<Integration>,
381}
382
383/// `required_status_checks`: these checks must pass on a pull request's
384/// head before it merges.
385#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(default)]
387#[derive(Default)]
388pub struct StatusChecksRule {
389 pub checks: Vec<RequiredCheck>,
390 /// The pull request must contain the branch's latest commits, so that
391 /// what merges is what was checked.
392 pub strict: bool,
393 /// Required only when the pull request changes a file matching one of
394 /// these patterns. Empty: always.
395 pub paths: Vec<String>,
396 /// Someone who may merge can merge past checks that have not passed,
397 /// saying so as they merge.
398 pub allow_bypass_on_merge: bool,
399}
400
401
402/// `merge_queue`: merging joins the queue, which tests each pull request
403/// together with those ahead of it. The queue lands on the default branch.
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(default)]
406pub struct MergeQueueRule {
407 pub merge_method: MergeMethod,
408 /// Entries tested at once.
409 pub max_entries_to_build: u32,
410 /// Entries a batch waits for before it starts, unless the oldest has
411 /// waited `min_entries_wait_minutes`.
412 pub min_entries_to_merge: u32,
413 pub min_entries_wait_minutes: u32,
414 /// How long a batch's checks may take before it is tested again.
415 pub check_response_timeout_minutes: u32,
416}
417
418impl Default for MergeQueueRule {
419 fn default() -> Self {
420 MergeQueueRule {
421 merge_method: MergeMethod::Merge,
422 max_entries_to_build: 4,
423 min_entries_to_merge: 1,
424 min_entries_wait_minutes: 0,
425 check_response_timeout_minutes: 45,
426 }
427 }
428}
429
430/// `required_deployments`: a pull request's head must have deployed
431/// successfully to these environments: `preview` (its preview), a
432/// project's slug for a repository with several, or any environment
433/// deployments are reported to (its `deploy / <environment>` check).
434#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(default)]
436pub struct DeploymentsRule {
437 pub environments: Vec<String>,
438}
439
440/// How a pattern rule compares.
441#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum PatternOperator {
444 #[default]
445 StartsWith,
446 EndsWith,
447 Contains,
448 /// A regular expression, run by a linear-time engine.
449 Regex,
450}
451
452/// A rule about text: a commit message, an author's or committer's email
453/// address, a branch's or tag's name. The text must match the pattern, or
454/// with `negate`, must not.
455#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(default)]
457pub struct PatternRule {
458 /// What people are told the rule is, such as "Conventional commits".
459 pub name: String,
460 pub operator: PatternOperator,
461 pub pattern: String,
462 pub negate: bool,
463}
464
465/// `file_path_restriction`: pushes and pull requests may not change files
466/// matching these patterns (fnmatch, `**` across directories).
467#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(default)]
469pub struct FilePathRule {
470 pub restricted_file_paths: Vec<String>,
471}
472
473/// `file_extension_restriction`: files with these extensions (`.exe`,
474/// `.zip`) may not be added or changed.
475#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(default)]
477pub struct FileExtensionRule {
478 pub restricted_file_extensions: Vec<String>,
479}
480
481/// `max_file_size`: no file larger than this, in megabytes (1 to 100).
482#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(default)]
484pub struct MaxFileSizeRule {
485 pub max_file_size_mb: u32,
486}
487
488impl Default for MaxFileSizeRule {
489 fn default() -> Self {
490 MaxFileSizeRule { max_file_size_mb: 10 }
491 }
492}
493
494/// `max_file_path_length`: no path longer than this many characters.
495#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(default)]
497pub struct MaxFilePathLengthRule {
498 pub max_file_path_length: u32,
499}
500
501impl Default for MaxFilePathLengthRule {
502 fn default() -> Self {
503 MaxFilePathLengthRule { max_file_path_length: 255 }
504 }
505}
506
507/// `max_files_changed`: a push's commits, each, and a pull request as a
508/// whole, change at most this many files.
509#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(default)]
511pub struct MaxFilesChangedRule {
512 pub max_files: u32,
513}
514
515impl Default for MaxFilesChangedRule {
516 fn default() -> Self {
517 MaxFilesChangedRule { max_files: 100 }
518 }
519}
520
521/// `path_review`: a pull request that changes a file matching `paths`
522/// needs `required_approvals` from people, from `team` when one is named
523/// (its slug, or `workspace/slug`).
524#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(default)]
526pub struct PathReviewRule {
527 pub paths: Vec<String>,
528 pub required_approvals: u32,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub team: Option<String>,
531}
532
533impl Default for PathReviewRule {
534 fn default() -> Self {
535 PathReviewRule { paths: Vec::new(), required_approvals: 1, team: None }
536 }
537}
538
539/// A day of the week.
540#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum Weekday {
543 Mon,
544 Tue,
545 Wed,
546 Thu,
547 Fri,
548 Sat,
549 Sun,
550}
551
552impl Weekday {
553 pub const ALL: [Weekday; 7] = [
554 Weekday::Mon,
555 Weekday::Tue,
556 Weekday::Wed,
557 Weekday::Thu,
558 Weekday::Fri,
559 Weekday::Sat,
560 Weekday::Sun,
561 ];
562
563 pub fn as_str(self) -> &'static str {
564 match self {
565 Weekday::Mon => "mon",
566 Weekday::Tue => "tue",
567 Weekday::Wed => "wed",
568 Weekday::Thu => "thu",
569 Weekday::Fri => "fri",
570 Weekday::Sat => "sat",
571 Weekday::Sun => "sun",
572 }
573 }
574}
575
576/// Hours on some days of the week when merging is allowed, `HH:MM` to
577/// `HH:MM` in the rule's time zone. An `end` before `start` runs past
578/// midnight.
579#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
580pub struct WeeklyWindow {
581 pub days: Vec<Weekday>,
582 pub start: String,
583 pub end: String,
584}
585
586/// A stretch of time, RFC 3339 UTC. With no `end`, it lasts until removed:
587/// an incident freeze.
588#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
589pub struct Period {
590 pub start: String,
591 #[serde(default)]
592 pub end: Option<String>,
593 #[serde(default)]
594 pub reason: String,
595}
596
597/// `merge_window`: when pull requests may merge into the branch. Outside
598/// every `windows` entry (when there are any), or during a `freezes` one,
599/// merging waits, unless an `exceptions` entry covers the moment.
600#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(default)]
602pub struct MergeWindowRule {
603 /// A fixed offset from UTC, `+02:00` or `-05:00`; `UTC` or empty is UTC.
604 /// Daylight saving time is not applied.
605 pub time_zone: String,
606 pub windows: Vec<WeeklyWindow>,
607 pub freezes: Vec<Period>,
608 pub exceptions: Vec<Period>,
609}
610
611impl Default for MergeWindowRule {
612 fn default() -> Self {
613 MergeWindowRule {
614 time_zone: "UTC".to_owned(),
615 windows: Vec::new(),
616 freezes: Vec::new(),
617 exceptions: Vec::new(),
618 }
619 }
620}
621
622/// `agent_auto_merge`: whether g1t lands an agent's ready pull request into
623/// the branch without a person pressing merge. The repository's auto-merge
624/// setting must be on as well. A `minimum_confidence` written before
625/// confidence ratings were removed is ignored.
626#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(default)]
628pub struct AgentAutoMergeRule {
629 pub allowed: bool,
630}
631
632impl Default for AgentAutoMergeRule {
633 fn default() -> Self {
634 AgentAutoMergeRule { allowed: true }
635 }
636}
637
638/// Rule types that are no longer offered: `confidence_threshold` and
639/// `cost_cap`, which were about g1t's own agents. A stored ruleset that has
640/// one still reads; the rule is left out, so it holds for nothing.
641pub const RETIRED_RULES: [&str; 2] = ["confidence_threshold", "cost_cap"];
642
643/// Reads a list of rules, leaving out those of a [retired](RETIRED_RULES)
644/// type.
645fn live_rules<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<RuleEntry>, D::Error> {
646 let written = Vec::<serde_json::Value>::deserialize(deserializer)?;
647 written
648 .into_iter()
649 .filter(|entry| !entry.get("type").and_then(serde_json::Value::as_str).is_some_and(|kind| RETIRED_RULES.contains(&kind)))
650 .map(|entry| serde_json::from_value(entry).map_err(serde::de::Error::custom))
651 .collect()
652}
653
654/// One rule and its parameters, tagged by `type`.
655#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
656#[serde(tag = "type", content = "parameters", rename_all = "snake_case")]
657pub enum Rule {
658 /// Only bypass actors may create a matching branch or tag.
659 Creation(NoParameters),
660 /// Only bypass actors may push to (move) a matching branch or tag.
661 Update(NoParameters),
662 /// Only bypass actors may delete a matching branch or tag.
663 Deletion(NoParameters),
664 /// Nobody force pushes: a push must only add to its history.
665 NonFastForward(NoParameters),
666 /// No merge commits: history stays a straight line.
667 RequiredLinearHistory(NoParameters),
668 /// Every commit carries a signature g1t verifies.
669 RequiredSignatures(NoParameters),
670 PullRequest(PullRequestRule),
671 RequiredStatusChecks(StatusChecksRule),
672 MergeQueue(MergeQueueRule),
673 RequiredDeployments(DeploymentsRule),
674 CommitMessagePattern(PatternRule),
675 CommitAuthorEmailPattern(PatternRule),
676 CommitterEmailPattern(PatternRule),
677 BranchNamePattern(PatternRule),
678 TagNamePattern(PatternRule),
679 FilePathRestriction(FilePathRule),
680 FileExtensionRestriction(FileExtensionRule),
681 MaxFileSize(MaxFileSizeRule),
682 MaxFilePathLength(MaxFilePathLengthRule),
683 MaxFilesChanged(MaxFilesChangedRule),
684 /// Pushes that add a secret are refused, whatever the repository's own
685 /// push protection setting says.
686 SecretScanning(NoParameters),
687 PathReview(PathReviewRule),
688 MergeWindow(MergeWindowRule),
689 AgentAutoMerge(AgentAutoMergeRule),
690}
691
692impl Rule {
693 /// Its `type`, as the API names it.
694 pub fn kind(&self) -> &'static str {
695 match self {
696 Rule::Creation(_) => "creation",
697 Rule::Update(_) => "update",
698 Rule::Deletion(_) => "deletion",
699 Rule::NonFastForward(_) => "non_fast_forward",
700 Rule::RequiredLinearHistory(_) => "required_linear_history",
701 Rule::RequiredSignatures(_) => "required_signatures",
702 Rule::PullRequest(_) => "pull_request",
703 Rule::RequiredStatusChecks(_) => "required_status_checks",
704 Rule::MergeQueue(_) => "merge_queue",
705 Rule::RequiredDeployments(_) => "required_deployments",
706 Rule::CommitMessagePattern(_) => "commit_message_pattern",
707 Rule::CommitAuthorEmailPattern(_) => "commit_author_email_pattern",
708 Rule::CommitterEmailPattern(_) => "committer_email_pattern",
709 Rule::BranchNamePattern(_) => "branch_name_pattern",
710 Rule::TagNamePattern(_) => "tag_name_pattern",
711 Rule::FilePathRestriction(_) => "file_path_restriction",
712 Rule::FileExtensionRestriction(_) => "file_extension_restriction",
713 Rule::MaxFileSize(_) => "max_file_size",
714 Rule::MaxFilePathLength(_) => "max_file_path_length",
715 Rule::MaxFilesChanged(_) => "max_files_changed",
716 Rule::SecretScanning(_) => "secret_scanning",
717 Rule::PathReview(_) => "path_review",
718 Rule::MergeWindow(_) => "merge_window",
719 Rule::AgentAutoMerge(_) => "agent_auto_merge",
720 }
721 }
722
723 /// How people are shown it.
724 pub fn label(&self) -> &'static str {
725 match self {
726 Rule::Creation(_) => "Restrict creations",
727 Rule::Update(_) => "Restrict updates",
728 Rule::Deletion(_) => "Restrict deletions",
729 Rule::NonFastForward(_) => "Block force pushes",
730 Rule::RequiredLinearHistory(_) => "Require linear history",
731 Rule::RequiredSignatures(_) => "Require signed commits",
732 Rule::PullRequest(_) => "Require a pull request before merging",
733 Rule::RequiredStatusChecks(_) => "Require status checks to pass",
734 Rule::MergeQueue(_) => "Require the merge queue",
735 Rule::RequiredDeployments(_) => "Require deployments to succeed",
736 Rule::CommitMessagePattern(_) => "Commit message pattern",
737 Rule::CommitAuthorEmailPattern(_) => "Commit author email pattern",
738 Rule::CommitterEmailPattern(_) => "Committer email pattern",
739 Rule::BranchNamePattern(_) => "Branch name pattern",
740 Rule::TagNamePattern(_) => "Tag name pattern",
741 Rule::FilePathRestriction(_) => "Restrict file paths",
742 Rule::FileExtensionRestriction(_) => "Restrict file extensions",
743 Rule::MaxFileSize(_) => "Restrict file size",
744 Rule::MaxFilePathLength(_) => "Restrict file path length",
745 Rule::MaxFilesChanged(_) => "Restrict files changed",
746 Rule::SecretScanning(_) => "Block pushes that add secrets",
747 Rule::PathReview(_) => "Review for sensitive paths",
748 Rule::MergeWindow(_) => "Merge window",
749 Rule::AgentAutoMerge(_) => "Agent auto-merge",
750 }
751 }
752
753 /// Whether the rule is about pushes: what a push may do or bring.
754 /// Pull request rules hold on merge.
755 pub fn on_push(&self) -> bool {
756 matches!(
757 self,
758 Rule::Creation(_)
759 | Rule::Update(_)
760 | Rule::Deletion(_)
761 | Rule::NonFastForward(_)
762 | Rule::RequiredLinearHistory(_)
763 | Rule::RequiredSignatures(_)
764 | Rule::PullRequest(_)
765 | Rule::MergeQueue(_)
766 | Rule::CommitMessagePattern(_)
767 | Rule::CommitAuthorEmailPattern(_)
768 | Rule::CommitterEmailPattern(_)
769 | Rule::BranchNamePattern(_)
770 | Rule::TagNamePattern(_)
771 | Rule::FilePathRestriction(_)
772 | Rule::FileExtensionRestriction(_)
773 | Rule::MaxFileSize(_)
774 | Rule::MaxFilePathLength(_)
775 | Rule::MaxFilesChanged(_)
776 | Rule::SecretScanning(_)
777 )
778 }
779
780 /// Whether it says anything only tags can break (or only branches).
781 pub fn for_branches_only(&self) -> bool {
782 matches!(
783 self,
784 Rule::PullRequest(_)
785 | Rule::RequiredStatusChecks(_)
786 | Rule::MergeQueue(_)
787 | Rule::RequiredDeployments(_)
788 | Rule::BranchNamePattern(_)
789 | Rule::PathReview(_)
790 | Rule::MergeWindow(_)
791 | Rule::AgentAutoMerge(_)
792 )
793 }
794
795 pub fn for_tags_only(&self) -> bool {
796 matches!(self, Rule::TagNamePattern(_))
797 }
798}
799
800/// One rule of a ruleset, and whose changes it holds for. `parameters`
801/// may be left out, or left partly out: what is missing takes its default.
802#[derive(Clone, Debug, PartialEq, Serialize)]
803pub struct RuleEntry {
804 #[serde(flatten)]
805 pub rule: Rule,
806 pub applies_to: AppliesTo,
807}
808
809impl<'de> Deserialize<'de> for RuleEntry {
810 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
811 #[derive(Deserialize)]
812 struct Written {
813 #[serde(rename = "type")]
814 kind: String,
815 #[serde(default)]
816 parameters: serde_json::Value,
817 #[serde(default)]
818 applies_to: AppliesTo,
819 }
820 let written = Written::deserialize(deserializer)?;
821 let parameters = match written.parameters {
822 serde_json::Value::Null => serde_json::json!({}),
823 other => other,
824 };
825 let rule = serde_json::from_value(serde_json::json!({ "type": written.kind, "parameters": parameters }))
826 .map_err(serde::de::Error::custom)?;
827 Ok(RuleEntry { rule, applies_to: written.applies_to })
828 }
829}
830
831impl RuleEntry {
832 pub fn everyone(rule: Rule) -> RuleEntry {
833 RuleEntry { rule, applies_to: AppliesTo::Everyone }
834 }
835}
836
837/// What a ruleset says, as it is created, changed, exported and imported.
838#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
839#[serde(default)]
840pub struct RulesetSpec {
841 pub name: String,
842 pub enforcement: Enforcement,
843 pub target: Target,
844 pub conditions: Conditions,
845 pub bypass_actors: Vec<BypassActor>,
846 #[serde(deserialize_with = "live_rules")]
847 pub rules: Vec<RuleEntry>,
848}
849
850/// A ruleset, as it is kept.
851#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
852pub struct Ruleset {
853 pub id: String,
854 pub level: Level,
855 /// The workspace it belongs to, or its repository's.
856 pub workspace: String,
857 /// A repository ruleset's repository: its id and `owner/name`.
858 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub repo_id: Option<String>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub repository: Option<String>,
862 #[serde(flatten)]
863 pub spec: RulesetSpec,
864 /// `branch_protection` for the ruleset made from a repository's branch
865 /// protection settings when rulesets arrived.
866 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub source: Option<String>,
868 pub created_by: String,
869 /// RFC 3339.
870 pub created_at: String,
871 pub updated_by: String,
872 pub updated_at: String,
873}
874
875/// Whose rulesets: a repository's (`repo`) or a workspace's (`workspace`).
876/// Exactly one is set.
877#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
878#[serde(default)]
879pub struct Owner {
880 #[serde(skip_serializing_if = "Option::is_none")]
881 pub repo: Option<RepoPath>,
882 #[serde(skip_serializing_if = "Option::is_none")]
883 pub workspace: Option<String>,
884}
885
886impl Owner {
887 pub fn repo(path: RepoPath) -> Owner {
888 Owner { repo: Some(path), workspace: None }
889 }
890
891 pub fn workspace(slug: &str) -> Owner {
892 Owner { repo: None, workspace: Some(slug.to_lowercase()) }
893 }
894}
895
896/// `list_rulesets`: a repository's or a workspace's rulesets. With
897/// `include_parents`, a repository's list also has its workspace's
898/// rulesets that hold in it. Anyone who may see the repository (members,
899/// for a workspace). Returns `Outcome<Vec<Ruleset>>`.
900#[derive(Clone, Debug, Serialize, Deserialize)]
901pub struct ListRulesetsArgs {
902 pub viewer: Viewer,
903 #[serde(flatten)]
904 pub owner: Owner,
905 #[serde(default)]
906 pub include_parents: bool,
907}
908
909/// `get_ruleset`. Returns `Outcome<Ruleset>`.
910#[derive(Clone, Debug, Serialize, Deserialize)]
911pub struct GetRulesetArgs {
912 pub viewer: Viewer,
913 #[serde(flatten)]
914 pub owner: Owner,
915 pub id: String,
916}
917
918/// `save_ruleset`: creates one (no `id`) or replaces one. The Maintain
919/// role on a repository (`ManageProtection`); a workspace's owners for its
920/// own. Returns `Outcome<Ruleset>`.
921#[derive(Clone, Debug, Serialize, Deserialize)]
922pub struct SaveRulesetArgs {
923 pub actor: User,
924 #[serde(flatten)]
925 pub owner: Owner,
926 #[serde(default)]
927 pub id: Option<String>,
928 pub ruleset: RulesetSpec,
929 /// Set by the API, which records the change in the audit log itself.
930 #[serde(default)]
931 pub from_api: bool,
932}
933
934/// `delete_ruleset`. Returns `Outcome<bool>`.
935#[derive(Clone, Debug, Serialize, Deserialize)]
936pub struct DeleteRulesetArgs {
937 pub actor: User,
938 #[serde(flatten)]
939 pub owner: Owner,
940 pub id: String,
941 #[serde(default)]
942 pub from_api: bool,
943}
944
945/// `effective_rules`: every rule that holds for a branch (or a tag, with
946/// `target` `tag`) of a repository, with the ruleset each comes from.
947/// Returns `Outcome<EffectiveRules>`.
948#[derive(Clone, Debug, Serialize, Deserialize)]
949pub struct EffectiveRulesArgs {
950 pub viewer: Viewer,
951 pub repo: RepoPath,
952 pub name: String,
953 #[serde(default)]
954 pub target: Target,
955}
956
957/// A rule that holds for a branch, and where it comes from.
958#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
959pub struct EffectiveRule {
960 #[serde(flatten)]
961 pub entry: RuleEntry,
962 pub ruleset_id: String,
963 pub ruleset_name: String,
964 pub level: Level,
965 pub enforcement: Enforcement,
966}
967
968/// What holds for one branch or tag.
969#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
970pub struct EffectiveRules {
971 pub name: String,
972 pub target: Target,
973 /// Whether it is the repository's default branch.
974 pub default_branch: bool,
975 /// Active rules first, then those being evaluated.
976 pub rules: Vec<EffectiveRule>,
977 /// The rulesets that hold, by id: their names and who may bypass them.
978 pub rulesets: Vec<RulesetSummary>,
979}
980
981/// A ruleset in brief.
982#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
983pub struct RulesetSummary {
984 pub id: String,
985 pub name: String,
986 pub level: Level,
987 pub enforcement: Enforcement,
988 pub bypass_actors: Vec<BypassActor>,
989}
990
991/// What a change was: a push, a merge, or a change made through g1t.
992#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
993#[serde(rename_all = "snake_case")]
994pub enum Action {
995 Push,
996 Merge,
997 CreateRef,
998 DeleteRef,
999 RenameRef,
1000 /// A commit made through g1t, such as a web edit.
1001 Commit,
1002}
1003
1004impl Action {
1005 pub fn as_str(self) -> &'static str {
1006 match self {
1007 Action::Push => "push",
1008 Action::Merge => "merge",
1009 Action::CreateRef => "create_ref",
1010 Action::DeleteRef => "delete_ref",
1011 Action::RenameRef => "rename_ref",
1012 Action::Commit => "commit",
1013 }
1014 }
1015}
1016
1017/// How an evaluation came out.
1018#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1019#[serde(rename_all = "snake_case")]
1020pub enum Verdict {
1021 /// Every rule was met.
1022 Pass,
1023 /// A rule was broken and the change refused (an active ruleset), or
1024 /// would have been (`evaluate`).
1025 Fail,
1026 /// A rule was broken by a bypass actor, who was let through.
1027 Bypass,
1028}
1029
1030impl Verdict {
1031 pub fn as_str(self) -> &'static str {
1032 match self {
1033 Verdict::Pass => "pass",
1034 Verdict::Fail => "fail",
1035 Verdict::Bypass => "bypass",
1036 }
1037 }
1038}
1039
1040/// One rule that a change breaks, and how to meet it.
1041#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct Violation {
1043 /// The rule's `type`.
1044 pub rule: String,
1045 pub ruleset_id: String,
1046 pub ruleset_name: String,
1047 pub enforcement: Enforcement,
1048 /// What is wrong, in a sentence.
1049 pub message: String,
1050 /// How to satisfy it, in a sentence. May be empty.
1051 #[serde(default)]
1052 pub remedy: String,
1053}
1054
1055/// One ruleset's evaluation of one change, as it is recorded.
1056#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1057pub struct NewEvaluation {
1058 pub repo_id: String,
1059 pub workspace: String,
1060 pub ruleset_id: String,
1061 pub ruleset_name: String,
1062 pub enforcement: Enforcement,
1063 pub action: Action,
1064 /// The full ref: `refs/heads/main`.
1065 pub git_ref: String,
1066 pub actor: String,
1067 /// `person`, `agent` or `g1t`.
1068 pub actor_kind: String,
1069 pub verdict: Verdict,
1070 #[serde(default)]
1071 pub violations: Vec<Violation>,
1072 /// The pull request merged, for a merge.
1073 #[serde(default)]
1074 pub number: Option<u32>,
1075 /// The commit it would have moved the ref to.
1076 #[serde(default)]
1077 pub sha: Option<String>,
1078}
1079
1080/// A recorded evaluation.
1081#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1082pub struct Evaluation {
1083 pub id: String,
1084 #[serde(flatten)]
1085 pub evaluation: NewEvaluation,
1086 /// `owner/name`, as it was.
1087 #[serde(default)]
1088 pub repository: String,
1089 /// RFC 3339.
1090 pub created_at: String,
1091}
1092
1093/// `record_evaluations`: services only. Returns how many were kept.
1094#[derive(Clone, Debug, Serialize, Deserialize)]
1095pub struct RecordEvaluationsArgs {
1096 pub evaluations: Vec<NewEvaluation>,
1097}
1098
1099/// `rule_evaluations`: the latest evaluations of a repository's or a
1100/// workspace's rulesets, newest first, filtered. Returns
1101/// `Outcome<EvaluationPage>`.
1102#[derive(Clone, Debug, Serialize, Deserialize)]
1103pub struct EvaluationsArgs {
1104 pub viewer: Viewer,
1105 #[serde(flatten)]
1106 pub owner: Owner,
1107 #[serde(default)]
1108 pub ruleset_id: Option<String>,
1109 #[serde(default)]
1110 pub verdict: Option<Verdict>,
1111 /// Only those that broke a rule (failed, would have failed, bypassed).
1112 #[serde(default)]
1113 pub problems_only: bool,
1114 /// An evaluation's id: only older ones.
1115 #[serde(default)]
1116 pub before: Option<String>,
1117 #[serde(default)]
1118 pub limit: Option<u32>,
1119}
1120
1121/// A page of evaluations, and how they came out over the last 30 days.
1122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1123pub struct EvaluationPage {
1124 pub evaluations: Vec<Evaluation>,
1125 /// The `before` for the next page, when there is one.
1126 #[serde(default)]
1127 pub next: Option<String>,
1128 pub insights: Insights,
1129}
1130
1131/// How a ruleset's evaluations came out.
1132#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1133pub struct Insights {
1134 pub days: u32,
1135 pub total: u32,
1136 pub passed: u32,
1137 /// Refused by an active ruleset.
1138 pub blocked: u32,
1139 /// Would have been refused by a ruleset in `evaluate`.
1140 pub would_block: u32,
1141 pub bypassed: u32,
1142 /// Per ruleset, most problems first.
1143 pub by_ruleset: Vec<RulesetInsight>,
1144 /// Per rule type, most problems first.
1145 pub by_rule: Vec<RuleInsight>,
1146}
1147
1148#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1149pub struct RulesetInsight {
1150 pub ruleset_id: String,
1151 pub ruleset_name: String,
1152 pub enforcement: Enforcement,
1153 pub total: u32,
1154 pub blocked: u32,
1155 pub would_block: u32,
1156 pub bypassed: u32,
1157}
1158
1159#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1160pub struct RuleInsight {
1161 pub rule: String,
1162 pub count: u32,
1163}
1164
1165/// A ruleset that holds for refs a service is about to change, with
1166/// whether the actor may bypass it and how. What `ref_rules` returns.
1167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1168pub struct Applicable {
1169 pub id: String,
1170 pub name: String,
1171 pub level: Level,
1172 pub enforcement: Enforcement,
1173 pub target: Target,
1174 pub conditions: RefCondition,
1175 #[serde(deserialize_with = "live_rules")]
1176 pub rules: Vec<RuleEntry>,
1177 /// How the actor may bypass it, if they may.
1178 #[serde(default)]
1179 pub bypass: Option<BypassMode>,
1180}
1181
1182/// `ref_rules`: services only. The rulesets of a repository (its own and
1183/// its workspace's) that are not disabled and hold for any of `refs` (full
1184/// refs), with whether `actor` may bypass each. Returns
1185/// `Outcome<RefRules>`.
1186#[derive(Clone, Debug, Serialize, Deserialize)]
1187pub struct RefRulesArgs {
1188 /// The repository, as the repos service read it.
1189 pub repo: crate::repos::Repo,
1190 pub actor: Option<User>,
1191 pub refs: Vec<String>,
1192}
1193
1194/// What a pull request's merge box shows of the rules for the branch it
1195/// merges into, for whoever is looking.
1196#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1197pub struct MergeRules {
1198 /// Rules not met, which refuse the merge.
1199 pub unmet: Vec<Violation>,
1200 /// Rules not met that the viewer may bypass, by asking to as they
1201 /// merge (`bypass_rules`).
1202 pub bypassable: Vec<Violation>,
1203 /// Rules of rulesets in `evaluate` that would refuse it.
1204 pub evaluate: Vec<Violation>,
1205 /// The rulesets that hold for the branch.
1206 pub rulesets: Vec<RulesetSummary>,
1207 /// Whether merging joins the merge queue.
1208 pub merge_queue: bool,
1209 /// What the active rules ask, as they stack: the approvals a merge
1210 /// needs, whether it must be up to date, and whether a merger may merge
1211 /// past required checks that have not passed.
1212 #[serde(default)]
1213 pub required_approvals: u32,
1214 #[serde(default)]
1215 pub strict: bool,
1216 #[serde(default)]
1217 pub allow_bypass_on_merge: bool,
1218}
1219
1220#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1221pub struct RefRules {
1222 pub default_branch: String,
1223 pub workspace: String,
1224 pub rulesets: Vec<Applicable>,
1225}
1226
1227/// What g1t made of a commit's signature.
1228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1229#[serde(tag = "state", rename_all = "snake_case")]
1230pub enum Signature {
1231 #[default]
1232 Unsigned,
1233 /// Valid, made with a key the account owning the committer's verified
1234 /// address registered: that account's username.
1235 Verified { signer: String },
1236 /// Signed, but not verified: why.
1237 Unverified { reason: String },
1238}
1239
1240/// One file a commit adds, changes or deletes.
1241#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1242pub struct FileChange {
1243 pub path: String,
1244 /// Its size in bytes, when its content is new and was read.
1245 #[serde(default)]
1246 pub size: Option<u64>,
1247 #[serde(default)]
1248 pub deleted: bool,
1249}
1250
1251/// What rules about commits look at, for one commit.
1252#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1253pub struct CommitFacts {
1254 pub sha: String,
1255 /// At most 4 KiB of it.
1256 pub message: String,
1257 #[serde(default)]
1258 pub author_email: Option<String>,
1259 #[serde(default)]
1260 pub committer_email: Option<String>,
1261 pub parents: u32,
1262 #[serde(default)]
1263 pub signature: Signature,
1264 #[serde(default)]
1265 pub files: Vec<FileChange>,
1266 /// Whether `files` is every file it changes.
1267 #[serde(default)]
1268 pub files_complete: bool,
1269}
1270
1271/// `inspect_commits`: services only. The commits a branch of `source_id`
1272/// adds on top of `base_branch` of `target_id`, read as rules look at
1273/// them, at most `limit`. Returns `Outcome<InspectedCommits>`.
1274#[derive(Clone, Debug, Serialize, Deserialize)]
1275pub struct InspectCommitsArgs {
1276 pub source_id: String,
1277 pub head: String,
1278 pub target_id: String,
1279 pub base_branch: String,
1280 #[serde(default)]
1281 pub limit: Option<u32>,
1282}
1283
1284#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1285pub struct InspectedCommits {
1286 pub commits: Vec<CommitFacts>,
1287 /// Whether `commits` holds every commit the branch adds, each read in
1288 /// full. A change too large to read is not.
1289 pub complete: bool,
1290}
1291
1292/// What kind of actor a change is by, for rules that hold only for agents'
1293/// or people's changes and for the evaluation log.
1294pub fn actor_kind(actor: &User) -> &'static str {
1295 use crate::PrincipalKind;
1296 match actor.kind {
1297 PrincipalKind::System => "g1t",
1298 PrincipalKind::Agent => "agent",
1299 _ if actor.acting.is_some() => "agent",
1300 PrincipalKind::Workspace => "token",
1301 PrincipalKind::User => "person",
1302 }
1303}
1304
1305/// Whether the actor is an agent: g1t's, or another acting through an
1306/// agent token. g1t acting on its own counts as an agent.
1307pub fn is_agent(actor: &User) -> bool {
1308 matches!(actor_kind(actor), "agent" | "g1t")
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313 use super::*;
1314 use serde_json::json;
1315
1316 #[test]
1317 fn a_rule_is_its_type_and_parameters() {
1318 let entry = RuleEntry {
1319 rule: Rule::PullRequest(PullRequestRule { required_approvals: 2, ..PullRequestRule::default() }),
1320 applies_to: AppliesTo::Agents,
1321 };
1322 let value = serde_json::to_value(&entry).unwrap();
1323 assert_eq!(value["type"], "pull_request");
1324 assert_eq!(value["parameters"]["required_approvals"], 2);
1325 assert_eq!(value["applies_to"], "agents");
1326 let back: RuleEntry = serde_json::from_value(value).unwrap();
1327 assert_eq!(back, entry);
1328 }
1329
1330 #[test]
1331 fn parameters_left_out_take_their_defaults() {
1332 let entry: RuleEntry = serde_json::from_value(json!({ "type": "deletion" })).unwrap();
1333 assert_eq!(entry.rule, Rule::Deletion(NoParameters {}));
1334 assert_eq!(entry.applies_to, AppliesTo::Everyone);
1335 let entry: RuleEntry = serde_json::from_value(json!({ "type": "deletion", "parameters": {} })).unwrap();
1336 assert_eq!(entry.rule.kind(), "deletion");
1337 let entry: RuleEntry =
1338 serde_json::from_value(json!({ "type": "merge_queue", "parameters": { "max_entries_to_build": 8 } })).unwrap();
1339 let Rule::MergeQueue(queue) = entry.rule else { panic!() };
1340 assert_eq!((queue.max_entries_to_build, queue.check_response_timeout_minutes), (8, 45));
1341 }
1342
1343 #[test]
1344 fn every_rule_type_reads_back_as_it_is_named() {
1345 let rules = [
1346 Rule::Creation(NoParameters {}),
1347 Rule::Update(NoParameters {}),
1348 Rule::Deletion(NoParameters {}),
1349 Rule::NonFastForward(NoParameters {}),
1350 Rule::RequiredLinearHistory(NoParameters {}),
1351 Rule::RequiredSignatures(NoParameters {}),
1352 Rule::PullRequest(PullRequestRule::default()),
1353 Rule::RequiredStatusChecks(StatusChecksRule::default()),
1354 Rule::MergeQueue(MergeQueueRule::default()),
1355 Rule::RequiredDeployments(DeploymentsRule::default()),
1356 Rule::CommitMessagePattern(PatternRule::default()),
1357 Rule::CommitAuthorEmailPattern(PatternRule::default()),
1358 Rule::CommitterEmailPattern(PatternRule::default()),
1359 Rule::BranchNamePattern(PatternRule::default()),
1360 Rule::TagNamePattern(PatternRule::default()),
1361 Rule::FilePathRestriction(FilePathRule::default()),
1362 Rule::FileExtensionRestriction(FileExtensionRule::default()),
1363 Rule::MaxFileSize(MaxFileSizeRule::default()),
1364 Rule::MaxFilePathLength(MaxFilePathLengthRule::default()),
1365 Rule::MaxFilesChanged(MaxFilesChangedRule::default()),
1366 Rule::SecretScanning(NoParameters {}),
1367 Rule::PathReview(PathReviewRule::default()),
1368 Rule::MergeWindow(MergeWindowRule::default()),
1369 Rule::AgentAutoMerge(AgentAutoMergeRule::default()),
1370 ];
1371 for rule in rules {
1372 let value = serde_json::to_value(RuleEntry::everyone(rule.clone())).unwrap();
1373 assert_eq!(value["type"], rule.kind());
1374 let back: RuleEntry = serde_json::from_value(value).unwrap();
1375 assert_eq!(back.rule, rule);
1376 assert!(!rule.label().is_empty());
1377 }
1378 }
1379
1380 #[test]
1381 fn a_ruleset_reads_as_the_api_shows_it() {
1382 let ruleset: RulesetSpec = serde_json::from_value(json!({
1383 "name": "Protect main",
1384 "enforcement": "evaluate",
1385 "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH", "release/**"], "exclude": [] } },
1386 "bypass_actors": [{ "kind": "role", "value": "admin", "mode": "pull_requests" }, { "kind": "g1t" }],
1387 "rules": [{ "type": "non_fast_forward" }, { "type": "required_status_checks", "parameters": { "checks": [{ "context": "CI", "integration": "actions" }], "strict": true } }]
1388 }))
1389 .unwrap();
1390 assert_eq!(ruleset.enforcement, Enforcement::Evaluate);
1391 assert_eq!(ruleset.target, Target::Branch);
1392 assert_eq!(ruleset.bypass_actors[1], BypassActor { kind: ActorKind::G1t, value: String::new(), mode: BypassMode::Always });
1393 let Rule::RequiredStatusChecks(checks) = &ruleset.rules[1].rule else { panic!() };
1394 assert_eq!(checks.checks[0].integration, Some(Integration::Actions));
1395 assert!(checks.strict);
1396 }
1397
1398 #[test]
1399 fn retired_rule_types_still_read_and_are_left_out() {
1400 let ruleset: RulesetSpec = serde_json::from_value(json!({
1401 "name": "Agents",
1402 "rules": [
1403 { "type": "confidence_threshold", "parameters": { "minimum": "high", "required_approvals": 1 } },
1404 { "type": "cost_cap", "parameters": { "max_usd": 5.0 } },
1405 { "type": "agent_auto_merge", "parameters": { "allowed": false, "minimum_confidence": "high" } },
1406 { "type": "deletion" }
1407 ]
1408 }))
1409 .unwrap();
1410 let kinds: Vec<&str> = ruleset.rules.iter().map(|entry| entry.rule.kind()).collect();
1411 assert_eq!(kinds, ["agent_auto_merge", "deletion"]);
1412 assert_eq!(ruleset.rules[0].rule, Rule::AgentAutoMerge(AgentAutoMergeRule { allowed: false }));
1413 // A type nobody ever offered is still refused.
1414 assert!(serde_json::from_value::<RulesetSpec>(json!({ "rules": [{ "type": "made_up" }] })).is_err());
1415 }
1416
1417 #[test]
1418 fn refs_split_into_their_target_and_name() {
1419 assert_eq!(Target::of_ref("refs/heads/release/1.x"), Some((Target::Branch, "release/1.x")));
1420 assert_eq!(Target::of_ref("refs/tags/v1"), Some((Target::Tag, "v1")));
1421 assert_eq!(Target::of_ref("refs/notes/x"), None);
1422 assert_eq!(Target::Tag.full_ref("v2"), "refs/tags/v2");
1423 }
1424
1425 #[test]
1426 fn whose_change_it_is() {
1427 assert!(AppliesTo::Everyone.covers(true) && AppliesTo::Everyone.covers(false));
1428 assert!(AppliesTo::Agents.covers(true) && !AppliesTo::Agents.covers(false));
1429 assert!(AppliesTo::People.covers(false) && !AppliesTo::People.covers(true));
1430 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
1431 assert_eq!(actor_kind(&person), "person");
1432 assert!(!is_agent(&person));
1433 assert!(is_agent(&User::system("acme")));
1434 }
1435}