Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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