Skip to content

g1t/services/work/src/settings.rs

228 lines10,034 bytesCodeBlame
1//! A repository's settings for how g1t's agents handle its pull requests,
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;
19const MAX_REVISIONS: u32 = 5;
20
21#[derive(Deserialize)]
22struct SettingsRow {
23 auto_merge: u8,
24 require_up_to_date: u8,
25 required_approvals: u32,
26 count_agent_approvals: u8,
27 allow_ignoring_checks: u8,
28 agent_review: u8,
29 max_revisions: u32,
30 #[serde(default)]
31 merge_queue: u8,
32 /// JSON array of names.
33 #[serde(default)]
34 required_checks: Option<String>,
35 #[serde(default)]
36 require_code_owner_review: u8,
37 updated_by: String,
38 updated_at: String,
39}
40
41impl From<SettingsRow> for RepoSettings {
42 fn from(row: SettingsRow) -> Self {
43 RepoSettings {
44 auto_merge: row.auto_merge != 0,
45 required_checks: row
46 .required_checks
47 .as_deref()
48 .and_then(|names| serde_json::from_str(names).ok())
49 .unwrap_or_default(),
50 require_up_to_date: row.require_up_to_date != 0,
51 required_approvals: row.required_approvals,
52 count_agent_approvals: row.count_agent_approvals != 0,
53 allow_ignoring_checks: row.allow_ignoring_checks != 0,
54 agent_review: row.agent_review != 0,
55 max_revisions: row.max_revisions,
56 merge_queue: row.merge_queue != 0,
57 // Kept in its own table (confidence.rs), read beside this row.
58 hold_low_confidence: true,
59 require_code_owner_review: row.require_code_owner_review != 0,
60 updated_by: Some(row.updated_by),
61 updated_at: Some(row.updated_at),
62 }
63 }
64}
65
66impl Work {
67 /// A repository's stored settings, by its id: how g1t's agents handle
68 /// its pull requests. Defaults if none were set. Branch protection is
69 /// its rules': see `settings_on`.
70 pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> {
71 if let Some(found) = self.prefetched_repo(repo_id) {
72 let row = found.first::<SettingsRow>(crate::prefetch::Slot::Settings)?;
73 let hold = found
74 .first::<crate::rows::NumberRow>(crate::prefetch::Slot::Hold)?
75 .is_none_or(|row| row.n != 0);
76 return Ok(RepoSettings {
77 hold_low_confidence: hold,
78 ..row.map_or_else(RepoSettings::default, RepoSettings::from)
79 });
80 }
81 let row = async {
82 self.db
83 .prepare("SELECT * FROM repo_settings WHERE repo_id = ?")
84 .bind(&[repo_id.into()])?
85 .first::<SettingsRow>(None)
86 .await
87 };
88 let (row, hold) = futures_util::future::try_join(row, self.holds_low_confidence(repo_id)).await?;
89 Ok(RepoSettings {
90 hold_low_confidence: hold,
91 ..row.map_or_else(RepoSettings::default, RepoSettings::from)
92 })
93 }
94
95 /// The default branch's settings: the stored ones, with branch
96 /// protection as its rules stack.
97 pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> {
98 let repo = match self.repo(&a.repo, &a.viewer).await? {
99 Outcome::Ok(repo) => repo,
100 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
101 };
102 Ok(Outcome::Ok(self.timing.db(3, self.default_branch_settings(&repo)).await?))
103 }
104
105 /// Replaces a repository's settings: how g1t's agents work, kept here,
106 /// and the branch protection ones, written to its "Default branch
107 /// protection" ruleset (made when it has none and they protect
108 /// anything). Rules that only rulesets have stay as they are.
109 pub(crate) async fn update_settings(
110 &self,
111 a: UpdateSettingsArgs,
112 ) -> Result<Outcome<RepoSettings>> {
113 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
114 Outcome::Ok(repo) => repo,
115 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
116 };
117 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
118 return Ok(Outcome::Fail(failure));
119 }
120 if !a.actor.verified {
121 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
122 }
123 if let Outcome::Fail(failure) =
124 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings)
125 {
126 return Ok(Outcome::Fail(failure));
127 }
128 let before = self.default_branch_settings(&repo).await?;
129 let settings = RepoSettings {
130 required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS),
131 max_revisions: a.settings.max_revisions.min(MAX_REVISIONS),
132 required_checks: tidy_required(&a.settings.required_checks),
133 updated_by: Some(a.actor.username.clone()),
134 updated_at: Some(rfc3339(now_ms())),
135 ..a.settings
136 };
137 // Branch protection changes need the role that changes it.
138 if protection_changed(&before, &settings)
139 && let Outcome::Fail(failure) =
140 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageProtection)
141 {
142 return Ok(Outcome::Fail(failure));
143 }
144 self.db
145 .prepare(
146 "INSERT INTO repo_settings
147 (repo_id, auto_merge, require_up_to_date, required_approvals,
148 count_agent_approvals, allow_ignoring_checks, agent_review, max_revisions,
149 merge_queue, required_checks, require_code_owner_review, updated_by, updated_at)
150 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
151 ON CONFLICT (repo_id) DO UPDATE SET
152 auto_merge = excluded.auto_merge,
153 require_up_to_date = excluded.require_up_to_date,
154 required_approvals = excluded.required_approvals,
155 count_agent_approvals = excluded.count_agent_approvals,
156 allow_ignoring_checks = excluded.allow_ignoring_checks,
157 agent_review = excluded.agent_review,
158 max_revisions = excluded.max_revisions,
159 merge_queue = excluded.merge_queue,
160 required_checks = excluded.required_checks,
161 require_code_owner_review = excluded.require_code_owner_review,
162 updated_by = excluded.updated_by,
163 updated_at = excluded.updated_at",
164 )
165 .bind(&[
166 repo.id.as_str().into(),
167 u32::from(settings.auto_merge).into(),
168 u32::from(settings.require_up_to_date).into(),
169 settings.required_approvals.into(),
170 u32::from(settings.count_agent_approvals).into(),
171 u32::from(settings.allow_ignoring_checks).into(),
172 u32::from(settings.agent_review).into(),
173 settings.max_revisions.into(),
174 u32::from(settings.merge_queue).into(),
175 serde_json::to_string(&settings.required_checks)?.into(),
176 u32::from(settings.require_code_owner_review).into(),
177 settings.updated_by.as_deref().unwrap_or_default().into(),
178 settings.updated_at.as_deref().unwrap_or_default().into(),
179 ])?
180 .run()
181 .await?;
182 self.set_hold_low_confidence(
183 &repo.id,
184 settings.hold_low_confidence,
185 settings.updated_by.as_deref().unwrap_or_default(),
186 settings.updated_at.as_deref().unwrap_or_default(),
187 )
188 .await?;
189 if protection_changed(&before, &settings) {
190 self.write_branch_protection(&repo, &settings, &a.actor).await?;
191 }
192 Ok(Outcome::Ok(self.default_branch_settings(&repo).await?))
193 }
194}
195
196/// Whether the branch protection part of the settings differs.
197fn protection_changed(before: &RepoSettings, after: &RepoSettings) -> bool {
198 let names = |settings: &RepoSettings| -> Vec<String> {
199 let mut names: Vec<String> = settings.required_checks.iter().map(|name| name.to_lowercase()).collect();
200 names.sort();
201 names
202 };
203 names(before) != names(after)
204 || before.require_up_to_date != after.require_up_to_date
205 || before.required_approvals != after.required_approvals
206 || before.count_agent_approvals != after.count_agent_approvals
207 || before.allow_ignoring_checks != after.allow_ignoring_checks
208 || before.merge_queue != after.merge_queue
209 || before.require_code_owner_review != after.require_code_owner_review
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn only_protection_changes_count_as_protection_changes() {
218 let before = RepoSettings::default();
219 let agents = RepoSettings { auto_merge: true, agent_review: false, max_revisions: 4, ..RepoSettings::default() };
220 assert!(!protection_changed(&before, &agents));
221 let checks = RepoSettings { required_checks: vec!["CI".into()], ..RepoSettings::default() };
222 assert!(protection_changed(&before, &checks));
223 let same = RepoSettings { required_checks: vec!["ci".into()], ..RepoSettings::default() };
224 assert!(!protection_changed(&checks, &same), "names compare without case");
225 let approvals = RepoSettings { required_approvals: 1, ..RepoSettings::default() };
226 assert!(protection_changed(&before, &approvals));
227 }
228}