Skip to content
1,313 linesCodeBlameRaw

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.

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