| 1 | //! What an issue is blocked by: the issues of the same repository that have |
| 2 | //! to land first (`issues.blocked_by`, a JSON array of numbers). |
| 3 | //! |
| 4 | //! `update_issue` replaces the set, refusing an issue that is not one of |
| 5 | //! the repository's, the issue itself, or a set that would make a cycle. |
| 6 | //! Each change, and each blocker that closes or has its pull request |
| 7 | //! merged, is a `dependency.changed` event on the issue it blocks. |
| 8 | |
| 9 | use std::collections::{HashMap, HashSet}; |
| 10 | |
| 11 | use g1t_contracts::work::*; |
| 12 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 13 | use serde::Deserialize; |
| 14 | use worker::Result; |
| 15 | use worker::wasm_bindgen::JsValue; |
| 16 | |
| 17 | use super::{CLAIM_COLUMNS, ClaimRow}; |
| 18 | use crate::Work; |
| 19 | |
| 20 | #[derive(Deserialize)] |
| 21 | struct EdgeRow { |
| 22 | number: u32, |
| 23 | blocked_by: String, |
| 24 | } |
| 25 | |
| 26 | #[derive(Deserialize)] |
| 27 | struct BriefRow { |
| 28 | number: u32, |
| 29 | title: String, |
| 30 | state: State, |
| 31 | } |
| 32 | |
| 33 | #[derive(Deserialize)] |
| 34 | struct PullBriefRow { |
| 35 | number: u32, |
| 36 | title: String, |
| 37 | status: PullStatus, |
| 38 | issue_number: u32, |
| 39 | } |
| 40 | |
| 41 | fn numbers(json: &str) -> Vec<u32> { |
| 42 | serde_json::from_str(json).unwrap_or_default() |
| 43 | } |
| 44 | |
| 45 | impl Work { |
| 46 | /// `asked` as `number`'s new `blocked_by`, or why it cannot be. |
| 47 | pub(crate) async fn checked_blocked_by(&self, repo_id: &str, number: u32, asked: &[u32]) -> Result<Outcome<Vec<u32>>> { |
| 48 | let wanted = serde_json::to_string(asked)?; |
| 49 | let results = self |
| 50 | .db |
| 51 | .batch(vec![ |
| 52 | self.db |
| 53 | .prepare( |
| 54 | "SELECT number, title, state FROM issues |
| 55 | WHERE repo_id = ? AND number IN (SELECT value FROM json_each(?))", |
| 56 | ) |
| 57 | .bind(&[repo_id.into(), wanted.into()])?, |
| 58 | self.db |
| 59 | .prepare("SELECT number, blocked_by FROM issues WHERE repo_id = ? AND blocked_by <> '[]'") |
| 60 | .bind(&[repo_id.into()])?, |
| 61 | ]) |
| 62 | .await?; |
| 63 | let mut results = results.into_iter(); |
| 64 | let found: Vec<BriefRow> = match results.next() { |
| 65 | Some(result) => result.results()?, |
| 66 | None => Vec::new(), |
| 67 | }; |
| 68 | let edges: Vec<EdgeRow> = match results.next() { |
| 69 | Some(result) => result.results()?, |
| 70 | None => Vec::new(), |
| 71 | }; |
| 72 | let issues: HashSet<u32> = found.iter().map(|row| row.number).collect(); |
| 73 | let graph: HashMap<u32, Vec<u32>> = edges.into_iter().map(|row| (row.number, numbers(&row.blocked_by))).collect(); |
| 74 | Ok(match check_blocked_by(number, asked, &issues, &graph) { |
| 75 | Ok(set) => Outcome::Ok(set), |
| 76 | Err(message) => Outcome::fail(FailureCode::Invalid, message), |
| 77 | }) |
| 78 | } |
| 79 | |
| 80 | /// Announces a change to `issue`'s `blocked_by` (from `before`), and |
| 81 | /// notes it in the issue's conversation. |
| 82 | pub(crate) async fn blocked_by_changed(&self, issue: &Issue, before: &[u32], actor: &User) -> Result<()> { |
| 83 | let added: Vec<u32> = issue.blocked_by.iter().filter(|n| !before.contains(n)).copied().collect(); |
| 84 | let removed: Vec<u32> = before.iter().filter(|n| !issue.blocked_by.contains(n)).copied().collect(); |
| 85 | if added.is_empty() && removed.is_empty() { |
| 86 | return Ok(()); |
| 87 | } |
| 88 | let remaining = self.still_open(&issue.repo_id, &issue.blocked_by, None).await?; |
| 89 | self.publish( |
| 90 | "dependency.changed", |
| 91 | &issue.repo_id, |
| 92 | actor, |
| 93 | DependencyChanged { |
| 94 | repo_id: issue.repo_id.clone(), |
| 95 | number: issue.number, |
| 96 | issue: issue.number, |
| 97 | blocked_by: issue.blocked_by.clone(), |
| 98 | added: added.clone(), |
| 99 | removed: removed.clone(), |
| 100 | cleared: Vec::new(), |
| 101 | remaining, |
| 102 | merged_pull: None, |
| 103 | }, |
| 104 | ) |
| 105 | .await?; |
| 106 | let list = |numbers: &[u32]| numbers.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "); |
| 107 | let who = (actor.id.as_str(), actor.username.as_str()); |
| 108 | if !added.is_empty() { |
| 109 | self.note(&issue.repo_id, issue.number, who, &format!("marked this as blocked by {}", list(&added))).await?; |
| 110 | } |
| 111 | if !removed.is_empty() { |
| 112 | self.note(&issue.repo_id, issue.number, who, &format!("marked this as no longer blocked by {}", list(&removed))) |
| 113 | .await?; |
| 114 | } |
| 115 | Ok(()) |
| 116 | } |
| 117 | |
| 118 | /// Those of `numbers` whose issue is still open, leaving out `cleared` |
| 119 | /// (a blocker whose pull request just merged). |
| 120 | async fn still_open(&self, repo_id: &str, numbers: &[u32], cleared: Option<u32>) -> Result<Vec<u32>> { |
| 121 | if numbers.is_empty() { |
| 122 | return Ok(Vec::new()); |
| 123 | } |
| 124 | let open: Vec<BriefRow> = self |
| 125 | .db |
| 126 | .prepare( |
| 127 | "SELECT number, title, state FROM issues |
| 128 | WHERE repo_id = ? AND state = 'open' AND number IN (SELECT value FROM json_each(?))", |
| 129 | ) |
| 130 | .bind(&[repo_id.into(), serde_json::to_string(numbers)?.into()])? |
| 131 | .all() |
| 132 | .await? |
| 133 | .results()?; |
| 134 | Ok(numbers |
| 135 | .iter() |
| 136 | .filter(|number| Some(**number) != cleared && open.iter().any(|row| row.number == **number)) |
| 137 | .copied() |
| 138 | .collect()) |
| 139 | } |
| 140 | |
| 141 | /// An issue closed (`closed`) or had a pull request merged for it |
| 142 | /// (`merged_pull`): its claims end if it closed, and each open issue it |
| 143 | /// blocks hears that it cleared. Never fails what caused it: a problem |
| 144 | /// is logged. |
| 145 | pub(crate) async fn issue_resolved(&self, repo_id: &str, number: u32, actor: &User, closed: bool, merged_pull: Option<u32>) { |
| 146 | let resolved = async { |
| 147 | if closed { |
| 148 | self.release_claims_on_close(repo_id, number, Some(actor)).await?; |
| 149 | } |
| 150 | let dependents: Vec<EdgeRow> = self |
| 151 | .db |
| 152 | .prepare( |
| 153 | "SELECT number, blocked_by FROM issues |
| 154 | WHERE repo_id = ? AND state = 'open' |
| 155 | AND EXISTS (SELECT 1 FROM json_each(issues.blocked_by) WHERE json_each.value = ?)", |
| 156 | ) |
| 157 | .bind(&[repo_id.into(), number.into()])? |
| 158 | .all() |
| 159 | .await? |
| 160 | .results()?; |
| 161 | for dependent in dependents { |
| 162 | let blocked_by = numbers(&dependent.blocked_by); |
| 163 | let remaining = self.still_open(repo_id, &blocked_by, Some(number)).await?; |
| 164 | self.publish( |
| 165 | "dependency.changed", |
| 166 | repo_id, |
| 167 | actor, |
| 168 | DependencyChanged { |
| 169 | repo_id: repo_id.to_owned(), |
| 170 | number: dependent.number, |
| 171 | issue: dependent.number, |
| 172 | blocked_by, |
| 173 | cleared: vec![number], |
| 174 | remaining, |
| 175 | merged_pull, |
| 176 | ..DependencyChanged::default() |
| 177 | }, |
| 178 | ) |
| 179 | .await?; |
| 180 | } |
| 181 | Ok::<(), worker::Error>(()) |
| 182 | }; |
| 183 | if let Err(error) = resolved.await { |
| 184 | worker::console_error!("#{number} resolved, but its dependents were not told: {error}"); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /// The issues `numbers` names, each with its active claims and its pull |
| 189 | /// requests, in the order given. |
| 190 | pub(crate) async fn blockers(&self, repo_id: &str, numbers: &[u32]) -> Result<Vec<Blocker>> { |
| 191 | if numbers.is_empty() { |
| 192 | return Ok(Vec::new()); |
| 193 | } |
| 194 | let list = serde_json::to_string(numbers)?; |
| 195 | let bind = |sql: String| -> Result<worker::D1PreparedStatement> { |
| 196 | self.db.prepare(sql).bind(&[repo_id.into(), JsValue::from(list.as_str())]) |
| 197 | }; |
| 198 | let results = self |
| 199 | .db |
| 200 | .batch(vec![ |
| 201 | bind("SELECT number, title, state FROM issues |
| 202 | WHERE repo_id = ? AND number IN (SELECT value FROM json_each(?))" |
| 203 | .to_owned())?, |
| 204 | bind(format!( |
| 205 | "SELECT {CLAIM_COLUMNS} FROM claims |
| 206 | WHERE repo_id = ? AND state = 'active' AND issue IN (SELECT value FROM json_each(?)) |
| 207 | ORDER BY created_at, id" |
| 208 | ))?, |
| 209 | bind("SELECT number, title, status, issue_number FROM pulls |
| 210 | WHERE repo_id = ? AND issue_number IN (SELECT value FROM json_each(?)) |
| 211 | ORDER BY number" |
| 212 | .to_owned())?, |
| 213 | ]) |
| 214 | .await?; |
| 215 | let mut results = results.into_iter(); |
| 216 | let mut next = || results.next(); |
| 217 | let issues: Vec<BriefRow> = match next() { |
| 218 | Some(result) => result.results()?, |
| 219 | None => Vec::new(), |
| 220 | }; |
| 221 | let claims: Vec<Claim> = match next() { |
| 222 | Some(result) => result.results::<ClaimRow>()?.into_iter().map(Claim::from).collect(), |
| 223 | None => Vec::new(), |
| 224 | }; |
| 225 | let pulls: Vec<PullBriefRow> = match next() { |
| 226 | Some(result) => result.results()?, |
| 227 | None => Vec::new(), |
| 228 | }; |
| 229 | Ok(numbers |
| 230 | .iter() |
| 231 | .filter_map(|number| { |
| 232 | let issue = issues.iter().find(|issue| issue.number == *number)?; |
| 233 | Some(Blocker { |
| 234 | number: issue.number, |
| 235 | title: issue.title.clone(), |
| 236 | state: issue.state, |
| 237 | claims: claims.iter().filter(|claim| claim.issue == *number).cloned().collect(), |
| 238 | pulls: pulls |
| 239 | .iter() |
| 240 | .filter(|pull| pull.issue_number == *number) |
| 241 | .map(|pull| PullBrief { number: pull.number, title: pull.title.clone(), status: pull.status }) |
| 242 | .collect(), |
| 243 | }) |
| 244 | }) |
| 245 | .collect()) |
| 246 | } |
| 247 | |
| 248 | /// The issues that name `number` in their `blocked_by`, by number. |
| 249 | pub(crate) async fn blocked_issues(&self, repo_id: &str, number: u32) -> Result<Vec<IssueBrief>> { |
| 250 | Ok(self |
| 251 | .db |
| 252 | .prepare( |
| 253 | "SELECT number, title, state FROM issues |
| 254 | WHERE repo_id = ? AND EXISTS (SELECT 1 FROM json_each(issues.blocked_by) WHERE json_each.value = ?) |
| 255 | ORDER BY number LIMIT 100", |
| 256 | ) |
| 257 | .bind(&[repo_id.into(), number.into()])? |
| 258 | .all() |
| 259 | .await? |
| 260 | .results::<BriefRow>()? |
| 261 | .into_iter() |
| 262 | .map(|row| IssueBrief { number: row.number, title: row.title, state: row.state }) |
| 263 | .collect()) |
| 264 | } |
| 265 | } |