flagon-io/g1t

public

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/services/work/src/settings.rs

322 lines12,481 bytesCodeBlame
1//! A repository's settings for how its pull requests are handled, and the
2//! rule about approvals that merging enforces.
3
4use std::collections::HashMap;
5
6use g1t_contracts::time::rfc3339;
7use g1t_contracts::work::*;
8use g1t_contracts::{FailureCode, Outcome};
9use g1t_kit::now_ms;
10use serde::Deserialize;
11use worker::Result;
12
13use crate::Work;
14use crate::reviews::AGENT_ID;
15
16const MAX_REQUIRED_APPROVALS: u32 = 6;
17const MAX_REVISIONS: u32 = 5;
18
19#[derive(Deserialize)]
20struct SettingsRow {
21 auto_merge: u8,
22 require_up_to_date: u8,
23 required_approvals: u32,
24 count_agent_approvals: u8,
25 allow_ignoring_checks: u8,
26 agent_review: u8,
27 max_revisions: u32,
28 #[serde(default)]
29 merge_queue: u8,
30 /// JSON array of names.
31 #[serde(default)]
32 required_checks: Option<String>,
33 updated_by: String,
34 updated_at: String,
35}
36
37impl From<SettingsRow> for RepoSettings {
38 fn from(row: SettingsRow) -> Self {
39 RepoSettings {
40 auto_merge: row.auto_merge != 0,
41 required_checks: row
42 .required_checks
43 .as_deref()
44 .and_then(|names| serde_json::from_str(names).ok())
45 .unwrap_or_default(),
46 require_up_to_date: row.require_up_to_date != 0,
47 required_approvals: row.required_approvals,
48 count_agent_approvals: row.count_agent_approvals != 0,
49 allow_ignoring_checks: row.allow_ignoring_checks != 0,
50 agent_review: row.agent_review != 0,
51 max_revisions: row.max_revisions,
52 merge_queue: row.merge_queue != 0,
53 // Kept in its own table (confidence.rs), read beside this row.
54 hold_low_confidence: true,
55 updated_by: Some(row.updated_by),
56 updated_at: Some(row.updated_at),
57 }
58 }
59}
60
61/// What is missing before a pull request has the approvals its repository
62/// asks for, or `None` if nothing is. `verdicts` is each reviewer's id and
63/// their most recent verdict; its owner's own (whoever asked g1t for it,
64/// or its author: Pull::owner) does not count.
65pub(crate) fn approvals_missing(
66 settings: &RepoSettings,
67 owner_id: &str,
68 verdicts: &[(String, Verdict)],
69) -> Option<String> {
70 if settings.required_approvals == 0 {
71 return None;
72 }
73 let others = || {
74 verdicts
75 .iter()
76 .filter(|(reviewer, _)| reviewer != owner_id)
77 };
78 if others().any(|(_, verdict)| *verdict == Verdict::RequestChanges) {
79 return Some("A reviewer has asked for changes.".to_owned());
80 }
81 let approvals = others()
82 .filter(|(reviewer, _)| settings.count_agent_approvals || reviewer != AGENT_ID)
83 .count() as u32;
84 if approvals >= settings.required_approvals {
85 return None;
86 }
87 let needed = settings.required_approvals;
88 let from = if settings.count_agent_approvals {
89 ""
90 } else {
91 " from people"
92 };
93 Some(format!(
94 "This repository requires {needed} approving {}{from} before a pull request merges; this one has {approvals}.",
95 if needed == 1 { "review" } else { "reviews" },
96 ))
97}
98
99#[derive(Deserialize)]
100struct VerdictRow {
101 author_id: String,
102 verdict: Verdict,
103}
104
105impl Work {
106 /// The settings of a repository, by its id. Defaults if none were set.
107 pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> {
108 if let Some(found) = self.prefetched_repo(repo_id) {
109 let row = found.first::<SettingsRow>(crate::prefetch::Slot::Settings)?;
110 let hold = found
111 .first::<crate::rows::NumberRow>(crate::prefetch::Slot::Hold)?
112 .is_none_or(|row| row.n != 0);
113 return Ok(RepoSettings {
114 hold_low_confidence: hold,
115 ..row.map_or_else(RepoSettings::default, RepoSettings::from)
116 });
117 }
118 let row = async {
119 self.db
120 .prepare("SELECT * FROM repo_settings WHERE repo_id = ?")
121 .bind(&[repo_id.into()])?
122 .first::<SettingsRow>(None)
123 .await
124 };
125 let (row, hold) = futures_util::future::try_join(row, self.holds_low_confidence(repo_id)).await?;
126 Ok(RepoSettings {
127 hold_low_confidence: hold,
128 ..row.map_or_else(RepoSettings::default, RepoSettings::from)
129 })
130 }
131
132 /// What is missing before a pull request has the approvals its
133 /// repository asks for, or `None` if nothing is.
134 pub(crate) async fn approvals_gap(
135 &self,
136 settings: &RepoSettings,
137 pull: &Pull,
138 ) -> Result<Option<String>> {
139 if settings.required_approvals == 0 {
140 return Ok(None);
141 }
142 let rows = match self.prefetched_pull(&pull.id) {
143 Some(found) => found.rows::<VerdictRow>(crate::prefetch::Slot::Verdicts)?,
144 None => self
145 .db
146 .prepare(
147 "SELECT author_id, verdict FROM comments
148 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
149 )
150 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
151 .all()
152 .await?
153 .results::<VerdictRow>()?,
154 };
155 // Each reviewer's latest verdict is the one that stands.
156 let mut latest: HashMap<String, Verdict> = HashMap::new();
157 for row in rows {
158 latest.insert(row.author_id, row.verdict);
159 }
160 let verdicts: Vec<(String, Verdict)> = latest.into_iter().collect();
161 Ok(approvals_missing(settings, &pull.owner().id, &verdicts))
162 }
163
164 pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> {
165 // Read beside the access check (prefetch.rs), kept only if it passes.
166 let read = |repo_id: String| async move { self.timing.db(2, self.settings(&repo_id)).await };
167 Ok(match self.repo_then(&a.repo, &a.viewer, read).await? {
168 Outcome::Ok((_, settings)) => Outcome::Ok(settings),
169 Outcome::Fail(failure) => Outcome::Fail(failure),
170 })
171 }
172
173 pub(crate) async fn update_settings(
174 &self,
175 a: UpdateSettingsArgs,
176 ) -> Result<Outcome<RepoSettings>> {
177 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
178 Outcome::Ok(repo) => repo,
179 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
180 };
181 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
182 return Ok(Outcome::Fail(failure));
183 }
184 if !a.actor.verified {
185 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
186 }
187 if let Outcome::Fail(failure) =
188 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings)
189 {
190 return Ok(Outcome::Fail(failure));
191 }
192 let settings = RepoSettings {
193 required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS),
194 max_revisions: a.settings.max_revisions.min(MAX_REVISIONS),
195 required_checks: tidy_required(&a.settings.required_checks),
196 updated_by: Some(a.actor.username),
197 updated_at: Some(rfc3339(now_ms())),
198 ..a.settings
199 };
200 self.db
201 .prepare(
202 "INSERT INTO repo_settings
203 (repo_id, auto_merge, require_up_to_date, required_approvals,
204 count_agent_approvals, allow_ignoring_checks, agent_review, max_revisions,
205 merge_queue, required_checks, updated_by, updated_at)
206 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
207 ON CONFLICT (repo_id) DO UPDATE SET
208 auto_merge = excluded.auto_merge,
209 require_up_to_date = excluded.require_up_to_date,
210 required_approvals = excluded.required_approvals,
211 count_agent_approvals = excluded.count_agent_approvals,
212 allow_ignoring_checks = excluded.allow_ignoring_checks,
213 agent_review = excluded.agent_review,
214 max_revisions = excluded.max_revisions,
215 merge_queue = excluded.merge_queue,
216 required_checks = excluded.required_checks,
217 updated_by = excluded.updated_by,
218 updated_at = excluded.updated_at",
219 )
220 .bind(&[
221 repo.id.as_str().into(),
222 u32::from(settings.auto_merge).into(),
223 u32::from(settings.require_up_to_date).into(),
224 settings.required_approvals.into(),
225 u32::from(settings.count_agent_approvals).into(),
226 u32::from(settings.allow_ignoring_checks).into(),
227 u32::from(settings.agent_review).into(),
228 settings.max_revisions.into(),
229 u32::from(settings.merge_queue).into(),
230 serde_json::to_string(&settings.required_checks)?.into(),
231 settings.updated_by.as_deref().unwrap_or_default().into(),
232 settings.updated_at.as_deref().unwrap_or_default().into(),
233 ])?
234 .run()
235 .await?;
236 self.set_hold_low_confidence(
237 &repo.id,
238 settings.hold_low_confidence,
239 settings.updated_by.as_deref().unwrap_or_default(),
240 settings.updated_at.as_deref().unwrap_or_default(),
241 )
242 .await?;
243 Ok(Outcome::Ok(settings))
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn verdicts(list: &[(&str, Verdict)]) -> Vec<(String, Verdict)> {
252 list.iter()
253 .map(|(reviewer, verdict)| ((*reviewer).to_owned(), *verdict))
254 .collect()
255 }
256
257 fn requiring(approvals: u32) -> RepoSettings {
258 RepoSettings {
259 required_approvals: approvals,
260 ..RepoSettings::default()
261 }
262 }
263
264 #[test]
265 fn nothing_is_required_by_default() {
266 assert_eq!(
267 approvals_missing(&RepoSettings::default(), "usr_a", &[]),
268 None
269 );
270 }
271
272 #[test]
273 fn approvals_are_counted_per_reviewer_and_not_from_the_author() {
274 let one = requiring(1);
275 assert!(approvals_missing(&one, "usr_a", &[]).is_some());
276 let own = verdicts(&[("usr_a", Verdict::Approve)]);
277 assert!(approvals_missing(&one, "usr_a", &own).is_some());
278 let other = verdicts(&[("usr_b", Verdict::Approve)]);
279 assert_eq!(approvals_missing(&one, "usr_a", &other), None);
280 assert!(approvals_missing(&requiring(2), "usr_a", &other).is_some());
281 }
282
283 #[test]
284 fn a_request_for_changes_blocks_whatever_else_was_approved() {
285 let mixed = verdicts(&[
286 ("usr_b", Verdict::Approve),
287 ("usr_c", Verdict::RequestChanges),
288 ]);
289 assert_eq!(
290 approvals_missing(&requiring(1), "usr_a", &mixed).as_deref(),
291 Some("A reviewer has asked for changes.")
292 );
293 }
294
295 #[test]
296 fn an_agents_approval_counts_only_where_the_repository_lets_it() {
297 let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]);
298 assert_eq!(approvals_missing(&requiring(1), "usr_a", &agent), None);
299 let people_only = RepoSettings {
300 count_agent_approvals: false,
301 ..requiring(1)
302 };
303 assert!(approvals_missing(&people_only, "usr_a", &agent).is_some());
304 }
305
306 #[test]
307 fn whoever_asked_g1t_cannot_approve_its_change_for_them() {
308 use crate::rows::stored::{ASKER, G1T, pull};
309 let made = pull(G1T, Some(ASKER));
310 let owner = &made.owner().id;
311 // Their own approval is no approval, as an author's was not.
312 let theirs = verdicts(&[(ASKER.0, Verdict::Approve)]);
313 assert!(approvals_missing(&requiring(1), owner, &theirs).is_some());
314 // Nor does their asking for changes block it: it is theirs.
315 let changes = verdicts(&[(ASKER.0, Verdict::RequestChanges), ("usr_b", Verdict::Approve)]);
316 assert_eq!(approvals_missing(&requiring(1), owner, &changes), None);
317 // g1t's agent reviewing the change g1t made counts where the
318 // repository lets an agent's approval count, as it did.
319 let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]);
320 assert_eq!(approvals_missing(&requiring(1), owner, &agent), None);
321 }
322}