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