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: kept here, with every evaluation of them, and enforced here on |
| 2 | //! merge. The repos service enforces them on push and on every other | |
| 3 | //! change to a branch or tag, asking `ref_rules` which hold and recording | |
| 4 | //! what it decided with `record_evaluations`. The rules engine itself is | |
| 5 | //! `g1t_rules`. | |
| 6 | //! | |
| 7 | //! A repository's branch protection, from before rulesets, is its | |
| 8 | //! "Default branch protection" ruleset (migration 0028). The repos | |
| 9 | //! service's `protected` flag is folded into it the first time its rulesets | |
| 10 | //! are read ([`Work::rulesets_for`]), once, and `get_settings` and | |
| 11 | //! `update_settings` read and write it, so callers of the old settings see | |
| 12 | //! no change. | |
| 13 | ||
| 14 | use std::collections::HashMap; | |
| 15 | ||
| 16 | use g1t_contracts::access::Capability; | |
| 17 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 18 | use g1t_contracts::events::{NewEvent, Publish}; | |
| 19 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 20 | use g1t_contracts::rules::*; | |
| 21 | use g1t_contracts::teams::{ResolveTeamsArgs, ResolvedTeam}; | |
| 22 | use g1t_contracts::time::rfc3339; | |
| 23 | use g1t_contracts::work::{Pull, RepoSettings, Verdict as ReviewVerdict}; | |
| 24 | use g1t_contracts::{FailureCode, Outcome, Role, User, Viewer, new_id}; | |
| 25 | use g1t_kit::now_ms; | |
| 26 | use g1t_rules::merge::{MergeFacts, Requirements, Review}; | |
| 27 | use g1t_rules::{ActorFacts, Judged, RepoFacts, legacy, select, validate}; | |
| 28 | use serde::{Deserialize, Serialize}; | |
| 29 | use worker::Result; | |
| 30 | use worker::wasm_bindgen::JsValue; | |
| 31 | ||
| 32 | use crate::Work; | |
| 33 | use crate::reviews::AGENT_ID; | |
| 34 | ||
| 35 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. | |
| 36 | macro_rules! check { | |
| 37 | ($outcome:expr) => { | |
| 38 | match $outcome { | |
| 39 | Outcome::Ok(value) => value, | |
| 40 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 41 | } | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | /// Evaluations a page lists at most. | |
| 46 | const MAX_PAGE: u32 = 100; | |
| 47 | /// The window insights cover. | |
| 48 | const INSIGHT_DAYS: u32 = 30; | |
| 49 | /// How long evaluations are kept. | |
| 50 | const KEEP_DAYS: u64 = 90; | |
| 51 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 52 | ||
| 53 | /// A workspace renamed: its rulesets and their evaluations move with it. | |
| 54 | pub(crate) const RENAMED: &[&str] = &[ | |
| 55 | "UPDATE rulesets SET workspace = ?1 WHERE workspace = ?2", | |
| 56 | "UPDATE rule_evaluations SET workspace = ?1 WHERE workspace = ?2", | |
| 57 | ]; | |
| 58 | ||
| 59 | #[derive(Deserialize)] | |
| 60 | pub(crate) struct RulesetRow { | |
| 61 | id: String, | |
| 62 | level: String, | |
| 63 | workspace: String, | |
| 64 | repo_id: Option<String>, | |
| 65 | spec: String, | |
| 66 | source: Option<String>, | |
| 67 | created_by: String, | |
| 68 | created_at: String, | |
| 69 | updated_by: String, | |
| 70 | updated_at: String, | |
| 71 | } | |
| 72 | ||
| 73 | impl RulesetRow { | |
| 74 | fn into_ruleset(self, repo: Option<&Repo>) -> Option<Ruleset> { | |
| 75 | let spec: RulesetSpec = match serde_json::from_str(&self.spec) { | |
| 76 | Ok(spec) => spec, | |
| 77 | Err(error) => { | |
| 78 | worker::console_error!("ruleset {} does not read: {error}", self.id); | |
| 79 | return None; | |
| 80 | } | |
| 81 | }; | |
| 82 | let level = if self.level == "workspace" { Level::Workspace } else { Level::Repository }; | |
| 83 | let repository = match (level, repo) { | |
| 84 | (Level::Repository, Some(repo)) if Some(&repo.id) == self.repo_id.as_ref() => { | |
| 85 | Some(format!("{}/{}", repo.namespace, repo.name)) | |
| 86 | } | |
| 87 | _ => None, | |
| 88 | }; | |
| 89 | Some(Ruleset { | |
| 90 | id: self.id, | |
| 91 | level, | |
| 92 | workspace: match (level, repo) { | |
| 93 | (Level::Repository, Some(repo)) => repo.namespace.to_lowercase(), | |
| 94 | _ => self.workspace, | |
| 95 | }, | |
| 96 | repo_id: self.repo_id, | |
| 97 | repository, | |
| 98 | spec, | |
| 99 | source: self.source, | |
| 100 | created_by: self.created_by, | |
| 101 | created_at: self.created_at, | |
| 102 | updated_by: self.updated_by, | |
| 103 | updated_at: self.updated_at, | |
| 104 | }) | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | #[derive(Deserialize)] | |
| 109 | struct EvaluationRow { | |
| 110 | id: String, | |
| 111 | repo_id: String, | |
| 112 | workspace: String, | |
| 113 | ruleset_id: String, | |
| 114 | ruleset_name: String, | |
| 115 | enforcement: Enforcement, | |
| 116 | action: Action, | |
| 117 | git_ref: String, | |
| 118 | actor: String, | |
| 119 | actor_kind: String, | |
| 120 | verdict: Verdict, | |
| 121 | violations: String, | |
| 122 | number: Option<u32>, | |
| 123 | sha: Option<String>, | |
| 124 | created_at: String, | |
| 125 | } | |
| 126 | ||
| 127 | impl From<EvaluationRow> for Evaluation { | |
| 128 | fn from(row: EvaluationRow) -> Self { | |
| 129 | Evaluation { | |
| 130 | id: row.id, | |
| 131 | evaluation: NewEvaluation { | |
| 132 | repo_id: row.repo_id, | |
| 133 | workspace: row.workspace, | |
| 134 | ruleset_id: row.ruleset_id, | |
| 135 | ruleset_name: row.ruleset_name, | |
| 136 | enforcement: row.enforcement, | |
| 137 | action: row.action, | |
| 138 | git_ref: row.git_ref, | |
| 139 | actor: row.actor, | |
| 140 | actor_kind: row.actor_kind, | |
| 141 | verdict: row.verdict, | |
| 142 | violations: serde_json::from_str(&row.violations).unwrap_or_default(), | |
| 143 | number: row.number, | |
| 144 | sha: row.sha, | |
| 145 | }, | |
| 146 | repository: String::new(), | |
| 147 | created_at: row.created_at, | |
| 148 | } | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /// Whose rulesets a call is about, once checked. | |
| 153 | struct Scope { | |
| 154 | level: Level, | |
| 155 | workspace: String, | |
| 156 | repo: Option<Repo>, | |
| 157 | } | |
| 158 | ||
| 159 | /// What a webhook and the event log are sent when a ruleset changes. | |
| 160 | #[derive(Serialize)] | |
| 161 | #[serde(rename_all = "camelCase")] | |
| 162 | struct RulesetEvent<'a> { | |
| 163 | workspace: &'a str, | |
| 164 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 165 | repository: Option<String>, | |
| 166 | ruleset: &'a Ruleset, | |
| 167 | } | |
| 168 | ||
| 169 | fn optional(value: Option<&str>) -> JsValue { | |
| 170 | value.map_or(JsValue::NULL, Into::into) | |
| 171 | } | |
| 172 | ||
| 173 | /// Whether a pull request is an agent's change: g1t made it, an agent | |
| 174 | /// opened it, or it was opened naming an agent rather than by its author | |
| 175 | /// on the site. | |
| 176 | pub(crate) fn agent_change(pull: &Pull) -> bool { | |
| 177 | crate::lifecycle::made_by_g1t(pull) | |
| 178 | || pull.author.id == AGENT_ID | |
| 179 | || g1t_contracts::rules::is_agent(&pull.author) | |
| 180 | || (!pull.agent.trim().is_empty() && !pull.agent.eq_ignore_ascii_case(&pull.author.username)) | |
| 181 | } | |
| 182 | ||
| 183 | /// The latest verdict of each reviewer, from verdict rows oldest first. | |
| 184 | pub(crate) fn latest_reviews(rows: Vec<(String, String, ReviewVerdict, String)>) -> Vec<Review> { | |
| 185 | let mut latest: HashMap<String, Review> = HashMap::new(); | |
| 186 | for (reviewer_id, username, verdict, at) in rows { | |
| 187 | let agent = reviewer_id == AGENT_ID; | |
| 188 | latest.insert(reviewer_id.clone(), Review { reviewer_id, username, verdict, at, agent }); | |
| 189 | } | |
| 190 | let mut reviews: Vec<Review> = latest.into_values().collect(); | |
| 191 | reviews.sort_by(|a, b| a.at.cmp(&b.at)); | |
| 192 | reviews | |
| 193 | } | |
| 194 | ||
| 195 | /// The settings that hold for a branch: the repository's own (how g1t's | |
| 196 | /// agents work), with branch protection as the active rules stack it. | |
| 197 | pub(crate) fn overlay(stored: RepoSettings, requirements: &Requirements, default_branch: bool) -> RepoSettings { | |
| 198 | RepoSettings { | |
| 199 | required_checks: requirements.required_checks.clone(), | |
| 200 | require_up_to_date: requirements.strict, | |
| 201 | required_approvals: requirements.required_approvals, | |
| 202 | count_agent_approvals: requirements.count_agent_approvals, | |
| 203 | allow_ignoring_checks: requirements.allow_bypass_on_merge, | |
| 204 | // The queue lands on the default branch only. | |
| 205 | merge_queue: requirements.merge_queue.is_some() && default_branch, | |
| 206 | require_code_owner_review: requirements.require_code_owner_review, | |
| 207 | ..stored | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /// What the merge box shows: each rule not met, and how to meet it. | |
| 212 | /// `default_branch`: whether it merges into the default branch, where the | |
| 213 | /// merge queue lands. | |
| 214 | pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements, default_branch: bool) -> MergeRules { | |
| 215 | let mut out = MergeRules { | |
| 216 | merge_queue: requirements.merge_queue.is_some() && default_branch, | |
| 217 | required_approvals: requirements.required_approvals, | |
| 218 | strict: requirements.strict, | |
| 219 | allow_bypass_on_merge: requirements.allow_bypass_on_merge, | |
| 220 | ..MergeRules::default() | |
| 221 | }; | |
| 222 | for one in judged { | |
| 223 | match (one.enforcement, one.verdict()) { | |
| 224 | (Enforcement::Active, Verdict::Fail) => out.unmet.extend(one.violations.iter().cloned()), | |
| 225 | (Enforcement::Active, Verdict::Bypass) => out.bypassable.extend(one.violations.iter().cloned()), | |
| 226 | (Enforcement::Evaluate, Verdict::Fail | Verdict::Bypass) => out.evaluate.extend(one.violations.iter().cloned()), | |
| 227 | _ => {} | |
| 228 | } | |
| 229 | out.rulesets.push(RulesetSummary { | |
| 230 | id: one.id.clone(), | |
| 231 | name: one.name.clone(), | |
| 232 | level: one.level, | |
| 233 | enforcement: one.enforcement, | |
| 234 | bypass_actors: Vec::new(), | |
| 235 | }); | |
| 236 | } | |
| 237 | out | |
| 238 | } | |
| 239 | ||
| 240 | impl Work { | |
| 241 | fn rules_statement(&self, sql: &str, binds: &[JsValue]) -> Result<worker::D1PreparedStatement> { | |
| 242 | self.db.prepare(sql).bind(binds) | |
| 243 | } | |
| 244 | ||
| 245 | /// The rulesets that may hold in `repo`: its own and its workspace's, | |
| 246 | /// disabled ones included. A pull request's working copy has none. | |
| 247 | /// Folds the repository's old `protected` flag in, once. | |
| 248 | pub(crate) async fn rulesets_for(&self, repo: &Repo) -> Result<Vec<Ruleset>> { | |
| 249 | if repo.fork_of.is_some() { | |
| 250 | return Ok(Vec::new()); | |
| 251 | } | |
| 252 | let prefetched = self.prefetched_repo(&repo.id).filter(|found| found.namespace == repo.namespace.to_lowercase()); | |
| 253 | let (rows, adopted) = match prefetched { | |
| 254 | Some(found) => ( | |
| 255 | found.rows::<RulesetRow>(crate::prefetch::Slot::Rulesets)?, | |
| 256 | found.first::<crate::rows::NumberRow>(crate::prefetch::Slot::Adopted)?.is_some(), | |
| 257 | ), | |
| 258 | None => { | |
| 259 | let results = self | |
| 260 | .db | |
| 261 | .batch(vec![ | |
| 262 | self.rules_statement(RULESETS_SQL, &[repo.id.as_str().into(), repo.namespace.to_lowercase().into()])?, | |
| 263 | self.rules_statement(ADOPTED_SQL, &[repo.id.as_str().into()])?, | |
| 264 | ]) | |
| 265 | .await?; | |
| 266 | let rows = results.first().map(|result| result.results::<RulesetRow>()).transpose()?.unwrap_or_default(); | |
| 267 | let adopted = results | |
| 268 | .get(1) | |
| 269 | .map(|result| result.results::<crate::rows::NumberRow>()) | |
| 270 | .transpose()? | |
| 271 | .is_some_and(|rows| !rows.is_empty()); | |
| 272 | (rows, adopted) | |
| 273 | } | |
| 274 | }; | |
| 275 | let mut rulesets: Vec<Ruleset> = rows.into_iter().filter_map(|row| row.into_ruleset(Some(repo))).collect(); | |
| 276 | if !adopted { | |
| 277 | self.adopt(repo, &mut rulesets).await?; | |
| 278 | } | |
| 279 | Ok(rulesets) | |
| 280 | } | |
| 281 | ||
| 282 | /// Folds the repos service's `protected` flag into the repository's | |
| 283 | /// branch protection ruleset, once: pushes to the default branch stay | |
| 284 | /// refused where they were. One batch, so two requests cannot both. | |
| 285 | async fn adopt(&self, repo: &Repo, rulesets: &mut Vec<Ruleset>) -> Result<()> { | |
| 286 | let now = rfc3339(now_ms()); | |
| 287 | let not_yet = "NOT EXISTS (SELECT 1 FROM ruleset_adoptions WHERE repo_id = ?)"; | |
| 288 | let mut statements = vec![self.rules_statement( | |
| 289 | "UPDATE rulesets SET workspace = ? WHERE repo_id = ? AND workspace = ''", | |
| 290 | &[repo.namespace.to_lowercase().into(), repo.id.as_str().into()], | |
| 291 | )?]; | |
| 292 | if repo.protected { | |
| 293 | let existing = rulesets | |
| 294 | .iter_mut() | |
| 295 | .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION)); | |
| 296 | match existing { | |
| 297 | Some(ruleset) => { | |
| 298 | let mut found = false; | |
| 299 | for entry in &mut ruleset.spec.rules { | |
| 300 | if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) { | |
| 301 | rule.allow_direct_pushes = false; | |
| 302 | found = true; | |
| 303 | } | |
| 304 | } | |
| 305 | if !found { | |
| 306 | ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default()))); | |
| 307 | } | |
| 308 | statements.push(self.rules_statement( | |
| 309 | &format!("UPDATE rulesets SET spec = ? WHERE id = ? AND {not_yet}"), | |
| 310 | &[serde_json::to_string(&ruleset.spec)?.into(), ruleset.id.as_str().into(), repo.id.as_str().into()], | |
| 311 | )?); | |
| 312 | } | |
| 313 | None => { | |
| 314 | if let Some(spec) = legacy::ruleset_of(&RepoSettings::default(), true) { | |
| 315 | let ruleset = Ruleset { | |
| 316 | id: new_id("rs", now_ms()), | |
| 317 | level: Level::Repository, | |
| 318 | workspace: repo.namespace.to_lowercase(), | |
| 319 | repo_id: Some(repo.id.clone()), | |
| 320 | repository: Some(format!("{}/{}", repo.namespace, repo.name)), | |
| 321 | spec, | |
| 322 | source: Some(BRANCH_PROTECTION.to_owned()), | |
| 323 | created_by: "g1t".to_owned(), | |
| 324 | created_at: now.clone(), | |
| 325 | updated_by: "g1t".to_owned(), | |
| 326 | updated_at: now.clone(), | |
| 327 | }; | |
| 328 | statements.push(self.rules_statement( | |
| 329 | &format!( | |
| 330 | "INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at) | |
| 331 | SELECT ?, 'repository', ?, ?, ?, 'active', 'branch', ?, ?, 'g1t', ?, 'g1t', ? WHERE {not_yet}" | |
| 332 | ), | |
| 333 | &[ | |
| 334 | ruleset.id.as_str().into(), | |
| 335 | ruleset.workspace.as_str().into(), | |
| 336 | repo.id.as_str().into(), | |
| 337 | ruleset.spec.name.as_str().into(), | |
| 338 | serde_json::to_string(&ruleset.spec)?.into(), | |
| 339 | BRANCH_PROTECTION.into(), | |
| 340 | now.as_str().into(), | |
| 341 | now.as_str().into(), | |
| 342 | repo.id.as_str().into(), | |
| 343 | ], | |
| 344 | )?); | |
| 345 | rulesets.push(ruleset); | |
| 346 | } | |
| 347 | } | |
| 348 | } | |
| 349 | } | |
| 350 | statements.push(self.rules_statement( | |
| 351 | "INSERT OR IGNORE INTO ruleset_adoptions (repo_id, adopted_at) VALUES (?, ?)", | |
| 352 | &[repo.id.as_str().into(), now.into()], | |
| 353 | )?); | |
| 354 | self.db.batch(statements).await?; | |
| 355 | Ok(()) | |
| 356 | } | |
| 357 | ||
| 358 | /// A workspace's own rulesets. | |
| 359 | async fn workspace_rulesets(&self, workspace: &str) -> Result<Vec<Ruleset>> { | |
| 360 | Ok(self | |
| 361 | .db | |
| 362 | .prepare("SELECT * FROM rulesets WHERE level = 'workspace' AND workspace = ? ORDER BY created_at") | |
| 363 | .bind(&[workspace.to_lowercase().into()])? | |
| 364 | .all() | |
| 365 | .await? | |
| 366 | .results::<RulesetRow>()? | |
| 367 | .into_iter() | |
| 368 | .filter_map(|row| row.into_ruleset(None)) | |
| 369 | .collect()) | |
| 370 | } | |
| 371 | ||
| 372 | /// The people of each team named, by `workspace/slug`, usernames | |
| 373 | /// lowercase, child teams' people included. | |
| 374 | pub(crate) async fn team_people(&self, names: &[String], workspace: &str, repo_id: &str) -> Result<HashMap<String, Vec<String>>> { | |
| 375 | let keys: Vec<String> = names.iter().map(|name| select::team_key(name, workspace)).collect(); | |
| 376 | if keys.is_empty() { | |
| 377 | return Ok(HashMap::new()); | |
| 378 | } | |
| 379 | let teams: Vec<ResolvedTeam> = g1t_kit::call( | |
| 380 | &self.identity, | |
| 381 | "resolve_teams", | |
| 382 | &ResolveTeamsArgs { teams: keys, repo_id: Some(repo_id.to_owned()), asker: None }, | |
| 383 | ) | |
| 384 | .await | |
| 385 | .unwrap_or_default(); | |
| 386 | Ok(teams | |
| 387 | .into_iter() | |
| 388 | .map(|team| { | |
| 389 | let people = team | |
| 390 | .members | |
| 391 | .iter() | |
| 392 | .chain(team.child_members.iter()) | |
| 393 | .map(|person| person.username.to_lowercase()) | |
| 394 | .collect(); | |
| 395 | (format!("{}/{}", team.workspace.to_lowercase(), team.slug.to_lowercase()), people) | |
| 396 | }) | |
| 397 | .collect()) | |
| 398 | } | |
| 399 | ||
| 400 | /// The actor as bypass lists name people, their teams looked up only | |
| 401 | /// when a bypass list names a team. | |
| 402 | pub(crate) async fn actor_facts(&self, actor: &User, repo: &Repo, rulesets: &[Ruleset]) -> Result<ActorFacts> { | |
| 403 | let mut facts = ActorFacts::of(actor, repo); | |
| 404 | if facts.kind == select::Who::Person && select::names_teams(rulesets) { | |
| 405 | let named: Vec<String> = rulesets | |
| 406 | .iter() | |
| 407 | .flat_map(|ruleset| ruleset.spec.bypass_actors.iter()) | |
| 408 | .filter(|entry| entry.kind == ActorKind::Team) | |
| 409 | .map(|entry| entry.value.clone()) | |
| 410 | .collect(); | |
| 411 | let people = self.team_people(&named, &repo.namespace, &repo.id).await?; | |
| 412 | let me = actor.username.to_lowercase(); | |
| 413 | facts.teams = people.into_iter().filter(|(_, people)| people.contains(&me)).map(|(team, _)| team).collect(); | |
| 414 | } | |
| 415 | Ok(facts) | |
| 416 | } | |
| 417 | ||
| 418 | /// Who may see or change rulesets of `owner`, checked. | |
| 419 | async fn scope(&self, owner: &Owner, viewer: &Viewer, manage: bool) -> Result<Outcome<Scope>> { | |
| 420 | match (&owner.repo, &owner.workspace) { | |
| 421 | (Some(path), _) => { | |
| 422 | let repo = check!(self.repo(path, viewer).await?); | |
| 423 | if manage { | |
| 424 | let Some(actor) = viewer else { | |
| 425 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in first.")); | |
| 426 | }; | |
| Merge branch 'mirroring' into artifacts-mode | 427 | check!(crate::retired::not_archived(&repo)); |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 428 | if !actor.verified { |
| 429 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); | |
| 430 | } | |
| 431 | check!(crate::allowed(Some(actor), &repo, Capability::ManageProtection)); | |
| 432 | } | |
| 433 | Ok(Outcome::Ok(Scope { level: Level::Repository, workspace: repo.namespace.to_lowercase(), repo: Some(repo) })) | |
| 434 | } | |
| 435 | (None, Some(workspace)) => { | |
| 436 | let workspace = workspace.trim().to_lowercase(); | |
| 437 | let Some(actor) = viewer else { | |
| 438 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in first.")); | |
| 439 | }; | |
| 440 | let role = actor.role_in(&workspace); | |
| 441 | if role.is_none() { | |
| 442 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 443 | } | |
| 444 | if manage { | |
| 445 | if !actor.verified { | |
| 446 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); | |
| 447 | } | |
| 448 | if role != Some(Role::Owner) { | |
| 449 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners of the workspace can change its rulesets.")); | |
| 450 | } | |
| 451 | } | |
| 452 | Ok(Outcome::Ok(Scope { level: Level::Workspace, workspace, repo: None })) | |
| 453 | } | |
| 454 | (None, None) => Ok(Outcome::fail(FailureCode::Invalid, "Say whose rulesets: a repository or a workspace.")), | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | /// The rulesets of a scope: a repository's own (with its workspace's | |
| 459 | /// that hold in it, when asked), or a workspace's. | |
| 460 | async fn scoped_rulesets(&self, scope: &Scope, include_parents: bool) -> Result<Vec<Ruleset>> { | |
| 461 | match &scope.repo { | |
| 462 | Some(repo) => { | |
| 463 | let all = self.rulesets_for(repo).await?; | |
| 464 | Ok(all | |
| 465 | .into_iter() | |
| 466 | .filter(|ruleset| match ruleset.level { | |
| 467 | Level::Repository => true, | |
| 468 | Level::Workspace => { | |
| 469 | include_parents | |
| 470 | && repo_matches_spec(ruleset, RepoFacts::from(repo)) | |
| 471 | } | |
| 472 | }) | |
| 473 | .collect()) | |
| 474 | } | |
| 475 | None => self.workspace_rulesets(&scope.workspace).await, | |
| 476 | } | |
| 477 | } | |
| 478 | ||
| 479 | pub(crate) async fn list_rulesets(&self, a: ListRulesetsArgs) -> Result<Outcome<Vec<Ruleset>>> { | |
| 480 | let scope = check!(self.scope(&a.owner, &a.viewer, false).await?); | |
| 481 | Ok(Outcome::Ok(self.scoped_rulesets(&scope, a.include_parents).await?)) | |
| 482 | } | |
| 483 | ||
| 484 | pub(crate) async fn get_ruleset(&self, a: GetRulesetArgs) -> Result<Outcome<Ruleset>> { | |
| 485 | let scope = check!(self.scope(&a.owner, &a.viewer, false).await?); | |
| 486 | match self.scoped_rulesets(&scope, true).await?.into_iter().find(|ruleset| ruleset.id == a.id) { | |
| 487 | Some(ruleset) => Ok(Outcome::Ok(ruleset)), | |
| 488 | None => Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")), | |
| 489 | } | |
| 490 | } | |
| 491 | ||
| 492 | pub(crate) async fn save_ruleset(&self, a: SaveRulesetArgs) -> Result<Outcome<Ruleset>> { | |
| 493 | let scope = check!(self.scope(&a.owner, &Some(a.actor.clone()), true).await?); | |
| 494 | let spec = match validate::validate(&a.ruleset, scope.level) { | |
| 495 | Ok(spec) => spec, | |
| 496 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 497 | }; | |
| 498 | let existing = self.scoped_rulesets(&scope, false).await?; | |
| 499 | let before = match &a.id { | |
| 500 | Some(id) => match existing.iter().find(|ruleset| &ruleset.id == id) { | |
| 501 | Some(found) => Some(found.clone()), | |
| 502 | None => return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")), | |
| 503 | }, | |
| 504 | None => { | |
| 505 | if existing.len() >= MAX_RULESETS { | |
| 506 | return Ok(Outcome::fail(FailureCode::Invalid, format!("There can be at most {MAX_RULESETS} rulesets here."))); | |
| 507 | } | |
| 508 | None | |
| 509 | } | |
| 510 | }; | |
| 511 | let now = rfc3339(now_ms()); | |
| 512 | let ruleset = Ruleset { | |
| 513 | id: before.as_ref().map_or_else(|| new_id("rs", now_ms()), |found| found.id.clone()), | |
| 514 | level: scope.level, | |
| 515 | workspace: scope.workspace.clone(), | |
| 516 | repo_id: scope.repo.as_ref().map(|repo| repo.id.clone()), | |
| 517 | repository: scope.repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)), | |
| 518 | spec, | |
| 519 | source: before.as_ref().and_then(|found| found.source.clone()), | |
| 520 | created_by: before.as_ref().map_or_else(|| a.actor.username.clone(), |found| found.created_by.clone()), | |
| 521 | created_at: before.as_ref().map_or_else(|| now.clone(), |found| found.created_at.clone()), | |
| 522 | updated_by: a.actor.username.clone(), | |
| 523 | updated_at: now, | |
| 524 | }; | |
| 525 | self.store_ruleset(&ruleset).await?; | |
| 526 | let kind = if before.is_some() { "ruleset.updated" } else { "ruleset.created" }; | |
| 527 | self.announce(kind, &ruleset, &a.actor).await; | |
| 528 | if !a.from_api { | |
| 529 | self.audit_ruleset(&a.actor, &ruleset, if before.is_some() { "update_ruleset" } else { "create_ruleset" }).await; | |
| 530 | } | |
| 531 | Ok(Outcome::Ok(ruleset)) | |
| 532 | } | |
| 533 | ||
| 534 | async fn store_ruleset(&self, ruleset: &Ruleset) -> Result<()> { | |
| 535 | self.db | |
| 536 | .prepare( | |
| 537 | "INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at) | |
| 538 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 539 | ON CONFLICT (id) DO UPDATE SET | |
| 540 | name = excluded.name, enforcement = excluded.enforcement, target = excluded.target, | |
| 541 | spec = excluded.spec, workspace = excluded.workspace, | |
| 542 | updated_by = excluded.updated_by, updated_at = excluded.updated_at", | |
| 543 | ) | |
| 544 | .bind(&[ | |
| 545 | ruleset.id.as_str().into(), | |
| 546 | ruleset.level.as_str().into(), | |
| 547 | ruleset.workspace.as_str().into(), | |
| 548 | optional(ruleset.repo_id.as_deref()), | |
| 549 | ruleset.spec.name.as_str().into(), | |
| 550 | ruleset.spec.enforcement.as_str().into(), | |
| 551 | ruleset.spec.target.as_str().into(), | |
| 552 | serde_json::to_string(&ruleset.spec)?.into(), | |
| 553 | optional(ruleset.source.as_deref()), | |
| 554 | ruleset.created_by.as_str().into(), | |
| 555 | ruleset.created_at.as_str().into(), | |
| 556 | ruleset.updated_by.as_str().into(), | |
| 557 | ruleset.updated_at.as_str().into(), | |
| 558 | ])? | |
| 559 | .run() | |
| 560 | .await?; | |
| 561 | Ok(()) | |
| 562 | } | |
| 563 | ||
| 564 | pub(crate) async fn delete_ruleset(&self, a: DeleteRulesetArgs) -> Result<Outcome<bool>> { | |
| 565 | let scope = check!(self.scope(&a.owner, &Some(a.actor.clone()), true).await?); | |
| 566 | let Some(ruleset) = self.scoped_rulesets(&scope, false).await?.into_iter().find(|ruleset| ruleset.id == a.id) else { | |
| 567 | return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")); | |
| 568 | }; | |
| 569 | self.db.prepare("DELETE FROM rulesets WHERE id = ?").bind(&[ruleset.id.as_str().into()])?.run().await?; | |
| 570 | self.announce("ruleset.deleted", &ruleset, &a.actor).await; | |
| 571 | if !a.from_api { | |
| 572 | self.audit_ruleset(&a.actor, &ruleset, "delete_ruleset").await; | |
| 573 | } | |
| 574 | Ok(Outcome::Ok(true)) | |
| 575 | } | |
| 576 | ||
| 577 | /// Publishes a ruleset's change: a repository's on its repository, a | |
| 578 | /// workspace's to the workspace (webhooks route it there). | |
| 579 | async fn announce(&self, kind: &'static str, ruleset: &Ruleset, actor: &User) { | |
| 580 | let event = NewEvent { | |
| 581 | kind, | |
| 582 | source: crate::SOURCE, | |
| 583 | repo_id: ruleset.repo_id.clone(), | |
| 584 | actor: Some(actor.id.clone()), | |
| 585 | data: RulesetEvent { workspace: &ruleset.workspace, repository: ruleset.repository.clone(), ruleset }, | |
| 586 | }; | |
| 587 | let published: Result<()> = g1t_kit::call(&self.events, "publish", &Publish { events: vec![event] }).await; | |
| 588 | if let Err(error) = published { | |
| 589 | worker::console_error!("{kind} not published: {error}"); | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | /// Records a change to a ruleset made on the site in the workspace's | |
| 594 | /// audit log. Changes through the API are recorded by the API. | |
| 595 | async fn audit_ruleset(&self, actor: &User, ruleset: &Ruleset, action: &str) { | |
| 596 | let entry = NewAuditEntry { | |
| 597 | actor: AuditActor::of(actor), | |
| 598 | action: action.to_owned(), | |
| 599 | surface: Surface::Web, | |
| 600 | target: AuditTarget { | |
| 601 | workspace: ruleset.workspace.clone(), | |
| 602 | repo: ruleset.repository.clone(), | |
| 603 | ..AuditTarget::default() | |
| 604 | }, | |
| 605 | outcome: AuditOutcome::Allowed, | |
| 606 | rule: "rulesets".to_owned(), | |
| 607 | result: Some("ok".to_owned()), | |
| 608 | message: Some(format!( | |
| 609 | "{} ruleset \"{}\" ({}, {} rules)", | |
| 610 | match action { | |
| 611 | "create_ruleset" => "Created", | |
| 612 | "delete_ruleset" => "Deleted", | |
| 613 | _ => "Changed", | |
| 614 | }, | |
| 615 | ruleset.spec.name, | |
| 616 | ruleset.spec.enforcement.as_str(), | |
| 617 | ruleset.spec.rules.len() | |
| 618 | )), | |
| 619 | request_id: new_id("req", now_ms()), | |
| 620 | }; | |
| 621 | let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await; | |
| 622 | if let Err(error) = recorded { | |
| 623 | worker::console_error!("ruleset change not recorded: {error}"); | |
| 624 | } | |
| 625 | } | |
| 626 | ||
| 627 | pub(crate) async fn effective_rules(&self, a: EffectiveRulesArgs) -> Result<Outcome<EffectiveRules>> { | |
| 628 | let repo = check!(self.repo(&a.repo, &a.viewer).await?); | |
| 629 | let name = a.name.trim(); | |
| 630 | let name = name | |
| 631 | .strip_prefix("refs/heads/") | |
| 632 | .or_else(|| name.strip_prefix("refs/tags/")) | |
| 633 | .unwrap_or(name); | |
| 634 | if name.is_empty() { | |
| 635 | return Ok(Outcome::fail(FailureCode::Invalid, "Name a branch or tag.")); | |
| 636 | } | |
| 637 | let rulesets = self.rulesets_for(&repo).await?; | |
| 638 | Ok(Outcome::Ok(select::effective(&rulesets, RepoFacts::from(&repo), a.target, name))) | |
| 639 | } | |
| 640 | ||
| 641 | /// Services only: the rulesets that hold for refs a service is about to | |
| 642 | /// change, with whether the actor may bypass each. | |
| 643 | pub(crate) async fn ref_rules(&self, a: RefRulesArgs) -> Result<Outcome<RefRules>> { | |
| 644 | let rulesets = self.rulesets_for(&a.repo).await?; | |
| 645 | let who = match &a.actor { | |
| 646 | Some(actor) => Some(self.actor_facts(actor, &a.repo, &rulesets).await?), | |
| 647 | None => None, | |
| 648 | }; | |
| 649 | Ok(Outcome::Ok(RefRules { | |
| 650 | default_branch: a.repo.default_branch.clone(), | |
| 651 | workspace: a.repo.namespace.to_lowercase(), | |
| 652 | rulesets: select::applicable(&rulesets, RepoFacts::from(&a.repo), &a.refs, who.as_ref(), &a.repo.namespace), | |
| 653 | })) | |
| 654 | } | |
| 655 | ||
| 656 | /// Services only: keeps evaluations, and lets old ones go. | |
| 657 | pub(crate) async fn record_evaluations(&self, a: RecordEvaluationsArgs) -> Result<u32> { | |
| 658 | if a.evaluations.is_empty() { | |
| 659 | return Ok(0); | |
| 660 | } | |
| 661 | let now = now_ms(); | |
| 662 | let at = rfc3339(now); | |
| 663 | let mut statements = Vec::new(); | |
| 664 | for evaluation in a.evaluations.iter().take(200) { | |
| 665 | statements.push(self.rules_statement( | |
| 666 | "INSERT INTO rule_evaluations (id, repo_id, workspace, ruleset_id, ruleset_name, enforcement, action, git_ref, actor, actor_kind, verdict, violations, number, sha, created_at) | |
| 667 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 668 | &[ | |
| 669 | new_id("rev", now).into(), | |
| 670 | evaluation.repo_id.as_str().into(), | |
| 671 | evaluation.workspace.to_lowercase().into(), | |
| 672 | evaluation.ruleset_id.as_str().into(), | |
| 673 | evaluation.ruleset_name.as_str().into(), | |
| 674 | evaluation.enforcement.as_str().into(), | |
| 675 | evaluation.action.as_str().into(), | |
| 676 | evaluation.git_ref.as_str().into(), | |
| 677 | evaluation.actor.as_str().into(), | |
| 678 | evaluation.actor_kind.as_str().into(), | |
| 679 | evaluation.verdict.as_str().into(), | |
| 680 | serde_json::to_string(&evaluation.violations)?.into(), | |
| 681 | evaluation.number.map_or(JsValue::NULL, Into::into), | |
| 682 | optional(evaluation.sha.as_deref()), | |
| 683 | at.as_str().into(), | |
| 684 | ], | |
| 685 | )?); | |
| 686 | } | |
| 687 | if let Some(first) = a.evaluations.first() { | |
| 688 | statements.push(self.rules_statement( | |
| 689 | "DELETE FROM rule_evaluations WHERE id IN | |
| 690 | (SELECT id FROM rule_evaluations WHERE repo_id = ? AND created_at < ? ORDER BY id LIMIT 200)", | |
| 691 | &[first.repo_id.as_str().into(), rfc3339(now.saturating_sub(KEEP_DAYS * DAY_MS)).into()], | |
| 692 | )?); | |
| 693 | } | |
| 694 | let count = a.evaluations.len().min(200) as u32; | |
| 695 | self.db.batch(statements).await?; | |
| 696 | Ok(count) | |
| 697 | } | |
| 698 | ||
| 699 | pub(crate) async fn rule_evaluations(&self, a: EvaluationsArgs) -> Result<Outcome<EvaluationPage>> { | |
| 700 | let scope = check!(self.scope(&a.owner, &a.viewer, false).await?); | |
| 701 | if let Some(repo) = &scope.repo { | |
| 702 | check!(crate::allowed(a.viewer.as_ref(), repo, Capability::Push)); | |
| 703 | } | |
| 704 | let (column, key) = match &scope.repo { | |
| 705 | Some(repo) => ("repo_id", repo.id.clone()), | |
| 706 | None => ("workspace", scope.workspace.clone()), | |
| 707 | }; | |
| 708 | let limit = a.limit.unwrap_or(30).clamp(1, MAX_PAGE); | |
| 709 | let mut sql = format!("SELECT * FROM rule_evaluations WHERE {column} = ?"); | |
| 710 | let mut binds: Vec<JsValue> = vec![key.as_str().into()]; | |
| 711 | if let Some(id) = &a.ruleset_id { | |
| 712 | sql.push_str(" AND ruleset_id = ?"); | |
| 713 | binds.push(id.as_str().into()); | |
| 714 | } | |
| 715 | if let Some(verdict) = a.verdict { | |
| 716 | sql.push_str(" AND verdict = ?"); | |
| 717 | binds.push(verdict.as_str().into()); | |
| 718 | } | |
| 719 | if a.problems_only { | |
| 720 | sql.push_str(" AND verdict != 'pass'"); | |
| 721 | } | |
| 722 | if let Some(before) = &a.before { | |
| 723 | sql.push_str(" AND id < ?"); | |
| 724 | binds.push(before.as_str().into()); | |
| 725 | } | |
| 726 | sql.push_str(" ORDER BY id DESC LIMIT ?"); | |
| 727 | binds.push((limit + 1).into()); | |
| 728 | let since = rfc3339(now_ms().saturating_sub(u64::from(INSIGHT_DAYS) * DAY_MS)); | |
| 729 | let ruleset_filter = if a.ruleset_id.is_some() { " AND ruleset_id = ?3" } else { "" }; | |
| 730 | let mut insight_binds: Vec<JsValue> = vec![key.as_str().into(), since.as_str().into()]; | |
| 731 | if let Some(id) = &a.ruleset_id { | |
| 732 | insight_binds.push(id.as_str().into()); | |
| 733 | } | |
| 734 | let results = self | |
| 735 | .db | |
| 736 | .batch(vec![ | |
| 737 | self.rules_statement(&sql, &binds)?, | |
| 738 | self.rules_statement( | |
| 739 | &format!( | |
| 740 | "SELECT ruleset_id, ruleset_name, enforcement, verdict, count(*) AS n FROM rule_evaluations | |
| 741 | WHERE {column} = ?1 AND created_at >= ?2{ruleset_filter} | |
| 742 | GROUP BY ruleset_id, enforcement, verdict" | |
| 743 | ), | |
| 744 | &insight_binds, | |
| 745 | )?, | |
| 746 | self.rules_statement( | |
| 747 | &format!( | |
| 748 | "SELECT json_extract(v.value, '$.rule') AS rule, count(*) AS n | |
| 749 | FROM rule_evaluations e, json_each(e.violations) v | |
| 750 | WHERE e.{column} = ?1 AND e.created_at >= ?2 AND e.verdict != 'pass'{} | |
| 751 | GROUP BY rule ORDER BY n DESC LIMIT 20", | |
| 752 | ruleset_filter.replace("ruleset_id", "e.ruleset_id") | |
| 753 | ), | |
| 754 | &insight_binds, | |
| 755 | )?, | |
| 756 | ]) | |
| 757 | .await?; | |
| 758 | let mut evaluations: Vec<Evaluation> = results[0].results::<EvaluationRow>()?.into_iter().map(Evaluation::from).collect(); | |
| 759 | let next = (evaluations.len() > limit as usize).then(|| { | |
| 760 | evaluations.truncate(limit as usize); | |
| 761 | evaluations.last().map(|last| last.id.clone()) | |
| 762 | }); | |
| 763 | let repository = scope.repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)).unwrap_or_default(); | |
| 764 | for evaluation in &mut evaluations { | |
| 765 | evaluation.repository = repository.clone(); | |
| 766 | } | |
| 767 | Ok(Outcome::Ok(EvaluationPage { | |
| 768 | evaluations, | |
| 769 | next: next.flatten(), | |
| 770 | insights: insights( | |
| 771 | results[1].results::<CountRow>()?, | |
| 772 | results[2].results::<RuleCountRow>()?, | |
| 773 | ), | |
| 774 | })) | |
| 775 | } | |
| 776 | ||
| 777 | /// Everything a merge of `pull` is judged on, and the judgement, for | |
| 778 | /// `actor`, or for nobody in particular. A bypass counts only with | |
| 779 | /// `honor_bypass`: a person merging asks for it (`bypass_rules`). | |
| 780 | pub(crate) async fn merge_gate( | |
| 781 | &self, | |
| 782 | repo: &Repo, | |
| 783 | pull: &Pull, | |
| 784 | actor: Option<&User>, | |
| 785 | ignore_checks: bool, | |
| 786 | honor_bypass: bool, | |
| 787 | ) -> Result<Gate> { | |
| 788 | let rulesets = self.rulesets_for(repo).await?; | |
| 789 | let base = pull.base_branch(&repo.default_branch).to_owned(); | |
| 790 | let git_ref = Target::Branch.full_ref(&base); | |
| 791 | let who = match actor { | |
| 792 | Some(actor) => Some(self.actor_facts(actor, repo, &rulesets).await?), | |
| 793 | None => None, | |
| 794 | }; | |
| 795 | let mut applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), who.as_ref(), &repo.namespace); | |
| 796 | if !honor_bypass { | |
| 797 | for ruleset in &mut applicable { | |
| 798 | ruleset.bypass = None; | |
| 799 | } | |
| 800 | } | |
| 801 | let agent = agent_change(pull); | |
| 802 | let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect(); | |
| 803 | let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent, &files); | |
| 804 | if applicable.is_empty() { | |
| 805 | return Ok(Gate { judged: Vec::new(), requirements, applicable, who }); | |
| 806 | } | |
| 807 | let needs_owners = applicable.iter().any(|ruleset| { | |
| 808 | ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::PullRequest(rule) if rule.require_code_owner_review)) | |
| 809 | }); | |
| 810 | let owners_settings = RepoSettings { require_code_owner_review: true, ..RepoSettings::default() }; | |
| 811 | let teams = g1t_rules::merge::named_teams(&applicable); | |
| 812 | let (verdicts, statuses, behind, code_owners, spent, team_members, commits) = futures_util::try_join!( | |
| 813 | self.verdict_rows(pull), | |
| 814 | self.statuses(&pull.repo_id, pull.head_commit.as_deref()), | |
| 815 | self.is_behind(&repo.id, pull), | |
| 816 | async { | |
| 817 | if needs_owners { self.code_owners_gap(&owners_settings, pull).await } else { Ok(None) } | |
| 818 | }, | |
| 819 | self.pull_spend(pull), | |
| 820 | async { | |
| 821 | if teams.is_empty() { Ok(HashMap::new()) } else { self.team_people(&teams, &repo.namespace, &repo.id).await } | |
| 822 | }, | |
| 823 | async { | |
| 824 | if g1t_rules::merge::needs_commits(&applicable, agent) { | |
| 825 | self.pull_commits(repo, pull, &base).await.map(Some) | |
| 826 | } else { | |
| 827 | Ok(None) | |
| 828 | } | |
| 829 | }, | |
| 830 | )?; | |
| 831 | let reviews = latest_reviews(verdicts); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 832 | // A required check named for a g1t Actions job is met by that job. |
| 833 | let requires_checks = | |
| 834 | applicable.iter().any(|ruleset| ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::RequiredStatusChecks(_)))); | |
| 835 | let statuses = if requires_checks { self.with_job_checks(&repo.id, pull.head_commit.as_deref(), statuses).await? } else { statuses }; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 836 | let facts = MergeFacts { |
| 837 | git_ref: git_ref.clone(), | |
| 838 | agent_change: agent, | |
| 839 | owner_id: &pull.owner().id, | |
| 840 | reviews: &reviews, | |
| 841 | head_pushed_at: pull.head_pushed_at.as_deref(), | |
| 842 | head_pushed_by: pull.head_pushed_by.as_deref(), | |
| 843 | code_owners_missing: code_owners.as_deref(), | |
| 844 | statuses: &statuses, | |
| 845 | behind, | |
| 846 | files: &files, | |
| 847 | commits: commits.as_ref(), | |
| 848 | confidence: pull.confidence.as_ref().map(|confidence| confidence.level), | |
| 849 | spent_usd: spent, | |
| 850 | now_ms: now_ms(), | |
| 851 | method: Some(MergeMethod::Merge), | |
| 852 | team_members: Some(&team_members), | |
| 853 | ignore_checks, | |
| 854 | }; | |
| 855 | let judged = g1t_rules::merge::judge(&applicable, &repo.default_branch, &facts); | |
| 856 | Ok(Gate { judged, requirements, applicable, who }) | |
| 857 | } | |
| 858 | ||
| 859 | /// Every verdict on a pull request, oldest first: who, and when. | |
| 860 | async fn verdict_rows(&self, pull: &Pull) -> Result<Vec<(String, String, ReviewVerdict, String)>> { | |
| 861 | #[derive(Deserialize)] | |
| 862 | struct Row { | |
| 863 | author_id: String, | |
| 864 | author_name: String, | |
| 865 | verdict: ReviewVerdict, | |
| 866 | created_at: String, | |
| 867 | } | |
| 868 | let rows = match self.prefetched_pull(&pull.id) { | |
| 869 | Some(found) => found.rows::<Row>(crate::prefetch::Slot::Verdicts)?, | |
| 870 | None => self | |
| 871 | .db | |
| 872 | .prepare( | |
| 873 | "SELECT author_id, author_name, verdict, created_at FROM comments | |
| 874 | WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id", | |
| 875 | ) | |
| 876 | .bind(&[pull.repo_id.as_str().into(), pull.number.into()])? | |
| 877 | .all() | |
| 878 | .await? | |
| 879 | .results::<Row>()?, | |
| 880 | }; | |
| 881 | Ok(rows.into_iter().map(|row| (row.author_id, row.author_name, row.verdict, row.created_at)).collect()) | |
| 882 | } | |
| 883 | ||
| 884 | /// What agents have spent on a pull request, in US dollars. | |
| 885 | pub(crate) async fn pull_spend(&self, pull: &Pull) -> Result<f64> { | |
| 886 | #[derive(Deserialize)] | |
| 887 | struct Row { | |
| 888 | spent: Option<f64>, | |
| 889 | } | |
| 890 | Ok(self | |
| 891 | .db | |
| 892 | .prepare("SELECT sum(cost_usd) AS spent FROM agent_runs WHERE pull_id = ?") | |
| 893 | .bind(&[pull.id.as_str().into()])? | |
| 894 | .first::<Row>(None) | |
| 895 | .await? | |
| 896 | .and_then(|row| row.spent) | |
| 897 | .unwrap_or(0.0)) | |
| 898 | } | |
| 899 | ||
| 900 | /// The commits a pull request would land, read as rules look at them. | |
| 901 | async fn pull_commits(&self, repo: &Repo, pull: &Pull, base: &str) -> Result<InspectedCommits> { | |
| 902 | let Some(head) = pull.head_commit.clone() else { | |
| 903 | return Ok(InspectedCommits::default()); | |
| 904 | }; | |
| 905 | let found: Outcome<InspectedCommits> = g1t_kit::call( | |
| 906 | &self.repos, | |
| 907 | "inspect_commits", | |
| 908 | &InspectCommitsArgs { | |
| 909 | source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()), | |
| 910 | head, | |
| 911 | target_id: repo.id.clone(), | |
| 912 | base_branch: base.to_owned(), | |
| 913 | limit: None, | |
| 914 | }, | |
| 915 | ) | |
| 916 | .await?; | |
| 917 | Ok(match found { | |
| 918 | Outcome::Ok(commits) => commits, | |
| 919 | Outcome::Fail(failure) => { | |
| 920 | worker::console_error!("inspect_commits for {}: {}", pull.id, failure.message); | |
| 921 | InspectedCommits::default() | |
| 922 | } | |
| 923 | }) | |
| 924 | } | |
| 925 | ||
| 926 | /// Records how each ruleset judged a merge. | |
| 927 | pub(crate) async fn record_merge_evaluations(&self, repo: &Repo, pull: &Pull, gate: &Gate) { | |
| 928 | let Some(who) = &gate.who else { return }; | |
| 929 | if gate.judged.is_empty() { | |
| 930 | return; | |
| 931 | } | |
| 932 | let evaluations = g1t_rules::evaluations( | |
| 933 | &gate.judged, | |
| 934 | &repo.id, | |
| 935 | &repo.namespace, | |
| 936 | Action::Merge, | |
| 937 | who, | |
| 938 | Some(pull.number), | |
| 939 | pull.head_commit.as_deref(), | |
| 940 | ); | |
| 941 | if let Err(error) = self.record_evaluations(RecordEvaluationsArgs { evaluations }).await { | |
| 942 | worker::console_error!("merge evaluations not recorded: {error}"); | |
| 943 | } | |
| 944 | } | |
| 945 | ||
| 946 | /// The settings that hold for a pull request: the repository's, with | |
| 947 | /// branch protection as the rules for the branch it merges into stack. | |
| 948 | pub(crate) async fn settings_on(&self, repo: &Repo, pull: &Pull) -> Result<RepoSettings> { | |
| 949 | let (stored, rulesets) = futures_util::future::try_join(self.settings(&repo.id), self.rulesets_for(repo)).await?; | |
| 950 | let base = pull.base_branch(&repo.default_branch); | |
| 951 | let git_ref = Target::Branch.full_ref(base); | |
| 952 | let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace); | |
| 953 | let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect(); | |
| 954 | let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent_change(pull), &files); | |
| 955 | Ok(overlay(stored, &requirements, base == repo.default_branch)) | |
| 956 | } | |
| 957 | ||
| 958 | /// The repository a pull request merges into: as read earlier in this | |
| 959 | /// request, or now. | |
| 960 | pub(crate) async fn repo_for(&self, pull: &Pull) -> Result<Option<Repo>> { | |
| 961 | if let Some(repo) = self.known_repos.borrow().get(&pull.repo_id) { | |
| 962 | return Ok(Some(repo.clone())); | |
| 963 | } | |
| 964 | let found = self.target_of(pull).await?; | |
| 965 | if let Some(repo) = &found { | |
| 966 | self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone()); | |
| 967 | } | |
| 968 | Ok(found) | |
| 969 | } | |
| 970 | ||
| 971 | /// The settings that hold for a pull request, when only it is at hand. | |
| 972 | pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> { | |
| 973 | match self.repo_for(pull).await? { | |
| 974 | Some(repo) => self.settings_on(&repo, pull).await, | |
| 975 | None => self.settings(&pull.repo_id).await, | |
| 976 | } | |
| 977 | } | |
| 978 | ||
| 979 | /// The settings that hold for the default branch. | |
| 980 | pub(crate) async fn default_branch_settings(&self, repo: &Repo) -> Result<RepoSettings> { | |
| 981 | let (stored, requirements) = futures_util::future::try_join(self.settings(&repo.id), self.default_requirements(repo)).await?; | |
| 982 | Ok(overlay(stored, &requirements, true)) | |
| 983 | } | |
| 984 | ||
| 985 | /// What the active rules ask of the default branch, for anyone. | |
| 986 | pub(crate) async fn default_requirements(&self, repo: &Repo) -> Result<Requirements> { | |
| 987 | let rulesets = self.rulesets_for(repo).await?; | |
| 988 | let git_ref = Target::Branch.full_ref(&repo.default_branch); | |
| 989 | let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace); | |
| 990 | Ok(g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, false, &[])) | |
| 991 | } | |
| 992 | ||
| 993 | /// How the default branch's merge queue batches: its rule's | |
| 994 | /// parameters, or the defaults. | |
| 995 | pub(crate) async fn queue_rule(&self, repo_id: &str) -> Result<MergeQueueRule> { | |
| 996 | let path: Option<RepoPath> = | |
| 997 | g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?; | |
| 998 | let repo = match path { | |
| 999 | Some(path) => self.repo_by_id(repo_id, &path.namespace).await?, | |
| 1000 | None => None, | |
| 1001 | }; | |
| 1002 | Ok(match repo { | |
| 1003 | Some(repo) => self.default_requirements(&repo).await?.merge_queue.unwrap_or_default(), | |
| 1004 | None => MergeQueueRule::default(), | |
| 1005 | }) | |
| 1006 | } | |
| 1007 | ||
| 1008 | /// The default branch's settings by repository id, for callers that do | |
| 1009 | /// not have the repository at hand (the merge queue, statuses). | |
| 1010 | pub(crate) async fn settings_by_id(&self, repo_id: &str) -> Result<RepoSettings> { | |
| 1011 | let path: Option<RepoPath> = | |
| 1012 | g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?; | |
| 1013 | let repo = match path { | |
| 1014 | Some(path) => self.repo_by_id(repo_id, &path.namespace).await?, | |
| 1015 | None => None, | |
| 1016 | }; | |
| 1017 | match repo { | |
| 1018 | Some(repo) => self.default_branch_settings(&repo).await, | |
| 1019 | None => self.settings(repo_id).await, | |
| 1020 | } | |
| 1021 | } | |
| 1022 | ||
| 1023 | /// The branch protection ruleset's rules rewritten from the old | |
| 1024 | /// settings (`update_settings`), creating it when needed. | |
| 1025 | pub(crate) async fn write_branch_protection(&self, repo: &Repo, settings: &RepoSettings, actor: &User) -> Result<()> { | |
| 1026 | let rulesets = self.rulesets_for(repo).await?; | |
| 1027 | let existing = rulesets | |
| 1028 | .iter() | |
| 1029 | .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION)); | |
| 1030 | let now = rfc3339(now_ms()); | |
| 1031 | match existing { | |
| 1032 | Some(found) => { | |
| 1033 | let protected = legacy::requires_pull_requests(&found.spec.rules); | |
| 1034 | let mut ruleset = found.clone(); | |
| 1035 | ruleset.spec.rules = legacy::replace(&found.spec.rules, settings, protected); | |
| 1036 | ruleset.updated_by = actor.username.clone(); | |
| 1037 | ruleset.updated_at = now; | |
| 1038 | self.store_ruleset(&ruleset).await?; | |
| 1039 | self.announce("ruleset.updated", &ruleset, actor).await; | |
| 1040 | } | |
| 1041 | None => { | |
| 1042 | let Some(spec) = legacy::ruleset_of(settings, false) else { return Ok(()) }; | |
| 1043 | let ruleset = Ruleset { | |
| 1044 | id: new_id("rs", now_ms()), | |
| 1045 | level: Level::Repository, | |
| 1046 | workspace: repo.namespace.to_lowercase(), | |
| 1047 | repo_id: Some(repo.id.clone()), | |
| 1048 | repository: Some(format!("{}/{}", repo.namespace, repo.name)), | |
| 1049 | spec, | |
| 1050 | source: Some(BRANCH_PROTECTION.to_owned()), | |
| 1051 | created_by: actor.username.clone(), | |
| 1052 | created_at: now.clone(), | |
| 1053 | updated_by: actor.username.clone(), | |
| 1054 | updated_at: now, | |
| 1055 | }; | |
| 1056 | self.store_ruleset(&ruleset).await?; | |
| 1057 | self.announce("ruleset.created", &ruleset, actor).await; | |
| 1058 | } | |
| 1059 | } | |
| 1060 | Ok(()) | |
| 1061 | } | |
| 1062 | ||
| 1063 | /// Services only (repos `update` with `protected`): whether the branch | |
| 1064 | /// protection ruleset refuses pushes to the default branch. | |
| 1065 | pub(crate) async fn set_requires_pull_request(&self, a: RequirePullRequestArgs) -> Result<Outcome<bool>> { | |
| 1066 | let rulesets = self.rulesets_for(&a.repo).await?; | |
| 1067 | let existing = rulesets | |
| 1068 | .iter() | |
| 1069 | .find(|ruleset| ruleset.repo_id.as_deref() == Some(&a.repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION)) | |
| 1070 | .cloned(); | |
| 1071 | let now = rfc3339(now_ms()); | |
| 1072 | let mut ruleset = match existing { | |
| 1073 | Some(found) => found, | |
| 1074 | None if !a.protected => return Ok(Outcome::Ok(false)), | |
| 1075 | None => Ruleset { | |
| 1076 | id: new_id("rs", now_ms()), | |
| 1077 | level: Level::Repository, | |
| 1078 | workspace: a.repo.namespace.to_lowercase(), | |
| 1079 | repo_id: Some(a.repo.id.clone()), | |
| 1080 | repository: Some(format!("{}/{}", a.repo.namespace, a.repo.name)), | |
| 1081 | spec: legacy::ruleset_of(&RepoSettings::default(), true).unwrap_or_default(), | |
| 1082 | source: Some(BRANCH_PROTECTION.to_owned()), | |
| 1083 | created_by: a.actor.username.clone(), | |
| 1084 | created_at: now.clone(), | |
| 1085 | updated_by: a.actor.username.clone(), | |
| 1086 | updated_at: now.clone(), | |
| 1087 | }, | |
| 1088 | }; | |
| 1089 | let mut found = false; | |
| 1090 | for entry in &mut ruleset.spec.rules { | |
| 1091 | if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) { | |
| 1092 | rule.allow_direct_pushes = !a.protected; | |
| 1093 | found = true; | |
| 1094 | } | |
| 1095 | } | |
| 1096 | if !found && a.protected { | |
| 1097 | ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default()))); | |
| 1098 | } | |
| 1099 | ruleset.updated_by = a.actor.username.clone(); | |
| 1100 | ruleset.updated_at = now; | |
| 1101 | self.store_ruleset(&ruleset).await?; | |
| 1102 | self.announce("ruleset.updated", &ruleset, &a.actor).await; | |
| 1103 | Ok(Outcome::Ok(true)) | |
| 1104 | } | |
| 1105 | } | |
| 1106 | ||
| 1107 | /// `set_requires_pull_request`: services only. | |
| 1108 | #[derive(Deserialize)] | |
| 1109 | pub(crate) struct RequirePullRequestArgs { | |
| 1110 | repo: Repo, | |
| 1111 | protected: bool, | |
| 1112 | actor: User, | |
| 1113 | } | |
| 1114 | ||
| 1115 | /// A merge, judged. | |
| 1116 | pub(crate) struct Gate { | |
| 1117 | pub(crate) judged: Vec<Judged>, | |
| 1118 | pub(crate) requirements: Requirements, | |
| 1119 | #[allow(dead_code)] | |
| 1120 | pub(crate) applicable: Vec<Applicable>, | |
| 1121 | pub(crate) who: Option<ActorFacts>, | |
| 1122 | } | |
| 1123 | ||
| 1124 | impl Gate { | |
| 1125 | /// What refuses the merge now, if anything, in one sentence. | |
| 1126 | pub(crate) fn refusal(&self) -> Option<String> { | |
| 1127 | g1t_rules::report::summary(&g1t_rules::outcome::blocking(&self.judged)) | |
| 1128 | } | |
| 1129 | ||
| 1130 | /// What people (not checks, which g1t's lifecycle waits for itself) | |
| 1131 | /// must still do before it can merge. | |
| 1132 | pub(crate) fn people_gap(&self) -> Option<String> { | |
| 1133 | let waiting: Vec<&Violation> = g1t_rules::outcome::blocking(&self.judged) | |
| 1134 | .into_iter() | |
| 1135 | .filter(|violation| !g1t_rules::merge::about_checks(&violation.rule)) | |
| 1136 | .collect(); | |
| 1137 | g1t_rules::report::summary(&waiting) | |
| 1138 | } | |
| 1139 | ||
| 1140 | /// Whether any rule not met could be bypassed by the actor. | |
| 1141 | pub(crate) fn bypassable(&self) -> bool { | |
| 1142 | self.judged.iter().any(|one| one.enforcement == Enforcement::Active && one.verdict() == Verdict::Bypass) | |
| 1143 | } | |
| 1144 | ||
| 1145 | /// The same judgement for an actor who did not ask to bypass anything. | |
| 1146 | pub(crate) fn without_bypass(mut self) -> Gate { | |
| 1147 | for one in &mut self.judged { | |
| 1148 | one.bypass = None; | |
| 1149 | } | |
| 1150 | self | |
| 1151 | } | |
| 1152 | } | |
| 1153 | ||
| 1154 | /// A workspace ruleset's repository condition, against one repository. | |
| 1155 | fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool { | |
| 1156 | select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo) | |
| 1157 | } | |
| 1158 | ||
| 1159 | const RULESETS_SQL: &str = | |
| 1160 | "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at"; | |
| 1161 | const ADOPTED_SQL: &str = "SELECT 1 AS n FROM ruleset_adoptions WHERE repo_id = ?1"; | |
| 1162 | ||
| 1163 | /// The statements prefetch.rs batches for a pull request's page. | |
| 1164 | pub(crate) fn prefetch_sql() -> (&'static str, &'static str) { | |
| 1165 | (RULESETS_SQL, ADOPTED_SQL) | |
| 1166 | } | |
| 1167 | ||
| 1168 | #[derive(Deserialize)] | |
| 1169 | struct CountRow { | |
| 1170 | ruleset_id: String, | |
| 1171 | ruleset_name: String, | |
| 1172 | enforcement: Enforcement, | |
| 1173 | verdict: Verdict, | |
| 1174 | n: u32, | |
| 1175 | } | |
| 1176 | ||
| 1177 | #[derive(Deserialize)] | |
| 1178 | struct RuleCountRow { | |
| 1179 | rule: Option<String>, | |
| 1180 | n: u32, | |
| 1181 | } | |
| 1182 | ||
| 1183 | /// Counts by ruleset and verdict, and violations by rule, as insights. | |
| 1184 | fn insights(counts: Vec<CountRow>, rules: Vec<RuleCountRow>) -> Insights { | |
| 1185 | let mut out = Insights { days: INSIGHT_DAYS, ..Insights::default() }; | |
| 1186 | let mut by_ruleset: Vec<RulesetInsight> = Vec::new(); | |
| 1187 | for row in counts { | |
| 1188 | out.total += row.n; | |
| 1189 | let index = match by_ruleset.iter().position(|have| have.ruleset_id == row.ruleset_id) { | |
| 1190 | Some(index) => index, | |
| 1191 | None => { | |
| 1192 | by_ruleset.push(RulesetInsight { | |
| 1193 | ruleset_id: row.ruleset_id.clone(), | |
| 1194 | ruleset_name: row.ruleset_name.clone(), | |
| 1195 | enforcement: row.enforcement, | |
| 1196 | ..RulesetInsight::default() | |
| 1197 | }); | |
| 1198 | by_ruleset.len() - 1 | |
| 1199 | } | |
| 1200 | }; | |
| 1201 | let one = &mut by_ruleset[index]; | |
| 1202 | one.total += row.n; | |
| 1203 | match (row.verdict, row.enforcement) { | |
| 1204 | (Verdict::Pass, _) => out.passed += row.n, | |
| 1205 | (Verdict::Bypass, _) => { | |
| 1206 | out.bypassed += row.n; | |
| 1207 | one.bypassed += row.n; | |
| 1208 | } | |
| 1209 | (Verdict::Fail, Enforcement::Evaluate) => { | |
| 1210 | out.would_block += row.n; | |
| 1211 | one.would_block += row.n; | |
| 1212 | } | |
| 1213 | (Verdict::Fail, _) => { | |
| 1214 | out.blocked += row.n; | |
| 1215 | one.blocked += row.n; | |
| 1216 | } | |
| 1217 | } | |
| 1218 | } | |
| 1219 | by_ruleset.sort_by_key(|one| std::cmp::Reverse(one.blocked + one.would_block + one.bypassed)); | |
| 1220 | out.by_ruleset = by_ruleset; | |
| 1221 | out.by_rule = rules | |
| 1222 | .into_iter() | |
| 1223 | .filter_map(|row| row.rule.map(|rule| RuleInsight { rule, count: row.n })) | |
| 1224 | .collect(); | |
| 1225 | out | |
| 1226 | } | |
| 1227 | ||
| 1228 | #[cfg(test)] | |
| 1229 | mod tests { | |
| 1230 | use super::*; | |
| 1231 | use g1t_contracts::rules::Level; | |
| 1232 | ||
| 1233 | fn count(ruleset: &str, enforcement: Enforcement, verdict: Verdict, n: u32) -> CountRow { | |
| 1234 | CountRow { ruleset_id: ruleset.into(), ruleset_name: ruleset.into(), enforcement, verdict, n } | |
| 1235 | } | |
| 1236 | ||
| 1237 | #[test] | |
| 1238 | fn insights_add_up_by_ruleset_and_verdict() { | |
| 1239 | let found = insights( | |
| 1240 | vec![ | |
| 1241 | count("a", Enforcement::Active, Verdict::Pass, 10), | |
| 1242 | count("a", Enforcement::Active, Verdict::Fail, 2), | |
| 1243 | count("b", Enforcement::Evaluate, Verdict::Fail, 5), | |
| 1244 | count("b", Enforcement::Evaluate, Verdict::Pass, 1), | |
| 1245 | count("c", Enforcement::Active, Verdict::Bypass, 1), | |
| 1246 | ], | |
| 1247 | vec![RuleCountRow { rule: Some("pull_request".into()), n: 4 }, RuleCountRow { rule: None, n: 1 }], | |
| 1248 | ); | |
| 1249 | assert_eq!((found.total, found.passed, found.blocked, found.would_block, found.bypassed), (19, 11, 2, 5, 1)); | |
| 1250 | assert_eq!(found.by_ruleset[0].ruleset_id, "b", "most problems first"); | |
| 1251 | assert_eq!(found.by_ruleset[0].would_block, 5); | |
| 1252 | assert_eq!(found.by_rule, vec![RuleInsight { rule: "pull_request".into(), count: 4 }]); | |
| 1253 | assert_eq!(found.days, 30); | |
| 1254 | } | |
| 1255 | ||
| 1256 | #[test] | |
| 1257 | fn a_stored_ruleset_reads_back_with_its_repository() { | |
| 1258 | let row = RulesetRow { | |
| 1259 | id: "rs_1".into(), | |
| 1260 | level: "repository".into(), | |
| 1261 | workspace: String::new(), | |
| 1262 | repo_id: Some("rep_1".into()), | |
| 1263 | spec: r#"{"name":"Default branch protection","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"deletion"}]}"#.into(), | |
| 1264 | source: Some(BRANCH_PROTECTION.into()), | |
| 1265 | created_by: "g1t".into(), | |
| 1266 | created_at: "2026-10-07T00:00:00.000Z".into(), | |
| 1267 | updated_by: "g1t".into(), | |
| 1268 | updated_at: "2026-10-07T00:00:00.000Z".into(), | |
| 1269 | }; | |
| 1270 | let repo: Repo = serde_json::from_value(serde_json::json!({ | |
| 1271 | "id": "rep_1", "namespace": "Acme", "name": "web", "description": null, "isPrivate": true, "ownerId": "usr_1", | |
| 1272 | "defaultBranch": "main", "forkOf": null, "createdAt": "" | |
| 1273 | })) | |
| 1274 | .unwrap(); | |
| 1275 | let ruleset = row.into_ruleset(Some(&repo)).unwrap(); | |
| 1276 | assert_eq!(ruleset.level, Level::Repository); | |
| 1277 | assert_eq!(ruleset.workspace, "acme"); | |
| 1278 | assert_eq!(ruleset.repository.as_deref(), Some("Acme/web")); | |
| 1279 | assert_eq!(ruleset.spec.rules[0].rule.kind(), "deletion"); | |
| 1280 | } | |
| 1281 | ||
| 1282 | #[test] | |
| 1283 | fn agents_changes_are_told_from_peoples() { | |
| 1284 | use crate::rows::stored::{ASKER, G1T, pull}; | |
| 1285 | assert!(agent_change(&pull(G1T, Some(ASKER)))); | |
| 1286 | let mut person: Pull = pull(ASKER, None); | |
| 1287 | person.agent = person.author.username.clone(); | |
| 1288 | person.runtime = g1t_contracts::work::Runtime::External; | |
| 1289 | person.fork = None; | |
| 1290 | assert!(!agent_change(&person)); | |
| 1291 | person.agent = "claude-code".into(); | |
| 1292 | assert!(agent_change(&person)); | |
| 1293 | } | |
| 1294 | ||
| 1295 | #[test] | |
| 1296 | fn latest_reviews_keep_each_reviewers_last_verdict() { | |
| 1297 | let reviews = latest_reviews(vec![ | |
| 1298 | ("usr_b".into(), "bob".into(), ReviewVerdict::RequestChanges, "1".into()), | |
| 1299 | (AGENT_ID.into(), "g1t".into(), ReviewVerdict::Approve, "2".into()), | |
| 1300 | ("usr_b".into(), "bob".into(), ReviewVerdict::Approve, "3".into()), | |
| 1301 | ]); | |
| 1302 | assert_eq!(reviews.len(), 2); | |
| 1303 | assert!(reviews[0].agent); | |
| 1304 | assert_eq!(reviews[1].verdict, ReviewVerdict::Approve); | |
| 1305 | } | |
| 1306 | ||
| 1307 | #[test] | |
| 1308 | fn protection_overlays_the_stored_settings() { | |
| 1309 | let requirements = Requirements { | |
| 1310 | required_checks: vec!["CI".into()], | |
| 1311 | strict: true, | |
| 1312 | required_approvals: 2, | |
| 1313 | count_agent_approvals: false, | |
| 1314 | allow_bypass_on_merge: false, | |
| 1315 | require_code_owner_review: true, | |
| 1316 | merge_queue: Some(MergeQueueRule::default()), | |
| 1317 | ..Requirements::default() | |
| 1318 | }; | |
| 1319 | let stored = RepoSettings { auto_merge: true, required_approvals: 5, ..RepoSettings::default() }; | |
| 1320 | let main = overlay(stored.clone(), &requirements, true); | |
| 1321 | assert_eq!((main.required_approvals, main.merge_queue, main.auto_merge), (2, true, true)); | |
| 1322 | assert!(main.require_up_to_date && !main.allow_ignoring_checks && main.require_code_owner_review); | |
| 1323 | assert!(!overlay(stored, &requirements, false).merge_queue, "the queue lands on the default branch only"); | |
| 1324 | } | |
| 1325 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.