| 1 | //! What landing a work item does, and closing one by a commit. |
| 2 | //! |
| 3 | //! When a pull request merges into the default branch, or a commit naming |
| 4 | //! a work item is pushed there, where the repository closes work items |
| 5 | //! that way (`RepoSettings::auto_close_items`), the repository's link to |
| 6 | //! each work item it closes is marked landed and the resolution recorded |
| 7 | //! ([`LAND_SQL`]); the work item closes once it has landed in every target |
| 8 | //! it has, or at once when it has none. What it is part of is linked |
| 9 | //! (`touched`), and closes nothing. |
| 10 | |
| 11 | use g1t_contracts::events::WorkItemEvent; |
| 12 | use g1t_contracts::repos::{BranchDriftArgs, BranchDrifts, Commit, LogArgs, Repo, RepoPath}; |
| 13 | use g1t_contracts::time::rfc3339; |
| 14 | use g1t_contracts::work::*; |
| 15 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 16 | use g1t_kit::now_ms; |
| 17 | use serde::Deserialize; |
| 18 | use worker::Result; |
| 19 | use worker::wasm_bindgen::JsValue; |
| 20 | |
| 21 | use super::links::PART_SQL; |
| 22 | use super::rows::{Held, StoredResolution, path_of}; |
| 23 | use crate::Work; |
| 24 | use crate::rows::{By, ValueRow}; |
| 25 | |
| 26 | /// What landed: a pull request merged, or a commit pushed. |
| 27 | #[derive(Clone, Debug, Default)] |
| 28 | pub(crate) struct Landing { |
| 29 | pub pull: Option<u32>, |
| 30 | pub commit: Option<String>, |
| 31 | pub pushed_by: Option<String>, |
| 32 | } |
| 33 | |
| 34 | /// Landing a work item (`?1`) in repository `?2` at `?3`, by `?4` (an |
| 35 | /// account id), resolved by `?5` (a stored resolution, JSON), whose pull |
| 36 | /// request and commit are `?6` and `?7`, in one batch: linked (as touched, |
| 37 | /// unless it is already), marked landed there, the resolution recorded |
| 38 | /// once, and closed when no target is left to land. |
| 39 | pub(crate) const LAND_SQL: [&str; 4] = [ |
| 40 | "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by) |
| 41 | VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING", |
| 42 | "UPDATE work_item_repos SET landed_at = COALESCE(landed_at, ?3) WHERE item_id = ?1 AND repo_id = ?2", |
| 43 | "UPDATE work_items SET resolutions = json_insert(resolutions, '$[#]', json(?5)), updated_at = ?3 |
| 44 | WHERE id = ?1 AND NOT EXISTS (SELECT 1 FROM json_each(work_items.resolutions) j |
| 45 | WHERE json_extract(j.value, '$.repo_id') = ?2 AND json_extract(j.value, '$.pull') IS ?6 |
| 46 | AND json_extract(j.value, '$.commit') IS ?7)", |
| 47 | "UPDATE work_items SET state = 'closed', reason = 'completed', closed_at = ?3, updated_at = ?3 |
| 48 | WHERE id = ?1 AND state = 'open' |
| 49 | AND NOT EXISTS (SELECT 1 FROM work_item_repos WHERE item_id = ?1 AND role = 'target' AND landed_at IS NULL) |
| 50 | RETURNING id AS value", |
| 51 | ]; |
| 52 | |
| 53 | /// The work items a commit (`?2`) pushed to repository `?1` resolved. |
| 54 | const CLOSED_BY_COMMIT: &str = "SELECT i.workspace, i.number, i.title, i.state = 'closed' AS closed |
| 55 | FROM work_item_repos r JOIN work_items i ON i.id = r.item_id |
| 56 | WHERE r.repo_id = ?1 AND i.number IS NOT NULL |
| 57 | AND EXISTS (SELECT 1 FROM json_each(i.resolutions) j |
| 58 | WHERE json_extract(j.value, '$.repo_id') = ?1 AND json_extract(j.value, '$.commit') = ?2) |
| 59 | ORDER BY i.number LIMIT 50"; |
| 60 | |
| 61 | /// The repositories of workspace `?1` with what earlier work items (not |
| 62 | /// `?2`) landed in them, newest first. |
| 63 | const LANDED_BEFORE: &str = "SELECT r.repo_id AS repo_id, i.title AS title, i.labels AS labels |
| 64 | FROM work_item_repos r JOIN work_items i ON i.id = r.item_id |
| 65 | WHERE i.workspace = ?1 AND i.id <> ?2 AND r.landed_at IS NOT NULL |
| 66 | ORDER BY r.landed_at DESC LIMIT ?3"; |
| 67 | |
| 68 | #[derive(Deserialize)] |
| 69 | struct ClosedRow { |
| 70 | workspace: String, |
| 71 | number: u32, |
| 72 | title: String, |
| 73 | closed: u8, |
| 74 | } |
| 75 | |
| 76 | /// A repository another work item of the workspace landed in. |
| 77 | #[derive(Clone, Debug, Deserialize)] |
| 78 | pub(crate) struct PastLanding { |
| 79 | pub repo_id: String, |
| 80 | pub title: String, |
| 81 | /// JSON array of label names. |
| 82 | pub labels: String, |
| 83 | } |
| 84 | |
| 85 | /// A note of a landing: where, and by what. |
| 86 | pub(crate) fn landing_note(path: &str, pull: Option<u32>, commit: Option<&str>) -> String { |
| 87 | match (pull, commit) { |
| 88 | (Some(pull), _) => format!("{path}#{pull}"), |
| 89 | (None, Some(commit)) => format!("{path}@{}", &commit[..7.min(commit.len())]), |
| 90 | (None, None) => path.to_owned(), |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | impl Work { |
| 95 | /// Lands `closes` (work items, by id) in `repo` by `landing`, and links |
| 96 | /// `parts`: see the module's notes. Says so on each, with the session |
| 97 | /// it was done in when one is known; `agent` when an agent pushed for |
| 98 | /// `actor` (the note is then g1t's, for them). Returns the work items |
| 99 | /// it closed. |
| 100 | #[allow(clippy::too_many_arguments)] |
| 101 | pub(super) async fn land( |
| 102 | &self, |
| 103 | repo: &Repo, |
| 104 | landing: &Landing, |
| 105 | actor: &User, |
| 106 | closes: &[String], |
| 107 | parts: &[String], |
| 108 | session: Option<&str>, |
| 109 | agent: bool, |
| 110 | ) -> Result<Vec<Held>> { |
| 111 | let now = rfc3339(now_ms()); |
| 112 | let path = path_of(repo); |
| 113 | let by = match By::from(actor) { |
| 114 | by if agent && by.acting_for.is_none() && by.id == actor.id => By { |
| 115 | id: g1t_contracts::identity::AGENT_ID, |
| 116 | name: g1t_contracts::identity::AGENT_NAME, |
| 117 | acting_for: Some((&actor.id, &actor.username)), |
| 118 | }, |
| 119 | by => by, |
| 120 | }; |
| 121 | let stored = StoredResolution { |
| 122 | repo_id: repo.id.clone(), |
| 123 | pull: landing.pull, |
| 124 | commit: landing.commit.clone(), |
| 125 | pushed_by: landing.pushed_by.clone(), |
| 126 | }; |
| 127 | let resolution = Resolution { |
| 128 | repo: path.clone(), |
| 129 | pull: landing.pull, |
| 130 | commit: landing.commit.clone(), |
| 131 | pushed_by: landing.pushed_by.clone(), |
| 132 | }; |
| 133 | let what = landing_note(&path, landing.pull, landing.commit.as_deref()); |
| 134 | let mut closed = Vec::new(); |
| 135 | for id in closes { |
| 136 | let Some(before) = self.item_by_id(id).await? else { continue }; |
| 137 | let binds: [JsValue; 7] = [ |
| 138 | id.as_str().into(), |
| 139 | repo.id.as_str().into(), |
| 140 | now.as_str().into(), |
| 141 | actor.id.as_str().into(), |
| 142 | serde_json::to_string(&stored)?.into(), |
| 143 | landing.pull.map_or(JsValue::NULL, JsValue::from), |
| 144 | landing.commit.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 145 | ]; |
| 146 | let statements = LAND_SQL |
| 147 | .iter() |
| 148 | .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)])) |
| 149 | .collect::<Result<Vec<_>>>()?; |
| 150 | let results = self.db.batch(statements).await?; |
| 151 | let closed_now = results |
| 152 | .get(3) |
| 153 | .map(|result| result.results::<ValueRow>()) |
| 154 | .transpose()? |
| 155 | .is_some_and(|rows| !rows.is_empty()); |
| 156 | let Some(held) = self.item_by_id(id).await? else { continue }; |
| 157 | if closed_now { |
| 158 | self.publish_item( |
| 159 | "work_item.closed", |
| 160 | &held, |
| 161 | Some(actor), |
| 162 | WorkItemEvent { |
| 163 | reason: Some(CloseReason::Completed.as_str()), |
| 164 | resolution: Some(resolution.clone()), |
| 165 | missing: Some(Vec::new()), |
| 166 | session: session.map(str::to_owned), |
| 167 | ..Self::item_event(&held) |
| 168 | }, |
| 169 | ) |
| 170 | .await?; |
| 171 | let text = match landing.pull { |
| 172 | Some(_) => format!("closed this by merging {what}"), |
| 173 | None => format!("closed this in {what}"), |
| 174 | }; |
| 175 | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; |
| 176 | self.item_resolved(&held, actor, true, landing.pull).await; |
| 177 | closed.push(held); |
| 178 | continue; |
| 179 | } |
| 180 | self.publish_item( |
| 181 | "work_item.landed", |
| 182 | &held, |
| 183 | Some(actor), |
| 184 | WorkItemEvent { |
| 185 | resolution: Some(resolution.clone()), |
| 186 | missing: Some(held.item.missing()), |
| 187 | session: session.map(str::to_owned), |
| 188 | ..Self::item_event(&held) |
| 189 | }, |
| 190 | ) |
| 191 | .await?; |
| 192 | if before.item.state == State::Open { |
| 193 | let missing = held.item.missing(); |
| 194 | let text = if missing.is_empty() { |
| 195 | format!("landed {what}") |
| 196 | } else { |
| 197 | format!("landed {what}; still to land in {}", missing.join(", ")) |
| 198 | }; |
| 199 | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; |
| 200 | // Merged for it: what it blocks hears, though it stays open. |
| 201 | self.item_resolved(&held, actor, false, landing.pull).await; |
| 202 | } |
| 203 | } |
| 204 | for id in parts { |
| 205 | let linked = self |
| 206 | .db |
| 207 | .prepare(PART_SQL) |
| 208 | .bind(&[id.as_str().into(), repo.id.as_str().into(), now.as_str().into(), actor.id.as_str().into()])? |
| 209 | .first::<ValueRow>(None) |
| 210 | .await?; |
| 211 | if linked.is_none() { |
| 212 | continue; |
| 213 | } |
| 214 | let Some(held) = self.item_by_id(id).await? else { continue }; |
| 215 | self.publish_item( |
| 216 | "work_item.linked", |
| 217 | &held, |
| 218 | Some(actor), |
| 219 | WorkItemEvent { repo: Some(path.clone()), role: Some(LinkRole::Touched), ..Self::item_event(&held) }, |
| 220 | ) |
| 221 | .await?; |
| 222 | self.note_item(id, by, &format!("linked {path}: {what} is part of this")).await?; |
| 223 | } |
| 224 | Ok(closed) |
| 225 | } |
| 226 | |
| 227 | /// `close_work_item` with a commit: the commit, on the default branch |
| 228 | /// of `repo`, whose message names work item `held` after a closing |
| 229 | /// keyword, with the session it names, if it names one of this |
| 230 | /// repository's. Anything else is refused, saying why. |
| 231 | pub(super) async fn closing_commit( |
| 232 | &self, |
| 233 | repo: &Repo, |
| 234 | held: &Held, |
| 235 | commit: &str, |
| 236 | actor: &User, |
| 237 | ) -> Result<Outcome<(String, Option<String>)>> { |
| 238 | let number = held.item.number; |
| 239 | let commit = commit.trim().to_lowercase(); |
| 240 | if !(4..=64).contains(&commit.len()) || !commit.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 241 | return Ok(Outcome::fail(FailureCode::Invalid, "commit is a commit's hash.")); |
| 242 | } |
| 243 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 244 | let log = |at: Option<String>| LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: at, limit: 1 }; |
| 245 | let (named, tip) = (log(Some(commit.clone())), log(None)); |
| 246 | let (found, head) = futures_util::future::join( |
| 247 | g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &named), |
| 248 | g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &tip), |
| 249 | ) |
| 250 | .await; |
| 251 | let found = match found? { |
| 252 | Outcome::Ok(commits) => commits.into_iter().next().filter(|found| found.hash.starts_with(&commit)), |
| 253 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 254 | }; |
| 255 | let Some(found) = found else { |
| 256 | return Ok(Outcome::fail( |
| 257 | FailureCode::NotFound, |
| 258 | format!("There is no commit {commit} in {}/{}; give its full hash.", repo.namespace, repo.name), |
| 259 | )); |
| 260 | }; |
| 261 | let short = &found.hash[..7.min(found.hash.len())]; |
| 262 | let mut names = false; |
| 263 | for reference in refs::named(&found.message).closes { |
| 264 | if self.ref_item(repo, &reference).await?.as_deref() == Some(held.item.id.as_str()) { |
| 265 | names = true; |
| 266 | break; |
| 267 | } |
| 268 | } |
| 269 | if !names { |
| 270 | return Ok(Outcome::fail( |
| 271 | FailureCode::Invalid, |
| 272 | format!("Commit {short} does not close #{number}: its message names it after no closing keyword (Fixes #{number})."), |
| 273 | )); |
| 274 | } |
| 275 | let head = match head? { |
| 276 | Outcome::Ok(commits) => commits.into_iter().next(), |
| 277 | Outcome::Fail(_) => None, |
| 278 | }; |
| 279 | let Some(head) = head else { |
| 280 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Commit {short} is not on {}.", repo.default_branch))); |
| 281 | }; |
| 282 | if head.hash != found.hash { |
| 283 | let drifts: Outcome<BranchDrifts> = g1t_kit::call( |
| 284 | &self.repos, |
| 285 | "branch_drift", |
| 286 | &BranchDriftArgs { path: path.clone(), viewer: Some(actor.clone()), base: head.hash, heads: vec![found.hash.clone()] }, |
| 287 | ) |
| 288 | .await?; |
| 289 | let drift = match drifts { |
| 290 | Outcome::Ok(drifts) => drifts.branches.into_iter().next().and_then(|branch| branch.drift), |
| 291 | Outcome::Fail(_) => None, |
| 292 | }; |
| 293 | match drift { |
| 294 | Some(drift) if drift.ahead == 0 => {} |
| 295 | Some(_) => { |
| 296 | return Ok(Outcome::fail( |
| 297 | FailureCode::Invalid, |
| 298 | format!("Commit {short} is not on {}, the default branch: only a commit there closes a work item.", repo.default_branch), |
| 299 | )); |
| 300 | } |
| 301 | None => { |
| 302 | return Ok(Outcome::fail( |
| 303 | FailureCode::Invalid, |
| 304 | format!("Could not tell whether commit {short} is on {}: it is too far back.", repo.default_branch), |
| 305 | )); |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | let session = self.trailer_session(&repo.id, &found.message).await?; |
| 310 | Ok(Outcome::Ok((found.hash, session))) |
| 311 | } |
| 312 | |
| 313 | /// `closed_by_commit`: the work items a commit pushed to a repository |
| 314 | /// resolved, for anyone who may read the repository. |
| 315 | pub(crate) async fn closed_by_commit(&self, a: ClosedByCommitArgs) -> Result<Outcome<Vec<ClosedItem>>> { |
| 316 | let Some(commit) = crate::commit_comments::valid_commit(&a.commit).filter(|commit| commit.len() >= 40) else { |
| 317 | return Ok(Outcome::Ok(Vec::new())); |
| 318 | }; |
| 319 | let repo = match self.repo(&a.repo, &a.viewer).await? { |
| 320 | Outcome::Ok(repo) => repo, |
| 321 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 322 | }; |
| 323 | Ok(Outcome::Ok( |
| 324 | self.db |
| 325 | .prepare(CLOSED_BY_COMMIT) |
| 326 | .bind(&[repo.id.as_str().into(), commit.into()])? |
| 327 | .all() |
| 328 | .await? |
| 329 | .results::<ClosedRow>()? |
| 330 | .into_iter() |
| 331 | .map(|row| ClosedItem { workspace: row.workspace, number: row.number, title: row.title, closed: row.closed != 0 }) |
| 332 | .collect(), |
| 333 | )) |
| 334 | } |
| 335 | |
| 336 | /// Where the other work items of `held`'s workspace landed, newest |
| 337 | /// first, at most `limit`. |
| 338 | pub(super) async fn landed_before(&self, held: &Held, limit: u32) -> Result<Vec<PastLanding>> { |
| 339 | Ok(self |
| 340 | .db |
| 341 | .prepare(LANDED_BEFORE) |
| 342 | .bind(&[held.item.workspace.as_str().into(), held.item.id.as_str().into(), limit.into()])? |
| 343 | .all() |
| 344 | .await? |
| 345 | .results()?) |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | #[cfg(test)] |
| 350 | mod tests { |
| 351 | use rusqlite::params; |
| 352 | |
| 353 | use super::*; |
| 354 | |
| 355 | #[test] |
| 356 | fn a_landing_is_named_by_its_pull_request_or_commit() { |
| 357 | assert_eq!(landing_note("acme/web", Some(12), None), "acme/web#12"); |
| 358 | assert_eq!(landing_note("acme/web", None, Some("abcdef1234")), "acme/web@abcdef1"); |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn landing_statements_take_the_item_repository_and_what_landed() { |
| 363 | let counts: Vec<usize> = LAND_SQL.iter().map(|sql| g1t_kit::rename::parameters(sql)).collect(); |
| 364 | assert_eq!(counts, vec![4, 3, 7, 3]); |
| 365 | assert_eq!(g1t_kit::rename::parameters(PART_SQL), 4); |
| 366 | } |
| 367 | |
| 368 | #[test] |
| 369 | fn a_commit_names_what_it_resolved_in_its_repository() { |
| 370 | let db = crate::migrated::database(); |
| 371 | let resolved = |repo: &str| format!(r#"[{{"repo_id":"{repo}","pull":null,"commit":"c1","pushed_by":"ana"}}]"#); |
| 372 | let item = |id: &str, number: u32, title: &str, state: &str, repo: &str| { |
| 373 | db.execute( |
| 374 | "INSERT INTO work_items (id, workspace, number, title, body, state, resolutions, author_id, author_name, created_at, updated_at) |
| 375 | VALUES (?1, 'acme', ?2, ?3, '', ?4, ?5, 'user', 'user', 'now', 'now')", |
| 376 | params![id, number, title, state, resolved(repo)], |
| 377 | ) |
| 378 | .unwrap(); |
| 379 | }; |
| 380 | item("wki_1", 7, "Broken header", "closed", "repo"); |
| 381 | item("wki_2", 8, "Footer", "open", "repo"); |
| 382 | item("wki_3", 9, "Elsewhere", "closed", "other"); |
| 383 | db.execute_batch( |
| 384 | "INSERT INTO work_item_repos (item_id, repo_id, role, added_at) VALUES |
| 385 | ('wki_1', 'repo', 'origin', 'now'), ('wki_2', 'repo', 'touched', 'now'), ('wki_3', 'other', 'origin', 'now');", |
| 386 | ) |
| 387 | .unwrap(); |
| 388 | let closed: Vec<(String, u32, String, u8)> = db |
| 389 | .prepare(CLOSED_BY_COMMIT) |
| 390 | .unwrap() |
| 391 | .query_map(params!["repo", "c1"], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) |
| 392 | .unwrap() |
| 393 | .map(Result::unwrap) |
| 394 | .collect(); |
| 395 | assert_eq!(closed, vec![("acme".to_owned(), 7, "Broken header".to_owned(), 1), ("acme".to_owned(), 8, "Footer".to_owned(), 0)]); |
| 396 | } |
| 397 | } |