g1t/services/work/src/settings.rs
| 1 | //! A repository's settings for how its pull requests are handled, and the |
| 2 | //! rule about approvals that merging enforces. |
| 3 | |
| 4 | use std::collections::HashMap; |
| 5 | |
| 6 | use g1t_contracts::time::rfc3339; |
| 7 | use g1t_contracts::work::*; |
| 8 | use g1t_contracts::{FailureCode, Outcome}; |
| 9 | use g1t_kit::now_ms; |
| 10 | use serde::Deserialize; |
| 11 | use worker::Result; |
| 12 | |
| 13 | use crate::Work; |
| 14 | use crate::reviews::AGENT_ID; |
| 15 | |
| 16 | const MAX_REQUIRED_APPROVALS: u32 = 6; |
| 17 | const MAX_REVISIONS: u32 = 5; |
| 18 | |
| 19 | #[derive(Deserialize)] |
| 20 | struct 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 | |
| 37 | impl 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; the author's own does not count. |
| 64 | pub(crate) fn approvals_missing( |
| 65 | settings: &RepoSettings, |
| 66 | author_id: &str, |
| 67 | verdicts: &[(String, Verdict)], |
| 68 | ) -> Option<String> { |
| 69 | if settings.required_approvals == 0 { |
| 70 | return None; |
| 71 | } |
| 72 | let others = || { |
| 73 | verdicts |
| 74 | .iter() |
| 75 | .filter(|(reviewer, _)| reviewer != author_id) |
| 76 | }; |
| 77 | if others().any(|(_, verdict)| *verdict == Verdict::RequestChanges) { |
| 78 | return Some("A reviewer has asked for changes.".to_owned()); |
| 79 | } |
| 80 | let approvals = others() |
| 81 | .filter(|(reviewer, _)| settings.count_agent_approvals || reviewer != AGENT_ID) |
| 82 | .count() as u32; |
| 83 | if approvals >= settings.required_approvals { |
| 84 | return None; |
| 85 | } |
| 86 | let needed = settings.required_approvals; |
| 87 | let from = if settings.count_agent_approvals { |
| 88 | "" |
| 89 | } else { |
| 90 | " from people" |
| 91 | }; |
| 92 | Some(format!( |
| 93 | "This repository requires {needed} approving {}{from} before a pull request merges; this one has {approvals}.", |
| 94 | if needed == 1 { "review" } else { "reviews" }, |
| 95 | )) |
| 96 | } |
| 97 | |
| 98 | #[derive(Deserialize)] |
| 99 | struct VerdictRow { |
| 100 | author_id: String, |
| 101 | verdict: Verdict, |
| 102 | } |
| 103 | |
| 104 | impl Work { |
| 105 | /// The settings of a repository, by its id. Defaults if none were set. |
| 106 | pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> { |
| 107 | let row = async { |
| 108 | self.db |
| 109 | .prepare("SELECT * FROM repo_settings WHERE repo_id = ?") |
| 110 | .bind(&[repo_id.into()])? |
| 111 | .first::<SettingsRow>(None) |
| 112 | .await |
| 113 | }; |
| 114 | let (row, hold) = futures_util::future::try_join(row, self.holds_low_confidence(repo_id)).await?; |
| 115 | Ok(RepoSettings { |
| 116 | hold_low_confidence: hold, |
| 117 | ..row.map_or_else(RepoSettings::default, RepoSettings::from) |
| 118 | }) |
| 119 | } |
| 120 | |
| 121 | /// What is missing before a pull request has the approvals its |
| 122 | /// repository asks for, or `None` if nothing is. |
| 123 | pub(crate) async fn approvals_gap( |
| 124 | &self, |
| 125 | settings: &RepoSettings, |
| 126 | pull: &Pull, |
| 127 | ) -> Result<Option<String>> { |
| 128 | if settings.required_approvals == 0 { |
| 129 | return Ok(None); |
| 130 | } |
| 131 | let rows = self |
| 132 | .db |
| 133 | .prepare( |
| 134 | "SELECT author_id, verdict FROM comments |
| 135 | WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id", |
| 136 | ) |
| 137 | .bind(&[pull.repo_id.as_str().into(), pull.number.into()])? |
| 138 | .all() |
| 139 | .await? |
| 140 | .results::<VerdictRow>()?; |
| 141 | // Each reviewer's latest verdict is the one that stands. |
| 142 | let mut latest: HashMap<String, Verdict> = HashMap::new(); |
| 143 | for row in rows { |
| 144 | latest.insert(row.author_id, row.verdict); |
| 145 | } |
| 146 | let verdicts: Vec<(String, Verdict)> = latest.into_iter().collect(); |
| 147 | Ok(approvals_missing(settings, &pull.author.id, &verdicts)) |
| 148 | } |
| 149 | |
| 150 | pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> { |
| 151 | let repo = match self.repo(&a.repo, &a.viewer).await? { |
| 152 | Outcome::Ok(repo) => repo, |
| 153 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 154 | }; |
| 155 | Ok(Outcome::Ok(self.settings(&repo.id).await?)) |
| 156 | } |
| 157 | |
| 158 | pub(crate) async fn update_settings( |
| 159 | &self, |
| 160 | a: UpdateSettingsArgs, |
| 161 | ) -> Result<Outcome<RepoSettings>> { |
| 162 | let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? { |
| 163 | Outcome::Ok(repo) => repo, |
| 164 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 165 | }; |
| 166 | if let Outcome::Fail(failure) = crate::retired::writable(&repo) { |
| 167 | return Ok(Outcome::Fail(failure)); |
| 168 | } |
| 169 | if !a.actor.verified { |
| 170 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 171 | } |
| 172 | if let Outcome::Fail(failure) = |
| 173 | crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings) |
| 174 | { |
| 175 | return Ok(Outcome::Fail(failure)); |
| 176 | } |
| 177 | let settings = RepoSettings { |
| 178 | required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS), |
| 179 | max_revisions: a.settings.max_revisions.min(MAX_REVISIONS), |
| 180 | required_checks: tidy_required(&a.settings.required_checks), |
| 181 | updated_by: Some(a.actor.username), |
| 182 | updated_at: Some(rfc3339(now_ms())), |
| 183 | ..a.settings |
| 184 | }; |
| 185 | self.db |
| 186 | .prepare( |
| 187 | "INSERT INTO repo_settings |
| 188 | (repo_id, auto_merge, require_up_to_date, required_approvals, |
| 189 | count_agent_approvals, allow_ignoring_checks, agent_review, max_revisions, |
| 190 | merge_queue, required_checks, updated_by, updated_at) |
| 191 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 192 | ON CONFLICT (repo_id) DO UPDATE SET |
| 193 | auto_merge = excluded.auto_merge, |
| 194 | require_up_to_date = excluded.require_up_to_date, |
| 195 | required_approvals = excluded.required_approvals, |
| 196 | count_agent_approvals = excluded.count_agent_approvals, |
| 197 | allow_ignoring_checks = excluded.allow_ignoring_checks, |
| 198 | agent_review = excluded.agent_review, |
| 199 | max_revisions = excluded.max_revisions, |
| 200 | merge_queue = excluded.merge_queue, |
| 201 | required_checks = excluded.required_checks, |
| 202 | updated_by = excluded.updated_by, |
| 203 | updated_at = excluded.updated_at", |
| 204 | ) |
| 205 | .bind(&[ |
| 206 | repo.id.as_str().into(), |
| 207 | u32::from(settings.auto_merge).into(), |
| 208 | u32::from(settings.require_up_to_date).into(), |
| 209 | settings.required_approvals.into(), |
| 210 | u32::from(settings.count_agent_approvals).into(), |
| 211 | u32::from(settings.allow_ignoring_checks).into(), |
| 212 | u32::from(settings.agent_review).into(), |
| 213 | settings.max_revisions.into(), |
| 214 | u32::from(settings.merge_queue).into(), |
| 215 | serde_json::to_string(&settings.required_checks)?.into(), |
| 216 | settings.updated_by.as_deref().unwrap_or_default().into(), |
| 217 | settings.updated_at.as_deref().unwrap_or_default().into(), |
| 218 | ])? |
| 219 | .run() |
| 220 | .await?; |
| 221 | self.set_hold_low_confidence( |
| 222 | &repo.id, |
| 223 | settings.hold_low_confidence, |
| 224 | settings.updated_by.as_deref().unwrap_or_default(), |
| 225 | settings.updated_at.as_deref().unwrap_or_default(), |
| 226 | ) |
| 227 | .await?; |
| 228 | Ok(Outcome::Ok(settings)) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | #[cfg(test)] |
| 233 | mod tests { |
| 234 | use super::*; |
| 235 | |
| 236 | fn verdicts(list: &[(&str, Verdict)]) -> Vec<(String, Verdict)> { |
| 237 | list.iter() |
| 238 | .map(|(reviewer, verdict)| ((*reviewer).to_owned(), *verdict)) |
| 239 | .collect() |
| 240 | } |
| 241 | |
| 242 | fn requiring(approvals: u32) -> RepoSettings { |
| 243 | RepoSettings { |
| 244 | required_approvals: approvals, |
| 245 | ..RepoSettings::default() |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn nothing_is_required_by_default() { |
| 251 | assert_eq!( |
| 252 | approvals_missing(&RepoSettings::default(), "usr_a", &[]), |
| 253 | None |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn approvals_are_counted_per_reviewer_and_not_from_the_author() { |
| 259 | let one = requiring(1); |
| 260 | assert!(approvals_missing(&one, "usr_a", &[]).is_some()); |
| 261 | let own = verdicts(&[("usr_a", Verdict::Approve)]); |
| 262 | assert!(approvals_missing(&one, "usr_a", &own).is_some()); |
| 263 | let other = verdicts(&[("usr_b", Verdict::Approve)]); |
| 264 | assert_eq!(approvals_missing(&one, "usr_a", &other), None); |
| 265 | assert!(approvals_missing(&requiring(2), "usr_a", &other).is_some()); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn a_request_for_changes_blocks_whatever_else_was_approved() { |
| 270 | let mixed = verdicts(&[ |
| 271 | ("usr_b", Verdict::Approve), |
| 272 | ("usr_c", Verdict::RequestChanges), |
| 273 | ]); |
| 274 | assert_eq!( |
| 275 | approvals_missing(&requiring(1), "usr_a", &mixed).as_deref(), |
| 276 | Some("A reviewer has asked for changes.") |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn an_agents_approval_counts_only_where_the_repository_lets_it() { |
| 282 | let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]); |
| 283 | assert_eq!(approvals_missing(&requiring(1), "usr_a", &agent), None); |
| 284 | let people_only = RepoSettings { |
| 285 | count_agent_approvals: false, |
| 286 | ..requiring(1) |
| 287 | }; |
| 288 | assert!(approvals_missing(&people_only, "usr_a", &agent).is_some()); |
| 289 | } |
| 290 | } |