Skip to content

g1t/services/work/src/settings.rs

351 lines13,770 bytesCodeBlame

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 shell1//! 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents30 /// 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 bar33 #[serde(default)]
34 require_code_owner_review: u8,
Agents as a team: lifecycle, merge queue, billing and a new shell35 updated_by: String,
36 updated_at: String,
37}
38
39impl 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 incidents43 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 shell48 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 step55 // 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 bar57 require_code_owner_review: row.require_code_owner_review != 0,
Agents as a team: lifecycle, merge queue, billing and a new shell58 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 rights66/// 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 shell68pub(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 rights70 owner_id: &str,
Agents as a team: lifecycle, merge queue, billing and a new shell71 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 rights79 .filter(|(reviewer, _)| reviewer != owner_id)
Agents as a team: lifecycle, merge queue, billing and a new shell80 };
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)]
103struct VerdictRow {
104 author_id: String,
105 verdict: Verdict,
106}
107
108impl 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 daily111 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 step121 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 shell133 }
134
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar135 /// 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 shell147 /// 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 bar148 /// 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 shell150 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 bar155 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 shell166 if settings.required_approvals == 0 {
167 return Ok(None);
168 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily169 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 shell182 // 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 rights188 Ok(approvals_missing(settings, &pull.owner().id, &verdicts))
Agents as a team: lifecycle, merge queue, billing and a new shell189 }
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 daily192 // 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 shell198 }
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 look208 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 shell218 }
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 incidents222 required_checks: tidy_required(&a.settings.required_checks),
Agents as a team: lifecycle, merge queue, billing and a new shell223 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 bar232 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 shell234 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 incidents243 required_checks = excluded.required_checks,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar244 require_code_owner_review = excluded.require_code_owner_review,
Agents as a team: lifecycle, merge queue, billing and a new shell245 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 incidents258 serde_json::to_string(&settings.required_checks)?.into(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar259 u32::from(settings.require_code_owner_review).into(),
Agents as a team: lifecycle, merge queue, billing and a new shell260 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 step265 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 shell272 Ok(Outcome::Ok(settings))
273 }
274}
275
276#[cfg(test)]
277mod 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 rights334
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 shell351}