Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
471 linesCodeBlameRaw
1//! A repository's settings for how its pull requests are handled,
2//! and the branch protection settings that are now its "Default branch
3//! protection" ruleset (rulesets.rs).
4//!
5//! What a merge needs is decided by the rules of the branch it merges into
6//! (`Work::merge_gate_by`, `Work::merge_gate_seen`); `approvals_gap` asks them for what people must
7//! still do, as g1t sees it when it merges by itself.
8
9use g1t_contracts::time::rfc3339;
10use g1t_contracts::work::*;
11use g1t_contracts::{FailureCode, Outcome};
12use g1t_kit::now_ms;
13use serde::Deserialize;
14use worker::Result;
15
16use crate::Work;
17
18const MAX_REQUIRED_APPROVALS: u32 = 6;
19
20#[derive(Deserialize)]
21struct SettingsRow {
22 auto_merge: u8,
23 require_up_to_date: u8,
24 required_approvals: u32,
25 count_agent_approvals: u8,
26 allow_ignoring_checks: u8,
27 #[serde(default)]
28 merge_queue: u8,
29 /// JSON array of names.
30 #[serde(default)]
31 required_checks: Option<String>,
32 #[serde(default)]
33 require_code_owner_review: u8,
34 /// NULL for the default (migrations/0041_pull_head_repo.sql).
35 #[serde(default)]
36 has_issues: Option<u8>,
37 // How pull requests merge (migrations/0042_merge_settings.sql): NULL
38 // for the default.
39 #[serde(default)]
40 allow_merge_commit: Option<u8>,
41 #[serde(default)]
42 allow_squash_merge: Option<u8>,
43 #[serde(default)]
44 allow_rebase_merge: Option<u8>,
45 #[serde(default)]
46 allow_fast_forward: Option<u8>,
47 #[serde(default)]
48 default_merge_method: Option<String>,
49 #[serde(default)]
50 merge_commit_message: Option<String>,
51 #[serde(default)]
52 squash_commit_message: Option<String>,
53 #[serde(default)]
54 merge_commit_template: Option<String>,
55 #[serde(default)]
56 squash_commit_template: Option<String>,
57 #[serde(default)]
58 suggest_updating_branches: Option<u8>,
59 #[serde(default)]
60 allow_auto_merge: Option<u8>,
61 #[serde(default)]
62 delete_branch_on_merge: Option<u8>,
63 #[serde(default)]
64 require_web_commit_sign_off: Option<u8>,
65 #[serde(default)]
66 allow_commit_comments: Option<u8>,
67 #[serde(default)]
68 auto_close_issues: Option<u8>,
69 updated_by: String,
70 updated_at: String,
71}
72
73/// A stored flag, or `default` where none was stored.
74fn flag(stored: Option<u8>, default: bool) -> bool {
75 stored.map_or(default, |on| on != 0)
76}
77
78/// A stored name of a contract enum (snake_case), if it names one.
79fn named<T: serde::de::DeserializeOwned>(stored: Option<&str>) -> Option<T> {
80 serde_json::from_value(serde_json::Value::String(stored?.to_owned())).ok()
81}
82
83/// The snake_case name a contract enum is stored as.
84fn name_of<T: serde::Serialize>(value: &T) -> String {
85 serde_json::to_value(value).ok().and_then(|value| value.as_str().map(str::to_owned)).unwrap_or_default()
86}
87
88/// A stored flag to bind.
89fn bit(on: bool) -> worker::wasm_bindgen::JsValue {
90 u32::from(on).into()
91}
92
93impl From<SettingsRow> for RepoSettings {
94 fn from(row: SettingsRow) -> Self {
95 let defaults = RepoSettings::default();
96 RepoSettings {
97 allow_merge_commit: flag(row.allow_merge_commit, defaults.allow_merge_commit),
98 allow_squash_merge: flag(row.allow_squash_merge, defaults.allow_squash_merge),
99 allow_rebase_merge: flag(row.allow_rebase_merge, defaults.allow_rebase_merge),
100 allow_fast_forward: flag(row.allow_fast_forward, defaults.allow_fast_forward),
101 default_merge_method: named(row.default_merge_method.as_deref()).unwrap_or(defaults.default_merge_method),
102 merge_commit_message: named(row.merge_commit_message.as_deref()).unwrap_or_default(),
103 squash_commit_message: named(row.squash_commit_message.as_deref()).unwrap_or_default(),
104 merge_commit_template: row.merge_commit_template,
105 squash_commit_template: row.squash_commit_template,
106 suggest_updating_branches: flag(row.suggest_updating_branches, defaults.suggest_updating_branches),
107 allow_auto_merge: flag(row.allow_auto_merge, defaults.allow_auto_merge),
108 delete_branch_on_merge: flag(row.delete_branch_on_merge, defaults.delete_branch_on_merge),
109 require_web_commit_sign_off: flag(row.require_web_commit_sign_off, defaults.require_web_commit_sign_off),
110 allow_commit_comments: flag(row.allow_commit_comments, defaults.allow_commit_comments),
111 auto_close_issues: flag(row.auto_close_issues, defaults.auto_close_issues),
112 auto_merge: row.auto_merge != 0,
113 required_checks: row
114 .required_checks
115 .as_deref()
116 .and_then(|names| serde_json::from_str(names).ok())
117 .unwrap_or_default(),
118 require_up_to_date: row.require_up_to_date != 0,
119 required_approvals: row.required_approvals,
120 count_agent_approvals: row.count_agent_approvals != 0,
121 allow_ignoring_checks: row.allow_ignoring_checks != 0,
122 merge_queue: row.merge_queue != 0,
123 require_code_owner_review: row.require_code_owner_review != 0,
124 has_issues: row.has_issues.map(|on| on != 0),
125 // Whether the repository is a fork is not known here: see
126 // `issues_enabled`.
127 issues_enabled: row.has_issues.is_none_or(|on| on != 0),
128 updated_by: Some(row.updated_by),
129 updated_at: Some(row.updated_at),
130 }
131 }
132}
133
134impl Work {
135 /// A repository's stored settings, by its id: how its pull requests are
136 /// handled. Defaults if none were set. Branch protection is
137 /// its rules': see `settings_on`.
138 pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> {
139 if let Some(found) = self.prefetched_repo(repo_id) {
140 let row = found.first::<SettingsRow>(crate::prefetch::Slot::Settings)?;
141 return Ok(row.map_or_else(RepoSettings::default, RepoSettings::from));
142 }
143 let row = self
144 .db
145 .prepare("SELECT * FROM repo_settings WHERE repo_id = ?")
146 .bind(&[repo_id.into()])?
147 .first::<SettingsRow>(None)
148 .await?;
149 Ok(row.map_or_else(RepoSettings::default, RepoSettings::from))
150 }
151
152 /// The default branch's settings: the stored ones, with branch
153 /// protection as its rules stack.
154 pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> {
155 let repo = match self.repo(&a.repo, &a.viewer).await? {
156 Outcome::Ok(repo) => repo,
157 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
158 };
159 Ok(Outcome::Ok(self.timing.db(3, self.default_branch_settings(&repo)).await?))
160 }
161
162 /// Replaces a repository's settings: how pull requests are handled, kept here,
163 /// and the branch protection ones, written to its "Default branch
164 /// protection" ruleset (made when it has none and they protect
165 /// anything). Rules that only rulesets have stay as they are.
166 pub(crate) async fn update_settings(
167 &self,
168 a: UpdateSettingsArgs,
169 ) -> Result<Outcome<RepoSettings>> {
170 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
171 Outcome::Ok(repo) => repo,
172 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
173 };
174 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
175 return Ok(Outcome::Fail(failure));
176 }
177 if !a.actor.verified {
178 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
179 }
180 if let Outcome::Fail(failure) =
181 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings)
182 {
183 return Ok(Outcome::Fail(failure));
184 }
185 let before = self.default_branch_settings(&repo).await?;
186 let a = match tidy_merge_settings(a.settings) {
187 Ok(settings) => UpdateSettingsArgs { settings, ..a },
188 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
189 };
190 let settings = RepoSettings {
191 required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS),
192 required_checks: tidy_required(&a.settings.required_checks),
193 updated_by: Some(a.actor.username.clone()),
194 updated_at: Some(rfc3339(now_ms())),
195 ..a.settings
196 };
197 // Branch protection changes need the role that changes it.
198 if protection_changed(&before, &settings)
199 && let Outcome::Fail(failure) =
200 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageProtection)
201 {
202 return Ok(Outcome::Fail(failure));
203 }
204 self.db
205 .prepare(
206 "INSERT INTO repo_settings
207 (repo_id, auto_merge, require_up_to_date, required_approvals,
208 count_agent_approvals, allow_ignoring_checks,
209 merge_queue, required_checks, require_code_owner_review, has_issues,
210 allow_merge_commit, allow_squash_merge, allow_rebase_merge, allow_fast_forward,
211 default_merge_method, merge_commit_message, squash_commit_message,
212 merge_commit_template, squash_commit_template, suggest_updating_branches,
213 allow_auto_merge, delete_branch_on_merge, require_web_commit_sign_off,
214 allow_commit_comments, auto_close_issues,
215 updated_by, updated_at)
216 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
217 ON CONFLICT (repo_id) DO UPDATE SET
218 auto_merge = excluded.auto_merge,
219 require_up_to_date = excluded.require_up_to_date,
220 required_approvals = excluded.required_approvals,
221 count_agent_approvals = excluded.count_agent_approvals,
222 allow_ignoring_checks = excluded.allow_ignoring_checks,
223 merge_queue = excluded.merge_queue,
224 required_checks = excluded.required_checks,
225 require_code_owner_review = excluded.require_code_owner_review,
226 has_issues = excluded.has_issues,
227 allow_merge_commit = excluded.allow_merge_commit,
228 allow_squash_merge = excluded.allow_squash_merge,
229 allow_rebase_merge = excluded.allow_rebase_merge,
230 allow_fast_forward = excluded.allow_fast_forward,
231 default_merge_method = excluded.default_merge_method,
232 merge_commit_message = excluded.merge_commit_message,
233 squash_commit_message = excluded.squash_commit_message,
234 merge_commit_template = excluded.merge_commit_template,
235 squash_commit_template = excluded.squash_commit_template,
236 suggest_updating_branches = excluded.suggest_updating_branches,
237 allow_auto_merge = excluded.allow_auto_merge,
238 delete_branch_on_merge = excluded.delete_branch_on_merge,
239 require_web_commit_sign_off = excluded.require_web_commit_sign_off,
240 allow_commit_comments = excluded.allow_commit_comments,
241 auto_close_issues = excluded.auto_close_issues,
242 updated_by = excluded.updated_by,
243 updated_at = excluded.updated_at",
244 )
245 .bind(&[
246 repo.id.as_str().into(),
247 u32::from(settings.auto_merge).into(),
248 u32::from(settings.require_up_to_date).into(),
249 settings.required_approvals.into(),
250 u32::from(settings.count_agent_approvals).into(),
251 u32::from(settings.allow_ignoring_checks).into(),
252 u32::from(settings.merge_queue).into(),
253 serde_json::to_string(&settings.required_checks)?.into(),
254 u32::from(settings.require_code_owner_review).into(),
255 settings.has_issues.map_or(worker::wasm_bindgen::JsValue::NULL, |on| u32::from(on).into()),
256 bit(settings.allow_merge_commit),
257 bit(settings.allow_squash_merge),
258 bit(settings.allow_rebase_merge),
259 bit(settings.allow_fast_forward),
260 name_of(&settings.default_merge_method).into(),
261 name_of(&settings.merge_commit_message).into(),
262 name_of(&settings.squash_commit_message).into(),
263 crate::optional(&settings.merge_commit_template),
264 crate::optional(&settings.squash_commit_template),
265 bit(settings.suggest_updating_branches),
266 bit(settings.allow_auto_merge),
267 bit(settings.delete_branch_on_merge),
268 bit(settings.require_web_commit_sign_off),
269 bit(settings.allow_commit_comments),
270 bit(settings.auto_close_issues),
271 settings.updated_by.as_deref().unwrap_or_default().into(),
272 settings.updated_at.as_deref().unwrap_or_default().into(),
273 ])?
274 .run()
275 .await?;
276 if protection_changed(&before, &settings) {
277 self.write_branch_protection(&repo, &settings, &a.actor).await?;
278 }
279 // Auto-merge turned off: every pull request waiting to merge on its
280 // own stops waiting (automerge.rs).
281 if before.allow_auto_merge && !settings.allow_auto_merge {
282 self.auto_merge_turned_off(&repo, &a.actor).await?;
283 }
284 // What its pull requests need may have changed with it.
285 self.forget_stages(&repo.id).await?;
286 Ok(Outcome::Ok(self.default_branch_settings(&repo).await?))
287 }
288
289 /// Whether issues can be opened in `repo`: its setting, or on unless it
290 /// is a fork of another repository.
291 pub(crate) async fn issues_enabled(&self, repo: &g1t_contracts::repos::Repo) -> Result<bool> {
292 Ok(self.settings(&repo.id).await?.issues_on(repo.forked_from.is_some()))
293 }
294}
295
296/// Why an issue cannot be opened in `repo`: its issues are off.
297pub(crate) fn issues_off(repo: &g1t_contracts::repos::Repo) -> String {
298 format!(
299 "Issues are turned off for {}/{}. Its admins can turn them on in Settings.",
300 repo.namespace, repo.name
301 )
302}
303
304/// The longest merge or squash commit template.
305const MAX_TEMPLATE_CHARS: usize = 2_000;
306
307/// How pull requests merge, made consistent, or why it cannot be: at least
308/// one method allowed, the default one of them (else the first allowed),
309/// templates trimmed, and a custom message only with a template.
310fn tidy_merge_settings(mut settings: RepoSettings) -> std::result::Result<RepoSettings, String> {
311 if settings.allowed_methods().is_empty() {
312 return Err("Allow at least one way to merge pull requests.".to_owned());
313 }
314 if !settings.allows(settings.default_merge_method) {
315 settings.default_merge_method = settings.allowed_methods()[0];
316 }
317 // A squash's commits are listed only in a squash's message.
318 if settings.merge_commit_message == CommitMessageDefault::PullTitleAndCommits {
319 return Err("A merge commit's message cannot list the pull request's commits; choose another default.".to_owned());
320 }
321 for (template, kind) in [
322 (&mut settings.merge_commit_template, "merge"),
323 (&mut settings.squash_commit_template, "squash"),
324 ] {
325 *template = template.as_deref().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned);
326 if template.as_ref().is_some_and(|text| text.chars().count() > MAX_TEMPLATE_CHARS) {
327 return Err(format!("The {kind} commit template is too long: keep it to {MAX_TEMPLATE_CHARS} characters."));
328 }
329 }
330 if settings.merge_commit_message == CommitMessageDefault::Custom && settings.merge_commit_template.is_none() {
331 return Err("Write the merge commit template, or choose another default message.".to_owned());
332 }
333 if settings.squash_commit_message == CommitMessageDefault::Custom && settings.squash_commit_template.is_none() {
334 return Err("Write the squash commit template, or choose another default message.".to_owned());
335 }
336 Ok(settings)
337}
338
339/// Whether the branch protection part of the settings differs.
340fn protection_changed(before: &RepoSettings, after: &RepoSettings) -> bool {
341 let names = |settings: &RepoSettings| -> Vec<String> {
342 let mut names: Vec<String> = settings.required_checks.iter().map(|name| name.to_lowercase()).collect();
343 names.sort();
344 names
345 };
346 names(before) != names(after)
347 || before.require_up_to_date != after.require_up_to_date
348 || before.required_approvals != after.required_approvals
349 || before.count_agent_approvals != after.count_agent_approvals
350 || before.allow_ignoring_checks != after.allow_ignoring_checks
351 || before.merge_queue != after.merge_queue
352 || before.require_code_owner_review != after.require_code_owner_review
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn only_protection_changes_count_as_protection_changes() {
361 let before = RepoSettings::default();
362 let auto = RepoSettings { auto_merge: true, ..RepoSettings::default() };
363 assert!(!protection_changed(&before, &auto));
364 let checks = RepoSettings { required_checks: vec!["CI".into()], ..RepoSettings::default() };
365 assert!(protection_changed(&before, &checks));
366 let same = RepoSettings { required_checks: vec!["ci".into()], ..RepoSettings::default() };
367 assert!(!protection_changed(&checks, &same), "names compare without case");
368 let approvals = RepoSettings { required_approvals: 1, ..RepoSettings::default() };
369 assert!(protection_changed(&before, &approvals));
370 let issues = RepoSettings { has_issues: Some(false), ..RepoSettings::default() };
371 assert!(!protection_changed(&before, &issues), "issues are not protection");
372 }
373
374 #[test]
375 fn merge_settings_are_made_consistent_or_refused() {
376 use g1t_contracts::rules::MergeMethod;
377 let none = RepoSettings {
378 allow_merge_commit: false,
379 allow_squash_merge: false,
380 allow_rebase_merge: false,
381 allow_fast_forward: false,
382 ..RepoSettings::default()
383 };
384 assert!(tidy_merge_settings(none).is_err(), "one method at least");
385 // A default that is not allowed becomes the first that is.
386 let squash_only = RepoSettings { allow_merge_commit: false, allow_rebase_merge: false, ..RepoSettings::default() };
387 assert_eq!(tidy_merge_settings(squash_only).unwrap().default_merge_method, MergeMethod::Squash);
388 let ff = RepoSettings {
389 allow_merge_commit: false,
390 allow_squash_merge: false,
391 allow_rebase_merge: false,
392 allow_fast_forward: true,
393 default_merge_method: MergeMethod::Rebase,
394 ..RepoSettings::default()
395 };
396 assert_eq!(tidy_merge_settings(ff).unwrap().default_merge_method, MergeMethod::FastForward);
397 // Templates are trimmed; an empty one is none, which Custom needs.
398 let blank = RepoSettings {
399 squash_commit_message: CommitMessageDefault::Custom,
400 squash_commit_template: Some(" ".into()),
401 ..RepoSettings::default()
402 };
403 assert!(tidy_merge_settings(blank).is_err());
404 let custom = RepoSettings {
405 merge_commit_message: CommitMessageDefault::Custom,
406 merge_commit_template: Some(" {title} (#{number})\n".into()),
407 ..RepoSettings::default()
408 };
409 assert_eq!(tidy_merge_settings(custom).unwrap().merge_commit_template.as_deref(), Some("{title} (#{number})"));
410 let long = RepoSettings { merge_commit_template: Some("x".repeat(MAX_TEMPLATE_CHARS + 1)), ..RepoSettings::default() };
411 assert!(tidy_merge_settings(long).is_err());
412 let commits = RepoSettings { merge_commit_message: CommitMessageDefault::PullTitleAndCommits, ..RepoSettings::default() };
413 assert!(tidy_merge_settings(commits).is_err(), "squash only");
414 }
415
416 #[test]
417 fn a_row_from_before_merge_settings_reads_as_the_defaults() {
418 let read: RepoSettings = serde_json::from_value::<SettingsRow>(serde_json::json!({
419 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
420 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
421 "updated_by": "ana", "updated_at": ""
422 }))
423 .unwrap()
424 .into();
425 let defaults = RepoSettings::default();
426 assert_eq!(read.allowed_methods(), defaults.allowed_methods());
427 assert_eq!(read.default_merge_method, defaults.default_merge_method);
428 assert!(read.allow_auto_merge && read.auto_close_issues && read.allow_commit_comments && read.suggest_updating_branches);
429 assert!(!read.delete_branch_on_merge && !read.require_web_commit_sign_off);
430 let stored: RepoSettings = serde_json::from_value::<SettingsRow>(serde_json::json!({
431 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
432 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
433 "allow_merge_commit": 0, "allow_fast_forward": 1, "default_merge_method": "fast_forward",
434 "squash_commit_message": "pull_title_and_commits", "delete_branch_on_merge": 1,
435 "updated_by": "ana", "updated_at": ""
436 }))
437 .unwrap()
438 .into();
439 assert!(!stored.allow_merge_commit && stored.allow_fast_forward && stored.delete_branch_on_merge);
440 assert_eq!(stored.default_merge_method, g1t_contracts::rules::MergeMethod::FastForward);
441 assert_eq!(stored.squash_commit_message, CommitMessageDefault::PullTitleAndCommits);
442 assert_eq!(name_of(&stored.squash_commit_message), "pull_title_and_commits");
443 }
444
445 #[test]
446 fn issues_are_on_by_default_except_in_a_fork() {
447 let unset = RepoSettings::default();
448 assert!(unset.issues_on(false));
449 assert!(!unset.issues_on(true));
450 let on = RepoSettings { has_issues: Some(true), ..RepoSettings::default() };
451 assert!(on.issues_on(true), "a fork can turn them on");
452 let off = RepoSettings { has_issues: Some(false), ..RepoSettings::default() };
453 assert!(!off.issues_on(false));
454 }
455
456 #[test]
457 fn a_stored_row_keeps_the_issues_setting_or_its_absence() {
458 let row = |has_issues: serde_json::Value| -> RepoSettings {
459 serde_json::from_value::<SettingsRow>(serde_json::json!({
460 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
461 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
462 "has_issues": has_issues, "updated_by": "ana", "updated_at": ""
463 }))
464 .unwrap()
465 .into()
466 };
467 assert_eq!(row(serde_json::Value::Null).has_issues, None);
468 assert_eq!(row(serde_json::json!(0)).has_issues, Some(false));
469 assert_eq!(row(serde_json::json!(1)).has_issues, Some(true));
470 }
471}