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

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