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
469 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`); `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 Ok(Outcome::Ok(self.default_branch_settings(&repo).await?))
285 }
286
287 /// Whether issues can be opened in `repo`: its setting, or on unless it
288 /// is a fork of another repository.
289 pub(crate) async fn issues_enabled(&self, repo: &g1t_contracts::repos::Repo) -> Result<bool> {
290 Ok(self.settings(&repo.id).await?.issues_on(repo.forked_from.is_some()))
291 }
292}
293
294/// Why an issue cannot be opened in `repo`: its issues are off.
295pub(crate) fn issues_off(repo: &g1t_contracts::repos::Repo) -> String {
296 format!(
297 "Issues are turned off for {}/{}. Its admins can turn them on in Settings.",
298 repo.namespace, repo.name
299 )
300}
301
302/// The longest merge or squash commit template.
303const MAX_TEMPLATE_CHARS: usize = 2_000;
304
305/// How pull requests merge, made consistent, or why it cannot be: at least
306/// one method allowed, the default one of them (else the first allowed),
307/// templates trimmed, and a custom message only with a template.
308fn tidy_merge_settings(mut settings: RepoSettings) -> std::result::Result<RepoSettings, String> {
309 if settings.allowed_methods().is_empty() {
310 return Err("Allow at least one way to merge pull requests.".to_owned());
311 }
312 if !settings.allows(settings.default_merge_method) {
313 settings.default_merge_method = settings.allowed_methods()[0];
314 }
315 // A squash's commits are listed only in a squash's message.
316 if settings.merge_commit_message == CommitMessageDefault::PullTitleAndCommits {
317 return Err("A merge commit's message cannot list the pull request's commits; choose another default.".to_owned());
318 }
319 for (template, kind) in [
320 (&mut settings.merge_commit_template, "merge"),
321 (&mut settings.squash_commit_template, "squash"),
322 ] {
323 *template = template.as_deref().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned);
324 if template.as_ref().is_some_and(|text| text.chars().count() > MAX_TEMPLATE_CHARS) {
325 return Err(format!("The {kind} commit template is too long: keep it to {MAX_TEMPLATE_CHARS} characters."));
326 }
327 }
328 if settings.merge_commit_message == CommitMessageDefault::Custom && settings.merge_commit_template.is_none() {
329 return Err("Write the merge commit template, or choose another default message.".to_owned());
330 }
331 if settings.squash_commit_message == CommitMessageDefault::Custom && settings.squash_commit_template.is_none() {
332 return Err("Write the squash commit template, or choose another default message.".to_owned());
333 }
334 Ok(settings)
335}
336
337/// Whether the branch protection part of the settings differs.
338fn protection_changed(before: &RepoSettings, after: &RepoSettings) -> bool {
339 let names = |settings: &RepoSettings| -> Vec<String> {
340 let mut names: Vec<String> = settings.required_checks.iter().map(|name| name.to_lowercase()).collect();
341 names.sort();
342 names
343 };
344 names(before) != names(after)
345 || before.require_up_to_date != after.require_up_to_date
346 || before.required_approvals != after.required_approvals
347 || before.count_agent_approvals != after.count_agent_approvals
348 || before.allow_ignoring_checks != after.allow_ignoring_checks
349 || before.merge_queue != after.merge_queue
350 || before.require_code_owner_review != after.require_code_owner_review
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn only_protection_changes_count_as_protection_changes() {
359 let before = RepoSettings::default();
360 let auto = RepoSettings { auto_merge: true, ..RepoSettings::default() };
361 assert!(!protection_changed(&before, &auto));
362 let checks = RepoSettings { required_checks: vec!["CI".into()], ..RepoSettings::default() };
363 assert!(protection_changed(&before, &checks));
364 let same = RepoSettings { required_checks: vec!["ci".into()], ..RepoSettings::default() };
365 assert!(!protection_changed(&checks, &same), "names compare without case");
366 let approvals = RepoSettings { required_approvals: 1, ..RepoSettings::default() };
367 assert!(protection_changed(&before, &approvals));
368 let issues = RepoSettings { has_issues: Some(false), ..RepoSettings::default() };
369 assert!(!protection_changed(&before, &issues), "issues are not protection");
370 }
371
372 #[test]
373 fn merge_settings_are_made_consistent_or_refused() {
374 use g1t_contracts::rules::MergeMethod;
375 let none = RepoSettings {
376 allow_merge_commit: false,
377 allow_squash_merge: false,
378 allow_rebase_merge: false,
379 allow_fast_forward: false,
380 ..RepoSettings::default()
381 };
382 assert!(tidy_merge_settings(none).is_err(), "one method at least");
383 // A default that is not allowed becomes the first that is.
384 let squash_only = RepoSettings { allow_merge_commit: false, allow_rebase_merge: false, ..RepoSettings::default() };
385 assert_eq!(tidy_merge_settings(squash_only).unwrap().default_merge_method, MergeMethod::Squash);
386 let ff = RepoSettings {
387 allow_merge_commit: false,
388 allow_squash_merge: false,
389 allow_rebase_merge: false,
390 allow_fast_forward: true,
391 default_merge_method: MergeMethod::Rebase,
392 ..RepoSettings::default()
393 };
394 assert_eq!(tidy_merge_settings(ff).unwrap().default_merge_method, MergeMethod::FastForward);
395 // Templates are trimmed; an empty one is none, which Custom needs.
396 let blank = RepoSettings {
397 squash_commit_message: CommitMessageDefault::Custom,
398 squash_commit_template: Some(" ".into()),
399 ..RepoSettings::default()
400 };
401 assert!(tidy_merge_settings(blank).is_err());
402 let custom = RepoSettings {
403 merge_commit_message: CommitMessageDefault::Custom,
404 merge_commit_template: Some(" {title} (#{number})\n".into()),
405 ..RepoSettings::default()
406 };
407 assert_eq!(tidy_merge_settings(custom).unwrap().merge_commit_template.as_deref(), Some("{title} (#{number})"));
408 let long = RepoSettings { merge_commit_template: Some("x".repeat(MAX_TEMPLATE_CHARS + 1)), ..RepoSettings::default() };
409 assert!(tidy_merge_settings(long).is_err());
410 let commits = RepoSettings { merge_commit_message: CommitMessageDefault::PullTitleAndCommits, ..RepoSettings::default() };
411 assert!(tidy_merge_settings(commits).is_err(), "squash only");
412 }
413
414 #[test]
415 fn a_row_from_before_merge_settings_reads_as_the_defaults() {
416 let read: RepoSettings = serde_json::from_value::<SettingsRow>(serde_json::json!({
417 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
418 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
419 "updated_by": "ana", "updated_at": ""
420 }))
421 .unwrap()
422 .into();
423 let defaults = RepoSettings::default();
424 assert_eq!(read.allowed_methods(), defaults.allowed_methods());
425 assert_eq!(read.default_merge_method, defaults.default_merge_method);
426 assert!(read.allow_auto_merge && read.auto_close_issues && read.allow_commit_comments && read.suggest_updating_branches);
427 assert!(!read.delete_branch_on_merge && !read.require_web_commit_sign_off);
428 let stored: RepoSettings = serde_json::from_value::<SettingsRow>(serde_json::json!({
429 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
430 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
431 "allow_merge_commit": 0, "allow_fast_forward": 1, "default_merge_method": "fast_forward",
432 "squash_commit_message": "pull_title_and_commits", "delete_branch_on_merge": 1,
433 "updated_by": "ana", "updated_at": ""
434 }))
435 .unwrap()
436 .into();
437 assert!(!stored.allow_merge_commit && stored.allow_fast_forward && stored.delete_branch_on_merge);
438 assert_eq!(stored.default_merge_method, g1t_contracts::rules::MergeMethod::FastForward);
439 assert_eq!(stored.squash_commit_message, CommitMessageDefault::PullTitleAndCommits);
440 assert_eq!(name_of(&stored.squash_commit_message), "pull_title_and_commits");
441 }
442
443 #[test]
444 fn issues_are_on_by_default_except_in_a_fork() {
445 let unset = RepoSettings::default();
446 assert!(unset.issues_on(false));
447 assert!(!unset.issues_on(true));
448 let on = RepoSettings { has_issues: Some(true), ..RepoSettings::default() };
449 assert!(on.issues_on(true), "a fork can turn them on");
450 let off = RepoSettings { has_issues: Some(false), ..RepoSettings::default() };
451 assert!(!off.issues_on(false));
452 }
453
454 #[test]
455 fn a_stored_row_keeps_the_issues_setting_or_its_absence() {
456 let row = |has_issues: serde_json::Value| -> RepoSettings {
457 serde_json::from_value::<SettingsRow>(serde_json::json!({
458 "auto_merge": 0, "require_up_to_date": 0, "required_approvals": 0,
459 "count_agent_approvals": 1, "allow_ignoring_checks": 1,
460 "has_issues": has_issues, "updated_by": "ana", "updated_at": ""
461 }))
462 .unwrap()
463 .into()
464 };
465 assert_eq!(row(serde_json::Value::Null).has_issues, None);
466 assert_eq!(row(serde_json::json!(0)).has_issues, Some(false));
467 assert_eq!(row(serde_json::json!(1)).has_issues, Some(true));
468 }
469}