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