Skip to content

Commit

Rulesets in the work service: kept, recorded, and enforced on merge

The work service keeps rulesets (a repository's and its workspace's) and every evaluation of them, and judges every merge by them through one gate, merge_gate: the merge button, the API and MCP, auto-merge, g1t's lifecycle and the merge queue all ask it. Pull requests into branches other than the default one now get the rules that target them. - rulesets.rs: list, get, save (validated by g1t_rules), delete, with ruleset.created/updated/deleted events (a workspace's routed to the workspace) and audit entries for changes made on the site; effective rules for a branch; evaluations with 30-day insights (blocked, would block, bypassed, by ruleset and by rule), kept 90 days; ref_rules and record_evaluations for the repos service. - Branch protection: the repos service's protected flag is folded into the "Default branch protection" ruleset the first time a repository's rulesets are read, once, in one batch. get_settings reads branch protection from the rules as they stack; update_settings writes the ruleset, keeping rules only rulesets have. No behaviour changes. - merge_pull asks the gate. A bypass counts only when the merger asks (bypass_rules), or for g1t when a ruleset lists it; the refusal says when one could bypass. Evaluations are recorded for every merge. - The pull request page carries rules: what is unmet, what the viewer may bypass, and what evaluate-mode rulesets would refuse. - The lifecycle sees the rules as g1t does when it merges by itself: what people must do, whether an agent's change may land unattended on the branch (and at what confidence), and a cost cap that holds the agent until a person approves. - The merge queue batches by the default branch's merge queue rule (batch size, smallest batch and how long to wait for it, timeout). - Pull requests record who pushed their head last, and when.

