Skip to content

g1t/crates/contracts/src/rules.rs

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