Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

369 linesCodeBlameRaw
1//! Decision records: why the work is the way it is, and how sure that is.
2//!
3//! g1t keeps the ones it saw proved itself: what the merge queue found when
4//! it combined pull requests (`tested`), a person's approval (`approved`)
5//! and merges. `record_decision` keeps an agent's or a person's own
6//! reasons, as `claimed`. Each is published as `decision.recorded`.
7
8use g1t_contracts::access::Capability;
9use g1t_contracts::decisions::{
10 Decision, DecisionConfidence, DecisionKind, DecisionRecorded, ListDecisionsArgs, RecordDecisionArgs,
11};
12use g1t_contracts::time::rfc3339;
13use g1t_contracts::{FailureCode, Outcome, User, new_id};
14use g1t_kit::now_ms;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use worker::Result;
18use worker::wasm_bindgen::JsValue;
19
20use crate::{Work, items};
21
22/// The longest summary kept.
23pub(crate) const MAX_SUMMARY_CHARS: usize = 2000;
24/// The most evidence kept, as JSON.
25pub(crate) const MAX_EVIDENCE_BYTES: usize = 8 * 1024;
26/// The most decisions one list returns.
27const LIST_LIMIT: u32 = 200;
28
29/// A decision about to be kept.
30pub(crate) struct NewDecision<'a> {
31 /// The work item it is about, by id.
32 pub(crate) item: Option<String>,
33 pub(crate) pull: Option<u32>,
34 pub(crate) session_id: Option<String>,
35 /// Who recorded it; `None` for g1t itself.
36 pub(crate) actor: Option<&'a User>,
37 pub(crate) kind: DecisionKind,
38 pub(crate) confidence: DecisionConfidence,
39 pub(crate) summary: String,
40 pub(crate) evidence: Value,
41}
42
43/// A decision's columns, with its work item's number.
44const DECISION_COLUMNS: &str = concat!("decisions.*, ", items::sql::item_number!("decisions.item_id"), " AS item");
45
46#[derive(Deserialize)]
47struct DecisionRow {
48 id: String,
49 #[serde(default)]
50 repo_id: String,
51 #[serde(default)]
52 item: Option<u32>,
53 pull: Option<u32>,
54 session_id: Option<String>,
55 actor_name: Option<String>,
56 kind: String,
57 confidence: String,
58 summary: String,
59 evidence: String,
60 created_at: String,
61}
62
63impl DecisionRow {
64 fn decision(self) -> Option<Decision> {
65 Some(Decision {
66 kind: DecisionKind::parse(&self.kind)?,
67 confidence: serde_json::from_value(Value::String(self.confidence)).ok()?,
68 evidence: serde_json::from_str(&self.evidence).unwrap_or_else(|_| json!({})),
69 id: self.id,
70 item: self.item,
71 pull: self.pull,
72 session_id: self.session_id,
73 actor: self.actor_name,
74 summary: self.summary,
75 created_at: self.created_at,
76 })
77 }
78}
79
80/// What `record_decision` keeps of what it was given: the summary, trimmed,
81/// and the evidence as an object. Says what is wrong otherwise.
82pub(crate) fn checked(
83 kind: DecisionKind,
84 summary: &str,
85 evidence: Option<&Value>,
86 session_id: Option<&str>,
87) -> std::result::Result<(String, Value), &'static str> {
88 if !DecisionKind::RECORDABLE.contains(&kind) {
89 return Err(
90 "kind must be proposal, claim, fix or note. g1t records integration results, approvals and merges itself.",
91 );
92 }
93 let summary = summary.trim();
94 if summary.is_empty() {
95 return Err("Give a summary: the decision, in a sentence or two.");
96 }
97 if summary.chars().count() > MAX_SUMMARY_CHARS {
98 return Err("The summary is too long: 2,000 characters at most.");
99 }
100 let evidence = match evidence {
101 None | Some(Value::Null) => json!({}),
102 Some(object @ Value::Object(_)) => object.clone(),
103 Some(_) => return Err("evidence must be an object, such as {\"commit\": \"…\"}."),
104 };
105 if evidence.to_string().len() > MAX_EVIDENCE_BYTES {
106 return Err("The evidence is too large: 8 KB of JSON at most.");
107 }
108 if session_id.is_some_and(|id| id.len() > 100) {
109 return Err("session_id is not a session's id.");
110 }
111 Ok((summary.to_owned(), evidence))
112}
113
114/// Whether g1t itself acted: as itself, or by a repository's settings
115/// (an older id it kept for those).
116pub(crate) fn is_g1t(actor: &User) -> bool {
117 actor.is_system() || g1t_contracts::system::is_system_id(&actor.id)
118}
119
120/// How sure a merge is: tested when the queue or the repository's own
121/// rules landed it once its checks passed, approved when a person merged
122/// it, claimed when an agent's token did.
123pub(crate) fn merge_confidence(through_queue: bool, actor: &User) -> DecisionConfidence {
124 if through_queue || is_g1t(actor) {
125 DecisionConfidence::Tested
126 } else if g1t_contracts::rules::is_agent(actor) {
127 DecisionConfidence::Claimed
128 } else {
129 DecisionConfidence::Approved
130 }
131}
132
133/// How sure an approval is: a person's is approved, an agent's only
134/// claimed.
135pub(crate) fn approval_confidence(actor: &User) -> DecisionConfidence {
136 if g1t_contracts::rules::is_agent(actor) {
137 DecisionConfidence::Claimed
138 } else {
139 DecisionConfidence::Approved
140 }
141}
142
143impl Work {
144 /// Keeps a decision and publishes `decision.recorded`.
145 pub(crate) async fn keep_decision(&self, repo_id: &str, new: NewDecision<'_>) -> Result<Decision> {
146 let now = now_ms();
147 let item = match &new.item {
148 Some(id) => items::by_id(self, id).await?.map(|held| held.item.number),
149 None => None,
150 };
151 let decision = Decision {
152 id: new_id("dec", now),
153 item,
154 pull: new.pull,
155 session_id: new.session_id,
156 actor: new.actor.map(|actor| actor.username.clone()),
157 kind: new.kind,
158 confidence: new.confidence,
159 summary: new.summary,
160 evidence: new.evidence,
161 created_at: rfc3339(now),
162 };
163 let number = |value: Option<u32>| value.map_or(JsValue::NULL, JsValue::from);
164 let event = serde_json::to_value(DecisionRecorded {
165 repo_id: repo_id.to_owned(),
166 number: decision.pull.or(decision.item).unwrap_or_default(),
167 item: decision.item,
168 decision: decision.clone(),
169 })?;
170 let mut statements = vec![self
171 .db
172 .prepare(
173 "INSERT INTO decisions
174 (id, repo_id, item_id, pull, session_id, actor, actor_name, kind, confidence,
175 summary, evidence, created_at)
176 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
177 )
178 .bind(&[
179 decision.id.as_str().into(),
180 repo_id.into(),
181 crate::optional(&new.item),
182 number(decision.pull),
183 decision.session_id.as_deref().map_or(JsValue::NULL, JsValue::from),
184 new.actor.map_or(JsValue::NULL, |actor| actor.id.as_str().into()),
185 decision.actor.as_deref().map_or(JsValue::NULL, JsValue::from),
186 decision.kind.as_str().into(),
187 decision.confidence.as_str().into(),
188 decision.summary.as_str().into(),
189 decision.evidence.to_string().into(),
190 decision.created_at.as_str().into(),
191 ])?];
192 // Kept and delivered to the sessions it concerns in one change.
193 let sent = crate::coordination::updates::push_delivery(
194 &mut statements,
195 self.delivery("decision.recorded", repo_id, event.clone(), &decision.id, "1", Vec::new())?,
196 );
197 self.db.batch(statements).await?;
198 self.delivered(sent, "decision.recorded", repo_id, new.actor.map(|actor| actor.id.clone()), event).await?;
199 Ok(decision)
200 }
201
202 /// Keeps a decision without failing what it records: a decision that
203 /// could not be kept only logs.
204 pub(crate) async fn keep_decision_quietly(&self, repo_id: &str, new: NewDecision<'_>) {
205 if let Err(error) = self.keep_decision(repo_id, new).await {
206 worker::console_warn!("decision not kept: {error}");
207 }
208 }
209
210 /// `record_decision`: an agent's or a person's reason on a pull
211 /// request, kept as claimed.
212 pub(crate) async fn record_decision(&self, a: RecordDecisionArgs) -> Result<Outcome<Decision>> {
213 let (summary, evidence) =
214 match checked(a.kind, &a.summary, a.evidence.as_ref(), a.session_id.as_deref()) {
215 Ok(checked) => checked,
216 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
217 };
218 let viewer = Some(a.actor.clone());
219 let (repo, pull) = match self.pull_at(&a.repo, a.number, &viewer).await? {
220 Outcome::Ok(found) => found,
221 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
222 };
223 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
224 return Ok(Outcome::Fail(failure));
225 }
226 if !a.actor.verified {
227 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
228 }
229 if !pull.is_owned_by(&a.actor.id)
230 && let Outcome::Fail(failure) = crate::allowed(Some(&a.actor), &repo, Capability::Participate)
231 {
232 return Ok(Outcome::Fail(failure));
233 }
234 let decision = self
235 .keep_decision(
236 &repo.id,
237 NewDecision {
238 item: self.pull_item_id(&pull.id).await?,
239 pull: Some(pull.number),
240 session_id: a.session_id,
241 actor: Some(&a.actor),
242 kind: a.kind,
243 confidence: DecisionConfidence::Claimed,
244 summary,
245 evidence,
246 },
247 )
248 .await?;
249 Ok(Outcome::Ok(decision))
250 }
251
252 /// `list_decisions`: a work item's, with those on its pull requests in
253 /// the repositories the viewer may read, or a pull request's, oldest
254 /// first.
255 pub(crate) async fn list_decisions(&self, a: ListDecisionsArgs) -> Result<Outcome<Vec<Decision>>> {
256 let rows = match &a.repo {
257 Some(path) => {
258 let (repo, pull) = match self.pull_at(path, a.number, &a.viewer).await? {
259 Outcome::Ok(found) => found,
260 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
261 };
262 self.db
263 .prepare(format!(
264 "SELECT {DECISION_COLUMNS} FROM decisions WHERE repo_id = ?1 AND pull = ?2
265 ORDER BY created_at, id LIMIT ?3"
266 ))
267 .bind(&[repo.id.as_str().into(), pull.number.into(), LIST_LIMIT.into()])?
268 .all()
269 .await?
270 .results::<DecisionRow>()?
271 }
272 None => {
273 let held = match items::for_viewer(self, &a.viewer, &a.workspace, a.number, Capability::Read).await? {
274 Outcome::Ok(held) => held,
275 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
276 };
277 let rows = self
278 .db
279 .prepare(format!(
280 "SELECT {DECISION_COLUMNS} FROM decisions
281 WHERE item_id = ?1 OR EXISTS (SELECT 1 FROM pulls p
282 WHERE p.item_id = ?1 AND p.repo_id = decisions.repo_id AND p.number = decisions.pull)
283 ORDER BY created_at, id LIMIT ?2"
284 ))
285 .bind(&[held.item.id.as_str().into(), LIST_LIMIT.into()])?
286 .all()
287 .await?
288 .results::<DecisionRow>()?;
289 // Those kept in a repository the viewer cannot read stay there.
290 let mut ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect();
291 ids.sort();
292 ids.dedup();
293 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
294 &self.repos,
295 "readable",
296 &g1t_contracts::repos::ReadableArgs { ids, viewer: a.viewer.clone() },
297 )
298 .await?;
299 rows.into_iter().filter(|row| readable.iter().any(|repo| repo.id == row.repo_id)).collect()
300 }
301 };
302 Ok(Outcome::Ok(rows.into_iter().filter_map(DecisionRow::decision).collect()))
303 }
304
305 /// Whether a pull request landed through the merge queue.
306 pub(crate) async fn landed_through_queue(&self, pull_id: &str) -> Result<bool> {
307 Ok(self
308 .db
309 .prepare("SELECT 1 AS value FROM queue_entries WHERE pull_id = ? AND state = 'landed' LIMIT 1")
310 .bind(&[pull_id.into()])?
311 .first::<u32>(Some("value"))
312 .await?
313 .is_some())
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn only_claims_are_recorded_by_hand() {
323 assert!(checked(DecisionKind::Proposal, "Use a trie.", None, None).is_ok());
324 assert!(checked(DecisionKind::Fix, "Rebased on #12.", None, None).is_ok());
325 for kind in [
326 DecisionKind::IntegrationFailure,
327 DecisionKind::IntegrationPassed,
328 DecisionKind::Approval,
329 DecisionKind::Merge,
330 ] {
331 assert!(checked(kind, "x", None, None).is_err(), "{kind:?}");
332 }
333 }
334
335 #[test]
336 fn a_summary_and_object_evidence_are_required() {
337 assert!(checked(DecisionKind::Note, " ", None, None).is_err());
338 let long = "x".repeat(MAX_SUMMARY_CHARS + 1);
339 assert!(checked(DecisionKind::Note, &long, None, None).is_err());
340 assert!(checked(DecisionKind::Note, "ok", Some(&json!([1])), None).is_err());
341 let big = json!({ "log": "x".repeat(MAX_EVIDENCE_BYTES) });
342 assert!(checked(DecisionKind::Note, "ok", Some(&big), None).is_err());
343 let (summary, evidence) =
344 checked(DecisionKind::Claim, " Fixes the race. ", Some(&json!({ "commit": "abc" })), Some("ses_1")).unwrap();
345 assert_eq!(summary, "Fixes the race.");
346 assert_eq!(evidence["commit"], "abc");
347 assert_eq!(checked(DecisionKind::Claim, "x", Some(&Value::Null), None).unwrap().1, json!({}));
348 }
349
350 #[test]
351 fn merges_and_approvals_are_as_sure_as_who_made_them() {
352 let person = User { id: "usr_1".into(), username: "ana".into(), ..User::default() };
353 assert_eq!(merge_confidence(false, &person), DecisionConfidence::Approved);
354 assert_eq!(merge_confidence(true, &person), DecisionConfidence::Tested);
355 assert_eq!(merge_confidence(false, &User::system("acme")), DecisionConfidence::Tested);
356 // A merge a repository's auto-merge made, under the id kept for it.
357 let policy = User { id: "g1t_policy".into(), username: "g1t".into(), ..User::default() };
358 assert_eq!(merge_confidence(false, &policy), DecisionConfidence::Tested);
359 assert_eq!(approval_confidence(&person), DecisionConfidence::Approved);
360 let agent = User {
361 kind: g1t_contracts::PrincipalKind::Agent,
362 ..person.clone()
363 };
364 if g1t_contracts::rules::is_agent(&agent) {
365 assert_eq!(approval_confidence(&agent), DecisionConfidence::Claimed);
366 assert_eq!(merge_confidence(false, &agent), DecisionConfidence::Claimed);
367 }
368 }
369}