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.

g1t.sh
338 linesCodeBlameRaw

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.

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

This file's history is long; its oldest lines are credited to the oldest commit read.