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,361 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).await
795 }
796
797 /// Re-evaluate current reviews and rules against the exact combined state.
798 /// Queue admission never grants a permanent bypass of later policy changes:
799 /// a bypass counts only as `merge_pull` let it count at admission (the
800 /// merger asked for it, or g1t merges), and only if the actor may still
801 /// bypass the rules as they are now.
802 /// `statuses`: the attempt's own evidence on `commit` (statuses.rs
803 /// `attempt_evidence`), jobs included, never the commit's every status.
804 pub(crate) async fn queue_landing_gate(
805 &self,
806 repo: &Repo,
807 pull: &Pull,
808 actor: &User,
809 commit: &str,
810 bypass_rules: bool,
811 statuses: Vec<g1t_contracts::work::CommitStatus>,
812 ) -> Result<Gate> {
813 let gate = self.merge_gate_at(repo, pull, Some(actor), false, true, Some((commit, statuses))).await?;
814 Ok(if landing_honors_bypass(actor, bypass_rules) { gate } else { gate.without_bypass() })
815 }
816
817 async fn merge_gate_at(
818 &self,
819 repo: &Repo,
820 pull: &Pull,
821 actor: Option<&User>,
822 ignore_checks: bool,
823 honor_bypass: bool,
824 tested: Option<(&str, Vec<g1t_contracts::work::CommitStatus>)>,
825 ) -> Result<Gate> {
826 let tested_commit = tested.as_ref().map(|(commit, _)| *commit);
827 let tested_statuses = tested.as_ref().map(|(_, statuses)| statuses.clone());
828 let checked_commit = tested_commit.or(pull.head_commit.as_deref());
829 let rulesets = self.rulesets_for(repo).await?;
830 let base = pull.base_branch(&repo.default_branch).to_owned();
831 let git_ref = Target::Branch.full_ref(&base);
832 let who = match actor {
833 Some(actor) => Some(self.actor_facts(actor, repo, &rulesets).await?),
834 None => None,
835 };
836 let mut applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), who.as_ref(), &repo.namespace);
837 if !honor_bypass {
838 for ruleset in &mut applicable {
839 ruleset.bypass = None;
840 }
841 }
842 let agent = agent_change(pull);
843 let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
844 let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent, &files);
845 if applicable.is_empty() {
846 return Ok(Gate { judged: Vec::new(), requirements, applicable, who });
847 }
848 let needs_owners = applicable.iter().any(|ruleset| {
849 ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::PullRequest(rule) if rule.require_code_owner_review))
850 });
851 let owners_settings = RepoSettings { require_code_owner_review: true, ..RepoSettings::default() };
852 let teams = g1t_rules::merge::named_teams(&applicable);
853 let (verdicts, statuses, behind, code_owners, team_members, commits) = futures_util::try_join!(
854 self.verdict_rows(pull),
855 async {
856 match &tested_statuses {
857 Some(statuses) => Ok(statuses.clone()),
858 None => self.statuses(&pull.repo_id, checked_commit).await,
859 }
860 },
861 async {
862 // The repository service verifies ancestry and CAS for the
863 // combined commit; the original PR may legitimately be behind.
864 if tested_commit.is_some() { Ok(false) } else { self.is_behind(&repo.id, pull).await }
865 },
866 async {
867 if needs_owners { self.code_owners_gap(&owners_settings, pull).await } else { Ok(None) }
868 },
869 async {
870 if teams.is_empty() { Ok(HashMap::new()) } else { self.team_people(&teams, &repo.namespace, &repo.id).await }
871 },
872 async {
873 if g1t_rules::merge::needs_commits(&applicable, agent) {
874 self.pull_commits(repo, pull, &base).await.map(Some)
875 } else {
876 Ok(None)
877 }
878 },
879 )?;
880 let reviews = latest_reviews(verdicts);
881 // A required check named for a g1t Actions job is met by that job.
882 let requires_checks =
883 applicable.iter().any(|ruleset| ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::RequiredStatusChecks(_))));
884 // A tested state's evidence already holds its own jobs.
885 let statuses = if requires_checks && tested_statuses.is_none() {
886 self.with_job_checks(&repo.id, checked_commit, statuses).await?
887 } else {
888 statuses
889 };
890 let facts = MergeFacts {
891 git_ref: git_ref.clone(),
892 agent_change: agent,
893 owner_id: &pull.owner().id,
894 reviews: &reviews,
895 head_pushed_at: pull.head_pushed_at.as_deref(),
896 head_pushed_by: pull.head_pushed_by.as_deref(),
897 code_owners_missing: code_owners.as_deref(),
898 statuses: &statuses,
899 behind,
900 files: &files,
901 commits: commits.as_ref(),
902 now_ms: now_ms(),
903 method: Some(MergeMethod::Merge),
904 team_members: Some(&team_members),
905 ignore_checks,
906 };
907 let judged = g1t_rules::merge::judge(&applicable, &repo.default_branch, &facts);
908 Ok(Gate { judged, requirements, applicable, who })
909 }
910
911 /// Every verdict on a pull request, oldest first: who, and when.
912 async fn verdict_rows(&self, pull: &Pull) -> Result<Vec<(String, String, ReviewVerdict, String)>> {
913 #[derive(Deserialize)]
914 struct Row {
915 author_id: String,
916 author_name: String,
917 verdict: ReviewVerdict,
918 created_at: String,
919 }
920 let rows = match self.prefetched_pull(&pull.id) {
921 Some(found) => found.rows::<Row>(crate::prefetch::Slot::Verdicts)?,
922 None => self
923 .db
924 .prepare(
925 "SELECT author_id, author_name, verdict, created_at FROM comments
926 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
927 )
928 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
929 .all()
930 .await?
931 .results::<Row>()?,
932 };
933 Ok(rows.into_iter().map(|row| (row.author_id, row.author_name, row.verdict, row.created_at)).collect())
934 }
935
936 /// The commits a pull request would land, read as rules look at them.
937 async fn pull_commits(&self, repo: &Repo, pull: &Pull, base: &str) -> Result<InspectedCommits> {
938 let Some(head) = pull.head_commit.clone() else {
939 return Ok(InspectedCommits::default());
940 };
941 let found: Outcome<InspectedCommits> = g1t_kit::call(
942 &self.repos,
943 "inspect_commits",
944 &InspectCommitsArgs {
945 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
946 head,
947 target_id: repo.id.clone(),
948 base_branch: base.to_owned(),
949 limit: None,
950 },
951 )
952 .await?;
953 Ok(match found {
954 Outcome::Ok(commits) => commits,
955 Outcome::Fail(failure) => {
956 worker::console_error!("inspect_commits for {}: {}", pull.id, failure.message);
957 InspectedCommits::default()
958 }
959 })
960 }
961
962 /// Records how each ruleset judged a merge.
963 pub(crate) async fn record_merge_evaluations(&self, repo: &Repo, pull: &Pull, gate: &Gate) {
964 let Some(who) = &gate.who else { return };
965 if gate.judged.is_empty() {
966 return;
967 }
968 let evaluations = g1t_rules::evaluations(
969 &gate.judged,
970 &repo.id,
971 &repo.namespace,
972 Action::Merge,
973 who,
974 Some(pull.number),
975 pull.head_commit.as_deref(),
976 );
977 if let Err(error) = self.record_evaluations(RecordEvaluationsArgs { evaluations }).await {
978 worker::console_error!("merge evaluations not recorded: {error}");
979 }
980 }
981
982 /// The settings that hold for a pull request: the repository's, with
983 /// branch protection as the rules for the branch it merges into stack.
984 pub(crate) async fn settings_on(&self, repo: &Repo, pull: &Pull) -> Result<RepoSettings> {
985 let (stored, rulesets) = futures_util::future::try_join(self.settings(&repo.id), self.rulesets_for(repo)).await?;
986 let base = pull.base_branch(&repo.default_branch);
987 let git_ref = Target::Branch.full_ref(base);
988 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
989 let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
990 let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent_change(pull), &files);
991 Ok(overlay(stored, &requirements, base == repo.default_branch))
992 }
993
994 /// The repository a pull request merges into: as read earlier in this
995 /// request, or now.
996 pub(crate) async fn repo_for(&self, pull: &Pull) -> Result<Option<Repo>> {
997 if let Some(repo) = self.known_repos.borrow().get(&pull.repo_id) {
998 return Ok(Some(repo.clone()));
999 }
1000 let found = self.target_of(pull).await?;
1001 if let Some(repo) = &found {
1002 self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
1003 }
1004 Ok(found)
1005 }
1006
1007 /// The settings that hold for a pull request, when only it is at hand.
1008 pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> {
1009 match self.repo_for(pull).await? {
1010 Some(repo) => self.settings_on(&repo, pull).await,
1011 None => self.settings(&pull.repo_id).await,
1012 }
1013 }
1014
1015 /// The settings that hold for the default branch.
1016 pub(crate) async fn default_branch_settings(&self, repo: &Repo) -> Result<RepoSettings> {
1017 let (stored, requirements) = futures_util::future::try_join(self.settings(&repo.id), self.default_requirements(repo)).await?;
1018 Ok(overlay(stored, &requirements, true))
1019 }
1020
1021 /// What the active rules ask of the default branch, for anyone.
1022 pub(crate) async fn default_requirements(&self, repo: &Repo) -> Result<Requirements> {
1023 let rulesets = self.rulesets_for(repo).await?;
1024 let git_ref = Target::Branch.full_ref(&repo.default_branch);
1025 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
1026 Ok(g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, false, &[]))
1027 }
1028
1029 /// How the default branch's merge queue batches: its rule's
1030 /// parameters, or the defaults.
1031 pub(crate) async fn queue_rule(&self, repo_id: &str) -> Result<MergeQueueRule> {
1032 let path: Option<RepoPath> =
1033 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1034 let repo = match path {
1035 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1036 None => None,
1037 };
1038 Ok(match repo {
1039 Some(repo) => self.default_requirements(&repo).await?.merge_queue.unwrap_or_default(),
1040 None => MergeQueueRule::default(),
1041 })
1042 }
1043
1044 /// The default branch's settings by repository id, for callers that do
1045 /// not have the repository at hand (the merge queue, statuses).
1046 pub(crate) async fn settings_by_id(&self, repo_id: &str) -> Result<RepoSettings> {
1047 let path: Option<RepoPath> =
1048 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1049 let repo = match path {
1050 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1051 None => None,
1052 };
1053 match repo {
1054 Some(repo) => self.default_branch_settings(&repo).await,
1055 None => self.settings(repo_id).await,
1056 }
1057 }
1058
1059 /// The branch protection ruleset's rules rewritten from the old
1060 /// settings (`update_settings`), creating it when needed.
1061 pub(crate) async fn write_branch_protection(&self, repo: &Repo, settings: &RepoSettings, actor: &User) -> Result<()> {
1062 let rulesets = self.rulesets_for(repo).await?;
1063 let existing = rulesets
1064 .iter()
1065 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION));
1066 let now = rfc3339(now_ms());
1067 match existing {
1068 Some(found) => {
1069 let protected = legacy::requires_pull_requests(&found.spec.rules);
1070 let mut ruleset = found.clone();
1071 ruleset.spec.rules = legacy::replace(&found.spec.rules, settings, protected);
1072 ruleset.updated_by = actor.username.clone();
1073 ruleset.updated_at = now;
1074 self.store_ruleset(&ruleset).await?;
1075 self.announce("ruleset.updated", &ruleset, actor).await;
1076 }
1077 None => {
1078 let Some(spec) = legacy::ruleset_of(settings, false) else { return Ok(()) };
1079 let ruleset = Ruleset {
1080 id: new_id("rs", now_ms()),
1081 level: Level::Repository,
1082 workspace: repo.namespace.to_lowercase(),
1083 repo_id: Some(repo.id.clone()),
1084 repository: Some(format!("{}/{}", repo.namespace, repo.name)),
1085 spec,
1086 source: Some(BRANCH_PROTECTION.to_owned()),
1087 created_by: actor.username.clone(),
1088 created_at: now.clone(),
1089 updated_by: actor.username.clone(),
1090 updated_at: now,
1091 };
1092 self.store_ruleset(&ruleset).await?;
1093 self.announce("ruleset.created", &ruleset, actor).await;
1094 }
1095 }
1096 Ok(())
1097 }
1098
1099 /// Services only (repos `update` with `protected`): whether the branch
1100 /// protection ruleset refuses pushes to the default branch.
1101 pub(crate) async fn set_requires_pull_request(&self, a: RequirePullRequestArgs) -> Result<Outcome<bool>> {
1102 let rulesets = self.rulesets_for(&a.repo).await?;
1103 let existing = rulesets
1104 .iter()
1105 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&a.repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION))
1106 .cloned();
1107 let now = rfc3339(now_ms());
1108 let mut ruleset = match existing {
1109 Some(found) => found,
1110 None if !a.protected => return Ok(Outcome::Ok(false)),
1111 None => Ruleset {
1112 id: new_id("rs", now_ms()),
1113 level: Level::Repository,
1114 workspace: a.repo.namespace.to_lowercase(),
1115 repo_id: Some(a.repo.id.clone()),
1116 repository: Some(format!("{}/{}", a.repo.namespace, a.repo.name)),
1117 spec: legacy::ruleset_of(&RepoSettings::default(), true).unwrap_or_default(),
1118 source: Some(BRANCH_PROTECTION.to_owned()),
1119 created_by: a.actor.username.clone(),
1120 created_at: now.clone(),
1121 updated_by: a.actor.username.clone(),
1122 updated_at: now.clone(),
1123 },
1124 };
1125 let mut found = false;
1126 for entry in &mut ruleset.spec.rules {
1127 if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) {
1128 rule.allow_direct_pushes = !a.protected;
1129 found = true;
1130 }
1131 }
1132 if !found && a.protected {
1133 ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default())));
1134 }
1135 ruleset.updated_by = a.actor.username.clone();
1136 ruleset.updated_at = now;
1137 self.store_ruleset(&ruleset).await?;
1138 self.announce("ruleset.updated", &ruleset, &a.actor).await;
1139 Ok(Outcome::Ok(true))
1140 }
1141}
1142
1143/// `set_requires_pull_request`: services only.
1144#[derive(Deserialize)]
1145pub(crate) struct RequirePullRequestArgs {
1146 repo: Repo,
1147 protected: bool,
1148 actor: User,
1149}
1150
1151/// A merge, judged.
1152pub(crate) struct Gate {
1153 pub(crate) judged: Vec<Judged>,
1154 pub(crate) requirements: Requirements,
1155 #[allow(dead_code)]
1156 pub(crate) applicable: Vec<Applicable>,
1157 pub(crate) who: Option<ActorFacts>,
1158}
1159
1160impl Gate {
1161 /// What refuses the merge now, if anything, in one sentence.
1162 pub(crate) fn refusal(&self) -> Option<String> {
1163 g1t_rules::report::summary(&g1t_rules::outcome::blocking(&self.judged))
1164 }
1165
1166 /// What people (not checks, which g1t's lifecycle waits for itself)
1167 /// must still do before it can merge.
1168 pub(crate) fn people_gap(&self) -> Option<String> {
1169 let waiting: Vec<&Violation> = g1t_rules::outcome::blocking(&self.judged)
1170 .into_iter()
1171 .filter(|violation| !g1t_rules::merge::about_checks(&violation.rule))
1172 .collect();
1173 g1t_rules::report::summary(&waiting)
1174 }
1175
1176 /// Whether any rule not met could be bypassed by the actor.
1177 pub(crate) fn bypassable(&self) -> bool {
1178 self.judged.iter().any(|one| one.enforcement == Enforcement::Active && one.verdict() == Verdict::Bypass)
1179 }
1180
1181 /// The same judgement for an actor who did not ask to bypass anything.
1182 pub(crate) fn without_bypass(mut self) -> Gate {
1183 for one in &mut self.judged {
1184 one.bypass = None;
1185 }
1186 self
1187 }
1188}
1189
1190/// A workspace ruleset's repository condition, against one repository.
1191fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
1192 select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo)
1193}
1194
1195const RULESETS_SQL: &str =
1196 "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at";
1197const ADOPTED_SQL: &str = "SELECT 1 AS n FROM ruleset_adoptions WHERE repo_id = ?1";
1198
1199/// The statements prefetch.rs batches for a pull request's page.
1200pub(crate) fn prefetch_sql() -> (&'static str, &'static str) {
1201 (RULESETS_SQL, ADOPTED_SQL)
1202}
1203
1204#[derive(Deserialize)]
1205struct CountRow {
1206 ruleset_id: String,
1207 ruleset_name: String,
1208 enforcement: Enforcement,
1209 verdict: Verdict,
1210 n: u32,
1211}
1212
1213#[derive(Deserialize)]
1214struct RuleCountRow {
1215 rule: Option<String>,
1216 n: u32,
1217}
1218
1219/// Counts by ruleset and verdict, and violations by rule, as insights.
1220fn insights(counts: Vec<CountRow>, rules: Vec<RuleCountRow>) -> Insights {
1221 let mut out = Insights { days: INSIGHT_DAYS, ..Insights::default() };
1222 let mut by_ruleset: Vec<RulesetInsight> = Vec::new();
1223 for row in counts {
1224 out.total += row.n;
1225 let index = match by_ruleset.iter().position(|have| have.ruleset_id == row.ruleset_id) {
1226 Some(index) => index,
1227 None => {
1228 by_ruleset.push(RulesetInsight {
1229 ruleset_id: row.ruleset_id.clone(),
1230 ruleset_name: row.ruleset_name.clone(),
1231 enforcement: row.enforcement,
1232 ..RulesetInsight::default()
1233 });
1234 by_ruleset.len() - 1
1235 }
1236 };
1237 let one = &mut by_ruleset[index];
1238 one.total += row.n;
1239 match (row.verdict, row.enforcement) {
1240 (Verdict::Pass, _) => out.passed += row.n,
1241 (Verdict::Bypass, _) => {
1242 out.bypassed += row.n;
1243 one.bypassed += row.n;
1244 }
1245 (Verdict::Fail, Enforcement::Evaluate) => {
1246 out.would_block += row.n;
1247 one.would_block += row.n;
1248 }
1249 (Verdict::Fail, _) => {
1250 out.blocked += row.n;
1251 one.blocked += row.n;
1252 }
1253 }
1254 }
1255 by_ruleset.sort_by_key(|one| std::cmp::Reverse(one.blocked + one.would_block + one.bypassed));
1256 out.by_ruleset = by_ruleset;
1257 out.by_rule = rules
1258 .into_iter()
1259 .filter_map(|row| row.rule.map(|rule| RuleInsight { rule, count: row.n }))
1260 .collect();
1261 out
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267 use g1t_contracts::rules::Level;
1268
1269 fn count(ruleset: &str, enforcement: Enforcement, verdict: Verdict, n: u32) -> CountRow {
1270 CountRow { ruleset_id: ruleset.into(), ruleset_name: ruleset.into(), enforcement, verdict, n }
1271 }
1272
1273 #[test]
1274 fn insights_add_up_by_ruleset_and_verdict() {
1275 let found = insights(
1276 vec![
1277 count("a", Enforcement::Active, Verdict::Pass, 10),
1278 count("a", Enforcement::Active, Verdict::Fail, 2),
1279 count("b", Enforcement::Evaluate, Verdict::Fail, 5),
1280 count("b", Enforcement::Evaluate, Verdict::Pass, 1),
1281 count("c", Enforcement::Active, Verdict::Bypass, 1),
1282 ],
1283 vec![RuleCountRow { rule: Some("pull_request".into()), n: 4 }, RuleCountRow { rule: None, n: 1 }],
1284 );
1285 assert_eq!((found.total, found.passed, found.blocked, found.would_block, found.bypassed), (19, 11, 2, 5, 1));
1286 assert_eq!(found.by_ruleset[0].ruleset_id, "b", "most problems first");
1287 assert_eq!(found.by_ruleset[0].would_block, 5);
1288 assert_eq!(found.by_rule, vec![RuleInsight { rule: "pull_request".into(), count: 4 }]);
1289 assert_eq!(found.days, 30);
1290 }
1291
1292 #[test]
1293 fn a_stored_ruleset_reads_back_with_its_repository() {
1294 let row = RulesetRow {
1295 id: "rs_1".into(),
1296 level: "repository".into(),
1297 workspace: String::new(),
1298 repo_id: Some("rep_1".into()),
1299 spec: r#"{"name":"Default branch protection","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"deletion"}]}"#.into(),
1300 source: Some(BRANCH_PROTECTION.into()),
1301 created_by: "g1t".into(),
1302 created_at: "2026-10-07T00:00:00.000Z".into(),
1303 updated_by: "g1t".into(),
1304 updated_at: "2026-10-07T00:00:00.000Z".into(),
1305 };
1306 let repo: Repo = serde_json::from_value(serde_json::json!({
1307 "id": "rep_1", "namespace": "Acme", "name": "web", "description": null, "isPrivate": true, "ownerId": "usr_1",
1308 "defaultBranch": "main", "forkOf": null, "createdAt": ""
1309 }))
1310 .unwrap();
1311 let ruleset = row.into_ruleset(Some(&repo)).unwrap();
1312 assert_eq!(ruleset.level, Level::Repository);
1313 assert_eq!(ruleset.workspace, "acme");
1314 assert_eq!(ruleset.repository.as_deref(), Some("Acme/web"));
1315 assert_eq!(ruleset.spec.rules[0].rule.kind(), "deletion");
1316 }
1317
1318 #[test]
1319 fn agents_changes_are_told_from_peoples() {
1320 use crate::rows::stored::{ASKER, G1T, pull};
1321 assert!(agent_change(&pull(G1T, Some(ASKER))));
1322 let mut person: Pull = pull(ASKER, None);
1323 person.agent = person.author.username.clone();
1324 person.runtime = g1t_contracts::work::Runtime::External;
1325 person.fork = None;
1326 assert!(!agent_change(&person));
1327 person.agent = "claude-code".into();
1328 assert!(agent_change(&person));
1329 }
1330
1331 #[test]
1332 fn latest_reviews_keep_each_reviewers_last_verdict() {
1333 let reviews = latest_reviews(vec![
1334 ("usr_b".into(), "bob".into(), ReviewVerdict::RequestChanges, "1".into()),
1335 (AGENT_ID.into(), "g1t".into(), ReviewVerdict::Approve, "2".into()),
1336 ("usr_b".into(), "bob".into(), ReviewVerdict::Approve, "3".into()),
1337 ]);
1338 assert_eq!(reviews.len(), 2);
1339 assert!(reviews[0].agent);
1340 assert_eq!(reviews[1].verdict, ReviewVerdict::Approve);
1341 }
1342
1343 #[test]
1344 fn protection_overlays_the_stored_settings() {
1345 let requirements = Requirements {
1346 required_checks: vec!["CI".into()],
1347 strict: true,
1348 required_approvals: 2,
1349 count_agent_approvals: false,
1350 allow_bypass_on_merge: false,
1351 require_code_owner_review: true,
1352 merge_queue: Some(MergeQueueRule::default()),
1353 ..Requirements::default()
1354 };
1355 let stored = RepoSettings { auto_merge: true, required_approvals: 5, ..RepoSettings::default() };
1356 let main = overlay(stored.clone(), &requirements, true);
1357 assert_eq!((main.required_approvals, main.merge_queue, main.auto_merge), (2, true, true));
1358 assert!(main.require_up_to_date && !main.allow_ignoring_checks && main.require_code_owner_review);
1359 assert!(!overlay(stored, &requirements, false).merge_queue, "the queue lands on the default branch only");
1360 }
1361}