g1t/services/work/src/settings.rs

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