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,410 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 // What its pull requests need may have changed with it.
533 if let Some(repo_id) = &ruleset.repo_id {
534 self.forget_stages(repo_id).await?;
535 }
536 let kind = if before.is_some() { "ruleset.updated" } else { "ruleset.created" };
537 self.announce(kind, &ruleset, &a.actor).await;
538 if !a.from_api {
539 self.audit_ruleset(&a.actor, &ruleset, if before.is_some() { "update_ruleset" } else { "create_ruleset" }).await;
540 }
541 Ok(Outcome::Ok(ruleset))
542 }
543
544 async fn store_ruleset(&self, ruleset: &Ruleset) -> Result<()> {
545 self.db
546 .prepare(
547 "INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at)
548 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
549 ON CONFLICT (id) DO UPDATE SET
550 name = excluded.name, enforcement = excluded.enforcement, target = excluded.target,
551 spec = excluded.spec, workspace = excluded.workspace,
552 updated_by = excluded.updated_by, updated_at = excluded.updated_at",
553 )
554 .bind(&[
555 ruleset.id.as_str().into(),
556 ruleset.level.as_str().into(),
557 ruleset.workspace.as_str().into(),
558 optional(ruleset.repo_id.as_deref()),
559 ruleset.spec.name.as_str().into(),
560 ruleset.spec.enforcement.as_str().into(),
561 ruleset.spec.target.as_str().into(),
562 serde_json::to_string(&ruleset.spec)?.into(),
563 optional(ruleset.source.as_deref()),
564 ruleset.created_by.as_str().into(),
565 ruleset.created_at.as_str().into(),
566 ruleset.updated_by.as_str().into(),
567 ruleset.updated_at.as_str().into(),
568 ])?
569 .run()
570 .await?;
571 Ok(())
572 }
573
574 pub(crate) async fn delete_ruleset(&self, a: DeleteRulesetArgs) -> Result<Outcome<bool>> {
575 let scope = check!(self.scope(&a.owner, &Some(a.actor.clone()), true).await?);
576 let Some(ruleset) = self.scoped_rulesets(&scope, false).await?.into_iter().find(|ruleset| ruleset.id == a.id) else {
577 return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found."));
578 };
579 self.db.prepare("DELETE FROM rulesets WHERE id = ?").bind(&[ruleset.id.as_str().into()])?.run().await?;
580 if let Some(repo_id) = &ruleset.repo_id {
581 self.forget_stages(repo_id).await?;
582 }
583 self.announce("ruleset.deleted", &ruleset, &a.actor).await;
584 if !a.from_api {
585 self.audit_ruleset(&a.actor, &ruleset, "delete_ruleset").await;
586 }
587 Ok(Outcome::Ok(true))
588 }
589
590 /// Publishes a ruleset's change: a repository's on its repository, a
591 /// workspace's to the workspace (webhooks route it there).
592 async fn announce(&self, kind: &'static str, ruleset: &Ruleset, actor: &User) {
593 let event = NewEvent {
594 kind,
595 source: crate::SOURCE,
596 repo_id: ruleset.repo_id.clone(),
597 actor: Some(actor.id.clone()),
598 data: RulesetEvent { workspace: &ruleset.workspace, repository: ruleset.repository.clone(), ruleset },
599 };
600 let published: Result<()> = g1t_kit::call(&self.events, "publish", &Publish { events: vec![event] }).await;
601 if let Err(error) = published {
602 worker::console_error!("{kind} not published: {error}");
603 }
604 }
605
606 /// Records a change to a ruleset made on the site in the workspace's
607 /// audit log. Changes through the API are recorded by the API.
608 async fn audit_ruleset(&self, actor: &User, ruleset: &Ruleset, action: &str) {
609 let entry = NewAuditEntry {
610 actor: AuditActor::of(actor),
611 action: action.to_owned(),
612 surface: Surface::Web,
613 target: AuditTarget {
614 workspace: ruleset.workspace.clone(),
615 repo: ruleset.repository.clone(),
616 ..AuditTarget::default()
617 },
618 outcome: AuditOutcome::Allowed,
619 rule: "rulesets".to_owned(),
620 result: Some("ok".to_owned()),
621 message: Some(format!(
622 "{} ruleset \"{}\" ({}, {} rules)",
623 match action {
624 "create_ruleset" => "Created",
625 "delete_ruleset" => "Deleted",
626 _ => "Changed",
627 },
628 ruleset.spec.name,
629 ruleset.spec.enforcement.as_str(),
630 ruleset.spec.rules.len()
631 )),
632 request_id: new_id("req", now_ms()),
633 };
634 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
635 if let Err(error) = recorded {
636 worker::console_error!("ruleset change not recorded: {error}");
637 }
638 }
639
640 pub(crate) async fn effective_rules(&self, a: EffectiveRulesArgs) -> Result<Outcome<EffectiveRules>> {
641 let repo = check!(self.repo(&a.repo, &a.viewer).await?);
642 let name = a.name.trim();
643 let name = name
644 .strip_prefix("refs/heads/")
645 .or_else(|| name.strip_prefix("refs/tags/"))
646 .unwrap_or(name);
647 if name.is_empty() {
648 return Ok(Outcome::fail(FailureCode::Invalid, "Name a branch or tag."));
649 }
650 let rulesets = self.rulesets_for(&repo).await?;
651 Ok(Outcome::Ok(select::effective(&rulesets, RepoFacts::from(&repo), a.target, name)))
652 }
653
654 /// Services only: the rulesets that hold for refs a service is about to
655 /// change, with whether the actor may bypass each.
656 pub(crate) async fn ref_rules(&self, a: RefRulesArgs) -> Result<Outcome<RefRules>> {
657 let rulesets = self.rulesets_for(&a.repo).await?;
658 let who = match &a.actor {
659 Some(actor) => Some(self.actor_facts(actor, &a.repo, &rulesets).await?),
660 None => None,
661 };
662 Ok(Outcome::Ok(RefRules {
663 default_branch: a.repo.default_branch.clone(),
664 workspace: a.repo.namespace.to_lowercase(),
665 rulesets: select::applicable(&rulesets, RepoFacts::from(&a.repo), &a.refs, who.as_ref(), &a.repo.namespace),
666 }))
667 }
668
669 /// Services only: keeps evaluations, and lets old ones go.
670 pub(crate) async fn record_evaluations(&self, a: RecordEvaluationsArgs) -> Result<u32> {
671 if a.evaluations.is_empty() {
672 return Ok(0);
673 }
674 let now = now_ms();
675 let at = rfc3339(now);
676 let mut statements = Vec::new();
677 for evaluation in a.evaluations.iter().take(200) {
678 statements.push(self.rules_statement(
679 "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)
680 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
681 &[
682 new_id("rev", now).into(),
683 evaluation.repo_id.as_str().into(),
684 evaluation.workspace.to_lowercase().into(),
685 evaluation.ruleset_id.as_str().into(),
686 evaluation.ruleset_name.as_str().into(),
687 evaluation.enforcement.as_str().into(),
688 evaluation.action.as_str().into(),
689 evaluation.git_ref.as_str().into(),
690 evaluation.actor.as_str().into(),
691 evaluation.actor_kind.as_str().into(),
692 evaluation.verdict.as_str().into(),
693 serde_json::to_string(&evaluation.violations)?.into(),
694 evaluation.number.map_or(JsValue::NULL, Into::into),
695 optional(evaluation.sha.as_deref()),
696 at.as_str().into(),
697 ],
698 )?);
699 }
700 if let Some(first) = a.evaluations.first() {
701 statements.push(self.rules_statement(
702 "DELETE FROM rule_evaluations WHERE id IN
703 (SELECT id FROM rule_evaluations WHERE repo_id = ? AND created_at < ? ORDER BY id LIMIT 200)",
704 &[first.repo_id.as_str().into(), rfc3339(now.saturating_sub(KEEP_DAYS * DAY_MS)).into()],
705 )?);
706 }
707 let count = a.evaluations.len().min(200) as u32;
708 self.db.batch(statements).await?;
709 Ok(count)
710 }
711
712 pub(crate) async fn rule_evaluations(&self, a: EvaluationsArgs) -> Result<Outcome<EvaluationPage>> {
713 let scope = check!(self.scope(&a.owner, &a.viewer, false).await?);
714 if let Some(repo) = &scope.repo {
715 check!(crate::allowed(a.viewer.as_ref(), repo, Capability::Push));
716 }
717 let (column, key) = match &scope.repo {
718 Some(repo) => ("repo_id", repo.id.clone()),
719 None => ("workspace", scope.workspace.clone()),
720 };
721 let limit = a.limit.unwrap_or(30).clamp(1, MAX_PAGE);
722 let mut sql = format!("SELECT * FROM rule_evaluations WHERE {column} = ?");
723 let mut binds: Vec<JsValue> = vec![key.as_str().into()];
724 if let Some(id) = &a.ruleset_id {
725 sql.push_str(" AND ruleset_id = ?");
726 binds.push(id.as_str().into());
727 }
728 if let Some(verdict) = a.verdict {
729 sql.push_str(" AND verdict = ?");
730 binds.push(verdict.as_str().into());
731 }
732 if a.problems_only {
733 sql.push_str(" AND verdict != 'pass'");
734 }
735 if let Some(before) = &a.before {
736 sql.push_str(" AND id < ?");
737 binds.push(before.as_str().into());
738 }
739 sql.push_str(" ORDER BY id DESC LIMIT ?");
740 binds.push((limit + 1).into());
741 let since = rfc3339(now_ms().saturating_sub(u64::from(INSIGHT_DAYS) * DAY_MS));
742 let ruleset_filter = if a.ruleset_id.is_some() { " AND ruleset_id = ?3" } else { "" };
743 let mut insight_binds: Vec<JsValue> = vec![key.as_str().into(), since.as_str().into()];
744 if let Some(id) = &a.ruleset_id {
745 insight_binds.push(id.as_str().into());
746 }
747 let results = self
748 .db
749 .batch(vec![
750 self.rules_statement(&sql, &binds)?,
751 self.rules_statement(
752 &format!(
753 "SELECT ruleset_id, ruleset_name, enforcement, verdict, count(*) AS n FROM rule_evaluations
754 WHERE {column} = ?1 AND created_at >= ?2{ruleset_filter}
755 GROUP BY ruleset_id, enforcement, verdict"
756 ),
757 &insight_binds,
758 )?,
759 self.rules_statement(
760 &format!(
761 "SELECT json_extract(v.value, '$.rule') AS rule, count(*) AS n
762 FROM rule_evaluations e, json_each(e.violations) v
763 WHERE e.{column} = ?1 AND e.created_at >= ?2 AND e.verdict != 'pass'{}
764 GROUP BY rule ORDER BY n DESC LIMIT 20",
765 ruleset_filter.replace("ruleset_id", "e.ruleset_id")
766 ),
767 &insight_binds,
768 )?,
769 ])
770 .await?;
771 let mut evaluations: Vec<Evaluation> = results[0].results::<EvaluationRow>()?.into_iter().map(Evaluation::from).collect();
772 let next = (evaluations.len() > limit as usize).then(|| {
773 evaluations.truncate(limit as usize);
774 evaluations.last().map(|last| last.id.clone())
775 });
776 let repository = scope.repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)).unwrap_or_default();
777 for evaluation in &mut evaluations {
778 evaluation.repository = repository.clone();
779 }
780 Ok(Outcome::Ok(EvaluationPage {
781 evaluations,
782 next: next.flatten(),
783 insights: insights(
784 results[1].results::<CountRow>()?,
785 results[2].results::<RuleCountRow>()?,
786 ),
787 }))
788 }
789
790 /// Everything a merge of `pull` is judged on, and the judgement, for
791 /// `actor`, or for nobody in particular. A bypass counts only with
792 /// `honor_bypass`: a person merging asks for it (`bypass_rules`).
793 pub(crate) async fn merge_gate(
794 &self,
795 repo: &Repo,
796 pull: &Pull,
797 actor: Option<&User>,
798 ignore_checks: bool,
799 honor_bypass: bool,
800 ) -> Result<Gate> {
801 self.merge_gate_at(repo, pull, actor, ignore_checks, honor_bypass, None, None, None).await
802 }
803
804 /// `merge_gate` with whether it is behind already worked out (from
805 /// what was stored with its mergeability, or asked once for this
806 /// request), so that showing a pull request does not ask repos again.
807 pub(crate) async fn merge_gate_seen(&self, repo: &Repo, pull: &Pull, actor: Option<&User>, behind: bool) -> Result<Gate> {
808 self.merge_gate_at(repo, pull, actor, false, true, None, None, Some(behind)).await
809 }
810
811 /// `merge_gate` for a merge by `method`: rules that allow only some
812 /// methods, and linear history, judge it too. Without a method they do
813 /// not, as for showing what a pull request still needs.
814 pub(crate) async fn merge_gate_by(
815 &self,
816 repo: &Repo,
817 pull: &Pull,
818 actor: Option<&User>,
819 ignore_checks: bool,
820 honor_bypass: bool,
821 method: MergeMethod,
822 ) -> Result<Gate> {
823 self.merge_gate_at(repo, pull, actor, ignore_checks, honor_bypass, None, Some(method), None).await
824 }
825
826 /// Re-evaluate current reviews and rules against the exact combined state.
827 /// Queue admission never grants a permanent bypass of later policy changes:
828 /// a bypass counts only as `merge_pull` let it count at admission (the
829 /// merger asked for it, or g1t merges), and only if the actor may still
830 /// bypass the rules as they are now.
831 /// `statuses`: the attempt's own evidence on `commit` (statuses.rs
832 /// `attempt_evidence`), jobs included, never the commit's every status.
833 pub(crate) async fn queue_landing_gate(
834 &self,
835 repo: &Repo,
836 pull: &Pull,
837 actor: &User,
838 commit: &str,
839 bypass_rules: bool,
840 statuses: Vec<g1t_contracts::work::CommitStatus>,
841 method: MergeMethod,
842 ) -> Result<Gate> {
843 // The tested state was built by the entry's method (queue.rs), which
844 // the rules judge as they are now.
845 let gate = self.merge_gate_at(repo, pull, Some(actor), false, true, Some((commit, statuses)), Some(method), None).await?;
846 Ok(if landing_honors_bypass(actor, bypass_rules) { gate } else { gate.without_bypass() })
847 }
848
849 #[allow(clippy::too_many_arguments)]
850 async fn merge_gate_at(
851 &self,
852 repo: &Repo,
853 pull: &Pull,
854 actor: Option<&User>,
855 ignore_checks: bool,
856 honor_bypass: bool,
857 tested: Option<(&str, Vec<g1t_contracts::work::CommitStatus>)>,
858 method: Option<MergeMethod>,
859 known_behind: Option<bool>,
860 ) -> Result<Gate> {
861 let tested_commit = tested.as_ref().map(|(commit, _)| *commit);
862 let tested_statuses = tested.as_ref().map(|(_, statuses)| statuses.clone());
863 let checked_commit = tested_commit.or(pull.head_commit.as_deref());
864 let rulesets = self.rulesets_for(repo).await?;
865 let base = pull.base_branch(&repo.default_branch).to_owned();
866 let git_ref = Target::Branch.full_ref(&base);
867 let who = match actor {
868 Some(actor) => Some(self.actor_facts(actor, repo, &rulesets).await?),
869 None => None,
870 };
871 let mut applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), who.as_ref(), &repo.namespace);
872 if !honor_bypass {
873 for ruleset in &mut applicable {
874 ruleset.bypass = None;
875 }
876 }
877 let agent = agent_change(pull);
878 let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
879 let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent, &files);
880 if applicable.is_empty() {
881 return Ok(Gate { judged: Vec::new(), requirements, applicable, who });
882 }
883 let needs_owners = applicable.iter().any(|ruleset| {
884 ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::PullRequest(rule) if rule.require_code_owner_review))
885 });
886 let owners_settings = RepoSettings { require_code_owner_review: true, ..RepoSettings::default() };
887 let teams = g1t_rules::merge::named_teams(&applicable);
888 let (verdicts, statuses, behind, code_owners, team_members, commits) = futures_util::try_join!(
889 self.verdict_rows(pull),
890 async {
891 match &tested_statuses {
892 Some(statuses) => Ok(statuses.clone()),
893 None => self.statuses(&pull.repo_id, checked_commit).await,
894 }
895 },
896 async {
897 // The repository service verifies ancestry and CAS for the
898 // combined commit; the original PR may legitimately be behind.
899 match (tested_commit, known_behind) {
900 (Some(_), _) => Ok(false),
901 (None, Some(behind)) => Ok(behind),
902 (None, None) => self.is_behind(pull).await,
903 }
904 },
905 async {
906 if needs_owners { self.code_owners_gap(&owners_settings, pull).await } else { Ok(None) }
907 },
908 async {
909 if teams.is_empty() { Ok(HashMap::new()) } else { self.team_people(&teams, &repo.namespace, &repo.id).await }
910 },
911 async {
912 if g1t_rules::merge::needs_commits(&applicable, agent) {
913 self.pull_commits(repo, pull, &base).await.map(Some)
914 } else {
915 Ok(None)
916 }
917 },
918 )?;
919 let reviews = latest_reviews(verdicts);
920 // A required check named for a g1t Actions job is met by that job.
921 let requires_checks =
922 applicable.iter().any(|ruleset| ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::RequiredStatusChecks(_))));
923 // A tested state's evidence already holds its own jobs.
924 let statuses = if requires_checks && tested_statuses.is_none() {
925 self.with_job_checks(&repo.id, checked_commit, statuses).await?
926 } else {
927 statuses
928 };
929 let facts = MergeFacts {
930 git_ref: git_ref.clone(),
931 agent_change: agent,
932 owner_id: &pull.owner().id,
933 reviews: &reviews,
934 head_pushed_at: pull.head_pushed_at.as_deref(),
935 head_pushed_by: pull.head_pushed_by.as_deref(),
936 code_owners_missing: code_owners.as_deref(),
937 statuses: &statuses,
938 behind,
939 files: &files,
940 commits: commits.as_ref(),
941 now_ms: now_ms(),
942 method,
943 team_members: Some(&team_members),
944 ignore_checks,
945 };
946 let judged = g1t_rules::merge::judge(&applicable, &repo.default_branch, &facts);
947 Ok(Gate { judged, requirements, applicable, who })
948 }
949
950 /// Every verdict on a pull request, oldest first: who, and when.
951 async fn verdict_rows(&self, pull: &Pull) -> Result<Vec<(String, String, ReviewVerdict, String)>> {
952 #[derive(Deserialize)]
953 struct Row {
954 author_id: String,
955 author_name: String,
956 verdict: ReviewVerdict,
957 created_at: String,
958 }
959 let rows = match self.prefetched_pull(&pull.id) {
960 Some(found) => found.rows::<Row>(crate::prefetch::Slot::Verdicts)?,
961 None => self
962 .db
963 .prepare(
964 "SELECT author_id, author_name, verdict, created_at FROM comments
965 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
966 )
967 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
968 .all()
969 .await?
970 .results::<Row>()?,
971 };
972 Ok(rows.into_iter().map(|row| (row.author_id, row.author_name, row.verdict, row.created_at)).collect())
973 }
974
975 /// The commits a pull request would land, read as rules look at them.
976 async fn pull_commits(&self, repo: &Repo, pull: &Pull, base: &str) -> Result<InspectedCommits> {
977 let Some(head) = pull.head_commit.clone() else {
978 return Ok(InspectedCommits::default());
979 };
980 let found: Outcome<InspectedCommits> = g1t_kit::call(
981 &self.repos,
982 "inspect_commits",
983 &InspectCommitsArgs {
984 source_id: pull.source_id().to_owned(),
985 head,
986 target_id: repo.id.clone(),
987 base_branch: base.to_owned(),
988 limit: None,
989 merge_base: None,
990 messages_only: false,
991 },
992 )
993 .await?;
994 Ok(match found {
995 Outcome::Ok(commits) => commits,
996 Outcome::Fail(failure) => {
997 worker::console_error!("inspect_commits for {}: {}", pull.id, failure.message);
998 InspectedCommits::default()
999 }
1000 })
1001 }
1002
1003 /// Records how each ruleset judged a merge.
1004 pub(crate) async fn record_merge_evaluations(&self, repo: &Repo, pull: &Pull, gate: &Gate) {
1005 let Some(who) = &gate.who else { return };
1006 if gate.judged.is_empty() {
1007 return;
1008 }
1009 let evaluations = g1t_rules::evaluations(
1010 &gate.judged,
1011 &repo.id,
1012 &repo.namespace,
1013 Action::Merge,
1014 who,
1015 Some(pull.number),
1016 pull.head_commit.as_deref(),
1017 );
1018 if let Err(error) = self.record_evaluations(RecordEvaluationsArgs { evaluations }).await {
1019 worker::console_error!("merge evaluations not recorded: {error}");
1020 }
1021 }
1022
1023 /// The settings that hold for a pull request: the repository's, with
1024 /// branch protection as the rules for the branch it merges into stack.
1025 pub(crate) async fn settings_on(&self, repo: &Repo, pull: &Pull) -> Result<RepoSettings> {
1026 Ok(self.settings_and_requirements(repo, pull).await?.0)
1027 }
1028
1029 /// `settings_on`, with what the rules for the branch it merges into
1030 /// ask, for anyone: which merge methods they allow, among the rest.
1031 pub(crate) async fn settings_and_requirements(&self, repo: &Repo, pull: &Pull) -> Result<(RepoSettings, Requirements)> {
1032 let (stored, rulesets) = futures_util::future::try_join(self.settings(&repo.id), self.rulesets_for(repo)).await?;
1033 let base = pull.base_branch(&repo.default_branch);
1034 let git_ref = Target::Branch.full_ref(base);
1035 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
1036 let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
1037 let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent_change(pull), &files);
1038 Ok((overlay(stored, &requirements, base == repo.default_branch), requirements))
1039 }
1040
1041 /// The repository a pull request merges into: as read earlier in this
1042 /// request, or now.
1043 pub(crate) async fn repo_for(&self, pull: &Pull) -> Result<Option<Repo>> {
1044 if let Some(repo) = self.known_repos.borrow().get(&pull.repo_id) {
1045 return Ok(Some(repo.clone()));
1046 }
1047 let found = self.target_of(pull).await?;
1048 if let Some(repo) = &found {
1049 self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
1050 }
1051 Ok(found)
1052 }
1053
1054 /// The settings that hold for a pull request, when only it is at hand.
1055 pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> {
1056 match self.repo_for(pull).await? {
1057 Some(repo) => self.settings_on(&repo, pull).await,
1058 None => self.settings(&pull.repo_id).await,
1059 }
1060 }
1061
1062 /// The settings that hold for the default branch.
1063 pub(crate) async fn default_branch_settings(&self, repo: &Repo) -> Result<RepoSettings> {
1064 let (stored, requirements) = futures_util::future::try_join(self.settings(&repo.id), self.default_requirements(repo)).await?;
1065 let mut settings = overlay(stored, &requirements, true);
1066 settings.issues_enabled = settings.issues_on(repo.forked_from.is_some());
1067 Ok(settings)
1068 }
1069
1070 /// What the active rules ask of the default branch, for anyone.
1071 pub(crate) async fn default_requirements(&self, repo: &Repo) -> Result<Requirements> {
1072 let rulesets = self.rulesets_for(repo).await?;
1073 let git_ref = Target::Branch.full_ref(&repo.default_branch);
1074 let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
1075 Ok(g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, false, &[]))
1076 }
1077
1078 /// How the default branch's merge queue batches: its rule's
1079 /// parameters, or the defaults.
1080 pub(crate) async fn queue_rule(&self, repo_id: &str) -> Result<MergeQueueRule> {
1081 let path: Option<RepoPath> =
1082 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1083 let repo = match path {
1084 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1085 None => None,
1086 };
1087 Ok(match repo {
1088 Some(repo) => self.default_requirements(&repo).await?.merge_queue.unwrap_or_default(),
1089 None => MergeQueueRule::default(),
1090 })
1091 }
1092
1093 /// The default branch's settings by repository id, for callers that do
1094 /// not have the repository at hand (the merge queue, statuses).
1095 pub(crate) async fn settings_by_id(&self, repo_id: &str) -> Result<RepoSettings> {
1096 let path: Option<RepoPath> =
1097 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1098 let repo = match path {
1099 Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1100 None => None,
1101 };
1102 match repo {
1103 Some(repo) => self.default_branch_settings(&repo).await,
1104 None => self.settings(repo_id).await,
1105 }
1106 }
1107
1108 /// The branch protection ruleset's rules rewritten from the old
1109 /// settings (`update_settings`), creating it when needed.
1110 pub(crate) async fn write_branch_protection(&self, repo: &Repo, settings: &RepoSettings, actor: &User) -> Result<()> {
1111 let rulesets = self.rulesets_for(repo).await?;
1112 let existing = rulesets
1113 .iter()
1114 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION));
1115 let now = rfc3339(now_ms());
1116 match existing {
1117 Some(found) => {
1118 let protected = legacy::requires_pull_requests(&found.spec.rules);
1119 let mut ruleset = found.clone();
1120 ruleset.spec.rules = legacy::replace(&found.spec.rules, settings, protected);
1121 ruleset.updated_by = actor.username.clone();
1122 ruleset.updated_at = now;
1123 self.store_ruleset(&ruleset).await?;
1124 self.announce("ruleset.updated", &ruleset, actor).await;
1125 }
1126 None => {
1127 let Some(spec) = legacy::ruleset_of(settings, false) else { return Ok(()) };
1128 let ruleset = Ruleset {
1129 id: new_id("rs", now_ms()),
1130 level: Level::Repository,
1131 workspace: repo.namespace.to_lowercase(),
1132 repo_id: Some(repo.id.clone()),
1133 repository: Some(format!("{}/{}", repo.namespace, repo.name)),
1134 spec,
1135 source: Some(BRANCH_PROTECTION.to_owned()),
1136 created_by: actor.username.clone(),
1137 created_at: now.clone(),
1138 updated_by: actor.username.clone(),
1139 updated_at: now,
1140 };
1141 self.store_ruleset(&ruleset).await?;
1142 self.announce("ruleset.created", &ruleset, actor).await;
1143 }
1144 }
1145 Ok(())
1146 }
1147
1148 /// Services only (repos `update` with `protected`): whether the branch
1149 /// protection ruleset refuses pushes to the default branch.
1150 pub(crate) async fn set_requires_pull_request(&self, a: RequirePullRequestArgs) -> Result<Outcome<bool>> {
1151 let rulesets = self.rulesets_for(&a.repo).await?;
1152 let existing = rulesets
1153 .iter()
1154 .find(|ruleset| ruleset.repo_id.as_deref() == Some(&a.repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION))
1155 .cloned();
1156 let now = rfc3339(now_ms());
1157 let mut ruleset = match existing {
1158 Some(found) => found,
1159 None if !a.protected => return Ok(Outcome::Ok(false)),
1160 None => Ruleset {
1161 id: new_id("rs", now_ms()),
1162 level: Level::Repository,
1163 workspace: a.repo.namespace.to_lowercase(),
1164 repo_id: Some(a.repo.id.clone()),
1165 repository: Some(format!("{}/{}", a.repo.namespace, a.repo.name)),
1166 spec: legacy::ruleset_of(&RepoSettings::default(), true).unwrap_or_default(),
1167 source: Some(BRANCH_PROTECTION.to_owned()),
1168 created_by: a.actor.username.clone(),
1169 created_at: now.clone(),
1170 updated_by: a.actor.username.clone(),
1171 updated_at: now.clone(),
1172 },
1173 };
1174 let mut found = false;
1175 for entry in &mut ruleset.spec.rules {
1176 if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) {
1177 rule.allow_direct_pushes = !a.protected;
1178 found = true;
1179 }
1180 }
1181 if !found && a.protected {
1182 ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default())));
1183 }
1184 ruleset.updated_by = a.actor.username.clone();
1185 ruleset.updated_at = now;
1186 self.store_ruleset(&ruleset).await?;
1187 self.announce("ruleset.updated", &ruleset, &a.actor).await;
1188 Ok(Outcome::Ok(true))
1189 }
1190}
1191
1192/// `set_requires_pull_request`: services only.
1193#[derive(Deserialize)]
1194pub(crate) struct RequirePullRequestArgs {
1195 repo: Repo,
1196 protected: bool,
1197 actor: User,
1198}
1199
1200/// A merge, judged.
1201pub(crate) struct Gate {
1202 pub(crate) judged: Vec<Judged>,
1203 pub(crate) requirements: Requirements,
1204 #[allow(dead_code)]
1205 pub(crate) applicable: Vec<Applicable>,
1206 pub(crate) who: Option<ActorFacts>,
1207}
1208
1209impl Gate {
1210 /// What refuses the merge now, if anything, in one sentence.
1211 pub(crate) fn refusal(&self) -> Option<String> {
1212 g1t_rules::report::summary(&g1t_rules::outcome::blocking(&self.judged))
1213 }
1214
1215 /// What people (not checks, which g1t's lifecycle waits for itself)
1216 /// must still do before it can merge.
1217 pub(crate) fn people_gap(&self) -> Option<String> {
1218 let waiting: Vec<&Violation> = g1t_rules::outcome::blocking(&self.judged)
1219 .into_iter()
1220 .filter(|violation| !g1t_rules::merge::about_checks(&violation.rule))
1221 .collect();
1222 g1t_rules::report::summary(&waiting)
1223 }
1224
1225 /// Whether any rule not met could be bypassed by the actor.
1226 pub(crate) fn bypassable(&self) -> bool {
1227 self.judged.iter().any(|one| one.enforcement == Enforcement::Active && one.verdict() == Verdict::Bypass)
1228 }
1229
1230 /// The same judgement for an actor who did not ask to bypass anything.
1231 pub(crate) fn without_bypass(mut self) -> Gate {
1232 for one in &mut self.judged {
1233 one.bypass = None;
1234 }
1235 self
1236 }
1237}
1238
1239/// A workspace ruleset's repository condition, against one repository.
1240fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
1241 select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo)
1242}
1243
1244const RULESETS_SQL: &str =
1245 "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at";
1246const ADOPTED_SQL: &str = "SELECT 1 AS n FROM ruleset_adoptions WHERE repo_id = ?1";
1247
1248/// The statements prefetch.rs batches for a pull request's page.
1249pub(crate) fn prefetch_sql() -> (&'static str, &'static str) {
1250 (RULESETS_SQL, ADOPTED_SQL)
1251}
1252
1253#[derive(Deserialize)]
1254struct CountRow {
1255 ruleset_id: String,
1256 ruleset_name: String,
1257 enforcement: Enforcement,
1258 verdict: Verdict,
1259 n: u32,
1260}
1261
1262#[derive(Deserialize)]
1263struct RuleCountRow {
1264 rule: Option<String>,
1265 n: u32,
1266}
1267
1268/// Counts by ruleset and verdict, and violations by rule, as insights.
1269fn insights(counts: Vec<CountRow>, rules: Vec<RuleCountRow>) -> Insights {
1270 let mut out = Insights { days: INSIGHT_DAYS, ..Insights::default() };
1271 let mut by_ruleset: Vec<RulesetInsight> = Vec::new();
1272 for row in counts {
1273 out.total += row.n;
1274 let index = match by_ruleset.iter().position(|have| have.ruleset_id == row.ruleset_id) {
1275 Some(index) => index,
1276 None => {
1277 by_ruleset.push(RulesetInsight {
1278 ruleset_id: row.ruleset_id.clone(),
1279 ruleset_name: row.ruleset_name.clone(),
1280 enforcement: row.enforcement,
1281 ..RulesetInsight::default()
1282 });
1283 by_ruleset.len() - 1
1284 }
1285 };
1286 let one = &mut by_ruleset[index];
1287 one.total += row.n;
1288 match (row.verdict, row.enforcement) {
1289 (Verdict::Pass, _) => out.passed += row.n,
1290 (Verdict::Bypass, _) => {
1291 out.bypassed += row.n;
1292 one.bypassed += row.n;
1293 }
1294 (Verdict::Fail, Enforcement::Evaluate) => {
1295 out.would_block += row.n;
1296 one.would_block += row.n;
1297 }
1298 (Verdict::Fail, _) => {
1299 out.blocked += row.n;
1300 one.blocked += row.n;
1301 }
1302 }
1303 }
1304 by_ruleset.sort_by_key(|one| std::cmp::Reverse(one.blocked + one.would_block + one.bypassed));
1305 out.by_ruleset = by_ruleset;
1306 out.by_rule = rules
1307 .into_iter()
1308 .filter_map(|row| row.rule.map(|rule| RuleInsight { rule, count: row.n }))
1309 .collect();
1310 out
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use g1t_contracts::rules::Level;
1317
1318 fn count(ruleset: &str, enforcement: Enforcement, verdict: Verdict, n: u32) -> CountRow {
1319 CountRow { ruleset_id: ruleset.into(), ruleset_name: ruleset.into(), enforcement, verdict, n }
1320 }
1321
1322 #[test]
1323 fn insights_add_up_by_ruleset_and_verdict() {
1324 let found = insights(
1325 vec![
1326 count("a", Enforcement::Active, Verdict::Pass, 10),
1327 count("a", Enforcement::Active, Verdict::Fail, 2),
1328 count("b", Enforcement::Evaluate, Verdict::Fail, 5),
1329 count("b", Enforcement::Evaluate, Verdict::Pass, 1),
1330 count("c", Enforcement::Active, Verdict::Bypass, 1),
1331 ],
1332 vec![RuleCountRow { rule: Some("pull_request".into()), n: 4 }, RuleCountRow { rule: None, n: 1 }],
1333 );
1334 assert_eq!((found.total, found.passed, found.blocked, found.would_block, found.bypassed), (19, 11, 2, 5, 1));
1335 assert_eq!(found.by_ruleset[0].ruleset_id, "b", "most problems first");
1336 assert_eq!(found.by_ruleset[0].would_block, 5);
1337 assert_eq!(found.by_rule, vec![RuleInsight { rule: "pull_request".into(), count: 4 }]);
1338 assert_eq!(found.days, 30);
1339 }
1340
1341 #[test]
1342 fn a_stored_ruleset_reads_back_with_its_repository() {
1343 let row = RulesetRow {
1344 id: "rs_1".into(),
1345 level: "repository".into(),
1346 workspace: String::new(),
1347 repo_id: Some("rep_1".into()),
1348 spec: r#"{"name":"Default branch protection","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"deletion"}]}"#.into(),
1349 source: Some(BRANCH_PROTECTION.into()),
1350 created_by: "g1t".into(),
1351 created_at: "2026-10-07T00:00:00.000Z".into(),
1352 updated_by: "g1t".into(),
1353 updated_at: "2026-10-07T00:00:00.000Z".into(),
1354 };
1355 let repo: Repo = serde_json::from_value(serde_json::json!({
1356 "id": "rep_1", "namespace": "Acme", "name": "web", "description": null, "isPrivate": true, "ownerId": "usr_1",
1357 "defaultBranch": "main", "forkOf": null, "createdAt": ""
1358 }))
1359 .unwrap();
1360 let ruleset = row.into_ruleset(Some(&repo)).unwrap();
1361 assert_eq!(ruleset.level, Level::Repository);
1362 assert_eq!(ruleset.workspace, "acme");
1363 assert_eq!(ruleset.repository.as_deref(), Some("Acme/web"));
1364 assert_eq!(ruleset.spec.rules[0].rule.kind(), "deletion");
1365 }
1366
1367 #[test]
1368 fn agents_changes_are_told_from_peoples() {
1369 use crate::rows::stored::{ASKER, G1T, pull};
1370 assert!(agent_change(&pull(G1T, Some(ASKER))));
1371 let mut person: Pull = pull(ASKER, None);
1372 person.agent = person.author.username.clone();
1373 person.runtime = g1t_contracts::work::Runtime::External;
1374 person.fork = None;
1375 assert!(!agent_change(&person));
1376 person.agent = "claude-code".into();
1377 assert!(agent_change(&person));
1378 }
1379
1380 #[test]
1381 fn latest_reviews_keep_each_reviewers_last_verdict() {
1382 let reviews = latest_reviews(vec![
1383 ("usr_b".into(), "bob".into(), ReviewVerdict::RequestChanges, "1".into()),
1384 (AGENT_ID.into(), "g1t".into(), ReviewVerdict::Approve, "2".into()),
1385 ("usr_b".into(), "bob".into(), ReviewVerdict::Approve, "3".into()),
1386 ]);
1387 assert_eq!(reviews.len(), 2);
1388 assert!(reviews[0].agent);
1389 assert_eq!(reviews[1].verdict, ReviewVerdict::Approve);
1390 }
1391
1392 #[test]
1393 fn protection_overlays_the_stored_settings() {
1394 let requirements = Requirements {
1395 required_checks: vec!["CI".into()],
1396 strict: true,
1397 required_approvals: 2,
1398 count_agent_approvals: false,
1399 allow_bypass_on_merge: false,
1400 require_code_owner_review: true,
1401 merge_queue: Some(MergeQueueRule::default()),
1402 ..Requirements::default()
1403 };
1404 let stored = RepoSettings { auto_merge: true, required_approvals: 5, ..RepoSettings::default() };
1405 let main = overlay(stored.clone(), &requirements, true);
1406 assert_eq!((main.required_approvals, main.merge_queue, main.auto_merge), (2, true, true));
1407 assert!(main.require_up_to_date && !main.allow_ignoring_checks && main.require_code_owner_review);
1408 assert!(!overlay(stored, &requirements, false).merge_queue, "the queue lands on the default branch only");
1409 }
1410}