| 1 | //! What `#N` names (`g1t_contracts::work::refs`): read in a repository or |
| 2 | //! a workspace alone, and the work items a text closes and is part of. |
| 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. Each is read in the repository the |
| 7 | //! pull request or commit is in, and only work items of that repository's |
| 8 | //! workspace count: another workspace's are never closed from here. |
| 9 | |
| 10 | use g1t_contracts::access::Capability; |
| 11 | use g1t_contracts::repos::{ReadableArgs, Repo}; |
| 12 | use g1t_contracts::work::refs::{self, Lookup, Ref, RefScope, first_found}; |
| 13 | use g1t_contracts::work::*; |
| 14 | use g1t_contracts::{Outcome, User}; |
| 15 | use serde::Deserialize; |
| 16 | use worker::Result; |
| 17 | use worker::wasm_bindgen::JsValue; |
| 18 | |
| 19 | use super::rows::{Held, path_of}; |
| 20 | use crate::Work; |
| 21 | use crate::rows::ValueRow; |
| 22 | |
| 23 | /// The most work items one pull request or commit closes this way. |
| 24 | pub(crate) const MAX_CLOSES: usize = refs::MAX_REFS; |
| 25 | |
| 26 | /// What `#N` could name, read in repository `?1` (NULL for none) of |
| 27 | /// workspace `?2`: a pull request of the repository, a work item of the |
| 28 | /// workspace filed there under that old number (a repository moved in from |
| 29 | /// another workspace brings old numbers that name that workspace's work |
| 30 | /// items, never this one's), the workspace's work item, and a pull request |
| 31 | /// of that number in the workspace's other repositories. Only a number |
| 32 | /// above the workspace's floor (`work_counters.floor`, the highest any of |
| 33 | /// its repositories used on its own) is the workspace's alone; one at or |
| 34 | /// below it may be any of its repositories' old pull requests, and names |
| 35 | /// none of them from elsewhere. |
| 36 | pub(crate) const REF_SQL: &str = "SELECT |
| 37 | (SELECT 1 FROM pulls WHERE repo_id = ?1 AND number = ?3) AS pull_here, |
| 38 | (SELECT item_id FROM work_item_repos WHERE repo_id = ?1 AND legacy_number = ?3 |
| 39 | AND item_id IN (SELECT id FROM work_items WHERE workspace = ?2 OR workspace IS NULL)) AS legacy, |
| 40 | (SELECT id FROM work_items WHERE workspace = ?2 AND number = ?3) AS item, |
| 41 | (SELECT repo_id FROM pulls WHERE number = ?3 AND repo_id IS NOT ?1 |
| 42 | AND repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?2) |
| 43 | AND number > COALESCE((SELECT floor FROM work_counters WHERE workspace = ?2), 0) |
| 44 | ORDER BY created_at DESC LIMIT 1) AS pull_there"; |
| 45 | |
| 46 | /// What [`REF_SQL`] found. |
| 47 | #[derive(Clone, Debug, Default, Deserialize)] |
| 48 | pub(crate) struct RefRow { |
| 49 | #[serde(default)] |
| 50 | pub pull_here: Option<u8>, |
| 51 | #[serde(default)] |
| 52 | pub legacy: Option<String>, |
| 53 | #[serde(default)] |
| 54 | pub item: Option<String>, |
| 55 | #[serde(default)] |
| 56 | pub pull_there: Option<String>, |
| 57 | } |
| 58 | |
| 59 | /// What a reference names, by the order of `refs::lookup_order`. |
| 60 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 61 | pub(crate) enum Named { |
| 62 | /// A pull request of the repository it was read in. |
| 63 | PullHere, |
| 64 | /// A work item, by id. |
| 65 | Item(String), |
| 66 | /// A pull request of another repository of the workspace, by id. |
| 67 | PullThere(String), |
| 68 | } |
| 69 | |
| 70 | impl RefRow { |
| 71 | /// The first thing found, in the order references are read in: in a |
| 72 | /// repository (`in_repo`), or a workspace alone. |
| 73 | pub(crate) fn named(self, in_repo: bool) -> Option<Named> { |
| 74 | first_found(in_repo, |lookup| match lookup { |
| 75 | Lookup::PullInRepo => self.pull_here.map(|_| Named::PullHere), |
| 76 | Lookup::LegacyItem => self.legacy.clone().map(Named::Item), |
| 77 | Lookup::Item => self.item.clone().map(Named::Item), |
| 78 | Lookup::PullInWorkspace => self.pull_there.clone().map(Named::PullThere), |
| 79 | }) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /// The work items a text names, by id and number, in the order named. |
| 84 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 85 | pub(crate) struct NamedItems { |
| 86 | pub closes: Vec<(String, u32)>, |
| 87 | pub parts: Vec<(String, u32)>, |
| 88 | } |
| 89 | |
| 90 | impl NamedItems { |
| 91 | pub(crate) fn close_ids(&self) -> Vec<String> { |
| 92 | self.closes.iter().map(|(id, _)| id.clone()).collect() |
| 93 | } |
| 94 | |
| 95 | pub(crate) fn part_ids(&self) -> Vec<String> { |
| 96 | self.parts.iter().map(|(id, _)| id.clone()).collect() |
| 97 | } |
| 98 | |
| 99 | pub(crate) fn close_numbers(&self) -> Vec<u32> { |
| 100 | self.closes.iter().map(|(_, number)| *number).collect() |
| 101 | } |
| 102 | |
| 103 | pub(crate) fn part_numbers(&self) -> Vec<u32> { |
| 104 | self.parts.iter().map(|(_, number)| *number).collect() |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | impl Work { |
| 109 | /// What [`REF_SQL`] finds for `#number` in `repo_id` (or none) of |
| 110 | /// `workspace`. |
| 111 | pub(super) async fn lookup_ref(&self, repo_id: Option<&str>, workspace: &str, number: u32) -> Result<RefRow> { |
| 112 | Ok(self |
| 113 | .db |
| 114 | .prepare(REF_SQL) |
| 115 | .bind(&[repo_id.map_or(JsValue::NULL, JsValue::from), workspace.to_lowercase().into(), number.into()])? |
| 116 | .first::<RefRow>(None) |
| 117 | .await? |
| 118 | .unwrap_or_default()) |
| 119 | } |
| 120 | |
| 121 | /// `resolve_ref`: what `#number` names for the viewer, read in `repo` |
| 122 | /// or in `workspace` (`refs`). |
| 123 | pub(crate) async fn resolve_ref(&self, a: ResolveRefArgs) -> Result<RefTarget> { |
| 124 | // Read in a repository the viewer may not read, `#N` names |
| 125 | // nothing, exactly as in one that does not exist: anything else |
| 126 | // would tell them a private repository is there. |
| 127 | let (repo, workspace) = match &a.repo { |
| 128 | Some(path) => match read_in(self.repo(path, &a.viewer).await?) { |
| 129 | Some(repo) => { |
| 130 | let workspace = repo.namespace.to_lowercase(); |
| 131 | (Some((repo.id.clone(), Some(path_of(&repo)))), workspace) |
| 132 | } |
| 133 | None => return Ok(RefTarget::None), |
| 134 | }, |
| 135 | None => (None, a.workspace.trim().to_lowercase()), |
| 136 | }; |
| 137 | if a.number == 0 || workspace.is_empty() { |
| 138 | return Ok(RefTarget::None); |
| 139 | } |
| 140 | // What the viewer may not see is passed over for what comes next. |
| 141 | let mut row = self.lookup_ref(repo.as_ref().map(|(id, _)| id.as_str()), &workspace, a.number).await?; |
| 142 | for _ in 0..4 { |
| 143 | let Some(named) = row.clone().named(repo.is_some()) else { |
| 144 | return Ok(RefTarget::None); |
| 145 | }; |
| 146 | match named { |
| 147 | Named::PullHere => match repo.as_ref().and_then(|(_, path)| path.clone()) { |
| 148 | Some(repo) => return Ok(RefTarget::Pull { repo, number: a.number }), |
| 149 | None => row.pull_here = None, |
| 150 | }, |
| 151 | Named::Item(id) => { |
| 152 | if let Some(held) = self.item_by_id(&id).await? |
| 153 | && held.may(a.viewer.as_ref(), Capability::Read) |
| 154 | { |
| 155 | return Ok(RefTarget::WorkItem { workspace: held.item.workspace, number: held.item.number, item_id: held.item.id }); |
| 156 | } |
| 157 | if row.legacy.as_deref() == Some(id.as_str()) { |
| 158 | row.legacy = None; |
| 159 | } else { |
| 160 | row.item = None; |
| 161 | } |
| 162 | } |
| 163 | Named::PullThere(repo_id) => { |
| 164 | let readable: Vec<Repo> = g1t_kit::call( |
| 165 | &self.repos, |
| 166 | "readable", |
| 167 | &ReadableArgs { ids: vec![repo_id.clone()], viewer: a.viewer.clone() }, |
| 168 | ) |
| 169 | .await?; |
| 170 | return Ok(match readable.first() { |
| 171 | Some(other) => RefTarget::Pull { repo: path_of(other), number: a.number }, |
| 172 | None => RefTarget::None, |
| 173 | }); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | Ok(RefTarget::None) |
| 178 | } |
| 179 | |
| 180 | /// The work item `reference` names, read in `repo`: its id, when it is |
| 181 | /// one of the workspace's work items. |
| 182 | pub(super) async fn ref_item(&self, repo: &Repo, reference: &Ref) -> Result<Option<String>> { |
| 183 | let workspace = repo.namespace.to_lowercase(); |
| 184 | let (repo_id, in_repo) = match &reference.scope { |
| 185 | RefScope::Here => (Some(repo.id.clone()), true), |
| 186 | RefScope::Workspace(named) => { |
| 187 | if *named != workspace { |
| 188 | return Ok(None); |
| 189 | } |
| 190 | (None, false) |
| 191 | } |
| 192 | RefScope::Repo(path) => { |
| 193 | if !path.namespace.eq_ignore_ascii_case(&workspace) { |
| 194 | return Ok(None); |
| 195 | } |
| 196 | let named = format!("{}/{}", path.namespace, path.name); |
| 197 | if named.eq_ignore_ascii_case(&path_of(repo)) { |
| 198 | (Some(repo.id.clone()), true) |
| 199 | } else { |
| 200 | let known = self |
| 201 | .db |
| 202 | .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE lower(path) = lower(?)") |
| 203 | .bind(&[named.as_str().into()])? |
| 204 | .first::<ValueRow>(None) |
| 205 | .await?; |
| 206 | // A repository not known names nothing, as resolve_ref |
| 207 | // reads it: never the workspace's work item of that number. |
| 208 | match known { |
| 209 | Some(row) => (Some(row.value), true), |
| 210 | None => return Ok(None), |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | }; |
| 215 | let found = self.lookup_ref(repo_id.as_deref(), &workspace, reference.number).await?; |
| 216 | Ok(match found.named(in_repo) { |
| 217 | Some(Named::Item(id)) => Some(id), |
| 218 | _ => None, |
| 219 | }) |
| 220 | } |
| 221 | |
| 222 | /// The work items `text`, written in `repo`, closes and is part of, |
| 223 | /// with `own` (a pull request's own work item) closed first. Each of |
| 224 | /// `who` (whoever wrote it, merges it or pushed it) must be able to |
| 225 | /// see what it names ([`admits`]); what one of them may not is left |
| 226 | /// unresolved, as a reference to nothing. |
| 227 | pub(super) async fn named_items(&self, repo: &Repo, own: Option<&str>, text: &str, who: &[&User]) -> Result<NamedItems> { |
| 228 | let named = refs::named(text); |
| 229 | let mut closes: Vec<String> = Vec::new(); |
| 230 | for reference in &named.closes { |
| 231 | if let Some(id) = self.ref_item(repo, reference).await? |
| 232 | && !closes.contains(&id) |
| 233 | { |
| 234 | closes.push(id); |
| 235 | } |
| 236 | } |
| 237 | let mut parts: Vec<String> = Vec::new(); |
| 238 | for reference in &named.parts { |
| 239 | if let Some(id) = self.ref_item(repo, reference).await? |
| 240 | && !closes.contains(&id) |
| 241 | && own != Some(id.as_str()) |
| 242 | && !parts.contains(&id) |
| 243 | { |
| 244 | parts.push(id); |
| 245 | } |
| 246 | } |
| 247 | let mut all: Vec<String> = own.map(str::to_owned).into_iter().collect(); |
| 248 | all.extend(closes.iter().take(MAX_CLOSES * 2).cloned()); |
| 249 | all.extend(parts.iter().take(MAX_CLOSES * 2).cloned()); |
| 250 | let held = self.items_by_ids(&all).await?; |
| 251 | let found = |id: &String| held.iter().find(|held| held.item.id == *id); |
| 252 | // Its own work item first, whoever may see it: the pull request |
| 253 | // was made for it. |
| 254 | let mut closing: Vec<(String, u32)> = own |
| 255 | .map(str::to_owned) |
| 256 | .into_iter() |
| 257 | .filter_map(|id| found(&id).map(|held| (id, held.item.number))) |
| 258 | .collect(); |
| 259 | for id in &closes { |
| 260 | if let Some(held) = found(id).filter(|held| admits(held, who, true)) |
| 261 | && !closing.iter().any(|(known, _)| known == id) |
| 262 | { |
| 263 | closing.push((id.clone(), held.item.number)); |
| 264 | } |
| 265 | } |
| 266 | let mut parted: Vec<(String, u32)> = Vec::new(); |
| 267 | for id in &parts { |
| 268 | if let Some(held) = found(id).filter(|held| admits(held, who, false)) { |
| 269 | parted.push((id.clone(), held.item.number)); |
| 270 | } |
| 271 | } |
| 272 | closing.truncate(MAX_CLOSES); |
| 273 | parted.truncate(MAX_CLOSES); |
| 274 | Ok(NamedItems { closes: closing, parts: parted }) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /// The repository `#N` is read in, as the viewer found it: none when they |
| 279 | /// may not read it, exactly as when there is no such repository. |
| 280 | fn read_in<T>(found: Outcome<T>) -> Option<T> { |
| 281 | match found { |
| 282 | Outcome::Ok(repo) => Some(repo), |
| 283 | Outcome::Fail(_) => None, |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | /// Whether everyone in `who` may have `held` closed (`closing`: they may |
| 288 | /// triage it) or linked as something they are part of (they may see it). |
| 289 | pub(crate) fn admits(held: &Held, who: &[&User], closing: bool) -> bool { |
| 290 | let capability = if closing { Capability::Triage } else { Capability::Read }; |
| 291 | who.iter().all(|user| held.may(Some(user), capability)) |
| 292 | } |
| 293 | |
| 294 | #[cfg(test)] |
| 295 | mod tests { |
| 296 | use super::*; |
| 297 | |
| 298 | #[test] |
| 299 | fn a_repository_the_viewer_may_not_read_is_read_as_none() { |
| 300 | assert_eq!(read_in(Outcome::<u8>::fail(g1t_contracts::FailureCode::NotFound, "Repository not found.")), None); |
| 301 | assert_eq!(read_in(Outcome::Ok(1)), Some(1)); |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn only_what_everyone_involved_may_see_is_closed_or_linked() { |
| 306 | use crate::items::rows_for_test::{held_for_test, person_for_test}; |
| 307 | let member = person_for_test("usr_2", Some("acme")); |
| 308 | // An outside collaborator with Write on public acme/web. |
| 309 | let mut outsider = person_for_test("usr_9", None); |
| 310 | outsider.grants.push(g1t_contracts::access::RepoGrant { |
| 311 | repo_id: "rep_web".into(), |
| 312 | workspace: "acme".into(), |
| 313 | role: g1t_contracts::access::RepoRole::Write, |
| 314 | team: None, |
| 315 | }); |
| 316 | let filed_in_web = held_for_test(1, &[("rep_web", "origin", "acme", false)], &[]); |
| 317 | let members_only = held_for_test(2, &[("rep_web", "target", "acme", false)], &[]); |
| 318 | let private = held_for_test(3, &[("rep_secret", "origin", "acme", true)], &[]); |
| 319 | assert!(admits(&filed_in_web, &[&outsider], true)); |
| 320 | assert!(!admits(&members_only, &[&outsider], true)); |
| 321 | assert!(!admits(&members_only, &[&outsider], false)); |
| 322 | assert!(!admits(&private, &[&outsider], true)); |
| 323 | assert!(admits(&private, &[&member], true)); |
| 324 | // Merged by a member, written by the outsider: what both may. |
| 325 | assert!(!admits(&private, &[&member, &outsider], true)); |
| 326 | // Someone who may only read a public work item may not close it. |
| 327 | let reader = person_for_test("usr_8", None); |
| 328 | assert!(admits(&filed_in_web, &[&reader], false)); |
| 329 | assert!(!admits(&filed_in_web, &[&reader], true)); |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn a_reference_names_the_first_thing_found_in_order() { |
| 334 | let row = |pull_here: bool, legacy: Option<&str>, item: Option<&str>, there: Option<&str>| RefRow { |
| 335 | pull_here: pull_here.then_some(1), |
| 336 | legacy: legacy.map(str::to_owned), |
| 337 | item: item.map(str::to_owned), |
| 338 | pull_there: there.map(str::to_owned), |
| 339 | }; |
| 340 | // In a repository: its own pull request, then its old issue, then |
| 341 | // the workspace's item, then another repository's pull request. |
| 342 | assert_eq!(row(true, Some("iss_1"), Some("wki_1"), None).named(true), Some(Named::PullHere)); |
| 343 | assert_eq!(row(false, Some("iss_1"), Some("wki_1"), None).named(true), Some(Named::Item("iss_1".into()))); |
| 344 | assert_eq!(row(false, None, Some("wki_1"), Some("rep_2")).named(true), Some(Named::Item("wki_1".into()))); |
| 345 | assert_eq!(row(false, None, None, Some("rep_2")).named(true), Some(Named::PullThere("rep_2".into()))); |
| 346 | // In a workspace alone: its item, then a pull request. |
| 347 | assert_eq!(row(false, None, Some("wki_1"), Some("rep_2")).named(false), Some(Named::Item("wki_1".into()))); |
| 348 | assert_eq!(row(false, None, None, None).named(false), None); |
| 349 | } |
| 350 | } |