| 1 | //! The work items a pull request or a commit says it closes or is part of, |
| 2 | //! and what landing them does. |
| 3 | //! |
| 4 | //! A closing keyword followed by a reference (`Fixes #12`, `closes: #3`, |
| 5 | //! `Resolves flagon-io/g1t#40`) names a work item to close; `Part of #12` |
| 6 | //! or `Refs #12` one it is part of (`g1t_contracts::work::refs`). Each is |
| 7 | //! read in the repository the pull request or commit is in, and only work |
| 8 | //! items of that repository's workspace count: another workspace's are |
| 9 | //! never closed from here. |
| 10 | //! |
| 11 | //! When a pull request merges into the default branch, or a commit naming |
| 12 | //! a work item is pushed there, where the repository closes work items |
| 13 | //! that way (`RepoSettings::auto_close_items`), the repository's link to |
| 14 | //! each work item it closes is marked landed and the resolution recorded |
| 15 | //! ([`LAND_SQL`]); the work item closes once it has landed in every target |
| 16 | //! it has, or at once when it has none. What it is part of is linked |
| 17 | //! (`touched`), and closes nothing. |
| 18 | |
| 19 | use g1t_contracts::events::WorkItemEvent; |
| 20 | use g1t_contracts::repos::Repo; |
| 21 | use g1t_contracts::time::rfc3339; |
| 22 | use g1t_contracts::work::refs::{self, Ref, RefScope}; |
| 23 | use g1t_contracts::work::*; |
| 24 | use g1t_contracts::User; |
| 25 | use g1t_kit::now_ms; |
| 26 | use worker::Result; |
| 27 | use worker::wasm_bindgen::JsValue; |
| 28 | |
| 29 | use crate::Work; |
| 30 | use crate::items::{Held, Named, StoredResolution, landing_note, path_of}; |
| 31 | use crate::rows::{By, ValueRow}; |
| 32 | |
| 33 | /// The most work items one pull request or commit closes this way. |
| 34 | pub(crate) const MAX_CLOSES: usize = refs::MAX_REFS; |
| 35 | |
| 36 | /// What landed: a pull request merged, or a commit pushed. |
| 37 | #[derive(Clone, Debug, Default)] |
| 38 | pub(crate) struct Landing { |
| 39 | pub pull: Option<u32>, |
| 40 | pub commit: Option<String>, |
| 41 | pub pushed_by: Option<String>, |
| 42 | } |
| 43 | |
| 44 | /// Landing a work item (`?1`) in repository `?2` at `?3`, by `?4` (an |
| 45 | /// account id), resolved by `?5` (a stored resolution, JSON), whose pull |
| 46 | /// request and commit are `?6` and `?7`, in one batch: linked (as touched, |
| 47 | /// unless it is already), marked landed there, the resolution recorded |
| 48 | /// once, and closed when no target is left to land. |
| 49 | pub(crate) const LAND_SQL: [&str; 4] = [ |
| 50 | "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by) |
| 51 | VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING", |
| 52 | "UPDATE work_item_repos SET landed_at = COALESCE(landed_at, ?3) WHERE item_id = ?1 AND repo_id = ?2", |
| 53 | "UPDATE work_items SET resolutions = json_insert(resolutions, '$[#]', json(?5)), updated_at = ?3 |
| 54 | WHERE id = ?1 AND NOT EXISTS (SELECT 1 FROM json_each(work_items.resolutions) j |
| 55 | WHERE json_extract(j.value, '$.repo_id') = ?2 AND json_extract(j.value, '$.pull') IS ?6 |
| 56 | AND json_extract(j.value, '$.commit') IS ?7)", |
| 57 | "UPDATE work_items SET state = 'closed', reason = 'completed', closed_at = ?3, updated_at = ?3 |
| 58 | WHERE id = ?1 AND state = 'open' |
| 59 | AND NOT EXISTS (SELECT 1 FROM work_item_repos WHERE item_id = ?1 AND role = 'target' AND landed_at IS NULL) |
| 60 | RETURNING id AS value", |
| 61 | ]; |
| 62 | |
| 63 | /// Linking a work item (`?1`) that a change in repository `?2` is part of, |
| 64 | /// at `?3`, by `?4`, unless it is linked already. |
| 65 | pub(crate) const PART_SQL: &str = "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by) |
| 66 | VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING RETURNING item_id AS value"; |
| 67 | |
| 68 | /// The work items a text names, by id and number, in the order named. |
| 69 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 70 | pub(crate) struct NamedItems { |
| 71 | pub closes: Vec<(String, u32)>, |
| 72 | pub parts: Vec<(String, u32)>, |
| 73 | } |
| 74 | |
| 75 | impl NamedItems { |
| 76 | pub(crate) fn close_ids(&self) -> Vec<String> { |
| 77 | self.closes.iter().map(|(id, _)| id.clone()).collect() |
| 78 | } |
| 79 | |
| 80 | pub(crate) fn part_ids(&self) -> Vec<String> { |
| 81 | self.parts.iter().map(|(id, _)| id.clone()).collect() |
| 82 | } |
| 83 | |
| 84 | pub(crate) fn close_numbers(&self) -> Vec<u32> { |
| 85 | self.closes.iter().map(|(_, number)| *number).collect() |
| 86 | } |
| 87 | |
| 88 | pub(crate) fn part_numbers(&self) -> Vec<u32> { |
| 89 | self.parts.iter().map(|(_, number)| *number).collect() |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | impl Work { |
| 94 | /// The work item `reference` names, read in `repo`: its id, when it is |
| 95 | /// one of the workspace's work items. |
| 96 | pub(crate) async fn ref_item(&self, repo: &Repo, reference: &Ref) -> Result<Option<String>> { |
| 97 | let workspace = repo.namespace.to_lowercase(); |
| 98 | let (repo_id, in_repo) = match &reference.scope { |
| 99 | RefScope::Here => (Some(repo.id.clone()), true), |
| 100 | RefScope::Workspace(named) => { |
| 101 | if *named != workspace { |
| 102 | return Ok(None); |
| 103 | } |
| 104 | (None, false) |
| 105 | } |
| 106 | RefScope::Repo(path) => { |
| 107 | if !path.namespace.eq_ignore_ascii_case(&workspace) { |
| 108 | return Ok(None); |
| 109 | } |
| 110 | let named = format!("{}/{}", path.namespace, path.name); |
| 111 | if named.eq_ignore_ascii_case(&path_of(repo)) { |
| 112 | (Some(repo.id.clone()), true) |
| 113 | } else { |
| 114 | let known = self |
| 115 | .db |
| 116 | .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE lower(path) = lower(?)") |
| 117 | .bind(&[named.as_str().into()])? |
| 118 | .first::<ValueRow>(None) |
| 119 | .await?; |
| 120 | match known { |
| 121 | Some(row) => (Some(row.value), true), |
| 122 | None => (None, false), |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | }; |
| 127 | let found = self.lookup_ref(repo_id.as_deref(), &workspace, reference.number).await?; |
| 128 | Ok(match found.named(in_repo) { |
| 129 | Some(Named::Item(id)) => Some(id), |
| 130 | _ => None, |
| 131 | }) |
| 132 | } |
| 133 | |
| 134 | /// The work items `text`, written in `repo`, closes and is part of, |
| 135 | /// with `own` (a pull request's own work item) closed first. |
| 136 | pub(crate) async fn named_items(&self, repo: &Repo, own: Option<&str>, text: &str) -> Result<NamedItems> { |
| 137 | let named = refs::named(text); |
| 138 | let mut closes: Vec<String> = own.map(str::to_owned).into_iter().collect(); |
| 139 | for reference in &named.closes { |
| 140 | if let Some(id) = self.ref_item(repo, reference).await? |
| 141 | && !closes.contains(&id) |
| 142 | { |
| 143 | closes.push(id); |
| 144 | } |
| 145 | } |
| 146 | let mut parts: Vec<String> = Vec::new(); |
| 147 | for reference in &named.parts { |
| 148 | if let Some(id) = self.ref_item(repo, reference).await? |
| 149 | && !closes.contains(&id) |
| 150 | && !parts.contains(&id) |
| 151 | { |
| 152 | parts.push(id); |
| 153 | } |
| 154 | } |
| 155 | closes.truncate(MAX_CLOSES); |
| 156 | parts.truncate(MAX_CLOSES); |
| 157 | let mut all = closes.clone(); |
| 158 | all.extend(parts.iter().cloned()); |
| 159 | let held = self.items_by_ids(&all).await?; |
| 160 | let number = |id: &String| held.iter().find(|held| held.item.id == *id).map(|held| (id.clone(), held.item.number)); |
| 161 | Ok(NamedItems { closes: closes.iter().filter_map(number).collect(), parts: parts.iter().filter_map(number).collect() }) |
| 162 | } |
| 163 | |
| 164 | /// The work items a pull request closes and is part of as its title and |
| 165 | /// description say now: none closed where it does not merge into the |
| 166 | /// default branch, where the repository does not close work items that |
| 167 | /// way, or where it is kept open (`keep_open`), when its own work item |
| 168 | /// is only something it is part of. |
| 169 | pub(crate) async fn items_to_close(&self, pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_open: bool) -> Result<NamedItems> { |
| 170 | let own = self.pull_item_id(&pull.id).await?; |
| 171 | let text = format!("{}\n{}", pull.title, pull.body.as_deref().unwrap_or_default()); |
| 172 | let mut named = self.named_items(repo, own.as_deref(), &text).await?; |
| 173 | if keep_open || !settings.auto_close_items || !pull.targets_default(&repo.default_branch) { |
| 174 | let closed = std::mem::take(&mut named.closes); |
| 175 | let mut parts = if keep_open { closed } else { Vec::new() }; |
| 176 | parts.extend(named.parts); |
| 177 | named.parts = parts; |
| 178 | } |
| 179 | Ok(named) |
| 180 | } |
| 181 | |
| 182 | /// The id of a pull request's own work item, if it has one. |
| 183 | pub(crate) async fn pull_item_id(&self, pull_id: &str) -> Result<Option<String>> { |
| 184 | Ok(self |
| 185 | .db |
| 186 | .prepare("SELECT item_id AS value FROM pulls WHERE id = ? AND item_id IS NOT NULL") |
| 187 | .bind(&[pull_id.into()])? |
| 188 | .first::<ValueRow>(None) |
| 189 | .await? |
| 190 | .map(|row| row.value)) |
| 191 | } |
| 192 | |
| 193 | /// Lands `closes` (work items, by id) in `repo` by `landing`, and links |
| 194 | /// `parts`: see the module's notes. Says so on each, with the session |
| 195 | /// it was done in when one is known; `agent` when an agent pushed for |
| 196 | /// `actor` (the note is then g1t's, for them). Returns the work items |
| 197 | /// it closed. |
| 198 | #[allow(clippy::too_many_arguments)] |
| 199 | pub(crate) async fn land( |
| 200 | &self, |
| 201 | repo: &Repo, |
| 202 | landing: &Landing, |
| 203 | actor: &User, |
| 204 | closes: &[String], |
| 205 | parts: &[String], |
| 206 | session: Option<&str>, |
| 207 | agent: bool, |
| 208 | ) -> Result<Vec<Held>> { |
| 209 | let now = rfc3339(now_ms()); |
| 210 | let path = path_of(repo); |
| 211 | let by = match By::from(actor) { |
| 212 | by if agent && by.acting_for.is_none() && by.id == actor.id => By { |
| 213 | id: g1t_contracts::identity::AGENT_ID, |
| 214 | name: g1t_contracts::identity::AGENT_NAME, |
| 215 | acting_for: Some((&actor.id, &actor.username)), |
| 216 | }, |
| 217 | by => by, |
| 218 | }; |
| 219 | let stored = StoredResolution { |
| 220 | repo_id: repo.id.clone(), |
| 221 | pull: landing.pull, |
| 222 | commit: landing.commit.clone(), |
| 223 | pushed_by: landing.pushed_by.clone(), |
| 224 | }; |
| 225 | let resolution = Resolution { |
| 226 | repo: path.clone(), |
| 227 | pull: landing.pull, |
| 228 | commit: landing.commit.clone(), |
| 229 | pushed_by: landing.pushed_by.clone(), |
| 230 | }; |
| 231 | let what = landing_note(&path, landing.pull, landing.commit.as_deref()); |
| 232 | let mut closed = Vec::new(); |
| 233 | for id in closes { |
| 234 | let Some(before) = self.item_by_id(id).await? else { continue }; |
| 235 | let binds: [JsValue; 7] = [ |
| 236 | id.as_str().into(), |
| 237 | repo.id.as_str().into(), |
| 238 | now.as_str().into(), |
| 239 | actor.id.as_str().into(), |
| 240 | serde_json::to_string(&stored)?.into(), |
| 241 | landing.pull.map_or(JsValue::NULL, JsValue::from), |
| 242 | landing.commit.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 243 | ]; |
| 244 | let statements = LAND_SQL |
| 245 | .iter() |
| 246 | .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)])) |
| 247 | .collect::<Result<Vec<_>>>()?; |
| 248 | let results = self.db.batch(statements).await?; |
| 249 | let closed_now = results |
| 250 | .get(3) |
| 251 | .map(|result| result.results::<ValueRow>()) |
| 252 | .transpose()? |
| 253 | .is_some_and(|rows| !rows.is_empty()); |
| 254 | let Some(held) = self.item_by_id(id).await? else { continue }; |
| 255 | if closed_now { |
| 256 | self.publish_item( |
| 257 | "work_item.closed", |
| 258 | &held, |
| 259 | Some(actor), |
| 260 | WorkItemEvent { |
| 261 | reason: Some(CloseReason::Completed.as_str()), |
| 262 | resolution: Some(resolution.clone()), |
| 263 | missing: Some(Vec::new()), |
| 264 | session: session.map(str::to_owned), |
| 265 | ..Self::item_event(&held) |
| 266 | }, |
| 267 | ) |
| 268 | .await?; |
| 269 | let text = match landing.pull { |
| 270 | Some(_) => format!("closed this by merging {what}"), |
| 271 | None => format!("closed this in {what}"), |
| 272 | }; |
| 273 | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; |
| 274 | self.item_resolved(&held, actor, true, landing.pull).await; |
| 275 | closed.push(held); |
| 276 | continue; |
| 277 | } |
| 278 | self.publish_item( |
| 279 | "work_item.landed", |
| 280 | &held, |
| 281 | Some(actor), |
| 282 | WorkItemEvent { |
| 283 | resolution: Some(resolution.clone()), |
| 284 | missing: Some(held.item.missing()), |
| 285 | session: session.map(str::to_owned), |
| 286 | ..Self::item_event(&held) |
| 287 | }, |
| 288 | ) |
| 289 | .await?; |
| 290 | if before.item.state == State::Open { |
| 291 | let missing = held.item.missing(); |
| 292 | let text = if missing.is_empty() { |
| 293 | format!("landed {what}") |
| 294 | } else { |
| 295 | format!("landed {what}; still to land in {}", missing.join(", ")) |
| 296 | }; |
| 297 | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; |
| 298 | // Merged for it: what it blocks hears, though it stays open. |
| 299 | self.item_resolved(&held, actor, false, landing.pull).await; |
| 300 | } |
| 301 | } |
| 302 | for id in parts { |
| 303 | let linked = self |
| 304 | .db |
| 305 | .prepare(PART_SQL) |
| 306 | .bind(&[id.as_str().into(), repo.id.as_str().into(), now.as_str().into(), actor.id.as_str().into()])? |
| 307 | .first::<ValueRow>(None) |
| 308 | .await?; |
| 309 | if linked.is_none() { |
| 310 | continue; |
| 311 | } |
| 312 | let Some(held) = self.item_by_id(id).await? else { continue }; |
| 313 | self.publish_item( |
| 314 | "work_item.linked", |
| 315 | &held, |
| 316 | Some(actor), |
| 317 | WorkItemEvent { repo: Some(path.clone()), role: Some(LinkRole::Touched), ..Self::item_event(&held) }, |
| 318 | ) |
| 319 | .await?; |
| 320 | self.note_item(id, by, &format!("linked {path}: {what} is part of this")).await?; |
| 321 | } |
| 322 | Ok(closed) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | #[cfg(test)] |
| 327 | mod tests { |
| 328 | use super::*; |
| 329 | |
| 330 | #[test] |
| 331 | fn landing_statements_take_the_item_repository_and_what_landed() { |
| 332 | let counts: Vec<usize> = LAND_SQL.iter().map(|sql| g1t_kit::rename::parameters(sql)).collect(); |
| 333 | assert_eq!(counts, vec![4, 3, 7, 3]); |
| 334 | assert_eq!(g1t_kit::rename::parameters(PART_SQL), 4); |
| 335 | } |
| 336 | } |