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

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