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.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! Version updates' tables in D1 (migration 0004): when each entry runs, |
| 2 | //! the update pull requests g1t makes, and ignore conditions from comments. | |
| 3 | ||
| 4 | use g1t_contracts::new_id; | |
| 5 | use g1t_contracts::security::{BumpArgs, UpdateState}; | |
| 6 | use g1t_contracts::updates::{IgnoreCondition, UpdatePull, UpdatedDependency}; | |
| 7 | use g1t_kit::now_ms; | |
| 8 | use serde::Deserialize; | |
| 9 | use worker::Result; | |
| 10 | use worker::wasm_bindgen::JsValue; | |
| 11 | ||
| 12 | use crate::store::{Store, now, optional}; | |
| 13 | ||
| 14 | /// Where one `updates` entry's checks stand. | |
| 15 | #[derive(Clone, Debug, Deserialize)] | |
| 16 | pub struct RunRow { | |
| 17 | pub repo_id: String, | |
| 18 | pub entry: String, | |
| 19 | pub next_run_at: Option<String>, | |
| 20 | pub last_checked_at: Option<String>, | |
| 21 | pub last_result: Option<String>, | |
| 22 | pub last_error: Option<String>, | |
| 23 | } | |
| 24 | ||
| 25 | /// An update pull request, as stored. | |
| 26 | #[derive(Clone, Debug, Deserialize)] | |
| 27 | pub struct PullRow { | |
| 28 | pub id: String, | |
| 29 | pub repo_id: String, | |
| 30 | pub kind: String, | |
| 31 | pub entry: String, | |
| 32 | pub ecosystem: String, | |
| 33 | pub subject: String, | |
| 34 | pub signature: String, | |
| 35 | pub group_name: Option<String>, | |
| 36 | pub branch: String, | |
| 37 | pub title: String, | |
| 38 | pub body: String, | |
| 39 | pub dependencies: String, | |
| 40 | pub bump: String, | |
| 41 | pub assignees: String, | |
| 42 | pub reviewers: String, | |
| 43 | pub state: String, | |
| 44 | pub pull: Option<i64>, | |
| Updates that g1t opened a "needs code changes" issue for are put where updates go now: the sweep closes the issue as not planned, saying where it is tracked, and marks the update failed, so a security update shows why on its alert and is tried again, and a version update opens again as a pull request with its checks. An alert's history no longer repeats an entry that says the same as its latest one, so an update that fails the same way each day adds it once. | 45 | /// The issue g1t opened for it, from before it stopped doing that. |
| 46 | #[serde(default)] | |
| 47 | pub issue: Option<i64>, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 48 | pub head: Option<String>, |
| 49 | pub merge_by: Option<String>, | |
| 50 | pub error: Option<String>, | |
| 51 | pub updated_at: String, | |
| 52 | } | |
| 53 | ||
| 54 | impl PullRow { | |
| 55 | pub fn state(&self) -> UpdateState { | |
| 56 | UpdateState::parse(&self.state).unwrap_or(UpdateState::Failed) | |
| 57 | } | |
| 58 | ||
| 59 | pub fn pull(&self) -> Option<u32> { | |
| 60 | self.pull.map(|n| n.max(0) as u32) | |
| 61 | } | |
| 62 | ||
| Updates that g1t opened a "needs code changes" issue for are put where updates go now: the sweep closes the issue as not planned, saying where it is tracked, and marks the update failed, so a security update shows why on its alert and is tried again, and a version update opens again as a pull request with its checks. An alert's history no longer repeats an entry that says the same as its latest one, so an update that fails the same way each day adds it once. | 63 | pub fn issue(&self) -> Option<u32> { |
| 64 | self.issue.map(|n| n.max(0) as u32) | |
| 65 | } | |
| 66 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 67 | pub fn dependencies(&self) -> Vec<UpdatedDependency> { |
| 68 | serde_json::from_str(&self.dependencies).unwrap_or_default() | |
| 69 | } | |
| 70 | ||
| 71 | pub fn bump(&self) -> Option<BumpArgs> { | |
| 72 | serde_json::from_str(&self.bump).ok() | |
| 73 | } | |
| 74 | ||
| 75 | pub fn names(&self, column: &str) -> Vec<String> { | |
| 76 | serde_json::from_str(if column == "assignees" { &self.assignees } else { &self.reviewers }).unwrap_or_default() | |
| 77 | } | |
| 78 | ||
| 79 | /// Who asked for it to merge, by username. | |
| 80 | pub fn merge_requested_by(&self) -> Option<String> { | |
| 81 | self.merge_by.clone() | |
| 82 | } | |
| 83 | ||
| 84 | pub fn to_contract(&self) -> UpdatePull { | |
| 85 | UpdatePull { | |
| 86 | kind: self.kind.clone(), | |
| 87 | entry: self.entry.clone(), | |
| 88 | ecosystem: self.ecosystem.clone(), | |
| 89 | group: self.group_name.clone(), | |
| 90 | branch: self.branch.clone(), | |
| 91 | title: self.title.clone(), | |
| 92 | state: self.state.clone(), | |
| 93 | pull: self.pull(), | |
| 94 | dependencies: self.dependencies(), | |
| 95 | merge_requested_by: self.merge_requested_by(), | |
| 96 | error: self.error.clone(), | |
| 97 | updated_at: self.updated_at.clone(), | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /// A pull request to record before its sandbox starts. | |
| 103 | pub struct NewPull<'a> { | |
| 104 | pub repo_id: &'a str, | |
| 105 | pub kind: &'a str, | |
| 106 | pub entry: &'a str, | |
| 107 | pub ecosystem: &'a str, | |
| 108 | pub subject: &'a str, | |
| 109 | pub signature: &'a str, | |
| 110 | pub group: Option<&'a str>, | |
| 111 | pub branch: &'a str, | |
| 112 | pub title: &'a str, | |
| 113 | pub body: &'a str, | |
| 114 | pub dependencies: &'a [UpdatedDependency], | |
| 115 | /// Without registries: credentials are never stored. | |
| 116 | pub bump: &'a BumpArgs, | |
| 117 | pub assignees: &'a [String], | |
| 118 | pub reviewers: &'a [String], | |
| 119 | } | |
| 120 | ||
| 121 | #[derive(Deserialize)] | |
| 122 | struct IgnoreRow { | |
| 123 | ecosystem: String, | |
| 124 | dependency: String, | |
| 125 | versions: Option<String>, | |
| 126 | update_type: Option<String>, | |
| 127 | by: String, | |
| 128 | pull: Option<i64>, | |
| 129 | at: String, | |
| 130 | } | |
| 131 | ||
| 132 | #[derive(Deserialize)] | |
| 133 | struct Claimed { | |
| 134 | #[allow(dead_code)] | |
| 135 | entry: String, | |
| 136 | } | |
| 137 | ||
| 138 | /// What clears a repository's version update rows when it is purged. | |
| 139 | pub const PURGED: &[&str] = &[ | |
| 140 | "DELETE FROM update_runs WHERE repo_id = ?1", | |
| 141 | "DELETE FROM update_pulls WHERE repo_id = ?1", | |
| 142 | "DELETE FROM update_ignores WHERE repo_id = ?1", | |
| 143 | ]; | |
| 144 | ||
| 145 | /// A run claimed this long ago and never finished is taken to have died. | |
| 146 | const RUN_CLAIM_MS: u64 = 30 * 60 * 1000; | |
| 147 | ||
| 148 | impl Store { | |
| 149 | pub async fn runs(&self, repo_id: &str) -> Result<Vec<RunRow>> { | |
| 150 | self.db.prepare("SELECT * FROM update_runs WHERE repo_id = ?").bind(&[repo_id.into()])?.all().await?.results::<RunRow>() | |
| 151 | } | |
| 152 | ||
| 153 | /// Keeps a row for each of `entries` (id, next run) and removes the rest. | |
| 154 | pub async fn set_runs(&self, repo_id: &str, entries: &[(String, Option<String>)]) -> Result<()> { | |
| 155 | let mut statements = Vec::new(); | |
| 156 | for (entry, next) in entries { | |
| 157 | statements.push( | |
| 158 | self.db | |
| 159 | .prepare( | |
| 160 | "INSERT INTO update_runs (repo_id, entry, next_run_at) VALUES (?1, ?2, ?3) | |
| 161 | ON CONFLICT (repo_id, entry) DO UPDATE SET next_run_at = ?3", | |
| 162 | ) | |
| 163 | .bind(&[repo_id.into(), entry.as_str().into(), optional(next.as_deref())])?, | |
| 164 | ); | |
| 165 | } | |
| 166 | let keep: Vec<JsValue> = entries.iter().map(|(entry, _)| JsValue::from(entry.as_str())).collect(); | |
| 167 | let marks = vec!["?"; keep.len()].join(", "); | |
| 168 | let mut binds = vec![JsValue::from(repo_id)]; | |
| 169 | binds.extend(keep); | |
| 170 | let sql = if entries.is_empty() { | |
| 171 | "DELETE FROM update_runs WHERE repo_id = ?".to_owned() | |
| 172 | } else { | |
| 173 | format!("DELETE FROM update_runs WHERE repo_id = ? AND entry NOT IN ({marks})") | |
| 174 | }; | |
| 175 | statements.push(self.db.prepare(sql).bind(&binds)?); | |
| 176 | self.db.batch(statements).await?; | |
| 177 | Ok(()) | |
| 178 | } | |
| 179 | ||
| 180 | /// Entries whose next run has come, oldest first, not being run now. | |
| 181 | pub async fn due_runs(&self, at: &str, limit: u32) -> Result<Vec<RunRow>> { | |
| 182 | let stale = g1t_contracts::time::rfc3339(now_ms().saturating_sub(RUN_CLAIM_MS)); | |
| 183 | self.db | |
| 184 | .prepare( | |
| 185 | "SELECT * FROM update_runs WHERE next_run_at IS NOT NULL AND next_run_at <= ? | |
| 186 | AND (running_at IS NULL OR running_at < ?) ORDER BY next_run_at LIMIT ?", | |
| 187 | ) | |
| 188 | .bind(&[at.into(), stale.into(), limit.into()])? | |
| 189 | .all() | |
| 190 | .await? | |
| 191 | .results::<RunRow>() | |
| 192 | } | |
| 193 | ||
| 194 | /// Takes an entry's run, unless another sweep already has. | |
| 195 | pub async fn claim_run(&self, repo_id: &str, entry: &str) -> Result<bool> { | |
| 196 | let stale = g1t_contracts::time::rfc3339(now_ms().saturating_sub(RUN_CLAIM_MS)); | |
| 197 | let claimed = self | |
| 198 | .db | |
| 199 | .prepare( | |
| 200 | "INSERT INTO update_runs (repo_id, entry, running_at) VALUES (?1, ?2, ?3) | |
| 201 | ON CONFLICT (repo_id, entry) DO UPDATE SET running_at = ?3 | |
| 202 | WHERE update_runs.running_at IS NULL OR update_runs.running_at < ?4 | |
| 203 | RETURNING entry", | |
| 204 | ) | |
| 205 | .bind(&[repo_id.into(), entry.into(), now().into(), stale.into()])? | |
| 206 | .first::<Claimed>(None) | |
| 207 | .await?; | |
| 208 | Ok(claimed.is_some()) | |
| 209 | } | |
| 210 | ||
| 211 | pub async fn finish_run(&self, repo_id: &str, entry: &str, next: Option<&str>, result: Option<&str>, error: Option<&str>) -> Result<()> { | |
| 212 | self.db | |
| 213 | .prepare( | |
| 214 | "UPDATE update_runs SET running_at = NULL, last_checked_at = ?, next_run_at = ?, last_result = ?, last_error = ? | |
| 215 | WHERE repo_id = ? AND entry = ?", | |
| 216 | ) | |
| 217 | .bind(&[now().into(), optional(next), optional(result), optional(error), repo_id.into(), entry.into()])? | |
| 218 | .run() | |
| 219 | .await?; | |
| 220 | Ok(()) | |
| 221 | } | |
| 222 | ||
| 223 | // --- Update pull requests ------------------------------------------------- | |
| 224 | ||
| 225 | pub async fn update_pulls(&self, repo_id: &str) -> Result<Vec<PullRow>> { | |
| 226 | self.db | |
| 227 | .prepare("SELECT * FROM update_pulls WHERE repo_id = ? ORDER BY requested_at DESC LIMIT 500") | |
| 228 | .bind(&[repo_id.into()])? | |
| 229 | .all() | |
| 230 | .await? | |
| 231 | .results::<PullRow>() | |
| 232 | } | |
| 233 | ||
| 234 | /// The latest update pull request made on `branch`. | |
| 235 | pub async fn update_pull_by_branch(&self, repo_id: &str, branch: &str) -> Result<Option<PullRow>> { | |
| 236 | self.db | |
| 237 | .prepare("SELECT * FROM update_pulls WHERE repo_id = ? AND branch = ? ORDER BY requested_at DESC LIMIT 1") | |
| 238 | .bind(&[repo_id.into(), branch.into()])? | |
| 239 | .first::<PullRow>(None) | |
| 240 | .await | |
| 241 | } | |
| 242 | ||
| 243 | pub async fn update_pull_by_number(&self, repo_id: &str, number: u32) -> Result<Option<PullRow>> { | |
| 244 | self.db | |
| 245 | .prepare("SELECT * FROM update_pulls WHERE repo_id = ? AND pull = ? ORDER BY requested_at DESC LIMIT 1") | |
| 246 | .bind(&[repo_id.into(), number.into()])? | |
| 247 | .first::<PullRow>(None) | |
| 248 | .await | |
| 249 | } | |
| 250 | ||
| 251 | /// Open update pull requests, the least recently looked at first. | |
| 252 | pub async fn open_update_pulls(&self, limit: u32) -> Result<Vec<PullRow>> { | |
| 253 | self.db | |
| 254 | .prepare("SELECT * FROM update_pulls WHERE state = 'open' ORDER BY updated_at LIMIT ?") | |
| 255 | .bind(&[limit.into()])? | |
| 256 | .all() | |
| 257 | .await? | |
| 258 | .results::<PullRow>() | |
| 259 | } | |
| 260 | ||
| 261 | /// Update pull requests asked of a sandbox before `before` that never pushed. | |
| Updates that g1t opened a "needs code changes" issue for are put where updates go now: the sweep closes the issue as not planned, saying where it is tracked, and marks the update failed, so a security update shows why on its alert and is tried again, and a version update opens again as a pull request with its checks. An alert's history no longer repeats an entry that says the same as its latest one, so an update that fails the same way each day adds it once. | 262 | /// Version updates left in `needs_code`, which g1t opened an issue for |
| 263 | /// before it stopped doing that. | |
| 264 | pub async fn needs_code_update_pulls(&self, limit: u32) -> Result<Vec<PullRow>> { | |
| 265 | self.db | |
| 266 | .prepare("SELECT * FROM update_pulls WHERE state = 'needs_code' ORDER BY updated_at LIMIT ?") | |
| 267 | .bind(&[limit.into()])? | |
| 268 | .all() | |
| 269 | .await? | |
| 270 | .results::<PullRow>() | |
| 271 | } | |
| 272 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 273 | pub async fn stalled_update_pulls(&self, before: &str, limit: u32) -> Result<Vec<PullRow>> { |
| 274 | self.db | |
| 275 | .prepare("SELECT * FROM update_pulls WHERE state = 'requested' AND updated_at < ? ORDER BY updated_at LIMIT ?") | |
| 276 | .bind(&[before.into(), limit.into()])? | |
| 277 | .all() | |
| 278 | .await? | |
| 279 | .results::<PullRow>() | |
| 280 | } | |
| 281 | ||
| 282 | pub async fn add_update_pull(&self, new: &NewPull<'_>) -> Result<String> { | |
| 283 | let id = new_id("upd", now_ms()); | |
| 284 | let at = now(); | |
| 285 | self.db | |
| 286 | .prepare( | |
| 287 | "INSERT INTO update_pulls (id, repo_id, kind, entry, ecosystem, subject, signature, group_name, branch, title, | |
| 288 | body, dependencies, bump, assignees, reviewers, state, requested_at, updated_at) | |
| 289 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'requested', ?16, ?16)", | |
| 290 | ) | |
| 291 | .bind(&[ | |
| 292 | id.as_str().into(), | |
| 293 | new.repo_id.into(), | |
| 294 | new.kind.into(), | |
| 295 | new.entry.into(), | |
| 296 | new.ecosystem.into(), | |
| 297 | new.subject.into(), | |
| 298 | new.signature.into(), | |
| 299 | optional(new.group), | |
| 300 | new.branch.into(), | |
| 301 | new.title.into(), | |
| 302 | new.body.into(), | |
| 303 | serde_json::to_string(new.dependencies)?.into(), | |
| 304 | serde_json::to_string(new.bump)?.into(), | |
| 305 | serde_json::to_string(new.assignees)?.into(), | |
| 306 | serde_json::to_string(new.reviewers)?.into(), | |
| 307 | at.as_str().into(), | |
| 308 | ])? | |
| 309 | .run() | |
| 310 | .await?; | |
| 311 | Ok(id) | |
| 312 | } | |
| 313 | ||
| 314 | pub async fn set_update_pull(&self, id: &str, state: UpdateState, pull: Option<u32>, issue: Option<u32>, error: Option<&str>) -> Result<()> { | |
| 315 | self.db | |
| 316 | .prepare("UPDATE update_pulls SET state = ?, pull = ?, issue = ?, error = ?, updated_at = ? WHERE id = ?") | |
| 317 | .bind(&[ | |
| 318 | state.as_str().into(), | |
| 319 | pull.map_or(JsValue::NULL, JsValue::from), | |
| 320 | issue.map_or(JsValue::NULL, JsValue::from), | |
| 321 | optional(error), | |
| 322 | now().into(), | |
| 323 | id.into(), | |
| 324 | ])? | |
| 325 | .run() | |
| 326 | .await?; | |
| 327 | Ok(()) | |
| 328 | } | |
| 329 | ||
| 330 | /// Asks for the pull request to be made again on its branch (a rebase | |
| 331 | /// or a recreate): it stays open while its sandbox works. | |
| 332 | pub async fn remake_update_pull(&self, id: &str, bump: &BumpArgs) -> Result<()> { | |
| 333 | self.db | |
| 334 | .prepare("UPDATE update_pulls SET bump = ?, head = NULL, error = NULL, updated_at = ? WHERE id = ?") | |
| 335 | .bind(&[serde_json::to_string(bump)?.into(), now().into(), id.into()])? | |
| 336 | .run() | |
| 337 | .await?; | |
| 338 | Ok(()) | |
| 339 | } | |
| 340 | ||
| 341 | pub async fn set_update_pull_head(&self, id: &str, head: &str) -> Result<()> { | |
| 342 | self.db | |
| 343 | .prepare("UPDATE update_pulls SET head = ?, updated_at = ? WHERE id = ?") | |
| 344 | .bind(&[head.into(), now().into(), id.into()])? | |
| 345 | .run() | |
| 346 | .await?; | |
| 347 | Ok(()) | |
| 348 | } | |
| 349 | ||
| 350 | /// Marks it looked at, so the sweep moves on to others. | |
| 351 | pub async fn touch_update_pull(&self, id: &str) -> Result<()> { | |
| 352 | self.db.prepare("UPDATE update_pulls SET updated_at = ? WHERE id = ?").bind(&[now().into(), id.into()])?.run().await?; | |
| 353 | Ok(()) | |
| 354 | } | |
| 355 | ||
| 356 | pub async fn set_merge_by(&self, id: &str, by: Option<&str>) -> Result<()> { | |
| 357 | self.db.prepare("UPDATE update_pulls SET merge_by = ? WHERE id = ?").bind(&[optional(by), id.into()])?.run().await?; | |
| 358 | Ok(()) | |
| 359 | } | |
| 360 | ||
| 361 | // --- Ignore conditions ---------------------------------------------------- | |
| 362 | ||
| 363 | pub async fn ignores(&self, repo_id: &str) -> Result<Vec<IgnoreCondition>> { | |
| 364 | Ok(self | |
| 365 | .db | |
| 366 | .prepare("SELECT * FROM update_ignores WHERE repo_id = ? ORDER BY dependency, at") | |
| 367 | .bind(&[repo_id.into()])? | |
| 368 | .all() | |
| 369 | .await? | |
| 370 | .results::<IgnoreRow>()? | |
| 371 | .into_iter() | |
| 372 | .map(|row| IgnoreCondition { | |
| 373 | ecosystem: row.ecosystem, | |
| 374 | dependency: row.dependency, | |
| 375 | versions: row.versions, | |
| 376 | update_type: row.update_type, | |
| 377 | by: row.by, | |
| 378 | pull: row.pull.map(|n| n.max(0) as u32), | |
| 379 | at: row.at, | |
| 380 | }) | |
| 381 | .collect()) | |
| 382 | } | |
| 383 | ||
| 384 | pub async fn add_ignore(&self, repo_id: &str, condition: &IgnoreCondition) -> Result<()> { | |
| 385 | let key = condition.versions.clone().or_else(|| condition.update_type.clone()).unwrap_or_default(); | |
| 386 | self.db | |
| 387 | .prepare( | |
| 388 | "INSERT INTO update_ignores (repo_id, ecosystem, dependency, condition, versions, update_type, by, pull, at) | |
| 389 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 390 | ON CONFLICT (repo_id, ecosystem, dependency, condition) DO UPDATE SET by = excluded.by, pull = excluded.pull, at = excluded.at", | |
| 391 | ) | |
| 392 | .bind(&[ | |
| 393 | repo_id.into(), | |
| 394 | condition.ecosystem.as_str().into(), | |
| 395 | condition.dependency.to_lowercase().as_str().into(), | |
| 396 | key.as_str().into(), | |
| 397 | optional(condition.versions.as_deref()), | |
| 398 | optional(condition.update_type.as_deref()), | |
| 399 | condition.by.as_str().into(), | |
| 400 | condition.pull.map_or(JsValue::NULL, JsValue::from), | |
| 401 | now().into(), | |
| 402 | ])? | |
| 403 | .run() | |
| 404 | .await?; | |
| 405 | Ok(()) | |
| 406 | } | |
| 407 | ||
| 408 | /// Removes the conditions on `dependency` (`*` for every one) in | |
| 409 | /// `ecosystem`, or only those `keep` refuses. Returns how many went. | |
| 410 | pub async fn remove_ignores(&self, repo_id: &str, ecosystem: &str, dependency: &str, condition: Option<&str>) -> Result<usize> { | |
| 411 | let found = self.ignores(repo_id).await?; | |
| 412 | let mut removed = 0; | |
| 413 | for item in found { | |
| 414 | let named = dependency == "*" || item.dependency.eq_ignore_ascii_case(dependency); | |
| 415 | let key = item.versions.clone().or_else(|| item.update_type.clone()).unwrap_or_default(); | |
| 416 | if item.ecosystem != ecosystem || !named || condition.is_some_and(|wanted| wanted != key) { | |
| 417 | continue; | |
| 418 | } | |
| 419 | self.db | |
| 420 | .prepare("DELETE FROM update_ignores WHERE repo_id = ? AND ecosystem = ? AND dependency = ? AND condition = ?") | |
| 421 | .bind(&[repo_id.into(), ecosystem.into(), item.dependency.as_str().into(), key.as_str().into()])? | |
| 422 | .run() | |
| 423 | .await?; | |
| 424 | removed += 1; | |
| 425 | } | |
| 426 | Ok(removed) | |
| 427 | } | |
| 428 | } |