Skip to content
1,325 linesCodeBlameRaw
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
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::not_archived(&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 // 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 };
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)]
1109pub(crate) struct RequirePullRequestArgs {
1110 repo: Repo,
1111 protected: bool,
1112 actor: User,
1113}
1114
1115/// A merge, judged.
1116pub(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
1124impl 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.
1155fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
1156 select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo)
1157}
1158
1159const RULESETS_SQL: &str =
1160 "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at";
1161const 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.
1164pub(crate) fn prefetch_sql() -> (&'static str, &'static str) {
1165 (RULESETS_SQL, ADOPTED_SQL)
1166}
1167
1168#[derive(Deserialize)]
1169struct CountRow {
1170 ruleset_id: String,
1171 ruleset_name: String,
1172 enforcement: Enforcement,
1173 verdict: Verdict,
1174 n: u32,
1175}
1176
1177#[derive(Deserialize)]
1178struct RuleCountRow {
1179 rule: Option<String>,
1180 n: u32,
1181}
1182
1183/// Counts by ruleset and verdict, and violations by rule, as insights.
1184fn 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)]
1229mod 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}