| 1 | //! Decision records, what the merge queue learns when changes are combined, |
| 2 | //! and why a line of code is the way it is. |
| 3 | //! |
| 4 | //! A decision record is a structured reason with a confidence: `claimed` |
| 5 | //! when an agent or a person said so, `tested` when a check or a combination |
| 6 | //! of pull requests proved it, `approved` when a person approved it. The |
| 7 | //! work service records the tested and approved ones itself (integration |
| 8 | //! results, approvals, merges); `record_decision` records claimed ones. |
| 9 | |
| 10 | use serde::{Deserialize, Serialize}; |
| 11 | |
| 12 | use crate::repos::{Commit, RepoPath}; |
| 13 | use crate::work::SessionEntry; |
| 14 | use crate::{User, Viewer}; |
| 15 | |
| 16 | /// What a decision is about. |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 18 | #[serde(rename_all = "snake_case")] |
| 19 | pub enum DecisionKind { |
| 20 | /// Why a change is made the way it is. |
| 21 | Proposal, |
| 22 | /// Something said to be true about the work, such as what it fixes. |
| 23 | Claim, |
| 24 | /// The merge queue found that a combination of pull requests fails. |
| 25 | IntegrationFailure, |
| 26 | /// The merge queue found that a combination of pull requests passes. |
| 27 | IntegrationPassed, |
| 28 | /// What was changed to fix a failure. |
| 29 | Fix, |
| 30 | /// A person approved the pull request. |
| 31 | Approval, |
| 32 | /// It was merged. |
| 33 | Merge, |
| 34 | /// Anything else worth keeping. |
| 35 | Note, |
| 36 | } |
| 37 | |
| 38 | impl DecisionKind { |
| 39 | pub fn as_str(self) -> &'static str { |
| 40 | match self { |
| 41 | DecisionKind::Proposal => "proposal", |
| 42 | DecisionKind::Claim => "claim", |
| 43 | DecisionKind::IntegrationFailure => "integration_failure", |
| 44 | DecisionKind::IntegrationPassed => "integration_passed", |
| 45 | DecisionKind::Fix => "fix", |
| 46 | DecisionKind::Approval => "approval", |
| 47 | DecisionKind::Merge => "merge", |
| 48 | DecisionKind::Note => "note", |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | pub fn parse(kind: &str) -> Option<DecisionKind> { |
| 53 | serde_json::from_value(serde_json::Value::String(kind.to_owned())).ok() |
| 54 | } |
| 55 | |
| 56 | /// The kinds `record_decision` takes. The others are what g1t saw |
| 57 | /// happen, and only g1t records them. |
| 58 | pub const RECORDABLE: [DecisionKind; 4] = |
| 59 | [DecisionKind::Proposal, DecisionKind::Claim, DecisionKind::Fix, DecisionKind::Note]; |
| 60 | } |
| 61 | |
| 62 | /// How sure the record is. |
| 63 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 64 | #[serde(rename_all = "snake_case")] |
| 65 | pub enum DecisionConfidence { |
| 66 | /// Someone said so. |
| 67 | Claimed, |
| 68 | /// A check or a combination of pull requests proved it. |
| 69 | Tested, |
| 70 | /// A person approved it. |
| 71 | Approved, |
| 72 | } |
| 73 | |
| 74 | impl DecisionConfidence { |
| 75 | pub fn as_str(self) -> &'static str { |
| 76 | match self { |
| 77 | DecisionConfidence::Claimed => "claimed", |
| 78 | DecisionConfidence::Tested => "tested", |
| 79 | DecisionConfidence::Approved => "approved", |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// One decision record. |
| 85 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 86 | #[serde(rename_all = "camelCase")] |
| 87 | pub struct Decision { |
| 88 | pub id: String, |
| 89 | /// The issue it is about, when known. |
| 90 | pub issue: Option<u32>, |
| 91 | /// The pull request it is about, when known. |
| 92 | pub pull: Option<u32>, |
| 93 | /// The session it was recorded in, if one was named. |
| 94 | #[serde(default)] |
| 95 | pub session_id: Option<String>, |
| 96 | /// Username of who recorded it; absent when g1t did. |
| 97 | pub actor: Option<String>, |
| 98 | pub kind: DecisionKind, |
| 99 | pub confidence: DecisionConfidence, |
| 100 | pub summary: String, |
| 101 | /// What it rests on, as an object: the commit, the check (`name`, |
| 102 | /// `conclusion`, `url`), the `combination` of pull requests tested |
| 103 | /// together, `other_pulls`, `suspects`, `bisected`, and for a failure |
| 104 | /// with nothing ahead, `landed`. |
| 105 | pub evidence: serde_json::Value, |
| 106 | /// RFC 3339. |
| 107 | pub created_at: String, |
| 108 | } |
| 109 | |
| 110 | /// `record_decision`: an agent's or a person's decision on a pull request, |
| 111 | /// recorded as `claimed`. Returns `Outcome<Decision>`. |
| 112 | #[derive(Debug, Serialize, Deserialize)] |
| 113 | #[serde(rename_all = "camelCase")] |
| 114 | pub struct RecordDecisionArgs { |
| 115 | pub actor: User, |
| 116 | pub repo: RepoPath, |
| 117 | /// The pull request's number. |
| 118 | pub number: u32, |
| 119 | pub kind: DecisionKind, |
| 120 | pub summary: String, |
| 121 | #[serde(default)] |
| 122 | pub evidence: Option<serde_json::Value>, |
| 123 | #[serde(default)] |
| 124 | pub session_id: Option<String>, |
| 125 | } |
| 126 | |
| 127 | /// `list_decisions`: the decisions on an issue (and on its pull requests) |
| 128 | /// or on a pull request, oldest first. Returns `Outcome<Vec<Decision>>`. |
| 129 | #[derive(Debug, Serialize, Deserialize)] |
| 130 | #[serde(rename_all = "camelCase")] |
| 131 | pub struct ListDecisionsArgs { |
| 132 | pub repo: RepoPath, |
| 133 | pub viewer: Viewer, |
| 134 | /// An issue's or a pull request's number: they share a sequence. |
| 135 | pub number: u32, |
| 136 | } |
| 137 | |
| 138 | /// The check that failed a combined state. |
| 139 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 140 | #[serde(rename_all = "camelCase")] |
| 141 | pub struct FailedCheck { |
| 142 | /// The check's name, such as `CI / merge_group`; `merge` for a merge |
| 143 | /// conflict, `build` when the state could not be built. |
| 144 | pub name: String, |
| 145 | /// `failure`, `error`, `missing` (a required check never reported), |
| 146 | /// or `conflict`. |
| 147 | pub conclusion: String, |
| 148 | /// Where to see it, such as the workflow run's page. |
| 149 | #[serde(default)] |
| 150 | pub url: Option<String>, |
| 151 | } |
| 152 | |
| 153 | /// `integration.failed`: a combined state in the merge queue failed, and |
| 154 | /// g1t worked out which pull request answers for it and which others it |
| 155 | /// breaks with. |
| 156 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 157 | #[serde(rename_all = "camelCase")] |
| 158 | pub struct IntegrationFailed { |
| 159 | pub repo_id: String, |
| 160 | pub pull_id: String, |
| 161 | /// The responsible pull request, as `pull`. |
| 162 | pub number: u32, |
| 163 | pub pull: u32, |
| 164 | /// Its issue, if it has one. |
| 165 | pub issue: Option<u32>, |
| 166 | /// The pull requests tested together, in queue order; the last is |
| 167 | /// `pull`. |
| 168 | pub combination: Vec<u32>, |
| 169 | pub check: FailedCheck, |
| 170 | /// Whether smaller combinations were built to narrow `suspects` down. |
| 171 | pub bisected: bool, |
| 172 | /// The pull requests ahead of it that it fails with. Empty when it |
| 173 | /// fails by itself. |
| 174 | pub suspects: Vec<u32>, |
| 175 | /// The other pull requests in `combination`. |
| 176 | pub other_pulls: Vec<u32>, |
| 177 | /// With nothing ahead of it, though its own checks pass on its head: |
| 178 | /// the pull requests merged into the default branch since it branched, |
| 179 | /// newest first and at most ten, any of which may be what it now fails |
| 180 | /// with. It still answers for the failure. Empty otherwise. |
| 181 | #[serde(default)] |
| 182 | pub landed: Vec<u32>, |
| 183 | /// The combined state that failed. |
| 184 | pub commit: Option<String>, |
| 185 | } |
| 186 | |
| 187 | /// `integration.passed`: a combined state in the merge queue passed. |
| 188 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 189 | #[serde(rename_all = "camelCase")] |
| 190 | pub struct IntegrationPassed { |
| 191 | pub repo_id: String, |
| 192 | pub pull_id: String, |
| 193 | pub number: u32, |
| 194 | pub pull: u32, |
| 195 | pub issue: Option<u32>, |
| 196 | pub combination: Vec<u32>, |
| 197 | pub commit: Option<String>, |
| 198 | } |
| 199 | |
| 200 | /// `decision.recorded`. |
| 201 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 202 | #[serde(rename_all = "camelCase")] |
| 203 | pub struct DecisionRecorded { |
| 204 | pub repo_id: String, |
| 205 | /// The pull request it is about, or else its issue. |
| 206 | pub number: u32, |
| 207 | pub issue: Option<u32>, |
| 208 | pub decision: Decision, |
| 209 | } |
| 210 | |
| 211 | /// The pull request a line arrived in, as `why` shows it. |
| 212 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 213 | #[serde(rename_all = "camelCase")] |
| 214 | pub struct WhyPull { |
| 215 | pub number: u32, |
| 216 | pub title: String, |
| 217 | pub author: String, |
| 218 | pub agent: String, |
| 219 | pub merged_by: Option<String>, |
| 220 | pub merged_at: Option<String>, |
| 221 | } |
| 222 | |
| 223 | /// The issue that pull request was for. |
| 224 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 225 | #[serde(rename_all = "camelCase")] |
| 226 | pub struct WhyIssue { |
| 227 | pub number: u32, |
| 228 | pub title: String, |
| 229 | pub state: String, |
| 230 | /// Its description, cut short when long. |
| 231 | pub body: String, |
| 232 | } |
| 233 | |
| 234 | /// `why`: why a line is the way it is. The commit that last changed it, |
| 235 | /// the merged pull request it arrived in, that pull request's issue, the |
| 236 | /// decisions recorded on them, and the session entries behind the commit. |
| 237 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 238 | #[serde(rename_all = "camelCase")] |
| 239 | pub struct Why { |
| 240 | pub path: String, |
| 241 | pub line: u32, |
| 242 | pub commit: Commit, |
| 243 | pub pull: Option<WhyPull>, |
| 244 | pub issue: Option<WhyIssue>, |
| 245 | pub decisions: Vec<Decision>, |
| 246 | pub sessions: Vec<SessionEntry>, |
| 247 | } |
| 248 | |
| 249 | #[cfg(test)] |
| 250 | mod tests { |
| 251 | use super::*; |
| 252 | |
| 253 | #[test] |
| 254 | fn kinds_read_and_write_as_their_names() { |
| 255 | for kind in [ |
| 256 | DecisionKind::Proposal, |
| 257 | DecisionKind::Claim, |
| 258 | DecisionKind::IntegrationFailure, |
| 259 | DecisionKind::IntegrationPassed, |
| 260 | DecisionKind::Fix, |
| 261 | DecisionKind::Approval, |
| 262 | DecisionKind::Merge, |
| 263 | DecisionKind::Note, |
| 264 | ] { |
| 265 | assert_eq!(DecisionKind::parse(kind.as_str()), Some(kind)); |
| 266 | assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str()); |
| 267 | } |
| 268 | assert_eq!(DecisionKind::parse("merged"), None); |
| 269 | assert!(!DecisionKind::RECORDABLE.contains(&DecisionKind::IntegrationFailure)); |
| 270 | assert!(!DecisionKind::RECORDABLE.contains(&DecisionKind::Approval)); |
| 271 | assert_eq!(serde_json::to_value(DecisionConfidence::Tested).unwrap(), "tested"); |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn an_integration_failure_carries_number_and_issue_for_filters() { |
| 276 | let event = IntegrationFailed { |
| 277 | repo_id: "rep_1".into(), |
| 278 | pull_id: "pr_1".into(), |
| 279 | number: 14, |
| 280 | pull: 14, |
| 281 | issue: Some(9), |
| 282 | combination: vec![11, 12, 14], |
| 283 | check: FailedCheck { name: "CI / merge_group".into(), conclusion: "failure".into(), url: None }, |
| 284 | bisected: true, |
| 285 | suspects: vec![12], |
| 286 | other_pulls: vec![11, 12], |
| 287 | landed: Vec::new(), |
| 288 | commit: Some("abc".into()), |
| 289 | }; |
| 290 | let sent = serde_json::to_value(&event).unwrap(); |
| 291 | assert_eq!(sent["number"], 14); |
| 292 | assert_eq!(sent["issue"], 9); |
| 293 | assert_eq!(sent["otherPulls"], serde_json::json!([11, 12])); |
| 294 | assert_eq!(sent["check"]["conclusion"], "failure"); |
| 295 | assert_eq!(sent["landed"], serde_json::json!([])); |
| 296 | // An event from before `landed` was sent still reads. |
| 297 | let mut older = sent.clone(); |
| 298 | older.as_object_mut().unwrap().remove("landed"); |
| 299 | let read: IntegrationFailed = serde_json::from_value(older).unwrap(); |
| 300 | assert!(read.landed.is_empty()); |
| 301 | } |
| 302 | } |