syntaqxcommitted Parente44dcefBrowse files
18 files+1505−1200/18 viewed
+1−0
11931193 "futures-util",
11941194 "g1t-contracts",
11951195 "g1t-kit",
1196+ "g1t-rules",
11961197 "getrandom 0.2.17",
11971198 "hex",
11981199 "serde",
+1−0
30643064 summary: text(input, "summary"),
30653065 keep_issue_open: input["keep_issue_open"].as_bool() == Some(true),
30663066 ignore_checks: input["ignore_checks"].as_bool() == Some(true),
3067+ bypass_rules: input["bypass_rules"].as_bool() == Some(true),
30673068 };
30683069 let Services {
30693070 identity,
+25−11
380380 /// head before it merges.
381381 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
382382 #[serde(default)]
383+#[derive(Default)]
383384 pub struct StatusChecksRule {
384385 pub checks: Vec<RequiredCheck>,
385386 /// The pull request must contain the branch's latest commits, so that
393394 pub allow_bypass_on_merge: bool,
394395 }
395396
396−impl Default for StatusChecksRule {
397− fn default() -> Self {
398− StatusChecksRule {
399− checks: Vec::new(),
400− strict: false,
401− paths: Vec::new(),
402− allow_bypass_on_merge: false,
403− }
404− }
405−}
406397
407398 /// `merge_queue`: merging joins the queue, which tests each pull request
408399 /// together with those ahead of it. The queue lands on the default branch.
952943 #[serde(default)]
953944 pub id: Option<String>,
954945 pub ruleset: RulesetSpec,
946+ /// Set by the API, which records the change in the audit log itself.
947+ #[serde(default)]
948+ pub from_api: bool,
955949 }
956950
957951 /// `delete_ruleset`. Returns `Outcome<bool>`.
961955 #[serde(flatten)]
962956 pub owner: Owner,
963957 pub id: String,
958+ #[serde(default)]
959+ pub from_api: bool,
964960 }
965961
966962 /// `effective_rules`: every rule that holds for a branch (or a tag, with
12051201 /// `Outcome<RefRules>`.
12061202 #[derive(Clone, Debug, Serialize, Deserialize)]
12071203 pub struct RefRulesArgs {
1208− pub repo_id: String,
1204+ /// The repository, as the repos service read it.
1205+ pub repo: crate::repos::Repo,
12091206 pub actor: Option<User>,
12101207 pub refs: Vec<String>,
12111208 }
12121209
1210+/// What a pull request's merge box shows of the rules for the branch it
1211+/// merges into, for whoever is looking.
1212+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1213+pub struct MergeRules {
1214+ /// Rules not met, which refuse the merge.
1215+ pub unmet: Vec<Violation>,
1216+ /// Rules not met that the viewer may bypass, by asking to as they
1217+ /// merge (`bypass_rules`).
1218+ pub bypassable: Vec<Violation>,
1219+ /// Rules of rulesets in `evaluate` that would refuse it.
1220+ pub evaluate: Vec<Violation>,
1221+ /// The rulesets that hold for the branch.
1222+ pub rulesets: Vec<RulesetSummary>,
1223+ /// Whether merging joins the merge queue.
1224+ pub merge_queue: bool,
1225+}
1226+
12131227 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
12141228 pub struct RefRules {
12151229 pub default_branch: String,
+15−39
474474 /// g1t is not seeing through.
475475 #[serde(default)]
476476 pub confidence: Option<Confidence>,
477+ /// Who last moved its head (a user id), and when, RFC 3339: for rules
478+ /// about the most recent push. Absent until a push after rulesets
479+ /// arrived.
480+ #[serde(default, skip_serializing_if = "Option::is_none")]
481+ pub head_pushed_by: Option<String>,
482+ #[serde(default, skip_serializing_if = "Option::is_none")]
483+ pub head_pushed_at: Option<String>,
477484 }
478485
479486 /// How sure g1t is that an agent's change is right. Low is below medium,
864871 /// stands on the head commit. Empty when none are required.
865872 #[serde(default, alias = "requiredChecks")]
866873 pub required_checks: Vec<RequiredCheck>,
874+ /// The rules of the branch it merges into that it does not meet yet,
875+ /// for whoever is looking. Absent while it is not open.
876+ #[serde(default, skip_serializing_if = "Option::is_none")]
877+ pub rules: Option<crate::rules::MergeRules>,
867878 /// Who owns the files it changes, from the CODEOWNERS file of the
868879 /// branch it merges into, and whose approval is still needed. Absent
869880 /// when that branch has no CODEOWNERS file.
13731384 pub updated_by: Option<String>,
13741385 /// RFC 3339.
13751386 pub updated_at: Option<String>,
1376−}
1377−
1378−impl RepoSettings {
1379− /// What holds for a pull request into `base`. The settings are the
1380− /// default branch's protection: a pull request into another branch
1381− /// needs no required checks or approvals, need not be up to date, and
1382− /// never goes through the merge queue, which lands on the default
1383− /// branch only. How g1t's agents review, revise and merge holds for
1384− /// every branch.
1385− pub fn for_base(&self, base: &str, default_branch: &str) -> RepoSettings {
1386− if base == default_branch {
1387− return self.clone();
1388− }
1389− RepoSettings {
1390− required_checks: Vec::new(),
1391− require_up_to_date: false,
1392− required_approvals: 0,
1393− merge_queue: false,
1394− ..self.clone()
1395− }
1396− }
13971387 }
13981388
13991389 impl Default for RepoSettings {
17621752 /// where the repository lets members bypass them.
17631753 #[serde(default)]
17641754 pub ignore_checks: bool,
1755+ /// For `merge_pull`: merge although rules are not met, where a ruleset
1756+ /// lists the actor as one who may bypass it. Recorded as a bypass.
1757+ #[serde(default)]
1758+ pub bypass_rules: bool,
17651759 }
17661760
17671761 /// Where a plan stands.
25352529 assert_eq!(check.description.as_deref(), Some("CI / push failure"));
25362530 let queue = [status("CI / merge_group", "success")];
25372531 assert_eq!(required_checks(&required, &queue)[0].state, RequiredState::Success);
2538− }
2539−
2540− #[test]
2541− fn only_the_default_branch_is_protected() {
2542− let settings = RepoSettings {
2543− required_checks: vec!["CI".into()],
2544− require_up_to_date: true,
2545− required_approvals: 2,
2546− merge_queue: true,
2547− auto_merge: true,
2548− ..RepoSettings::default()
2549− };
2550− let main = settings.for_base("main", "main");
2551− assert_eq!((main.required_checks.len(), main.required_approvals, main.merge_queue), (1, 2, true));
2552− let release = settings.for_base("release/1.x", "main");
2553− assert!(release.required_checks.is_empty() && !release.require_up_to_date && !release.merge_queue);
2554− assert_eq!(release.required_approvals, 0);
2555− assert!(release.auto_merge, "how g1t's agents merge holds for every branch");
25562532 }
25572533
25582534 #[test]
+2−2
292292 fn restricted_paths_extensions_sizes_and_lengths() {
293293 let paths = Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] });
294294 let change = commit("a", "x", &["src/a.rs", ".g1t/workflows/ci.yml", "docs/CODEOWNERS"]);
295− assert_eq!(problems(&paths, &[change.clone()], true).len(), 2);
295+ assert_eq!(problems(&paths, std::slice::from_ref(&change), true).len(), 2);
296296 let extensions = Rule::FileExtensionRestriction(FileExtensionRule { restricted_file_extensions: vec!["exe".into(), ".ZIP".into()] });
297297 let binaries = commit("b", "x", &["tool.exe", "a.zip", "README", ".env"]);
298298 assert_eq!(problems(&extensions, &[binaries], true).len(), 2);
303303 let long = commit("d", "x", &[&"a/".repeat(200)]);
304304 assert_eq!(problems(&Rule::MaxFilePathLength(MaxFilePathLengthRule { max_file_path_length: 255 }), &[long], true).len(), 1);
305305 let many = commit("e", "x", &["1", "2", "3"]);
306− assert_eq!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 2 }), &[many.clone()], true).len(), 1);
306+ assert_eq!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 2 }), std::slice::from_ref(&many), true).len(), 1);
307307 assert!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 3 }), &[many], true).is_empty());
308308 }
309309
+12−12
176176 #[test]
177177 fn a_protected_branch_takes_changes_only_through_pull_requests() {
178178 let protect = ruleset("main", &["~DEFAULT_BRANCH"], vec![all(Rule::PullRequest(PullRequestRule::default()))]);
179− let judged = judge(&[protect.clone()], "main", Who::Person, &update("refs/heads/main"));
179+ let judged = judge(std::slice::from_ref(&protect), "main", Who::Person, &update("refs/heads/main"));
180180 assert!(refused(&judged));
181181 assert_eq!(blocking(&judged)[0].message, "Changes to main must be made through a pull request.");
182182 assert_eq!(blocking(&judged)[0].rule, "pull_request");
183183 // Creating it, as the first push to an empty repository does, is allowed.
184184 let created = RefChange { old: None, ..update("refs/heads/main") };
185− assert!(!refused(&judge(&[protect.clone()], "main", Who::Person, &created)));
185+ assert!(!refused(&judge(std::slice::from_ref(&protect), "main", Who::Person, &created)));
186186 // Another branch is not covered.
187187 assert!(judge(&[protect], "main", Who::Person, &update("refs/heads/feature")).is_empty());
188188 }
195195 vec![all(Rule::Creation(NoParameters {})), all(Rule::Deletion(NoParameters {})), all(Rule::NonFastForward(NoParameters {}))],
196196 );
197197 let created = RefChange { old: None, ..update("refs/heads/release/2") };
198− assert_eq!(blocking(&judge(&[guard.clone()], "main", Who::Person, &created))[0].rule, "creation");
198+ assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &created))[0].rule, "creation");
199199 let deleted = RefChange { new: None, ..update("refs/heads/release/2") };
200− assert_eq!(blocking(&judge(&[guard.clone()], "main", Who::Person, &deleted))[0].rule, "deletion");
200+ assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &deleted))[0].rule, "deletion");
201201 let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/release/2") };
202− let judged = judge(&[guard.clone()], "main", Who::Person, &forced);
202+ let judged = judge(std::slice::from_ref(&guard), "main", Who::Person, &forced);
203203 assert_eq!(blocking(&judged)[0].message, "Force pushes to release/2 are blocked: the push would rewrite its history.");
204204 assert!(!refused(&judge(&[guard], "main", Who::Person, &update("refs/heads/release/2"))));
205205 }
236236 let workflows = ruleset("w", &["~ALL"], vec![agents_only]);
237237 let mut change = update("refs/heads/feature");
238238 change.commits = vec![crate::content::tests_support::commit("c1", "x", &[".g1t/workflows/deploy.yml"])];
239− assert!(refused(&judge(&[workflows.clone()], "main", Who::Agent, &change)));
240− assert!(refused(&judge(&[workflows.clone()], "main", Who::G1t, &change)));
239+ assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::Agent, &change)));
240+ assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::G1t, &change)));
241241 assert!(!refused(&judge(&[workflows], "main", Who::Person, &change)));
242242 }
243243
255255 );
256256 let bad = RefChange { old: None, ..update("refs/heads/stuff") };
257257 assert_eq!(
258− blocking(&judge(&[branches.clone()], "main", Who::Person, &bad))[0].message,
258+ blocking(&judge(std::slice::from_ref(&branches), "main", Who::Person, &bad))[0].message,
259259 "The branch name stuff does not match /^(main|(feature|fix)/.+)$/."
260260 );
261261 let good = RefChange { old: None, ..update("refs/heads/feature/rules") };
262− assert!(!refused(&judge(&[branches.clone()], "main", Who::Person, &good)));
262+ assert!(!refused(&judge(std::slice::from_ref(&branches), "main", Who::Person, &good)));
263263 // Pushing to an existing branch is not naming it.
264264 assert!(!refused(&judge(&[branches], "main", Who::Person, &update("refs/heads/stuff"))));
265265 let tags = ruleset(
279279 #[test]
280280 fn content_rules_need_the_change_read_whole() {
281281 let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]);
282− assert!(needs_content(&[signed.clone()], Who::Person));
283− assert!(needs_signatures(&[signed.clone()]));
282+ assert!(needs_content(std::slice::from_ref(&signed), Who::Person));
283+ assert!(needs_signatures(std::slice::from_ref(&signed)));
284284 let unread = RefChange { complete: false, ..update("refs/heads/feature") };
285285 assert_eq!(
286− blocking(&judge(&[signed.clone()], "main", Who::Person, &unread))[0].message,
286+ blocking(&judge(std::slice::from_ref(&signed), "main", Who::Person, &unread))[0].message,
287287 "The change is too large for g1t to check against this rule."
288288 );
289289 let mut bypassed = signed;
+1−0
237237 summary: String::new(),
238238 keep_issue_open: false,
239239 ignore_checks: false,
240+ bypass_rules: false,
240241 },
241242 )
242243 .await?;
+2−1
10491049 summary: String::new(),
10501050 keep_issue_open: false,
10511051 ignore_checks: false,
1052+ bypass_rules: false,
10521053 },
10531054 )
10541055 .await?;
13881389 let merged: Outcome<Pull> = g1t_kit::call(
13891390 &self.work,
13901391 "merge_pull",
1391− &PullActionArgs { actor: system.clone(), repo: path.clone(), number, summary: String::new(), keep_issue_open: false, ignore_checks: false },
1392+ &PullActionArgs { actor: system.clone(), repo: path.clone(), number, summary: String::new(), keep_issue_open: false, ignore_checks: false, bypass_rules: false },
13921393 )
13931394 .await?;
13941395 match merged {
+1−0
1111 [dependencies]
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
14+g1t-rules.workspace = true
1415 serde.workspace = true
1516 serde_json.workspace = true
1617 worker.workspace = true
+2−2
292292 }))
293293 }
294294
295− async fn repo_by_id(&self, repo_id: &str, namespace: &str) -> Result<Option<Repo>> {
295+ pub(crate) async fn repo_by_id(&self, repo_id: &str, namespace: &str) -> Result<Option<Repo>> {
296296 let found: Outcome<Repo> = g1t_kit::call(
297297 &self.repos,
298298 "get_by_id",
309309 }
310310
311311 /// The target repository of a pull request, as g1t reads it.
312− async fn target_of(&self, pull: &Pull) -> Result<Option<Repo>> {
312+ pub(crate) async fn target_of(&self, pull: &Pull) -> Result<Option<Repo>> {
313313 let path: Option<RepoPath> = g1t_kit::call(
314314 &self.repos,
315315 "path_by_id",
+78−40
2525 mod retired;
2626 mod reviews;
2727 mod rows;
28+mod rulesets;
2829 mod runs;
2930 mod settings;
3031 mod statuses;
5354 use retired::writable;
5455 use rows::{CommentRow, IssueRow, MovedRow, NumberRow, PULL_COLUMNS, PullRow, SessionRow, Snapshot};
5556
56−const SOURCE: &str = "work";
57+pub(crate) const SOURCE: &str = "work";
5758 const MAX_ENTRY_BATCH: usize = 200;
5859 const MAX_ENTRY_CHARS: usize = 64_000;
5960 const MAX_TITLE_CHARS: usize = 200;
6061 const SESSION_PAGE: u32 = 500;
6162 const LIST_PAGE: u32 = 100;
6263 const MAX_ASSIGNEES: usize = 10;
63−const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
64+pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
6465
6566 const ISSUE_COLUMNS: &str = "issues.*,
6667 (SELECT count(*) FROM pulls WHERE pulls.issue_id = issues.id) AS pull_count,
178179 /// A pull request's rows read in one batch for this request
179180 /// (prefetch.rs), which the helpers below read instead of the database.
180181 prefetched: std::cell::RefCell<Option<std::rc::Rc<prefetch::Prefetched>>>,
182+ /// Repositories read in this request, by id, for rules that need one
183+ /// where only a pull request is at hand (rulesets.rs `repo_for`).
184+ known_repos: std::cell::RefCell<std::collections::HashMap<String, Repo>>,
181185 }
182186
183187 impl Work {
244248 /// The repository, if the viewer may see it. Whether they may is
245249 /// decided by the repos service.
246250 async fn repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<Repo>> {
247− self.timing
251+ let found: Outcome<Repo> = self
252+ .timing
248253 .rpc(g1t_kit::call(
249254 &self.repos,
250255 "get",
253258 viewer: viewer.clone(),
254259 },
255260 ))
256− .await
261+ .await?;
262+ if let Outcome::Ok(repo) = &found {
263+ self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
264+ }
265+ Ok(found)
257266 }
258267
259268 /// The next number in the repository's sequence. Taking it is one
13331342 let number = a.number;
13341343 // Every row the page and the lifecycle read, in one batch started
13351344 // beside the access check; the helpers below read from it.
1336− let read = |repo_id: String| self.prefetch_pull(repo_id, number);
1345+ let namespace = a.repo.namespace.clone();
1346+ let read = |repo_id: String| self.prefetch_pull(repo_id, namespace.clone(), number);
13371347 let Outcome::Ok((repo, Some(found))) = self.repo_then(&a.repo, &a.viewer, read).await? else {
13381348 return Ok(no_pull());
13391349 };
13461356 found.rows::<CommentRow>(prefetch::Slot::Comments)?.into_iter().map(Comment::from).collect();
13471357 self.keep_prefetched(Some(found));
13481358 let default_branch = repo.default_branch.clone();
1349− let detail = self.pull_detail(repo, Pull::from(row), issue, comments, stored).await;
1359+ let detail = self.pull_detail(repo, Pull::from(row), issue, comments, stored, &a.viewer).await;
13501360 self.keep_prefetched(None);
13511361 // The branch it merges into, named, for whoever reads it.
13521362 Ok(match detail? {
13671377 issue: Option<Issue>,
13681378 comments: Vec<Comment>,
13691379 stored: Option<StoredBehind>,
1380+ viewer: &Viewer,
13701381 ) -> Result<Outcome<PullDetail>> {
13711382 // Whether it is behind, as worked out with its mergeability on the
13721383 // last push to either side (mergeability.rs), when that was for
14261437 if confidence.is_some() {
14271438 pull.confidence = confidence;
14281439 }
1429− let (statuses, settings) =
1430− try_join(self.statuses(&repo.id, pull.head_commit.as_deref()), self.settings(&repo.id)).await?;
1431− // The protection of the branch it merges into.
1432− let settings = settings.for_base(pull.base_branch(&repo.default_branch), &repo.default_branch);
1440+ // The rules of the branch it merges into, as they stack, and which
1441+ // of them it does not meet yet, for whoever is looking.
1442+ let (statuses, settings, gate) = try_join3(
1443+ self.statuses(&repo.id, pull.head_commit.as_deref()),
1444+ self.settings_on(&repo, &pull),
1445+ async {
1446+ if pull.status.is_active() {
1447+ self.merge_gate(&repo, &pull, viewer.as_ref(), false, true).await.map(Some)
1448+ } else {
1449+ Ok(None)
1450+ }
1451+ },
1452+ )
1453+ .await?;
14331454 let code_owners = self.pull_code_owners(&pull, &comments, &settings).await?;
14341455 Ok(Outcome::Ok(PullDetail {
14351456 required_checks: required_checks(&settings.required_checks, &statuses),
1457+ rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements)),
14361458 code_owners,
14371459 comments,
14381460 checks,
18311853 }
18321854 }
18331855 let base = pull.base_branch(&repo.default_branch).to_owned();
1834− // The protection of the branch it merges into: the default branch's
1835− // settings, or none for another branch (RepoSettings::for_base).
1836− let settings = self.settings(&repo.id).await?.for_base(&base, &repo.default_branch);
1837− // The default branch's protection: its required checks must pass on
1838− // the head, for a person's pull request and an agent's alike. Where
1839− // the repository does not allow bypassing them, asking to bypass
1840− // them changes nothing.
1841− if !a.ignore_checks || !settings.allow_ignoring_checks {
1842− let queue = (pull.check_status == Some(CheckStatus::Failed))
1843− .then(|| "It failed in the merge queue; push a fix to try again.".to_owned());
1844− let required = statuses::WorkflowFacts::of(
1845− &self.statuses(&repo.id, pull.head_commit.as_deref()).await?,
1846− &settings.required_checks,
1847− )
1848− .refusal();
1849− if let Some(reason) = queue.or(required) {
1850− let remedy = if settings.allow_ignoring_checks {
1851− "Wait or fix them, or bypass the required checks as you merge."
1852− } else {
1853− "This repository only merges pull requests whose required checks pass."
1854− };
1855− return Ok(Outcome::fail(
1856− FailureCode::Conflict,
1857− format!("{reason} {remedy}"),
1858− ));
1856+ // The rules of the branch it merges into, as they stack: the one
1857+ // gate for the merge button, the API, MCP, auto-merge and the queue,
1858+ // for a person's pull request and an agent's alike. A bypass counts
1859+ // when the merger asks for it, or for g1t when a ruleset lists it.
1860+ let gate = self.merge_gate(&repo, &pull, Some(&a.actor), a.ignore_checks, true).await?;
1861+ let bypassable = gate.bypassable();
1862+ let gate = if a.bypass_rules || a.actor.is_system() { gate } else { gate.without_bypass() };
1863+ let settings = rulesets::overlay(self.settings(&repo.id).await?, &gate.requirements, base == repo.default_branch);
1864+ if pull.check_status == Some(CheckStatus::Failed) && !(a.ignore_checks && settings.allow_ignoring_checks) {
1865+ return Ok(Outcome::fail(
1866+ FailureCode::Conflict,
1867+ "It failed in the merge queue; push a fix to try again.",
1868+ ));
1869+ }
1870+ if let Some(refusal) = gate.refusal() {
1871+ if access::can(Some(&a.actor), &repo, Capability::Merge) {
1872+ self.record_merge_evaluations(&repo, &pull, &gate).await;
18591873 }
1860− }
1861− if let Some(missing) = self.approvals_gap(&settings, &pull).await? {
1862− return Ok(Outcome::fail(FailureCode::Conflict, missing));
1874+ let offer = if bypassable && !a.bypass_rules {
1875+ " You may bypass these rules: merge again and ask to bypass them (bypass_rules)."
1876+ } else {
1877+ ""
1878+ };
1879+ return Ok(Outcome::fail(FailureCode::Conflict, format!("{refusal}{offer}")));
18631880 }
18641881 // Known ahead of time to conflict: neither a merge nor the queue
18651882 // would get through, so say what has to be resolved now.
18841901 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
18851902 }
18861903 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
1904+ self.record_merge_evaluations(&repo, &pull, &gate).await;
18871905 return self.enqueue(&repo, &pull, &a.actor, a.keep_issue_open).await;
18881906 }
18891907
19031921 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
19041922 }
19051923 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
1924+ self.record_merge_evaluations(&repo, &pull, &gate).await;
19061925 self.request_landing(&pull, &a.actor, a.keep_issue_open)
19071926 .await?;
19081927 return Ok(Outcome::Ok(pull));
19221941 )
19231942 .await?;
19241943 let landed = check!(landed);
1944+ self.record_merge_evaluations(&repo, &pull, &gate).await;
19251945 Ok(Outcome::Ok(
19261946 self.record_merge(&repo, pull, &a.actor, a.keep_issue_open, landed)
19271947 .await?,
22622282 return Ok(());
22632283 };
22642284 let now = rfc3339(now_ms());
2285+ // Who moved it, for rules about the most recent push.
2286+ let pusher: JsValue = event.actor.as_deref().map_or(JsValue::NULL, Into::into);
22652287 // The head moved, so whatever the checks said no longer applies, and
22662288 // whatever step g1t was waiting on has been taken.
22672289 let moved = "UPDATE pulls
2268− SET head_commit = ?, updated_at = ?, check_status = NULL, check_run_id = NULL,
2290+ SET head_commit = ?, updated_at = ?, head_pushed_by = ?, head_pushed_at = ?, check_status = NULL, check_run_id = NULL,
22692291 working_on = NULL, working_until = NULL, stalled = NULL";
22702292 let active = "status IN ('draft', 'open') AND head_commit IS NOT ?";
22712293 let returning =
22812303 .bind(&[
22822304 after.into(),
22832305 now.as_str().into(),
2306+ pusher.clone(),
2307+ now.as_str().into(),
22842308 repo_id.into(),
22852309 after.into(),
22862310 ])?
22982322 .bind(&[
22992323 after.into(),
23002324 now.as_str().into(),
2325+ pusher.clone(),
2326+ now.as_str().into(),
23012327 repo_id.into(),
23022328 branch.into(),
23032329 after.into(),
23682394 actions: env.service("ACTIONS")?,
23692395 timing: g1t_kit::d1::Timing::default(),
23702396 prefetched: std::cell::RefCell::new(None),
2397+ known_repos: std::cell::RefCell::new(std::collections::HashMap::new()),
23712398 })
23722399 }
23732400
24852512 "reply_mention" => reply(&work.reply_mention(args(body)?).await?),
24862513 "get_agent_rules" => reply(&work.get_agent_rules(args(body)?).await?),
24872514 "set_agent_rules" => reply(&work.set_agent_rules(args(body)?).await?),
2515+ // Rulesets (rulesets.rs): kept here, enforced here on merge and by
2516+ // repos on push.
2517+ "list_rulesets" => reply(&work.list_rulesets(args(body)?).await?),
2518+ "get_ruleset" => reply(&work.get_ruleset(args(body)?).await?),
2519+ "save_ruleset" => reply(&work.save_ruleset(args(body)?).await?),
2520+ "delete_ruleset" => reply(&work.delete_ruleset(args(body)?).await?),
2521+ "effective_rules" => reply(&work.effective_rules(args(body)?).await?),
2522+ "rule_evaluations" => reply(&work.rule_evaluations(args(body)?).await?),
2523+ "ref_rules" => reply(&work.ref_rules(args(body)?).await?),
2524+ "record_evaluations" => reply(&work.record_evaluations(args(body)?).await?),
2525+ "set_requires_pull_request" => reply(&work.set_requires_pull_request(args(body)?).await?),
24882526 _ => Response::error("Unknown method", 404),
24892527 };
24902528 served.finish_timed(answered, &work.timing)
24962534 let work = service(&env)?;
24972535 for message in batch.messages()? {
24982536 // A workspace renamed: its agent runs and memory move to the slug it has now.
2499− if g1t_kit::rename::on_event(&env, &env.d1("DB")?, message.body(), &[memory::RENAMED, guardrails::RENAMED].concat()).await? {
2537+ if g1t_kit::rename::on_event(&env, &env.d1("DB")?, message.body(), &[memory::RENAMED, guardrails::RENAMED, rulesets::RENAMED].concat()).await? {
25002538 message.ack();
25012539 continue;
25022540 }
+23−3
601601 let reasons = held.then(|| confidence.reasons.join(", "));
602602 (Some(confidence), reasons)
603603 };
604+ // The rules of the branch it merges into, as g1t sees them when it
605+ // merges by itself: what people must still do, whether an agent's
606+ // change may land here unattended, and a cost cap that holds the
607+ // agent until a person approves.
608+ let gate = match self.repo_for(pull).await? {
609+ Some(repo) => Some(self.merge_gate(&repo, pull, Some(&User::system(&repo.namespace)), false, true).await?),
610+ None => None,
611+ };
612+ let level = confidence.as_ref().map(|confidence| confidence.level).or(pull.confidence.as_ref().map(|c| c.level));
613+ let rules_allow_auto_merge = gate
614+ .as_ref()
615+ .is_none_or(|gate| g1t_rules::merge::auto_merge_refusal(&gate.requirements, level).is_none());
616+ let held = gate.as_ref().and_then(|gate| {
617+ g1t_rules::outcome::blocking(&gate.judged)
618+ .into_iter()
619+ .find(|violation| violation.rule == "cost_cap")
620+ .map(|violation| format!("{} {}", violation.message, violation.remedy))
621+ });
604622 let (lifecycle, next) = decide(Facts {
605623 draft: pull.status == PullStatus::Draft,
606− stalled: progress.stalled,
624+ stalled: progress.stalled.or(held),
607625 working_on,
608626 check_status: pull.check_status,
609627 review_pending: self.review_pending(&pull.id).await?,
611629 review,
612630 behind,
613631 conflicting: self.conflicting_files(pull).await?.is_some(),
614− auto_merge: settings.auto_merge,
632+ auto_merge: settings.auto_merge && rules_allow_auto_merge,
615633 require_up_to_date: settings.require_up_to_date,
616634 agent_review: settings.agent_review,
617635 max_revisions: settings.max_revisions,
618− approvals_missing: self.approvals_gap(&settings, pull).await?,
636+ approvals_missing: gate.as_ref().and_then(|gate| gate.people_gap()),
619637 person_request: self
620638 .person_request(pull, progress.revised_at.as_deref())
621639 .await?,
11181136 summary: String::new(),
11191137 keep_issue_open: false,
11201138 ignore_checks: false,
1139+ bypass_rules: false,
11211140 })
11221141 .await?;
11231142 match merged {
12661285 summary: String::new(),
12671286 keep_issue_open: request.keep_issue_open,
12681287 ignore_checks: true,
1288+ bypass_rules: false,
12691289 })
12701290 .await?;
12711291 if let Outcome::Fail(failure) = merged {
+9−1
6262 Denials,
6363 Unanswered,
6464 Planned,
65+ Rulesets,
66+ Adopted,
6567 }
6668
6769 /// How many runs `latest_checks` and `earlier_checks` show together.
7173 pub(crate) struct Prefetched {
7274 pub(crate) pull_id: String,
7375 pub(crate) repo_id: String,
76+ /// The workspace its rulesets were read for, lowercase.
77+ pub(crate) namespace: String,
7478 /// Its head commit when read, which its statuses are for.
7579 pub(crate) head: Option<String>,
7680 results: Vec<D1Result>,
175179
176180 /// Everything a pull request's page and its lifecycle read, in one
177181 /// batch. `None` when there is no such pull request.
178− pub(crate) async fn prefetch_pull(&self, repo_id: String, number: u32) -> Result<Option<Prefetched>> {
182+ pub(crate) async fn prefetch_pull(&self, repo_id: String, namespace: String, number: u32) -> Result<Option<Prefetched>> {
179183 let key = || -> [JsValue; 2] { [repo_id.as_str().into(), number.into()] };
180184 let with = |extra: JsValue| -> [JsValue; 3] { [repo_id.as_str().into(), number.into(), extra] };
181185 let repo_only = || -> [JsValue; 1] { [repo_id.as_str().into()] };
307311 LIMIT 1",
308312 &key(),
309313 )?,
314+ // Slot::Rulesets and Slot::Adopted (rulesets.rs `rulesets_for`)
315+ self.statement(crate::rulesets::prefetch_sql().0, &[repo_id.as_str().into(), namespace.to_lowercase().into()])?,
316+ self.statement(crate::rulesets::prefetch_sql().1, &repo_only())?,
310317 ];
311318 let count = statements.len() as u32;
312319 let results = self.timing.db(count, self.db.batch(statements)).await?;
321328 Ok(Some(Prefetched {
322329 pull_id: row.id,
323330 repo_id,
331+ namespace: namespace.to_lowercase(),
324332 head: row.head_commit,
325333 results,
326334 }))
+14−9
11 //! The merge queue: pull requests are tested together with the ones ahead
22 //! of them, and only a combination that passed reaches the default branch.
33 //!
4−//! A batch of up to [`BATCH`] entries is tested at once, speculatively: one
4+//! A batch of up to the merge queue rule's `max_entries_to_build` entries (4 unless
5+//! the default branch's rules say otherwise) is tested at once, speculatively: one
56 //! sandbox per entry builds the default branch with that entry and every
67 //! entry ahead of it merged in, and pushes the result to
78 //! `g1t-queue/<entry>`. The repository's `merge_group` workflows then run
2526 use crate::Work;
2627 use crate::checks::{hash, new_token};
2728
28−/// How many entries are tested at once.
29−const BATCH: usize = 4;
30−/// How long a batch may take before it is tested again.
31−const TESTING_MINUTES: u64 = 45;
3229 /// How many entries that left the queue are shown.
3330 const RECENT: u32 = 20;
3431 /// Where tested states are pushed, in the repository itself.
177174 Outcome::Ok(repo) => repo,
178175 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
179176 };
180− let enabled = self.settings(&repo.id).await?.merge_queue;
177+ let enabled = self.default_branch_settings(&repo).await?.merge_queue;
181178 let (active, recent) = (self.entries(&repo.id, true).await?, self.entries(&repo.id, false).await?);
182179 let numbers: Vec<u32> = active.iter().chain(&recent).map(|row| row.number).collect();
183180 let pulls = try_join_all(numbers.iter().map(|number| self.pull(&repo.id, *number))).await?;
319316 /// entry ahead of it.
320317 pub(crate) async fn queue_build(&self, a: QueueBuildArgs) -> Result<Vec<QueueJob>> {
321318 let active = self.entries(&a.repo_id, true).await?;
319+ // How the default branch's merge queue rule batches (rulesets.rs).
320+ let rule = self.queue_rule(&a.repo_id).await?;
322321 // A batch that has taken too long is tested again.
323− let stale = minutes_ago(TESTING_MINUTES);
322+ let stale = minutes_ago(u64::from(rule.check_response_timeout_minutes).max(1));
324323 let stuck: Vec<&EntryRow> = active
325324 .iter()
326325 .filter(|row| {
335334 if active.iter().any(|row| row.state() != QueueState::Waiting) {
336335 return Ok(Vec::new());
337336 }
338− let batch: Vec<EntryRow> = active.into_iter().take(BATCH).collect();
337+ let batch: Vec<EntryRow> = active.into_iter().take(rule.max_entries_to_build.max(1) as usize).collect();
339338 let Some(first) = batch.first() else {
340339 return Ok(Vec::new());
341340 };
341+ // Too few to start yet, and the oldest has not waited long enough.
342+ if batch.len() < rule.min_entries_to_merge as usize
343+ && first.created_at.as_str() > minutes_ago(u64::from(rule.min_entries_wait_minutes)).as_str()
344+ {
345+ return Ok(Vec::new());
346+ }
342347 let Some(actor) = first.actor() else {
343348 return Ok(Vec::new());
344349 };
479484 (Some(commit), true) => self.start_merge_group(&row, commit).await.unwrap_or(0),
480485 _ => 0,
481486 };
482− let required = self.settings(&row.repo_id).await?.required_checks;
487+ let required = self.settings_by_id(&row.repo_id).await?.required_checks;
483488 // Nothing runs on it, so the required checks never would report.
484489 let unchecked = (built && workflows == 0 && !required.is_empty()).then(|| {
485490 format!(
+6−0
173173 /// The branch it merges into; NULL for the default branch.
174174 #[serde(default)]
175175 pub base_branch: Option<String>,
176+ #[serde(default)]
177+ pub head_pushed_by: Option<String>,
178+ #[serde(default)]
179+ pub head_pushed_at: Option<String>,
176180 }
177181
178182 impl From<PullRow> for Pull {
226230 .unwrap_or_default(),
227231 milestone: milestone_ref(row.milestone, row.milestone_title),
228232 base: row.base_branch,
233+ head_pushed_by: row.head_pushed_by,
234+ head_pushed_at: row.head_pushed_at,
229235 }
230236 }
231237 }
+1313−0
1+//! Rulesets: kept here, with every evaluation of them, and enforced here on
2+//! merge. The repos service enforces them on push and on every other
3+//! change to a branch or tag, asking `ref_rules` which hold and recording
4+//! what it decided with `record_evaluations`. The rules engine itself is
5+//! `g1t_rules`.
6+//!
7+//! A repository's branch protection, from before rulesets, is its
8+//! "Default branch protection" ruleset (migration 0028). The repos
9+//! service's `protected` flag is folded into it the first time its rulesets
10+//! are read ([`Work::rulesets_for`]), once, and `get_settings` and
11+//! `update_settings` read and write it, so callers of the old settings see
12+//! no change.
13+
14+use std::collections::HashMap;
15+
16+use g1t_contracts::access::Capability;
17+use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
18+use g1t_contracts::events::{NewEvent, Publish};
19+use g1t_contracts::repos::{Repo, RepoPath};
20+use g1t_contracts::rules::*;
21+use g1t_contracts::teams::{ResolveTeamsArgs, ResolvedTeam};
22+use g1t_contracts::time::rfc3339;
23+use g1t_contracts::work::{Pull, RepoSettings, Verdict as ReviewVerdict};
24+use g1t_contracts::{FailureCode, Outcome, Role, User, Viewer, new_id};
25+use g1t_kit::now_ms;
26+use g1t_rules::merge::{MergeFacts, Requirements, Review};
27+use g1t_rules::{ActorFacts, Judged, RepoFacts, legacy, select, validate};
28+use serde::{Deserialize, Serialize};
29+use worker::Result;
30+use worker::wasm_bindgen::JsValue;
31+
32+use crate::Work;
33+use crate::reviews::AGENT_ID;
34+
35+/// Unwraps an `Outcome`, returning its failure from the enclosing method.
36+macro_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.
46+const MAX_PAGE: u32 = 100;
47+/// The window insights cover.
48+const INSIGHT_DAYS: u32 = 30;
49+/// How long evaluations are kept.
50+const KEEP_DAYS: u64 = 90;
51+const DAY_MS: u64 = 24 * 60 * 60 * 1000;
52+
53+/// A workspace renamed: its rulesets and their evaluations move with it.
54+pub(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)]
60+pub(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+
73+impl 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)]
109+struct 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+
127+impl 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.
153+struct 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")]
162+struct RulesetEvent<'a> {
163+ workspace: &'a str,
164+ #[serde(skip_serializing_if = "Option::is_none")]
165+ repository: Option<String>,
166+ ruleset: &'a Ruleset,
167+}
168+
169+fn 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.
176+pub(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.
184+pub(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.
197+pub(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+pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements) -> MergeRules {
213+ let mut out = MergeRules { merge_queue: requirements.merge_queue.is_some(), ..MergeRules::default() };
214+ for one in judged {
215+ match (one.enforcement, one.verdict()) {
216+ (Enforcement::Active, Verdict::Fail) => out.unmet.extend(one.violations.iter().cloned()),
217+ (Enforcement::Active, Verdict::Bypass) => out.bypassable.extend(one.violations.iter().cloned()),
218+ (Enforcement::Evaluate, Verdict::Fail | Verdict::Bypass) => out.evaluate.extend(one.violations.iter().cloned()),
219+ _ => {}
220+ }
221+ out.rulesets.push(RulesetSummary {
222+ id: one.id.clone(),
223+ name: one.name.clone(),
224+ level: one.level,
225+ enforcement: one.enforcement,
226+ bypass_actors: Vec::new(),
227+ });
228+ }
229+ out
230+}
231+
232+impl Work {
233+ fn rules_statement(&self, sql: &str, binds: &[JsValue]) -> Result<worker::D1PreparedStatement> {
234+ self.db.prepare(sql).bind(binds)
235+ }
236+
237+ /// The rulesets that may hold in `repo`: its own and its workspace's,
238+ /// disabled ones included. A pull request's working copy has none.
239+ /// Folds the repository's old `protected` flag in, once.
240+ pub(crate) async fn rulesets_for(&self, repo: &Repo) -> Result<Vec<Ruleset>> {
241+ if repo.fork_of.is_some() {
242+ return Ok(Vec::new());
243+ }
244+ let prefetched = self.prefetched_repo(&repo.id).filter(|found| found.namespace == repo.namespace.to_lowercase());
245+ let (rows, adopted) = match prefetched {
246+ Some(found) => (
247+ found.rows::<RulesetRow>(crate::prefetch::Slot::Rulesets)?,
248+ found.first::<crate::rows::NumberRow>(crate::prefetch::Slot::Adopted)?.is_some(),
249+ ),
250+ None => {
251+ let results = self
252+ .db
253+ .batch(vec![
254+ self.rules_statement(RULESETS_SQL, &[repo.id.as_str().into(), repo.namespace.to_lowercase().into()])?,
255+ self.rules_statement(ADOPTED_SQL, &[repo.id.as_str().into()])?,
256+ ])
257+ .await?;
258+ let rows = results.first().map(|result| result.results::<RulesetRow>()).transpose()?.unwrap_or_default();
259+ let adopted = results
260+ .get(1)
261+ .map(|result| result.results::<crate::rows::NumberRow>())
262+ .transpose()?
263+ .is_some_and(|rows| !rows.is_empty());
264+ (rows, adopted)
265+ }
266+ };
267+ let mut rulesets: Vec<Ruleset> = rows.into_iter().filter_map(|row| row.into_ruleset(Some(repo))).collect();
268+ if !adopted {
269+ self.adopt(repo, &mut rulesets).await?;
270+ }
271+ Ok(rulesets)
272+ }
273+
274+ /// Folds the repos service's `protected` flag into the repository's
275+ /// branch protection ruleset, once: pushes to the default branch stay
276+ /// refused where they were. One batch, so two requests cannot both.
277+ async fn adopt(&self, repo: &Repo, rulesets: &mut Vec<Ruleset>) -> Result<()> {
278+ let now = rfc3339(now_ms());
279+ let not_yet = "NOT EXISTS (SELECT 1 FROM ruleset_adoptions WHERE repo_id = ?)";
280+ let mut statements = vec![self.rules_statement(
281+ "UPDATE rulesets SET workspace = ? WHERE repo_id = ? AND workspace = ''",
282+ &[repo.namespace.to_lowercase().into(), repo.id.as_str().into()],
283+ )?];
284+ if repo.protected {
285+ let existing = rulesets
286+ .iter_mut()
287+ .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION));
288+ match existing {
289+ Some(ruleset) => {
290+ let mut found = false;
291+ for entry in &mut ruleset.spec.rules {
292+ if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) {
293+ rule.allow_direct_pushes = false;
294+ found = true;
295+ }
296+ }
297+ if !found {
298+ ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default())));
299+ }
300+ statements.push(self.rules_statement(
301+ &format!("UPDATE rulesets SET spec = ? WHERE id = ? AND {not_yet}"),
302+ &[serde_json::to_string(&ruleset.spec)?.into(), ruleset.id.as_str().into(), repo.id.as_str().into()],
303+ )?);
304+ }
305+ None => {
306+ if let Some(spec) = legacy::ruleset_of(&RepoSettings::default(), true) {
307+ let ruleset = Ruleset {
308+ id: new_id("rs", now_ms()),
309+ level: Level::Repository,
310+ workspace: repo.namespace.to_lowercase(),
311+ repo_id: Some(repo.id.clone()),
312+ repository: Some(format!("{}/{}", repo.namespace, repo.name)),
313+ spec,
314+ source: Some(BRANCH_PROTECTION.to_owned()),
315+ created_by: "g1t".to_owned(),
316+ created_at: now.clone(),
317+ updated_by: "g1t".to_owned(),
318+ updated_at: now.clone(),
319+ };
320+ statements.push(self.rules_statement(
321+ &format!(
322+ "INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at)
323+ SELECT ?, 'repository', ?, ?, ?, 'active', 'branch', ?, ?, 'g1t', ?, 'g1t', ? WHERE {not_yet}"
324+ ),
325+ &[
326+ ruleset.id.as_str().into(),
327+ ruleset.workspace.as_str().into(),
328+ repo.id.as_str().into(),
329+ ruleset.spec.name.as_str().into(),
330+ serde_json::to_string(&ruleset.spec)?.into(),
331+ BRANCH_PROTECTION.into(),
332+ now.as_str().into(),
333+ now.as_str().into(),
334+ repo.id.as_str().into(),
335+ ],
336+ )?);
337+ rulesets.push(ruleset);
338+ }
339+ }
340+ }
341+ }
342+ statements.push(self.rules_statement(
343+ "INSERT OR IGNORE INTO ruleset_adoptions (repo_id, adopted_at) VALUES (?, ?)",
344+ &[repo.id.as_str().into(), now.into()],
345+ )?);
346+ self.db.batch(statements).await?;
347+ Ok(())
348+ }
349+
350+ /// A workspace's own rulesets.
351+ async fn workspace_rulesets(&self, workspace: &str) -> Result<Vec<Ruleset>> {
352+ Ok(self
353+ .db
354+ .prepare("SELECT * FROM rulesets WHERE level = 'workspace' AND workspace = ? ORDER BY created_at")
355+ .bind(&[workspace.to_lowercase().into()])?
356+ .all()
357+ .await?
358+ .results::<RulesetRow>()?
359+ .into_iter()
360+ .filter_map(|row| row.into_ruleset(None))
361+ .collect())
362+ }
363+
364+ /// The people of each team named, by `workspace/slug`, usernames
365+ /// lowercase, child teams' people included.
366+ pub(crate) async fn team_people(&self, names: &[String], workspace: &str, repo_id: &str) -> Result<HashMap<String, Vec<String>>> {
367+ let keys: Vec<String> = names.iter().map(|name| select::team_key(name, workspace)).collect();
368+ if keys.is_empty() {
369+ return Ok(HashMap::new());
370+ }
371+ let teams: Vec<ResolvedTeam> = g1t_kit::call(
372+ &self.identity,
373+ "resolve_teams",
374+ &ResolveTeamsArgs { teams: keys, repo_id: Some(repo_id.to_owned()), asker: None },
375+ )
376+ .await
377+ .unwrap_or_default();
378+ Ok(teams
379+ .into_iter()
380+ .map(|team| {
381+ let people = team
382+ .members
383+ .iter()
384+ .chain(team.child_members.iter())
385+ .map(|person| person.username.to_lowercase())
386+ .collect();
387+ (format!("{}/{}", team.workspace.to_lowercase(), team.slug.to_lowercase()), people)
388+ })
389+ .collect())
390+ }
391+
392+ /// The actor as bypass lists name people, their teams looked up only
393+ /// when a bypass list names a team.
394+ pub(crate) async fn actor_facts(&self, actor: &User, repo: &Repo, rulesets: &[Ruleset]) -> Result<ActorFacts> {
395+ let mut facts = ActorFacts::of(actor, repo);
396+ if facts.kind == select::Who::Person && select::names_teams(rulesets) {
397+ let named: Vec<String> = rulesets
398+ .iter()
399+ .flat_map(|ruleset| ruleset.spec.bypass_actors.iter())
400+ .filter(|entry| entry.kind == ActorKind::Team)
401+ .map(|entry| entry.value.clone())
402+ .collect();
403+ let people = self.team_people(&named, &repo.namespace, &repo.id).await?;
404+ let me = actor.username.to_lowercase();
405+ facts.teams = people.into_iter().filter(|(_, people)| people.contains(&me)).map(|(team, _)| team).collect();
406+ }
407+ Ok(facts)
408+ }
409+
410+ /// Who may see or change rulesets of `owner`, checked.
411+ async fn scope(&self, owner: &Owner, viewer: &Viewer, manage: bool) -> Result<Outcome<Scope>> {
412+ match (&owner.repo, &owner.workspace) {
413+ (Some(path), _) => {
414+ let repo = check!(self.repo(path, viewer).await?);
415+ if manage {
416+ let Some(actor) = viewer else {
417+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in first."));
418+ };
419+ check!(crate::retired::writable(&repo));
420+ if !actor.verified {
421+ return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
422+ }
423+ check!(crate::allowed(Some(actor), &repo, Capability::ManageProtection));
424+ }
425+ Ok(Outcome::Ok(Scope { level: Level::Repository, workspace: repo.namespace.to_lowercase(), repo: Some(repo) }))
426+ }
427+ (None, Some(workspace)) => {
428+ let workspace = workspace.trim().to_lowercase();
429+ let Some(actor) = viewer else {
430+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in first."));
431+ };
432+ let role = actor.role_in(&workspace);
433+ if role.is_none() {
434+ return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
435+ }
436+ if manage {
437+ if !actor.verified {
438+ return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
439+ }
440+ if role != Some(Role::Owner) {
441+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners of the workspace can change its rulesets."));
442+ }
443+ }
444+ Ok(Outcome::Ok(Scope { level: Level::Workspace, workspace, repo: None }))
445+ }
446+ (None, None) => Ok(Outcome::fail(FailureCode::Invalid, "Say whose rulesets: a repository or a workspace.")),
447+ }
448+ }
449+
450+ /// The rulesets of a scope: a repository's own (with its workspace's
451+ /// that hold in it, when asked), or a workspace's.
452+ async fn scoped_rulesets(&self, scope: &Scope, include_parents: bool) -> Result<Vec<Ruleset>> {
453+ match &scope.repo {
454+ Some(repo) => {
455+ let all = self.rulesets_for(repo).await?;
456+ Ok(all
457+ .into_iter()
458+ .filter(|ruleset| match ruleset.level {
459+ Level::Repository => true,
460+ Level::Workspace => {
461+ include_parents
462+ && repo_matches_spec(ruleset, RepoFacts::from(repo))
463+ }
464+ })
465+ .collect())
466+ }
467+ None => self.workspace_rulesets(&scope.workspace).await,
468+ }
469+ }
470+
471+ pub(crate) async fn list_rulesets(&self, a: ListRulesetsArgs) -> Result<Outcome<Vec<Ruleset>>> {
472+ let scope = check!(self.scope(&a.owner, &a.viewer, false).await?);
473+ Ok(Outcome::Ok(self.scoped_rulesets(&scope, a.include_parents).await?))
474+ }
475+
476+ pub(crate) async fn get_ruleset(&self, a: GetRulesetArgs) -> Result<Outcome<Ruleset>> {
477+ let scope = check!(self.scope(&a.owner, &a.viewer, false).await?);
478+ match self.scoped_rulesets(&scope, true).await?.into_iter().find(|ruleset| ruleset.id == a.id) {
479+ Some(ruleset) => Ok(Outcome::Ok(ruleset)),
480+ None => Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")),
481+ }
482+ }
483+
484+ pub(crate) async fn save_ruleset(&self, a: SaveRulesetArgs) -> Result<Outcome<Ruleset>> {
485+ let scope = check!(self.scope(&a.owner, &Some(a.actor.clone()), true).await?);
486+ let spec = match validate::validate(&a.ruleset, scope.level) {
487+ Ok(spec) => spec,
488+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
489+ };
490+ let existing = self.scoped_rulesets(&scope, false).await?;
491+ let before = match &a.id {
492+ Some(id) => match existing.iter().find(|ruleset| &ruleset.id == id) {
493+ Some(found) => Some(found.clone()),
494+ None => return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")),
495+ },
496+ None => {
497+ if existing.len() >= MAX_RULESETS {
498+ return Ok(Outcome::fail(FailureCode::Invalid, format!("There can be at most {MAX_RULESETS} rulesets here.")));
499+ }
500+ None
501+ }
502+ };
503+ let now = rfc3339(now_ms());
504+ let ruleset = Ruleset {
505+ id: before.as_ref().map_or_else(|| new_id("rs", now_ms()), |found| found.id.clone()),
506+ level: scope.level,
507+ workspace: scope.workspace.clone(),
508+ repo_id: scope.repo.as_ref().map(|repo| repo.id.clone()),
509+ repository: scope.repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)),
510+ spec,
511+ source: before.as_ref().and_then(|found| found.source.clone()),
512+ created_by: before.as_ref().map_or_else(|| a.actor.username.clone(), |found| found.created_by.clone()),
513+ created_at: before.as_ref().map_or_else(|| now.clone(), |found| found.created_at.clone()),
514+ updated_by: a.actor.username.clone(),
515+ updated_at: now,
516+ };
517+ self.store_ruleset(&ruleset).await?;
518+ let kind = if before.is_some() { "ruleset.updated" } else { "ruleset.created" };
519+ self.announce(kind, &ruleset, &a.actor).await;
520+ if !a.from_api {
521+ self.audit_ruleset(&a.actor, &ruleset, if before.is_some() { "update_ruleset" } else { "create_ruleset" }).await;
522+ }
523+ Ok(Outcome::Ok(ruleset))
524+ }
525+
526+ async fn store_ruleset(&self, ruleset: &Ruleset) -> Result<()> {
527+ self.db
528+ .prepare(
529+ "INSERT INTO rulesets (id, level, workspace, repo_id, name, enforcement, target, spec, source, created_by, created_at, updated_by, updated_at)
530+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
531+ ON CONFLICT (id) DO UPDATE SET
532+ name = excluded.name, enforcement = excluded.enforcement, target = excluded.target,
533+ spec = excluded.spec, workspace = excluded.workspace,
534+ updated_by = excluded.updated_by, updated_at = excluded.updated_at",
535+ )
536+ .bind(&[
537+ ruleset.id.as_str().into(),
538+ ruleset.level.as_str().into(),
539+ ruleset.workspace.as_str().into(),
540+ optional(ruleset.repo_id.as_deref()),
541+ ruleset.spec.name.as_str().into(),
542+ ruleset.spec.enforcement.as_str().into(),
543+ ruleset.spec.target.as_str().into(),
544+ serde_json::to_string(&ruleset.spec)?.into(),
545+ optional(ruleset.source.as_deref()),
546+ ruleset.created_by.as_str().into(),
547+ ruleset.created_at.as_str().into(),
548+ ruleset.updated_by.as_str().into(),
549+ ruleset.updated_at.as_str().into(),
550+ ])?
551+ .run()
552+ .await?;
553+ Ok(())
554+ }
555+
556+ pub(crate) async fn delete_ruleset(&self, a: DeleteRulesetArgs) -> Result<Outcome<bool>> {
557+ let scope = check!(self.scope(&a.owner, &Some(a.actor.clone()), true).await?);
558+ let Some(ruleset) = self.scoped_rulesets(&scope, false).await?.into_iter().find(|ruleset| ruleset.id == a.id) else {
559+ return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found."));
560+ };
561+ self.db.prepare("DELETE FROM rulesets WHERE id = ?").bind(&[ruleset.id.as_str().into()])?.run().await?;
562+ self.announce("ruleset.deleted", &ruleset, &a.actor).await;
563+ if !a.from_api {
564+ self.audit_ruleset(&a.actor, &ruleset, "delete_ruleset").await;
565+ }
566+ Ok(Outcome::Ok(true))
567+ }
568+
569+ /// Publishes a ruleset's change: a repository's on its repository, a
570+ /// workspace's to the workspace (webhooks route it there).
571+ async fn announce(&self, kind: &'static str, ruleset: &Ruleset, actor: &User) {
572+ let event = NewEvent {
573+ kind,
574+ source: crate::SOURCE,
575+ repo_id: ruleset.repo_id.clone(),
576+ actor: Some(actor.id.clone()),
577+ data: RulesetEvent { workspace: &ruleset.workspace, repository: ruleset.repository.clone(), ruleset },
578+ };
579+ let published: Result<()> = g1t_kit::call(&self.events, "publish", &Publish { events: vec![event] }).await;
580+ if let Err(error) = published {
581+ worker::console_error!("{kind} not published: {error}");
582+ }
583+ }
584+
585+ /// Records a change to a ruleset made on the site in the workspace's
586+ /// audit log. Changes through the API are recorded by the API.
587+ async fn audit_ruleset(&self, actor: &User, ruleset: &Ruleset, action: &str) {
588+ let entry = NewAuditEntry {
589+ actor: AuditActor::of(actor),
590+ action: action.to_owned(),
591+ surface: Surface::Web,
592+ target: AuditTarget {
593+ workspace: ruleset.workspace.clone(),
594+ repo: ruleset.repository.clone(),
595+ ..AuditTarget::default()
596+ },
597+ outcome: AuditOutcome::Allowed,
598+ rule: "rulesets".to_owned(),
599+ result: Some("ok".to_owned()),
600+ message: Some(format!(
601+ "{} ruleset \"{}\" ({}, {} rules)",
602+ match action {
603+ "create_ruleset" => "Created",
604+ "delete_ruleset" => "Deleted",
605+ _ => "Changed",
606+ },
607+ ruleset.spec.name,
608+ ruleset.spec.enforcement.as_str(),
609+ ruleset.spec.rules.len()
610+ )),
611+ request_id: new_id("req", now_ms()),
612+ };
613+ let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
614+ if let Err(error) = recorded {
615+ worker::console_error!("ruleset change not recorded: {error}");
616+ }
617+ }
618+
619+ pub(crate) async fn effective_rules(&self, a: EffectiveRulesArgs) -> Result<Outcome<EffectiveRules>> {
620+ let repo = check!(self.repo(&a.repo, &a.viewer).await?);
621+ let name = a.name.trim();
622+ let name = name
623+ .strip_prefix("refs/heads/")
624+ .or_else(|| name.strip_prefix("refs/tags/"))
625+ .unwrap_or(name);
626+ if name.is_empty() {
627+ return Ok(Outcome::fail(FailureCode::Invalid, "Name a branch or tag."));
628+ }
629+ let rulesets = self.rulesets_for(&repo).await?;
630+ Ok(Outcome::Ok(select::effective(&rulesets, RepoFacts::from(&repo), a.target, name)))
631+ }
632+
633+ /// Services only: the rulesets that hold for refs a service is about to
634+ /// change, with whether the actor may bypass each.
635+ pub(crate) async fn ref_rules(&self, a: RefRulesArgs) -> Result<Outcome<RefRules>> {
636+ let rulesets = self.rulesets_for(&a.repo).await?;
637+ let who = match &a.actor {
638+ Some(actor) => Some(self.actor_facts(actor, &a.repo, &rulesets).await?),
639+ None => None,
640+ };
641+ Ok(Outcome::Ok(RefRules {
642+ default_branch: a.repo.default_branch.clone(),
643+ workspace: a.repo.namespace.to_lowercase(),
644+ rulesets: select::applicable(&rulesets, RepoFacts::from(&a.repo), &a.refs, who.as_ref(), &a.repo.namespace),
645+ }))
646+ }
647+
648+ /// Services only: keeps evaluations, and lets old ones go.
649+ pub(crate) async fn record_evaluations(&self, a: RecordEvaluationsArgs) -> Result<u32> {
650+ if a.evaluations.is_empty() {
651+ return Ok(0);
652+ }
653+ let now = now_ms();
654+ let at = rfc3339(now);
655+ let mut statements = Vec::new();
656+ for evaluation in a.evaluations.iter().take(200) {
657+ statements.push(self.rules_statement(
658+ "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)
659+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
660+ &[
661+ new_id("rev", now).into(),
662+ evaluation.repo_id.as_str().into(),
663+ evaluation.workspace.to_lowercase().into(),
664+ evaluation.ruleset_id.as_str().into(),
665+ evaluation.ruleset_name.as_str().into(),
666+ evaluation.enforcement.as_str().into(),
667+ evaluation.action.as_str().into(),
668+ evaluation.git_ref.as_str().into(),
669+ evaluation.actor.as_str().into(),
670+ evaluation.actor_kind.as_str().into(),
671+ evaluation.verdict.as_str().into(),
672+ serde_json::to_string(&evaluation.violations)?.into(),
673+ evaluation.number.map_or(JsValue::NULL, Into::into),
674+ optional(evaluation.sha.as_deref()),
675+ at.as_str().into(),
676+ ],
677+ )?);
678+ }
679+ if let Some(first) = a.evaluations.first() {
680+ statements.push(self.rules_statement(
681+ "DELETE FROM rule_evaluations WHERE id IN
682+ (SELECT id FROM rule_evaluations WHERE repo_id = ? AND created_at < ? ORDER BY id LIMIT 200)",
683+ &[first.repo_id.as_str().into(), rfc3339(now.saturating_sub(KEEP_DAYS * DAY_MS)).into()],
684+ )?);
685+ }
686+ let count = a.evaluations.len().min(200) as u32;
687+ self.db.batch(statements).await?;
688+ Ok(count)
689+ }
690+
691+ pub(crate) async fn rule_evaluations(&self, a: EvaluationsArgs) -> Result<Outcome<EvaluationPage>> {
692+ let scope = check!(self.scope(&a.owner, &a.viewer, false).await?);
693+ if let Some(repo) = &scope.repo {
694+ check!(crate::allowed(a.viewer.as_ref(), repo, Capability::Push));
695+ }
696+ let (column, key) = match &scope.repo {
697+ Some(repo) => ("repo_id", repo.id.clone()),
698+ None => ("workspace", scope.workspace.clone()),
699+ };
700+ let limit = a.limit.unwrap_or(30).clamp(1, MAX_PAGE);
701+ let mut sql = format!("SELECT * FROM rule_evaluations WHERE {column} = ?");
702+ let mut binds: Vec<JsValue> = vec![key.as_str().into()];
703+ if let Some(id) = &a.ruleset_id {
704+ sql.push_str(" AND ruleset_id = ?");
705+ binds.push(id.as_str().into());
706+ }
707+ if let Some(verdict) = a.verdict {
708+ sql.push_str(" AND verdict = ?");
709+ binds.push(verdict.as_str().into());
710+ }
711+ if a.problems_only {
712+ sql.push_str(" AND verdict != 'pass'");
713+ }
714+ if let Some(before) = &a.before {
715+ sql.push_str(" AND id < ?");
716+ binds.push(before.as_str().into());
717+ }
718+ sql.push_str(" ORDER BY id DESC LIMIT ?");
719+ binds.push((limit + 1).into());
720+ let since = rfc3339(now_ms().saturating_sub(u64::from(INSIGHT_DAYS) * DAY_MS));
721+ let ruleset_filter = if a.ruleset_id.is_some() { " AND ruleset_id = ?3" } else { "" };
722+ let mut insight_binds: Vec<JsValue> = vec![key.as_str().into(), since.as_str().into()];
723+ if let Some(id) = &a.ruleset_id {
724+ insight_binds.push(id.as_str().into());
725+ }
726+ let results = self
727+ .db
728+ .batch(vec![
729+ self.rules_statement(&sql, &binds)?,
730+ self.rules_statement(
731+ &format!(
732+ "SELECT ruleset_id, ruleset_name, enforcement, verdict, count(*) AS n FROM rule_evaluations
733+ WHERE {column} = ?1 AND created_at >= ?2{ruleset_filter}
734+ GROUP BY ruleset_id, enforcement, verdict"
735+ ),
736+ &insight_binds,
737+ )?,
738+ self.rules_statement(
739+ &format!(
740+ "SELECT json_extract(v.value, '$.rule') AS rule, count(*) AS n
741+ FROM rule_evaluations e, json_each(e.violations) v
742+ WHERE e.{column} = ?1 AND e.created_at >= ?2 AND e.verdict != 'pass'{}
743+ GROUP BY rule ORDER BY n DESC LIMIT 20",
744+ ruleset_filter.replace("ruleset_id", "e.ruleset_id")
745+ ),
746+ &insight_binds,
747+ )?,
748+ ])
749+ .await?;
750+ let mut evaluations: Vec<Evaluation> = results[0].results::<EvaluationRow>()?.into_iter().map(Evaluation::from).collect();
751+ let next = (evaluations.len() > limit as usize).then(|| {
752+ evaluations.truncate(limit as usize);
753+ evaluations.last().map(|last| last.id.clone())
754+ });
755+ let repository = scope.repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)).unwrap_or_default();
756+ for evaluation in &mut evaluations {
757+ evaluation.repository = repository.clone();
758+ }
759+ Ok(Outcome::Ok(EvaluationPage {
760+ evaluations,
761+ next: next.flatten(),
762+ insights: insights(
763+ results[1].results::<CountRow>()?,
764+ results[2].results::<RuleCountRow>()?,
765+ ),
766+ }))
767+ }
768+
769+ /// Everything a merge of `pull` is judged on, and the judgement, for
770+ /// `actor`, or for nobody in particular. A bypass counts only with
771+ /// `honor_bypass`: a person merging asks for it (`bypass_rules`).
772+ pub(crate) async fn merge_gate(
773+ &self,
774+ repo: &Repo,
775+ pull: &Pull,
776+ actor: Option<&User>,
777+ ignore_checks: bool,
778+ honor_bypass: bool,
779+ ) -> Result<Gate> {
780+ let rulesets = self.rulesets_for(repo).await?;
781+ let base = pull.base_branch(&repo.default_branch).to_owned();
782+ let git_ref = Target::Branch.full_ref(&base);
783+ let who = match actor {
784+ Some(actor) => Some(self.actor_facts(actor, repo, &rulesets).await?),
785+ None => None,
786+ };
787+ let mut applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), who.as_ref(), &repo.namespace);
788+ if !honor_bypass {
789+ for ruleset in &mut applicable {
790+ ruleset.bypass = None;
791+ }
792+ }
793+ let agent = agent_change(pull);
794+ let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
795+ let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent, &files);
796+ if applicable.is_empty() {
797+ return Ok(Gate { judged: Vec::new(), requirements, applicable, who });
798+ }
799+ let needs_owners = applicable.iter().any(|ruleset| {
800+ ruleset.rules.iter().any(|entry| matches!(&entry.rule, Rule::PullRequest(rule) if rule.require_code_owner_review))
801+ });
802+ let owners_settings = RepoSettings { require_code_owner_review: true, ..RepoSettings::default() };
803+ let teams = g1t_rules::merge::named_teams(&applicable);
804+ let (verdicts, statuses, behind, code_owners, spent, team_members, commits) = futures_util::try_join!(
805+ self.verdict_rows(pull),
806+ self.statuses(&pull.repo_id, pull.head_commit.as_deref()),
807+ self.is_behind(&repo.id, pull),
808+ async {
809+ if needs_owners { self.code_owners_gap(&owners_settings, pull).await } else { Ok(None) }
810+ },
811+ self.pull_spend(pull),
812+ async {
813+ if teams.is_empty() { Ok(HashMap::new()) } else { self.team_people(&teams, &repo.namespace, &repo.id).await }
814+ },
815+ async {
816+ if g1t_rules::merge::needs_commits(&applicable, agent) {
817+ self.pull_commits(repo, pull, &base).await.map(Some)
818+ } else {
819+ Ok(None)
820+ }
821+ },
822+ )?;
823+ let reviews = latest_reviews(verdicts);
824+ let facts = MergeFacts {
825+ git_ref: git_ref.clone(),
826+ agent_change: agent,
827+ owner_id: &pull.owner().id,
828+ reviews: &reviews,
829+ head_pushed_at: pull.head_pushed_at.as_deref(),
830+ head_pushed_by: pull.head_pushed_by.as_deref(),
831+ code_owners_missing: code_owners.as_deref(),
832+ statuses: &statuses,
833+ behind,
834+ files: &files,
835+ commits: commits.as_ref(),
836+ confidence: pull.confidence.as_ref().map(|confidence| confidence.level),
837+ spent_usd: spent,
838+ now_ms: now_ms(),
839+ method: Some(MergeMethod::Merge),
840+ team_members: Some(&team_members),
841+ ignore_checks,
842+ };
843+ let judged = g1t_rules::merge::judge(&applicable, &repo.default_branch, &facts);
844+ Ok(Gate { judged, requirements, applicable, who })
845+ }
846+
847+ /// Every verdict on a pull request, oldest first: who, and when.
848+ async fn verdict_rows(&self, pull: &Pull) -> Result<Vec<(String, String, ReviewVerdict, String)>> {
849+ #[derive(Deserialize)]
850+ struct Row {
851+ author_id: String,
852+ author_name: String,
853+ verdict: ReviewVerdict,
854+ created_at: String,
855+ }
856+ let rows = match self.prefetched_pull(&pull.id) {
857+ Some(found) => found.rows::<Row>(crate::prefetch::Slot::Verdicts)?,
858+ None => self
859+ .db
860+ .prepare(
861+ "SELECT author_id, author_name, verdict, created_at FROM comments
862+ WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
863+ )
864+ .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
865+ .all()
866+ .await?
867+ .results::<Row>()?,
868+ };
869+ Ok(rows.into_iter().map(|row| (row.author_id, row.author_name, row.verdict, row.created_at)).collect())
870+ }
871+
872+ /// What agents have spent on a pull request, in US dollars.
873+ pub(crate) async fn pull_spend(&self, pull: &Pull) -> Result<f64> {
874+ #[derive(Deserialize)]
875+ struct Row {
876+ spent: Option<f64>,
877+ }
878+ Ok(self
879+ .db
880+ .prepare("SELECT sum(cost_usd) AS spent FROM agent_runs WHERE pull_id = ?")
881+ .bind(&[pull.id.as_str().into()])?
882+ .first::<Row>(None)
883+ .await?
884+ .and_then(|row| row.spent)
885+ .unwrap_or(0.0))
886+ }
887+
888+ /// The commits a pull request would land, read as rules look at them.
889+ async fn pull_commits(&self, repo: &Repo, pull: &Pull, base: &str) -> Result<InspectedCommits> {
890+ let Some(head) = pull.head_commit.clone() else {
891+ return Ok(InspectedCommits::default());
892+ };
893+ let found: Outcome<InspectedCommits> = g1t_kit::call(
894+ &self.repos,
895+ "inspect_commits",
896+ &InspectCommitsArgs {
897+ source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
898+ head,
899+ target_id: repo.id.clone(),
900+ base_branch: base.to_owned(),
901+ limit: None,
902+ },
903+ )
904+ .await?;
905+ Ok(match found {
906+ Outcome::Ok(commits) => commits,
907+ Outcome::Fail(failure) => {
908+ worker::console_error!("inspect_commits for {}: {}", pull.id, failure.message);
909+ InspectedCommits::default()
910+ }
911+ })
912+ }
913+
914+ /// Records how each ruleset judged a merge.
915+ pub(crate) async fn record_merge_evaluations(&self, repo: &Repo, pull: &Pull, gate: &Gate) {
916+ let Some(who) = &gate.who else { return };
917+ if gate.judged.is_empty() {
918+ return;
919+ }
920+ let evaluations = g1t_rules::evaluations(
921+ &gate.judged,
922+ &repo.id,
923+ &repo.namespace,
924+ Action::Merge,
925+ who,
926+ Some(pull.number),
927+ pull.head_commit.as_deref(),
928+ );
929+ if let Err(error) = self.record_evaluations(RecordEvaluationsArgs { evaluations }).await {
930+ worker::console_error!("merge evaluations not recorded: {error}");
931+ }
932+ }
933+
934+ /// The settings that hold for a pull request: the repository's, with
935+ /// branch protection as the rules for the branch it merges into stack.
936+ pub(crate) async fn settings_on(&self, repo: &Repo, pull: &Pull) -> Result<RepoSettings> {
937+ let (stored, rulesets) = futures_util::future::try_join(self.settings(&repo.id), self.rulesets_for(repo)).await?;
938+ let base = pull.base_branch(&repo.default_branch);
939+ let git_ref = Target::Branch.full_ref(base);
940+ let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
941+ let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect();
942+ let requirements = g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, agent_change(pull), &files);
943+ Ok(overlay(stored, &requirements, base == repo.default_branch))
944+ }
945+
946+ /// The repository a pull request merges into: as read earlier in this
947+ /// request, or now.
948+ pub(crate) async fn repo_for(&self, pull: &Pull) -> Result<Option<Repo>> {
949+ if let Some(repo) = self.known_repos.borrow().get(&pull.repo_id) {
950+ return Ok(Some(repo.clone()));
951+ }
952+ let found = self.target_of(pull).await?;
953+ if let Some(repo) = &found {
954+ self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
955+ }
956+ Ok(found)
957+ }
958+
959+ /// The settings that hold for a pull request, when only it is at hand.
960+ pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> {
961+ match self.repo_for(pull).await? {
962+ Some(repo) => self.settings_on(&repo, pull).await,
963+ None => self.settings(&pull.repo_id).await,
964+ }
965+ }
966+
967+ /// The settings that hold for the default branch.
968+ pub(crate) async fn default_branch_settings(&self, repo: &Repo) -> Result<RepoSettings> {
969+ let (stored, requirements) = futures_util::future::try_join(self.settings(&repo.id), self.default_requirements(repo)).await?;
970+ Ok(overlay(stored, &requirements, true))
971+ }
972+
973+ /// What the active rules ask of the default branch, for anyone.
974+ pub(crate) async fn default_requirements(&self, repo: &Repo) -> Result<Requirements> {
975+ let rulesets = self.rulesets_for(repo).await?;
976+ let git_ref = Target::Branch.full_ref(&repo.default_branch);
977+ let applicable = select::applicable(&rulesets, RepoFacts::from(repo), std::slice::from_ref(&git_ref), None, &repo.namespace);
978+ Ok(g1t_rules::merge::requirements(&applicable, &git_ref, &repo.default_branch, false, &[]))
979+ }
980+
981+ /// How the default branch's merge queue batches: its rule's
982+ /// parameters, or the defaults.
983+ pub(crate) async fn queue_rule(&self, repo_id: &str) -> Result<MergeQueueRule> {
984+ let path: Option<RepoPath> =
985+ g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
986+ let repo = match path {
987+ Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
988+ None => None,
989+ };
990+ Ok(match repo {
991+ Some(repo) => self.default_requirements(&repo).await?.merge_queue.unwrap_or_default(),
992+ None => MergeQueueRule::default(),
993+ })
994+ }
995+
996+ /// The default branch's settings by repository id, for callers that do
997+ /// not have the repository at hand (the merge queue, statuses).
998+ pub(crate) async fn settings_by_id(&self, repo_id: &str) -> Result<RepoSettings> {
999+ let path: Option<RepoPath> =
1000+ g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
1001+ let repo = match path {
1002+ Some(path) => self.repo_by_id(repo_id, &path.namespace).await?,
1003+ None => None,
1004+ };
1005+ match repo {
1006+ Some(repo) => self.default_branch_settings(&repo).await,
1007+ None => self.settings(repo_id).await,
1008+ }
1009+ }
1010+
1011+ /// The branch protection ruleset's rules rewritten from the old
1012+ /// settings (`update_settings`), creating it when needed.
1013+ pub(crate) async fn write_branch_protection(&self, repo: &Repo, settings: &RepoSettings, actor: &User) -> Result<()> {
1014+ let rulesets = self.rulesets_for(repo).await?;
1015+ let existing = rulesets
1016+ .iter()
1017+ .find(|ruleset| ruleset.repo_id.as_deref() == Some(&repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION));
1018+ let now = rfc3339(now_ms());
1019+ match existing {
1020+ Some(found) => {
1021+ let protected = legacy::requires_pull_requests(&found.spec.rules);
1022+ let mut ruleset = found.clone();
1023+ ruleset.spec.rules = legacy::replace(&found.spec.rules, settings, protected);
1024+ ruleset.updated_by = actor.username.clone();
1025+ ruleset.updated_at = now;
1026+ self.store_ruleset(&ruleset).await?;
1027+ self.announce("ruleset.updated", &ruleset, actor).await;
1028+ }
1029+ None => {
1030+ let Some(spec) = legacy::ruleset_of(settings, false) else { return Ok(()) };
1031+ let ruleset = Ruleset {
1032+ id: new_id("rs", now_ms()),
1033+ level: Level::Repository,
1034+ workspace: repo.namespace.to_lowercase(),
1035+ repo_id: Some(repo.id.clone()),
1036+ repository: Some(format!("{}/{}", repo.namespace, repo.name)),
1037+ spec,
1038+ source: Some(BRANCH_PROTECTION.to_owned()),
1039+ created_by: actor.username.clone(),
1040+ created_at: now.clone(),
1041+ updated_by: actor.username.clone(),
1042+ updated_at: now,
1043+ };
1044+ self.store_ruleset(&ruleset).await?;
1045+ self.announce("ruleset.created", &ruleset, actor).await;
1046+ }
1047+ }
1048+ Ok(())
1049+ }
1050+
1051+ /// Services only (repos `update` with `protected`): whether the branch
1052+ /// protection ruleset refuses pushes to the default branch.
1053+ pub(crate) async fn set_requires_pull_request(&self, a: RequirePullRequestArgs) -> Result<Outcome<bool>> {
1054+ let rulesets = self.rulesets_for(&a.repo).await?;
1055+ let existing = rulesets
1056+ .iter()
1057+ .find(|ruleset| ruleset.repo_id.as_deref() == Some(&a.repo.id) && ruleset.source.as_deref() == Some(BRANCH_PROTECTION))
1058+ .cloned();
1059+ let now = rfc3339(now_ms());
1060+ let mut ruleset = match existing {
1061+ Some(found) => found,
1062+ None if !a.protected => return Ok(Outcome::Ok(false)),
1063+ None => Ruleset {
1064+ id: new_id("rs", now_ms()),
1065+ level: Level::Repository,
1066+ workspace: a.repo.namespace.to_lowercase(),
1067+ repo_id: Some(a.repo.id.clone()),
1068+ repository: Some(format!("{}/{}", a.repo.namespace, a.repo.name)),
1069+ spec: legacy::ruleset_of(&RepoSettings::default(), true).unwrap_or_default(),
1070+ source: Some(BRANCH_PROTECTION.to_owned()),
1071+ created_by: a.actor.username.clone(),
1072+ created_at: now.clone(),
1073+ updated_by: a.actor.username.clone(),
1074+ updated_at: now.clone(),
1075+ },
1076+ };
1077+ let mut found = false;
1078+ for entry in &mut ruleset.spec.rules {
1079+ if let (Rule::PullRequest(rule), AppliesTo::Everyone) = (&mut entry.rule, entry.applies_to) {
1080+ rule.allow_direct_pushes = !a.protected;
1081+ found = true;
1082+ }
1083+ }
1084+ if !found && a.protected {
1085+ ruleset.spec.rules.insert(0, RuleEntry::everyone(Rule::PullRequest(PullRequestRule::default())));
1086+ }
1087+ ruleset.updated_by = a.actor.username.clone();
1088+ ruleset.updated_at = now;
1089+ self.store_ruleset(&ruleset).await?;
1090+ self.announce("ruleset.updated", &ruleset, &a.actor).await;
1091+ Ok(Outcome::Ok(true))
1092+ }
1093+}
1094+
1095+/// `set_requires_pull_request`: services only.
1096+#[derive(Deserialize)]
1097+pub(crate) struct RequirePullRequestArgs {
1098+ repo: Repo,
1099+ protected: bool,
1100+ actor: User,
1101+}
1102+
1103+/// A merge, judged.
1104+pub(crate) struct Gate {
1105+ pub(crate) judged: Vec<Judged>,
1106+ pub(crate) requirements: Requirements,
1107+ #[allow(dead_code)]
1108+ pub(crate) applicable: Vec<Applicable>,
1109+ pub(crate) who: Option<ActorFacts>,
1110+}
1111+
1112+impl Gate {
1113+ /// What refuses the merge now, if anything, in one sentence.
1114+ pub(crate) fn refusal(&self) -> Option<String> {
1115+ g1t_rules::report::summary(&g1t_rules::outcome::blocking(&self.judged))
1116+ }
1117+
1118+ /// What people (not checks, which g1t's lifecycle waits for itself)
1119+ /// must still do before it can merge.
1120+ pub(crate) fn people_gap(&self) -> Option<String> {
1121+ let waiting: Vec<&Violation> = g1t_rules::outcome::blocking(&self.judged)
1122+ .into_iter()
1123+ .filter(|violation| !g1t_rules::merge::about_checks(&violation.rule))
1124+ .collect();
1125+ g1t_rules::report::summary(&waiting)
1126+ }
1127+
1128+ /// Whether any rule not met could be bypassed by the actor.
1129+ pub(crate) fn bypassable(&self) -> bool {
1130+ self.judged.iter().any(|one| one.enforcement == Enforcement::Active && one.verdict() == Verdict::Bypass)
1131+ }
1132+
1133+ /// The same judgement for an actor who did not ask to bypass anything.
1134+ pub(crate) fn without_bypass(mut self) -> Gate {
1135+ for one in &mut self.judged {
1136+ one.bypass = None;
1137+ }
1138+ self
1139+ }
1140+}
1141+
1142+/// A workspace ruleset's repository condition, against one repository.
1143+fn repo_matches_spec(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
1144+ select::repo_matches(&ruleset.spec.conditions.repository.clone().unwrap_or_default(), repo)
1145+}
1146+
1147+const RULESETS_SQL: &str =
1148+ "SELECT * FROM rulesets WHERE repo_id = ?1 OR (level = 'workspace' AND workspace = ?2) ORDER BY created_at";
1149+const ADOPTED_SQL: &str = "SELECT 1 AS n FROM ruleset_adoptions WHERE repo_id = ?1";
1150+
1151+/// The statements prefetch.rs batches for a pull request's page.
1152+pub(crate) fn prefetch_sql() -> (&'static str, &'static str) {
1153+ (RULESETS_SQL, ADOPTED_SQL)
1154+}
1155+
1156+#[derive(Deserialize)]
1157+struct CountRow {
1158+ ruleset_id: String,
1159+ ruleset_name: String,
1160+ enforcement: Enforcement,
1161+ verdict: Verdict,
1162+ n: u32,
1163+}
1164+
1165+#[derive(Deserialize)]
1166+struct RuleCountRow {
1167+ rule: Option<String>,
1168+ n: u32,
1169+}
1170+
1171+/// Counts by ruleset and verdict, and violations by rule, as insights.
1172+fn insights(counts: Vec<CountRow>, rules: Vec<RuleCountRow>) -> Insights {
1173+ let mut out = Insights { days: INSIGHT_DAYS, ..Insights::default() };
1174+ let mut by_ruleset: Vec<RulesetInsight> = Vec::new();
1175+ for row in counts {
1176+ out.total += row.n;
1177+ let index = match by_ruleset.iter().position(|have| have.ruleset_id == row.ruleset_id) {
1178+ Some(index) => index,
1179+ None => {
1180+ by_ruleset.push(RulesetInsight {
1181+ ruleset_id: row.ruleset_id.clone(),
1182+ ruleset_name: row.ruleset_name.clone(),
1183+ enforcement: row.enforcement,
1184+ ..RulesetInsight::default()
1185+ });
1186+ by_ruleset.len() - 1
1187+ }
1188+ };
1189+ let one = &mut by_ruleset[index];
1190+ one.total += row.n;
1191+ match (row.verdict, row.enforcement) {
1192+ (Verdict::Pass, _) => out.passed += row.n,
1193+ (Verdict::Bypass, _) => {
1194+ out.bypassed += row.n;
1195+ one.bypassed += row.n;
1196+ }
1197+ (Verdict::Fail, Enforcement::Evaluate) => {
1198+ out.would_block += row.n;
1199+ one.would_block += row.n;
1200+ }
1201+ (Verdict::Fail, _) => {
1202+ out.blocked += row.n;
1203+ one.blocked += row.n;
1204+ }
1205+ }
1206+ }
1207+ by_ruleset.sort_by_key(|one| std::cmp::Reverse(one.blocked + one.would_block + one.bypassed));
1208+ out.by_ruleset = by_ruleset;
1209+ out.by_rule = rules
1210+ .into_iter()
1211+ .filter_map(|row| row.rule.map(|rule| RuleInsight { rule, count: row.n }))
1212+ .collect();
1213+ out
1214+}
1215+
1216+#[cfg(test)]
1217+mod tests {
1218+ use super::*;
1219+ use g1t_contracts::rules::Level;
1220+
1221+ fn count(ruleset: &str, enforcement: Enforcement, verdict: Verdict, n: u32) -> CountRow {
1222+ CountRow { ruleset_id: ruleset.into(), ruleset_name: ruleset.into(), enforcement, verdict, n }
1223+ }
1224+
1225+ #[test]
1226+ fn insights_add_up_by_ruleset_and_verdict() {
1227+ let found = insights(
1228+ vec![
1229+ count("a", Enforcement::Active, Verdict::Pass, 10),
1230+ count("a", Enforcement::Active, Verdict::Fail, 2),
1231+ count("b", Enforcement::Evaluate, Verdict::Fail, 5),
1232+ count("b", Enforcement::Evaluate, Verdict::Pass, 1),
1233+ count("c", Enforcement::Active, Verdict::Bypass, 1),
1234+ ],
1235+ vec![RuleCountRow { rule: Some("pull_request".into()), n: 4 }, RuleCountRow { rule: None, n: 1 }],
1236+ );
1237+ assert_eq!((found.total, found.passed, found.blocked, found.would_block, found.bypassed), (19, 11, 2, 5, 1));
1238+ assert_eq!(found.by_ruleset[0].ruleset_id, "b", "most problems first");
1239+ assert_eq!(found.by_ruleset[0].would_block, 5);
1240+ assert_eq!(found.by_rule, vec![RuleInsight { rule: "pull_request".into(), count: 4 }]);
1241+ assert_eq!(found.days, 30);
1242+ }
1243+
1244+ #[test]
1245+ fn a_stored_ruleset_reads_back_with_its_repository() {
1246+ let row = RulesetRow {
1247+ id: "rs_1".into(),
1248+ level: "repository".into(),
1249+ workspace: String::new(),
1250+ repo_id: Some("rep_1".into()),
1251+ spec: r#"{"name":"Default branch protection","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"]}},"rules":[{"type":"deletion"}]}"#.into(),
1252+ source: Some(BRANCH_PROTECTION.into()),
1253+ created_by: "g1t".into(),
1254+ created_at: "2026-10-07T00:00:00.000Z".into(),
1255+ updated_by: "g1t".into(),
1256+ updated_at: "2026-10-07T00:00:00.000Z".into(),
1257+ };
1258+ let repo: Repo = serde_json::from_value(serde_json::json!({
1259+ "id": "rep_1", "namespace": "Acme", "name": "web", "description": null, "isPrivate": true, "ownerId": "usr_1",
1260+ "defaultBranch": "main", "forkOf": null, "createdAt": ""
1261+ }))
1262+ .unwrap();
1263+ let ruleset = row.into_ruleset(Some(&repo)).unwrap();
1264+ assert_eq!(ruleset.level, Level::Repository);
1265+ assert_eq!(ruleset.workspace, "acme");
1266+ assert_eq!(ruleset.repository.as_deref(), Some("Acme/web"));
1267+ assert_eq!(ruleset.spec.rules[0].rule.kind(), "deletion");
1268+ }
1269+
1270+ #[test]
1271+ fn agents_changes_are_told_from_peoples() {
1272+ use crate::rows::stored::{ASKER, G1T, pull};
1273+ assert!(agent_change(&pull(G1T, Some(ASKER))));
1274+ let mut person: Pull = pull(ASKER, None);
1275+ person.agent = person.author.username.clone();
1276+ person.runtime = g1t_contracts::work::Runtime::External;
1277+ person.fork = None;
1278+ assert!(!agent_change(&person));
1279+ person.agent = "claude-code".into();
1280+ assert!(agent_change(&person));
1281+ }
1282+
1283+ #[test]
1284+ fn latest_reviews_keep_each_reviewers_last_verdict() {
1285+ let reviews = latest_reviews(vec![
1286+ ("usr_b".into(), "bob".into(), ReviewVerdict::RequestChanges, "1".into()),
1287+ (AGENT_ID.into(), "g1t".into(), ReviewVerdict::Approve, "2".into()),
1288+ ("usr_b".into(), "bob".into(), ReviewVerdict::Approve, "3".into()),
1289+ ]);
1290+ assert_eq!(reviews.len(), 2);
1291+ assert!(reviews[0].agent);
1292+ assert_eq!(reviews[1].verdict, ReviewVerdict::Approve);
1293+ }
1294+
1295+ #[test]
1296+ fn protection_overlays_the_stored_settings() {
1297+ let requirements = Requirements {
1298+ required_checks: vec!["CI".into()],
1299+ strict: true,
1300+ required_approvals: 2,
1301+ count_agent_approvals: false,
1302+ allow_bypass_on_merge: false,
1303+ require_code_owner_review: true,
1304+ merge_queue: Some(MergeQueueRule::default()),
1305+ ..Requirements::default()
1306+ };
1307+ let stored = RepoSettings { auto_merge: true, required_approvals: 5, ..RepoSettings::default() };
1308+ let main = overlay(stored.clone(), &requirements, true);
1309+ assert_eq!((main.required_approvals, main.merge_queue, main.auto_merge), (2, true, true));
1310+ assert!(main.require_up_to_date && !main.allow_ignoring_checks && main.require_code_owner_review);
1311+ assert!(!overlay(stored, &requirements, false).merge_queue, "the queue lands on the default branch only");
1312+ }
1313+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.