Skip to content
1,325 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.