Skip to content

g1t/services/work/src/rulesets.rs

1,321 lines58,307 bytesCodeBlame

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 merge1//! 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
14use std::collections::HashMap;
15
16use g1t_contracts::access::Capability;
17use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
18use g1t_contracts::events::{NewEvent, Publish};
19use g1t_contracts::repos::{Repo, RepoPath};
20use g1t_contracts::rules::*;
21use g1t_contracts::teams::{ResolveTeamsArgs, ResolvedTeam};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::work::{Pull, RepoSettings, Verdict as ReviewVerdict};
24use g1t_contracts::{FailureCode, Outcome, Role, User, Viewer, new_id};
25use g1t_kit::now_ms;
26use g1t_rules::merge::{MergeFacts, Requirements, Review};
27use g1t_rules::{ActorFacts, Judged, RepoFacts, legacy, select, validate};
28use serde::{Deserialize, Serialize};
29use worker::Result;
30use worker::wasm_bindgen::JsValue;
31
32use crate::Work;
33use crate::reviews::AGENT_ID;
34
35/// Unwraps an `Outcome`, returning its failure from the enclosing method.
36macro_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.
46const MAX_PAGE: u32 = 100;
47/// The window insights cover.
48const INSIGHT_DAYS: u32 = 30;
49/// How long evaluations are kept.
50const KEEP_DAYS: u64 = 90;
51const DAY_MS: u64 = 24 * 60 * 60 * 1000;
52
53/// A workspace renamed: its rulesets and their evaluations move with it.
54pub(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)]
60pub(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
73impl 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)]
109struct 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
127impl 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.
153struct 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")]
162struct RulesetEvent<'a> {
163 workspace: &'a str,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 repository: Option<String>,
166 ruleset: &'a Ruleset,
167}
168
169fn 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.
176pub(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.
184pub(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.
197pub(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.
214pub(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
240impl 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 };
427 check!(crate::retired::writable(&repo));
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);
832 let facts = MergeFacts {
833 git_ref: git_ref.clone(),
834 agent_change: agent,
835 owner_id: &pull.owner().id,
836 reviews: &reviews,
837 head_pushed_at: pull.head_pushed_at.as_deref(),
838 head_pushed_by: pull.head_pushed_by.as_deref(),
839 code_owners_missing: code_owners.as_deref(),
840 statuses: &statuses,
841 behind,
842 files: &files,
843 commits: commits.as_ref(),
844 confidence: pull.confidence.as_ref().map(|confidence| confidence.level),
845 spent_usd: spent,
846 now_ms: now_ms(),
847 method: Some(MergeMethod::Merge),
848 team_members: Some(&team_members),
849 ignore_checks,
850 };
851 let judged = g1t_rules::merge::judge(&applicable, &repo.default_branch, &facts);
852 Ok(Gate { judged, requirements, applicable, who })
853 }
854
855 /// Every verdict on a pull request, oldest first: who, and when.
856 async fn verdict_rows(&self, pull: &Pull) -> Result<Vec<(String, String, ReviewVerdict, String)>> {
857 #[derive(Deserialize)]
858 struct Row {
859 author_id: String,
860 author_name: String,
861 verdict: ReviewVerdict,
862 created_at: String,
863 }
864 let rows = match self.prefetched_pull(&pull.id) {
865 Some(found) => found.rows::<Row>(crate::prefetch::Slot::Verdicts)?,
866 None => self
867 .db
868 .prepare(
869 "SELECT author_id, author_name, verdict, created_at FROM comments
870 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
871 )
872 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
873 .all()
874 .await?
875 .results::<Row>()?,
876 };
877 Ok(rows.into_iter().map(|row| (row.author_id, row.author_name, row.verdict, row.created_at)).collect())
878 }
879
880 /// What agents have spent on a pull request, in US dollars.
881 pub(crate) async fn pull_spend(&self, pull: &Pull) -> Result<f64> {
882 #[derive(Deserialize)]
883 struct Row {
884 spent: Option<f64>,
885 }
886 Ok(self
887 .db
888 .prepare("SELECT sum(cost_usd) AS spent FROM agent_runs WHERE pull_id = ?")
889 .bind(&[pull.id.as_str().into()])?
890 .first::<Row>(None)
891 .await?
892 .and_then(|row| row.spent)
893 .unwrap_or(0.0))
894 }
895
896 /// The commits a pull request would land, read as rules look at them.
897 async fn pull_commits(&self, repo: &Repo, pull: &Pull, base: &str) -> Result<InspectedCommits> {
898 let Some(head) = pull.head_commit.clone() else {
899 return Ok(InspectedCommits::default());
900 };
901 let found: Outcome<InspectedCommits> = g1t_kit::call(
902 &self.repos,
903 "inspect_commits",
904 &InspectCommitsArgs {
905 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
906 head,
907 target_id: repo.id.clone(),
908 base_branch: base.to_owned(),
909 limit: None,
910 },
911 )
912 .await?;
913 Ok(match found {
914 Outcome::Ok(commits) => commits,
915 Outcome::Fail(failure) => {
916 worker::console_error!("inspect_commits for {}: {}", pull.id, failure.message);
917 InspectedCommits::default()
918 }
919 })
920 }
921
922 /// Records how each ruleset judged a merge.
923 pub(crate) async fn record_merge_evaluations(&self, repo: &Repo, pull: &Pull, gate: &Gate) {
924 let Some(who) = &gate.who else { return };
925 if gate.judged.is_empty() {
926 return;
927 }
928 let evaluations = g1t_rules::evaluations(
929 &gate.judged,
930 &repo.id,
931 &repo.namespace,
932 Action::Merge,
933 who,
934 Some(pull.number),
935 pull.head_commit.as_deref(),
936 );
937 if let Err(error) = self.record_evaluations(RecordEvaluationsArgs { evaluations }).await {
938 worker::console_error!("merge evaluations not recorded: {error}");
939 }
940 }
941
942 /// The settings that hold for a pull request: the repository's, with
943 /// branch protection as the rules for the branch it merges into stack.
944 pub(crate) async fn settings_on(&self, repo: &Repo, pull: &Pull) -> Result<RepoSettings> {
945 let (stored, rulesets) = futures_util::future::try_join(self.settings(&repo.id), self.rulesets_for(repo)).await?;
946 let base = pull.base_branch(&repo.default_branch);
947 let git_ref = Target::Branch.full_ref(base);
948 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
949 let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
950 let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent_change(pull), &files);
951 Ok(overlay(stored, &requirements, base == repo.default_branch))
952 }
953
954 /// The repository a pull request merges into: as read earlier in this
955 /// request, or now.
956 pub(crate) async fn repo_for(&self, pull: &Pull) -> Result<Option<Repo>> {
957 if let Some(repo) = self.known_repos.borrow().get(&pull.repo_id) {
958 return Ok(Some(repo.clone()));
959 }
960 let found = self.target_of(pull).await?;
961 if let Some(repo) = &found {
962 self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
963 }
964 Ok(found)
965 }
966
967 /// The settings that hold for a pull request, when only it is at hand.
968 pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> {
969 match self.repo_for(pull).await? {
970 Some(repo) => self.settings_on(&repo, pull).await,
971 None => self.settings(&pull.repo_id).await,
972 }
973 }
974
975 /// The settings that hold for the default branch.
976 pub(crate) async fn default_branch_settings(&self, repo: &Repo) -> Result<RepoSettings> {
977 let (stored, requirements) = futures_util::future::try_join(self.settings(&repo.id), self.default_requirements(repo)).await?;
978 Ok(overlay(stored, &requirements, true))
979 }
980
981 /// What the active rules ask of the default branch, for anyone.
982 pub(crate) async fn default_requirements(&self, repo: &Repo) -> Result<Requirements> {
983 let rulesets = self.rulesets_for(repo).await?;
984 let git_ref = Target::Branch.full_ref(&repo.default_branch);
985 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
986 Ok(g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, false, &[]))
987 }
988
989 /// How the default branch's merge queue batches: its rule's
990 /// parameters, or the defaults.
991 pub(crate) async fn queue_rule(&self, repo_id: &str) -> Result<MergeQueueRule> {
992 let path: Option<RepoPath> =
993 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
994 let repo = match path {
995 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
996 None => None,
997 };
998 Ok(match repo {
999 Some(repo) => self.default_requirements(&repo).await?.merge_queue.unwrap_or_default(),
1000 None => MergeQueueRule::default(),
1001 })
1002 }
1003
1004 /// The default branch's settings by repository id, for callers that do
1005 /// not have the repository at hand (the merge queue, statuses).
1006 pub(crate) async fn settings_by_id(&self, repo_id: &str) -> Result<RepoSettings> {
1007 let path: Option<RepoPath> =
1008 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1009 let repo = match path {
1010 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1011 None => None,
1012 };
1013 match repo {
1014 Some(repo) => self.default_branch_settings(&repo).await,
1015 None => self.settings(repo_id).await,
1016 }
1017 }
1018
1019 /// The branch protection ruleset's rules rewritten from the old
1020 /// settings (`update_settings`), creating it when needed.
1021 pub(crate) async fn write_branch_protection(&self, repo: &Repo, settings: &RepoSettings, actor: &User) -> Result<()> {
1022 let rulesets = self.rulesets_for(repo).await?;
1023 let existing = rulesets
1024 .iter()
1025 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION));
1026 let now = rfc3339(now_ms());
1027 match existing {
1028 Some(found) => {
1029 let protected = legacy::requires_pull_requests(&found.spec.rules);
1030 let mut ruleset = found.clone();
1031 ruleset.spec.rules = legacy::replace(&found.spec.rules, settings, protected);
1032 ruleset.updated_by = actor.username.clone();
1033 ruleset.updated_at = now;
1034 self.store_ruleset(&ruleset).await?;
1035 self.announce("ruleset.updated", &ruleset, actor).await;
1036 }
1037 None => {
1038 let Some(spec) = legacy::ruleset_of(settings, false) else { return Ok(()) };
1039 let ruleset = Ruleset {
1040 id: new_id("rs", now_ms()),
1041 level: Level::Repository,
1042 workspace: repo.namespace.to_lowercase(),
1043 repo_id: Some(repo.id.clone()),
1044 repository: Some(format!("{}/{}", repo.namespace, repo.name)),
1045 spec,
1046 source: Some(BRANCH_PROTECTION.to_owned()),
1047 created_by: actor.username.clone(),
1048 created_at: now.clone(),
1049 updated_by: actor.username.clone(),
1050 updated_at: now,
1051 };
1052 self.store_ruleset(&ruleset).await?;
1053 self.announce("ruleset.created", &ruleset, actor).await;
1054 }
1055 }
1056 Ok(())
1057 }
1058
1059 /// Services only (repos `update` with `protected`): whether the branch
1060 /// protection ruleset refuses pushes to the default branch.
1061 pub(crate) async fn set_requires_pull_request(&self, a: RequirePullRequestArgs) -> Result<Outcome<bool>> {
1062 let rulesets = self.rulesets_for(&a.repo).await?;
1063 let existing = rulesets
1064 .iter()
1065 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&a.repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION))
1066 .cloned();
1067 let now = rfc3339(now_ms());
1068 let mut ruleset = match existing {
1069 Some(found) => found,
1070 None if !a.protected => return Ok(Outcome::Ok(false)),
1071 None => Ruleset {
1072 id: new_id("rs", now_ms()),
1073 level: Level::Repository,
1074 workspace: a.repo.namespace.to_lowercase(),
1075 repo_id: Some(a.repo.id.clone()),
1076 repository: Some(format!("{}/{}", a.repo.namespace, a.repo.name)),
1077 spec: legacy::ruleset_of(&RepoSettings::default(), true).unwrap_or_default(),
1078 source: Some(BRANCH_PROTECTION.to_owned()),
1079 created_by: a.actor.username.clone(),
1080 created_at: now.clone(),
1081 updated_by: a.actor.username.clone(),
1082 updated_at: now.clone(),
1083 },
1084 };
1085 let mut found = false;
1086 for entry in &mut ruleset.spec.rules {
1087 if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) {
1088 rule.allow_direct_pushes = !a.protected;
1089 found = true;
1090 }
1091 }
1092 if !found && a.protected {
1093 ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default())));
1094 }
1095 ruleset.updated_by = a.actor.username.clone();
1096 ruleset.updated_at = now;
1097 self.store_ruleset(&ruleset).await?;
1098 self.announce("ruleset.updated", &ruleset, &a.actor).await;
1099 Ok(Outcome::Ok(true))
1100 }
1101}
1102
1103/// `set_requires_pull_request`: services only.
1104#[derive(Deserialize)]
1105pub(crate) struct RequirePullRequestArgs {
1106 repo: Repo,
1107 protected: bool,
1108 actor: User,
1109}
1110
1111/// A merge, judged.
1112pub(crate) struct Gate {
1113 pub(crate) judged: Vec<Judged>,
1114 pub(crate) requirements: Requirements,
1115 #[allow(dead_code)]
1116 pub(crate) applicable: Vec<Applicable>,
1117 pub(crate) who: Option<ActorFacts>,
1118}
1119
1120impl Gate {
1121 /// What refuses the merge now, if anything, in one sentence.
1122 pub(crate) fn refusal(&self) -> Option<String> {
1123 g1t_rules::report::summary(&g1t_rules::outcome::blocking(&self.judged))
1124 }
1125
1126 /// What people (not checks, which g1t's lifecycle waits for itself)
1127 /// must still do before it can merge.
1128 pub(crate) fn people_gap(&self) -> Option<String> {
1129 let waiting: Vec<&Violation> = g1t_rules::outcome::blocking(&self.judged)
1130 .into_iter()
1131 .filter(|violation| !g1t_rules::merge::about_checks(&violation.rule))
1132 .collect();
1133 g1t_rules::report::summary(&waiting)
1134 }
1135
1136 /// Whether any rule not met could be bypassed by the actor.
1137 pub(crate) fn bypassable(&self) -> bool {
1138 self.judged.iter().any(|one| one.enforcement == Enforcement::Active && one.verdict() == Verdict::Bypass)
1139 }
1140
1141 /// The same judgement for an actor who did not ask to bypass anything.
1142 pub(crate) fn without_bypass(mut self) -> Gate {
1143 for one in &mut self.judged {
1144 one.bypass = None;
1145 }
1146 self
1147 }
1148}
1149
1150/// A workspace ruleset's repository condition, against one repository.
1151fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
1152 select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo)
1153}
1154
1155const RULESETS_SQL: &str =
1156 "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at";
1157const ADOPTED_SQL: &str = "SELECT 1 AS n FROM ruleset_adoptions WHERE repo_id = ?1";
1158
1159/// The statements prefetch.rs batches for a pull request's page.
1160pub(crate) fn prefetch_sql() -> (&'static str, &'static str) {
1161 (RULESETS_SQL, ADOPTED_SQL)
1162}
1163
1164#[derive(Deserialize)]
1165struct CountRow {
1166 ruleset_id: String,
1167 ruleset_name: String,
1168 enforcement: Enforcement,
1169 verdict: Verdict,
1170 n: u32,
1171}
1172
1173#[derive(Deserialize)]
1174struct RuleCountRow {
1175 rule: Option<String>,
1176 n: u32,
1177}
1178
1179/// Counts by ruleset and verdict, and violations by rule, as insights.
1180fn insights(counts: Vec<CountRow>, rules: Vec<RuleCountRow>) -> Insights {
1181 let mut out = Insights { days: INSIGHT_DAYS, ..Insights::default() };
1182 let mut by_ruleset: Vec<RulesetInsight> = Vec::new();
1183 for row in counts {
1184 out.total += row.n;
1185 let index = match by_ruleset.iter().position(|have| have.ruleset_id == row.ruleset_id) {
1186 Some(index) => index,
1187 None => {
1188 by_ruleset.push(RulesetInsight {
1189 ruleset_id: row.ruleset_id.clone(),
1190 ruleset_name: row.ruleset_name.clone(),
1191 enforcement: row.enforcement,
1192 ..RulesetInsight::default()
1193 });
1194 by_ruleset.len() - 1
1195 }
1196 };
1197 let one = &mut by_ruleset[index];
1198 one.total += row.n;
1199 match (row.verdict, row.enforcement) {
1200 (Verdict::Pass, _) => out.passed += row.n,
1201 (Verdict::Bypass, _) => {
1202 out.bypassed += row.n;
1203 one.bypassed += row.n;
1204 }
1205 (Verdict::Fail, Enforcement::Evaluate) => {
1206 out.would_block += row.n;
1207 one.would_block += row.n;
1208 }
1209 (Verdict::Fail, _) => {
1210 out.blocked += row.n;
1211 one.blocked += row.n;
1212 }
1213 }
1214 }
1215 by_ruleset.sort_by_key(|one| std::cmp::Reverse(one.blocked + one.would_block + one.bypassed));
1216 out.by_ruleset = by_ruleset;
1217 out.by_rule = rules
1218 .into_iter()
1219 .filter_map(|row| row.rule.map(|rule| RuleInsight { rule, count: row.n }))
1220 .collect();
1221 out
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::*;
1227 use g1t_contracts::rules::Level;
1228
1229 fn count(ruleset: &str, enforcement: Enforcement, verdict: Verdict, n: u32) -> CountRow {
1230 CountRow { ruleset_id: ruleset.into(), ruleset_name: ruleset.into(), enforcement, verdict, n }
1231 }
1232
1233 #[test]
1234 fn insights_add_up_by_ruleset_and_verdict() {
1235 let found = insights(
1236 vec![
1237 count("a", Enforcement::Active, Verdict::Pass, 10),
1238 count("a", Enforcement::Active, Verdict::Fail, 2),
1239 count("b", Enforcement::Evaluate, Verdict::Fail, 5),
1240 count("b", Enforcement::Evaluate, Verdict::Pass, 1),
1241 count("c", Enforcement::Active, Verdict::Bypass, 1),
1242 ],
1243 vec![RuleCountRow { rule: Some("pull_request".into()), n: 4 }, RuleCountRow { rule: None, n: 1 }],
1244 );
1245 assert_eq!((found.total, found.passed, found.blocked, found.would_block, found.bypassed), (19, 11, 2, 5, 1));
1246 assert_eq!(found.by_ruleset[0].ruleset_id, "b", "most problems first");
1247 assert_eq!(found.by_ruleset[0].would_block, 5);
1248 assert_eq!(found.by_rule, vec![RuleInsight { rule: "pull_request".into(), count: 4 }]);
1249 assert_eq!(found.days, 30);
1250 }
1251
1252 #[test]
1253 fn a_stored_ruleset_reads_back_with_its_repository() {
1254 let row = RulesetRow {
1255 id: "rs_1".into(),
1256 level: "repository".into(),
1257 workspace: String::new(),
1258 repo_id: Some("rep_1".into()),
1259 spec: r#"{"name":"Default branch protection","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"deletion"}]}"#.into(),
1260 source: Some(BRANCH_PROTECTION.into()),
1261 created_by: "g1t".into(),
1262 created_at: "2026-10-07T00:00:00.000Z".into(),
1263 updated_by: "g1t".into(),
1264 updated_at: "2026-10-07T00:00:00.000Z".into(),
1265 };
1266 let repo: Repo = serde_json::from_value(serde_json::json!({
1267 "id": "rep_1", "namespace": "Acme", "name": "web", "description": null, "isPrivate": true, "ownerId": "usr_1",
1268 "defaultBranch": "main", "forkOf": null, "createdAt": ""
1269 }))
1270 .unwrap();
1271 let ruleset = row.into_ruleset(Some(&repo)).unwrap();
1272 assert_eq!(ruleset.level, Level::Repository);
1273 assert_eq!(ruleset.workspace, "acme");
1274 assert_eq!(ruleset.repository.as_deref(), Some("Acme/web"));
1275 assert_eq!(ruleset.spec.rules[0].rule.kind(), "deletion");
1276 }
1277
1278 #[test]
1279 fn agents_changes_are_told_from_peoples() {
1280 use crate::rows::stored::{ASKER, G1T, pull};
1281 assert!(agent_change(&pull(G1T, Some(ASKER))));
1282 let mut person: Pull = pull(ASKER, None);
1283 person.agent = person.author.username.clone();
1284 person.runtime = g1t_contracts::work::Runtime::External;
1285 person.fork = None;
1286 assert!(!agent_change(&person));
1287 person.agent = "claude-code".into();
1288 assert!(agent_change(&person));
1289 }
1290
1291 #[test]
1292 fn latest_reviews_keep_each_reviewers_last_verdict() {
1293 let reviews = latest_reviews(vec![
1294 ("usr_b".into(), "bob".into(), ReviewVerdict::RequestChanges, "1".into()),
1295 (AGENT_ID.into(), "g1t".into(), ReviewVerdict::Approve, "2".into()),
1296 ("usr_b".into(), "bob".into(), ReviewVerdict::Approve, "3".into()),
1297 ]);
1298 assert_eq!(reviews.len(), 2);
1299 assert!(reviews[0].agent);
1300 assert_eq!(reviews[1].verdict, ReviewVerdict::Approve);
1301 }
1302
1303 #[test]
1304 fn protection_overlays_the_stored_settings() {
1305 let requirements = Requirements {
1306 required_checks: vec!["CI".into()],
1307 strict: true,
1308 required_approvals: 2,
1309 count_agent_approvals: false,
1310 allow_bypass_on_merge: false,
1311 require_code_owner_review: true,
1312 merge_queue: Some(MergeQueueRule::default()),
1313 ..Requirements::default()
1314 };
1315 let stored = RepoSettings { auto_merge: true, required_approvals: 5, ..RepoSettings::default() };
1316 let main = overlay(stored.clone(), &requirements, true);
1317 assert_eq!((main.required_approvals, main.merge_queue, main.auto_merge), (2, true, true));
1318 assert!(main.require_up_to_date && !main.allow_ignoring_checks && main.require_code_owner_review);
1319 assert!(!overlay(stored, &requirements, false).merge_queue, "the queue lands on the default branch only");
1320 }
1321}

This file's history is long; its oldest lines are credited to the oldest commit read.