Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 30 | /// JSON array of names. |
| 31 | #[serde(default)] | |
| 32 | required_checks: Option<String>, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 33 | #[serde(default)] |
| 34 | require_code_owner_review: u8, | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 35 | updated_by: String, |
| 36 | updated_at: String, | |
| 37 | } | |
| 38 | ||
| 39 | impl From<SettingsRow> for RepoSettings { | |
| 40 | fn from(row: SettingsRow) -> Self { | |
| 41 | RepoSettings { | |
| 42 | auto_merge: row.auto_merge != 0, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 43 | required_checks: row |
| 44 | .required_checks | |
| 45 | .as_deref() | |
| 46 | .and_then(|names| serde_json::from_str(names).ok()) | |
| 47 | .unwrap_or_default(), | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 48 | require_up_to_date: row.require_up_to_date != 0, |
| 49 | required_approvals: row.required_approvals, | |
| 50 | count_agent_approvals: row.count_agent_approvals != 0, | |
| 51 | allow_ignoring_checks: row.allow_ignoring_checks != 0, | |
| 52 | agent_review: row.agent_review != 0, | |
| 53 | max_revisions: row.max_revisions, | |
| 54 | merge_queue: row.merge_queue != 0, | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 55 | // Kept in its own table (confidence.rs), read beside this row. |
| 56 | hold_low_confidence: true, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 57 | require_code_owner_review: row.require_code_owner_review != 0, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 58 | updated_by: Some(row.updated_by), |
| 59 | updated_at: Some(row.updated_at), | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /// What is missing before a pull request has the approvals its repository | |
| 65 | /// asks for, or `None` if nothing is. `verdicts` is each reviewer's id and | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 66 | /// their most recent verdict; its owner's own (whoever asked g1t for it, |
| 67 | /// or its author: Pull::owner) does not count. | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 68 | pub(crate) fn approvals_missing( |
| 69 | settings: &RepoSettings, | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 70 | owner_id: &str, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 71 | verdicts: &[(String, Verdict)], |
| 72 | ) -> Option<String> { | |
| 73 | if settings.required_approvals == 0 { | |
| 74 | return None; | |
| 75 | } | |
| 76 | let others = || { | |
| 77 | verdicts | |
| 78 | .iter() | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 79 | .filter(|(reviewer, _)| reviewer != owner_id) |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 80 | }; |
| 81 | if others().any(|(_, verdict)| *verdict == Verdict::RequestChanges) { | |
| 82 | return Some("A reviewer has asked for changes.".to_owned()); | |
| 83 | } | |
| 84 | let approvals = others() | |
| 85 | .filter(|(reviewer, _)| settings.count_agent_approvals || reviewer != AGENT_ID) | |
| 86 | .count() as u32; | |
| 87 | if approvals >= settings.required_approvals { | |
| 88 | return None; | |
| 89 | } | |
| 90 | let needed = settings.required_approvals; | |
| 91 | let from = if settings.count_agent_approvals { | |
| 92 | "" | |
| 93 | } else { | |
| 94 | " from people" | |
| 95 | }; | |
| 96 | Some(format!( | |
| 97 | "This repository requires {needed} approving {}{from} before a pull request merges; this one has {approvals}.", | |
| 98 | if needed == 1 { "review" } else { "reviews" }, | |
| 99 | )) | |
| 100 | } | |
| 101 | ||
| 102 | #[derive(Deserialize)] | |
| 103 | struct VerdictRow { | |
| 104 | author_id: String, | |
| 105 | verdict: Verdict, | |
| 106 | } | |
| 107 | ||
| 108 | impl Work { | |
| 109 | /// The settings of a repository, by its id. Defaults if none were set. | |
| 110 | pub(crate) async fn settings(&self, repo_id: &str) -> Result<RepoSettings> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 111 | if let Some(found) = self.prefetched_repo(repo_id) { |
| 112 | let row = found.first::<SettingsRow>(crate::prefetch::Slot::Settings)?; | |
| 113 | let hold = found | |
| 114 | .first::<crate::rows::NumberRow>(crate::prefetch::Slot::Hold)? | |
| 115 | .is_none_or(|row| row.n != 0); | |
| 116 | return Ok(RepoSettings { | |
| 117 | hold_low_confidence: hold, | |
| 118 | ..row.map_or_else(RepoSettings::default, RepoSettings::from) | |
| 119 | }); | |
| 120 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 121 | let row = async { |
| 122 | self.db | |
| 123 | .prepare("SELECT * FROM repo_settings WHERE repo_id = ?") | |
| 124 | .bind(&[repo_id.into()])? | |
| 125 | .first::<SettingsRow>(None) | |
| 126 | .await | |
| 127 | }; | |
| 128 | let (row, hold) = futures_util::future::try_join(row, self.holds_low_confidence(repo_id)).await?; | |
| 129 | Ok(RepoSettings { | |
| 130 | hold_low_confidence: hold, | |
| 131 | ..row.map_or_else(RepoSettings::default, RepoSettings::from) | |
| 132 | }) | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 133 | } |
| 134 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 135 | /// The settings that hold for a pull request: its repository's, which |
| 136 | /// protect the default branch, or for one into another branch, those | |
| 137 | /// without the protection (`RepoSettings::for_base`). A pull request's | |
| 138 | /// base is stored as none for the default branch. | |
| 139 | pub(crate) async fn settings_for(&self, pull: &Pull) -> Result<RepoSettings> { | |
| 140 | let settings = self.settings(&pull.repo_id).await?; | |
| 141 | Ok(match pull.base.as_deref().filter(|base| !base.is_empty()) { | |
| 142 | None => settings, | |
| 143 | Some(base) => settings.for_base(base, ""), | |
| 144 | }) | |
| 145 | } | |
| 146 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 147 | /// What is missing before a pull request has the approvals its |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 148 | /// repository asks for, or `None` if nothing is: the number of |
| 149 | /// approvals, then its code owners' (codeowners.rs). | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 150 | pub(crate) async fn approvals_gap( |
| 151 | &self, | |
| 152 | settings: &RepoSettings, | |
| 153 | pull: &Pull, | |
| 154 | ) -> Result<Option<String>> { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 155 | if let Some(missing) = self.count_gap(settings, pull).await? { |
| 156 | return Ok(Some(missing)); | |
| 157 | } | |
| 158 | self.code_owners_gap(settings, pull).await | |
| 159 | } | |
| 160 | ||
| 161 | async fn count_gap( | |
| 162 | &self, | |
| 163 | settings: &RepoSettings, | |
| 164 | pull: &Pull, | |
| 165 | ) -> Result<Option<String>> { | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 166 | if settings.required_approvals == 0 { |
| 167 | return Ok(None); | |
| 168 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 169 | let rows = match self.prefetched_pull(&pull.id) { |
| 170 | Some(found) => found.rows::<VerdictRow>(crate::prefetch::Slot::Verdicts)?, | |
| 171 | None => self | |
| 172 | .db | |
| 173 | .prepare( | |
| 174 | "SELECT author_id, verdict FROM comments | |
| 175 | WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id", | |
| 176 | ) | |
| 177 | .bind(&[pull.repo_id.as_str().into(), pull.number.into()])? | |
| 178 | .all() | |
| 179 | .await? | |
| 180 | .results::<VerdictRow>()?, | |
| 181 | }; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 182 | // Each reviewer's latest verdict is the one that stands. |
| 183 | let mut latest: HashMap<String, Verdict> = HashMap::new(); | |
| 184 | for row in rows { | |
| 185 | latest.insert(row.author_id, row.verdict); | |
| 186 | } | |
| 187 | let verdicts: Vec<(String, Verdict)> = latest.into_iter().collect(); | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 188 | Ok(approvals_missing(settings, &pull.owner().id, &verdicts)) |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 189 | } |
| 190 | ||
| 191 | pub(crate) async fn get_settings(&self, a: ViewArgs) -> Result<Outcome<RepoSettings>> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 192 | // Read beside the access check (prefetch.rs), kept only if it passes. |
| 193 | let read = |repo_id: String| async move { self.timing.db(2, self.settings(&repo_id)).await }; | |
| 194 | Ok(match self.repo_then(&a.repo, &a.viewer, read).await? { | |
| 195 | Outcome::Ok((_, settings)) => Outcome::Ok(settings), | |
| 196 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 197 | }) | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 198 | } |
| 199 | ||
| 200 | pub(crate) async fn update_settings( | |
| 201 | &self, | |
| 202 | a: UpdateSettingsArgs, | |
| 203 | ) -> Result<Outcome<RepoSettings>> { | |
| 204 | let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? { | |
| 205 | Outcome::Ok(repo) => repo, | |
| 206 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 207 | }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 208 | if let Outcome::Fail(failure) = crate::retired::writable(&repo) { |
| 209 | return Ok(Outcome::Fail(failure)); | |
| 210 | } | |
| 211 | if !a.actor.verified { | |
| 212 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); | |
| 213 | } | |
| 214 | if let Outcome::Fail(failure) = | |
| 215 | crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::ManageSettings) | |
| 216 | { | |
| 217 | return Ok(Outcome::Fail(failure)); | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 218 | } |
| 219 | let settings = RepoSettings { | |
| 220 | required_approvals: a.settings.required_approvals.min(MAX_REQUIRED_APPROVALS), | |
| 221 | max_revisions: a.settings.max_revisions.min(MAX_REVISIONS), | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 222 | required_checks: tidy_required(&a.settings.required_checks), |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 223 | updated_by: Some(a.actor.username), |
| 224 | updated_at: Some(rfc3339(now_ms())), | |
| 225 | ..a.settings | |
| 226 | }; | |
| 227 | self.db | |
| 228 | .prepare( | |
| 229 | "INSERT INTO repo_settings | |
| 230 | (repo_id, auto_merge, require_up_to_date, required_approvals, | |
| 231 | count_agent_approvals, allow_ignoring_checks, agent_review, max_revisions, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 232 | merge_queue, required_checks, require_code_owner_review, updated_by, updated_at) |
| 233 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 234 | ON CONFLICT (repo_id) DO UPDATE SET |
| 235 | auto_merge = excluded.auto_merge, | |
| 236 | require_up_to_date = excluded.require_up_to_date, | |
| 237 | required_approvals = excluded.required_approvals, | |
| 238 | count_agent_approvals = excluded.count_agent_approvals, | |
| 239 | allow_ignoring_checks = excluded.allow_ignoring_checks, | |
| 240 | agent_review = excluded.agent_review, | |
| 241 | max_revisions = excluded.max_revisions, | |
| 242 | merge_queue = excluded.merge_queue, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 243 | required_checks = excluded.required_checks, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 244 | require_code_owner_review = excluded.require_code_owner_review, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 245 | updated_by = excluded.updated_by, |
| 246 | updated_at = excluded.updated_at", | |
| 247 | ) | |
| 248 | .bind(&[ | |
| 249 | repo.id.as_str().into(), | |
| 250 | u32::from(settings.auto_merge).into(), | |
| 251 | u32::from(settings.require_up_to_date).into(), | |
| 252 | settings.required_approvals.into(), | |
| 253 | u32::from(settings.count_agent_approvals).into(), | |
| 254 | u32::from(settings.allow_ignoring_checks).into(), | |
| 255 | u32::from(settings.agent_review).into(), | |
| 256 | settings.max_revisions.into(), | |
| 257 | u32::from(settings.merge_queue).into(), | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 258 | serde_json::to_string(&settings.required_checks)?.into(), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 259 | u32::from(settings.require_code_owner_review).into(), |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 260 | settings.updated_by.as_deref().unwrap_or_default().into(), |
| 261 | settings.updated_at.as_deref().unwrap_or_default().into(), | |
| 262 | ])? | |
| 263 | .run() | |
| 264 | .await?; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 265 | self.set_hold_low_confidence( |
| 266 | &repo.id, | |
| 267 | settings.hold_low_confidence, | |
| 268 | settings.updated_by.as_deref().unwrap_or_default(), | |
| 269 | settings.updated_at.as_deref().unwrap_or_default(), | |
| 270 | ) | |
| 271 | .await?; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 272 | Ok(Outcome::Ok(settings)) |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | #[cfg(test)] | |
| 277 | mod tests { | |
| 278 | use super::*; | |
| 279 | ||
| 280 | fn verdicts(list: &[(&str, Verdict)]) -> Vec<(String, Verdict)> { | |
| 281 | list.iter() | |
| 282 | .map(|(reviewer, verdict)| ((*reviewer).to_owned(), *verdict)) | |
| 283 | .collect() | |
| 284 | } | |
| 285 | ||
| 286 | fn requiring(approvals: u32) -> RepoSettings { | |
| 287 | RepoSettings { | |
| 288 | required_approvals: approvals, | |
| 289 | ..RepoSettings::default() | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | #[test] | |
| 294 | fn nothing_is_required_by_default() { | |
| 295 | assert_eq!( | |
| 296 | approvals_missing(&RepoSettings::default(), "usr_a", &[]), | |
| 297 | None | |
| 298 | ); | |
| 299 | } | |
| 300 | ||
| 301 | #[test] | |
| 302 | fn approvals_are_counted_per_reviewer_and_not_from_the_author() { | |
| 303 | let one = requiring(1); | |
| 304 | assert!(approvals_missing(&one, "usr_a", &[]).is_some()); | |
| 305 | let own = verdicts(&[("usr_a", Verdict::Approve)]); | |
| 306 | assert!(approvals_missing(&one, "usr_a", &own).is_some()); | |
| 307 | let other = verdicts(&[("usr_b", Verdict::Approve)]); | |
| 308 | assert_eq!(approvals_missing(&one, "usr_a", &other), None); | |
| 309 | assert!(approvals_missing(&requiring(2), "usr_a", &other).is_some()); | |
| 310 | } | |
| 311 | ||
| 312 | #[test] | |
| 313 | fn a_request_for_changes_blocks_whatever_else_was_approved() { | |
| 314 | let mixed = verdicts(&[ | |
| 315 | ("usr_b", Verdict::Approve), | |
| 316 | ("usr_c", Verdict::RequestChanges), | |
| 317 | ]); | |
| 318 | assert_eq!( | |
| 319 | approvals_missing(&requiring(1), "usr_a", &mixed).as_deref(), | |
| 320 | Some("A reviewer has asked for changes.") | |
| 321 | ); | |
| 322 | } | |
| 323 | ||
| 324 | #[test] | |
| 325 | fn an_agents_approval_counts_only_where_the_repository_lets_it() { | |
| 326 | let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]); | |
| 327 | assert_eq!(approvals_missing(&requiring(1), "usr_a", &agent), None); | |
| 328 | let people_only = RepoSettings { | |
| 329 | count_agent_approvals: false, | |
| 330 | ..requiring(1) | |
| 331 | }; | |
| 332 | assert!(approvals_missing(&people_only, "usr_a", &agent).is_some()); | |
| 333 | } | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 334 | |
| 335 | #[test] | |
| 336 | fn whoever_asked_g1t_cannot_approve_its_change_for_them() { | |
| 337 | use crate::rows::stored::{ASKER, G1T, pull}; | |
| 338 | let made = pull(G1T, Some(ASKER)); | |
| 339 | let owner = &made.owner().id; | |
| 340 | // Their own approval is no approval, as an author's was not. | |
| 341 | let theirs = verdicts(&[(ASKER.0, Verdict::Approve)]); | |
| 342 | assert!(approvals_missing(&requiring(1), owner, &theirs).is_some()); | |
| 343 | // Nor does their asking for changes block it: it is theirs. | |
| 344 | let changes = verdicts(&[(ASKER.0, Verdict::RequestChanges), ("usr_b", Verdict::Approve)]); | |
| 345 | assert_eq!(approvals_missing(&requiring(1), owner, &changes), None); | |
| 346 | // g1t's agent reviewing the change g1t made counts where the | |
| 347 | // repository lets an agent's approval count, as it did. | |
| 348 | let agent = verdicts(&[(AGENT_ID, Verdict::Approve)]); | |
| 349 | assert_eq!(approvals_missing(&requiring(1), owner, &agent), None); | |
| 350 | } | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 351 | } |