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

265 lines9,543 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 updated_by: String,
31 updated_at: String,
32}
33
34impl From<SettingsRow> for RepoSettings {
35 fn from(row: SettingsRow) -> Self {
36 RepoSettings {
37 auto_merge: row.auto_merge != 0,
38 require_up_to_date: row.require_up_to_date != 0,
39 required_approvals: row.required_approvals,
40 count_agent_approvals: row.count_agent_approvals != 0,
41 allow_ignoring_checks: row.allow_ignoring_checks != 0,
42 agent_review: row.agent_review != 0,
43 max_revisions: row.max_revisions,
44 merge_queue: row.merge_queue != 0,
45 updated_by: Some(row.updated_by),
46 updated_at: Some(row.updated_at),
47 }
48 }
49}
50
51/// What is missing before a pull request has the approvals its repository
52/// asks for, or `None` if nothing is. `verdicts` is each reviewer's id and
53/// their most recent verdict; the author's own does not count.
54pub(crate) fn approvals_missing(
55 settings: &RepoSettings,
56 author_id: &str,
57 verdicts: &[(String, Verdict)],
58) -> Option<String> {
59 if settings.required_approvals == 0 {
60 return None;
61 }
62 let others = || {
63 verdicts
64 .iter()
65 .filter(|(reviewer, _)| reviewer != author_id)
66 };
67 if others().any(|(_, verdict)| *verdict == Verdict::RequestChanges) {
68 return Some("A reviewer has asked for changes.".to_owned());
69 }
70 let approvals = others()
71 .filter(|(reviewer, _)| settings.count_agent_approvals || reviewer != AGENT_ID)
72 .count() as u32;
73 if approvals >= settings.required_approvals {
74 return None;
75 }
76 let needed = settings.required_approvals;
77 let from = if settings.count_agent_approvals {
78 ""
79 } else {
80 " from people"
81 };
82 Some(format!(
83 "This repository requires {needed} approving {}{from} before a pull request merges; this one has {approvals}.",
84 if needed == 1 { "review" } else { "reviews" },
85 ))
86}
87
88#[derive(Deserialize)]
89struct VerdictRow {
90 author_id: String,
91 verdict: Verdict,
92}
93
94impl Work {
95 /// The settings of a repository, by its id. Defaults if none were set.
96 pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> {
97 Ok(self
98 .db
99 .prepare("SELECT * FROM repo_settings WHERE repo_id = ?")
100 .bind(&[repo_id.into()])?
101 .first::<SettingsRow>(None)
102 .await?
103 .map_or_else(RepoSettings::default, RepoSettings::from))
104 }
105
106 /// What is missing before a pull request has the approvals its
107 /// repository asks for, or `None` if nothing is.
108 pub(crate) async fn approvals_gap(
109 &self,
110 settings: &RepoSettings,
111 pull: &Pull,
112 ) -> Result<Option<String>> {
113 if settings.required_approvals == 0 {
114 return Ok(None);
115 }
116 let rows = self
117 .db
118 .prepare(
119 "SELECT author_id, verdict FROM comments
120 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id",
121 )
122 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
123 .all()
124 .await?
125 .results::<VerdictRow>()?;
126 // Each reviewer's latest verdict is the one that stands.
127 let mut latest: HashMap<String, Verdict> = HashMap::new();
128 for row in rows {
129 latest.insert(row.author_id, row.verdict);
130 }
131 let verdicts: Vec<(String, Verdict)> = latest.into_iter().collect();
132 Ok(approvals_missing(settings, &pull.author.id, &verdicts))
133 }
134
135 pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> {
136 let repo = match self.repo(&a.repo, &a.viewer).await? {
137 Outcome::Ok(repo) => repo,
138 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
139 };
140 Ok(Outcome::Ok(self.settings(&repo.id).await?))
141 }
142
143 pub(crate) async fn update_settings(
144 &self,
145 a: UpdateSettingsArgs,
146 ) -> Result<Outcome<RepoSettings>> {
147 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
148 Outcome::Ok(repo) => repo,
149 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
150 };
151 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
152 return Ok(Outcome::Fail(failure));
153 }
154 if !a.actor.verified {
155 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
156 }
157 if let Outcome::Fail(failure) =
158 crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings)
159 {
160 return Ok(Outcome::Fail(failure));
161 }
162 let settings = RepoSettings {
163 required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS),
164 max_revisions: a.settings.max_revisions.min(MAX_REVISIONS),
165 updated_by: Some(a.actor.username),
166 updated_at: Some(rfc3339(now_ms())),
167 ..a.settings
168 };
169 self.db
170 .prepare(
171 "INSERT INTO repo_settings
172 (repo_id, auto_merge, require_up_to_date, required_approvals,
173 count_agent_approvals, allow_ignoring_checks, agent_review, max_revisions,
174 merge_queue, updated_by, updated_at)
175 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
176 ON CONFLICT (repo_id) DO UPDATE SET
177 auto_merge = excluded.auto_merge,
178 require_up_to_date = excluded.require_up_to_date,
179 required_approvals = excluded.required_approvals,
180 count_agent_approvals = excluded.count_agent_approvals,
181 allow_ignoring_checks = excluded.allow_ignoring_checks,
182 agent_review = excluded.agent_review,
183 max_revisions = excluded.max_revisions,
184 merge_queue = excluded.merge_queue,
185 updated_by = excluded.updated_by,
186 updated_at = excluded.updated_at",
187 )
188 .bind(&[
189 repo.id.as_str().into(),
190 u32::from(settings.auto_merge).into(),
191 u32::from(settings.require_up_to_date).into(),
192 settings.required_approvals.into(),
193 u32::from(settings.count_agent_approvals).into(),
194 u32::from(settings.allow_ignoring_checks).into(),
195 u32::from(settings.agent_review).into(),
196 settings.max_revisions.into(),
197 u32::from(settings.merge_queue).into(),
198 settings.updated_by.as_deref().unwrap_or_default().into(),
199 settings.updated_at.as_deref().unwrap_or_default().into(),
200 ])?
201 .run()
202 .await?;
203 Ok(Outcome::Ok(settings))
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn verdicts(list: &[(&str, Verdict)]) -> Vec<(String, Verdict)> {
212 list.iter()
213 .map(|(reviewer, verdict)| ((*reviewer).to_owned(), *verdict))
214 .collect()
215 }
216
217 fn requiring(approvals: u32) -> RepoSettings {
218 RepoSettings {
219 required_approvals: approvals,
220 ..RepoSettings::default()
221 }
222 }
223
224 #[test]
225 fn nothing_is_required_by_default() {
226 assert_eq!(
227 approvals_missing(&RepoSettings::default(), "usr_a", &[]),
228 None
229 );
230 }
231
232 #[test]
233 fn approvals_are_counted_per_reviewer_and_not_from_the_author() {
234 let one = requiring(1);
235 assert!(approvals_missing(&one, "usr_a", &[]).is_some());
236 let own = verdicts(&[("usr_a", Verdict::Approve)]);
237 assert!(approvals_missing(&one, "usr_a", &own).is_some());
238 let other = verdicts(&[("usr_b", Verdict::Approve)]);
239 assert_eq!(approvals_missing(&one, "usr_a", &other), None);
240 assert!(approvals_missing(&requiring(2), "usr_a", &other).is_some());
241 }
242
243 #[test]
244 fn a_request_for_changes_blocks_whatever_else_was_approved() {
245 let mixed = verdicts(&[
246 ("usr_b", Verdict::Approve),
247 ("usr_c", Verdict::RequestChanges),
248 ]);
249 assert_eq!(
250 approvals_missing(&requiring(1), "usr_a", &mixed).as_deref(),
251 Some("A reviewer has asked for changes.")
252 );
253 }
254
255 #[test]
256 fn an_agents_approval_counts_only_where_the_repository_lets_it() {
257 let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]);
258 assert_eq!(approvals_missing(&requiring(1), "usr_a", &agent), None);
259 let people_only = RepoSettings {
260 count_agent_approvals: false,
261 ..requiring(1)
262 };
263 assert!(approvals_missing(&people_only, "usr_a", &agent).is_some());
264 }
265}