| 1 | //! A work item as stored and as read: its row, its links and who may see |
| 2 | //! it ([`Held`]). |
| 3 | |
| 4 | use g1t_contracts::access::{self, Capability, Denied, RepoRef}; |
| 5 | use g1t_contracts::repos::{Repo, RepoPath}; |
| 6 | use g1t_contracts::work::*; |
| 7 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | |
| 10 | use crate::rows::{milestone_ref, requester, user}; |
| 11 | |
| 12 | /// A work item's columns, with what it is read with: its counts, agent, |
| 13 | /// milestone's title, blockers by number and links. |
| 14 | pub(crate) const ITEM_COLUMNS: &str = "work_items.*, |
| 15 | (SELECT count(*) FROM pulls WHERE pulls.item_id = work_items.id) AS pull_count, |
| 16 | (SELECT agent FROM pulls |
| 17 | WHERE pulls.item_id = work_items.id AND pulls.status IN ('draft', 'open') |
| 18 | AND pulls.fork_repo_id IS NOT NULL |
| 19 | ORDER BY pulls.created_at DESC LIMIT 1) AS agent, |
| 20 | (SELECT count(*) FROM comments |
| 21 | WHERE comments.item_id = work_items.id AND comments.kind = 'comment') AS comment_count, |
| 22 | (SELECT title FROM milestones |
| 23 | WHERE milestones.workspace = work_items.workspace AND milestones.number = work_items.milestone) AS milestone_title, |
| 24 | (SELECT json_group_array(number) FROM |
| 25 | (SELECT b.number AS number FROM json_each(work_items.blocked_by) j JOIN work_items b ON b.id = j.value |
| 26 | WHERE b.number IS NOT NULL ORDER BY j.key)) AS blocked_numbers, |
| 27 | (SELECT json_group_array(json_object('repo_id', l.repo_id, 'path', COALESCE(rw.path, l.path, ''), 'role', l.role, |
| 28 | 'legacy_number', l.legacy_number, 'landed', l.landed_at IS NOT NULL, 'workspace', rw.workspace, |
| 29 | 'private', rw.private)) |
| 30 | FROM work_item_repos l LEFT JOIN repo_workspaces rw ON rw.repo_id = l.repo_id |
| 31 | WHERE l.item_id = work_items.id) AS links"; |
| 32 | |
| 33 | #[derive(Deserialize)] |
| 34 | pub(crate) struct ItemRow { |
| 35 | pub id: String, |
| 36 | pub workspace: Option<String>, |
| 37 | pub number: Option<u32>, |
| 38 | pub title: String, |
| 39 | pub body: String, |
| 40 | /// JSON array of label names. |
| 41 | pub labels: String, |
| 42 | pub state: State, |
| 43 | pub reason: Option<CloseReason>, |
| 44 | /// JSON array of `{ repo_id, pull, commit, pushed_by }`. |
| 45 | #[serde(default)] |
| 46 | pub resolutions: Option<String>, |
| 47 | pub author_id: String, |
| 48 | pub author_name: String, |
| 49 | #[serde(default)] |
| 50 | pub requested_by_id: Option<String>, |
| 51 | #[serde(default)] |
| 52 | pub requested_by_name: Option<String>, |
| 53 | pub created_at: String, |
| 54 | pub updated_at: String, |
| 55 | pub closed_at: Option<String>, |
| 56 | /// JSON array of usernames. |
| 57 | pub assignees: String, |
| 58 | #[serde(default)] |
| 59 | pub milestone: Option<u32>, |
| 60 | #[serde(default)] |
| 61 | pub milestone_title: Option<String>, |
| 62 | #[serde(default)] |
| 63 | pub pull_count: u32, |
| 64 | #[serde(default)] |
| 65 | pub comment_count: u32, |
| 66 | #[serde(default)] |
| 67 | pub agent: Option<String>, |
| 68 | /// JSON array of the numbers of the work items it is blocked by. |
| 69 | #[serde(default)] |
| 70 | pub blocked_numbers: Option<String>, |
| 71 | /// JSON array of [`LinkRow`]. |
| 72 | #[serde(default)] |
| 73 | pub links: Option<String>, |
| 74 | } |
| 75 | |
| 76 | /// A link as [`ITEM_COLUMNS`] reads it. |
| 77 | #[derive(Deserialize)] |
| 78 | struct LinkRow { |
| 79 | repo_id: String, |
| 80 | path: String, |
| 81 | role: LinkRole, |
| 82 | #[serde(default)] |
| 83 | legacy_number: Option<u32>, |
| 84 | #[serde(default)] |
| 85 | landed: u8, |
| 86 | /// Where the repository is, when known. |
| 87 | #[serde(default)] |
| 88 | workspace: Option<String>, |
| 89 | #[serde(default)] |
| 90 | private: Option<u8>, |
| 91 | } |
| 92 | |
| 93 | /// A resolution as stored. |
| 94 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 95 | pub(crate) struct StoredResolution { |
| 96 | pub repo_id: String, |
| 97 | #[serde(default)] |
| 98 | pub pull: Option<u32>, |
| 99 | #[serde(default)] |
| 100 | pub commit: Option<String>, |
| 101 | #[serde(default)] |
| 102 | pub pushed_by: Option<String>, |
| 103 | } |
| 104 | |
| 105 | /// A work item, with what deciding who may see it needs: its origin's |
| 106 | /// workspace and whether it is private. |
| 107 | #[derive(Clone, Debug)] |
| 108 | pub(crate) struct Held { |
| 109 | pub item: WorkItem, |
| 110 | /// The origin's id, workspace and privacy, while the repository is |
| 111 | /// known: one deleted for good no longer makes it public. |
| 112 | origin: Option<(String, String, bool)>, |
| 113 | /// Each linked repository's id with its workspace and privacy, while |
| 114 | /// known: what deciding who may see that it is linked needs. |
| 115 | known: Vec<(String, String, bool)>, |
| 116 | /// The repository of each of `item.resolutions`, by id, in order. |
| 117 | resolved_in: Vec<String>, |
| 118 | } |
| 119 | |
| 120 | impl Held { |
| 121 | /// Whether `viewer` may read linked repository `repo_id`: one whose |
| 122 | /// workspace and privacy are not known is not shown. |
| 123 | pub(crate) fn may_read_repo(&self, viewer: Option<&User>, repo_id: &str) -> bool { |
| 124 | self.known |
| 125 | .iter() |
| 126 | .find(|(id, _, _)| id == repo_id) |
| 127 | .is_some_and(|(id, namespace, private)| repo_readable(viewer, id, Some(namespace), Some(u8::from(*private)))) |
| 128 | } |
| 129 | |
| 130 | /// The work item as `viewer` is shown it: only the linked repositories |
| 131 | /// and the resolutions in repositories they may read. Who may see a |
| 132 | /// work item is its workspace's and its origin's business; that it is |
| 133 | /// linked to a private repository is that repository's. |
| 134 | pub(crate) fn shown(self, viewer: Option<&User>) -> WorkItem { |
| 135 | let readable = |repo_id: &str| self.may_read_repo(viewer, repo_id); |
| 136 | let repos: Vec<ItemRepo> = self.item.repos.iter().filter(|repo| readable(&repo.repo_id)).cloned().collect(); |
| 137 | let resolutions: Vec<Resolution> = self |
| 138 | .item |
| 139 | .resolutions |
| 140 | .iter() |
| 141 | .zip(&self.resolved_in) |
| 142 | .filter(|(_, repo_id)| readable(repo_id)) |
| 143 | .map(|(resolution, _)| resolution.clone()) |
| 144 | .collect(); |
| 145 | WorkItem { repos, resolutions, ..self.item } |
| 146 | } |
| 147 | |
| 148 | pub(crate) fn origin(&self) -> Option<RepoRef<'_>> { |
| 149 | self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private }) |
| 150 | } |
| 151 | |
| 152 | /// Every repository it is linked to, its origin first. |
| 153 | pub(crate) fn repo_ids(&self) -> Vec<String> { |
| 154 | self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect() |
| 155 | } |
| 156 | |
| 157 | /// The repository its events are filed under: its origin, or none for |
| 158 | /// one filed in the workspace. Never another linked repository: its |
| 159 | /// readers may not see a members-only work item, and events filed |
| 160 | /// under a repository are shown to whoever reads it. The others hear |
| 161 | /// of it through the event's `repoIds`. |
| 162 | pub(crate) fn event_repo(&self) -> Option<&str> { |
| 163 | self.item.repos.iter().find(|repo| repo.role == LinkRole::Origin).map(|repo| repo.repo_id.as_str()) |
| 164 | } |
| 165 | |
| 166 | pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool { |
| 167 | access::can_item(viewer, &self.item.workspace, self.origin(), capability) |
| 168 | } |
| 169 | |
| 170 | /// `Ok` when `viewer` may do `capability` on it; not found when they |
| 171 | /// may not see it. |
| 172 | pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> { |
| 173 | match access::check_item(viewer, &self.item.workspace, self.origin(), capability) { |
| 174 | Ok(()) => Outcome::Ok(()), |
| 175 | Err(Denied::NotFound) => no_item(), |
| 176 | Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)), |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | /// Whether `viewer` may read repository `repo_id`, as `repo_workspaces` |
| 182 | /// records it (its `workspace` and `private`): one not recorded is not. |
| 183 | pub(crate) fn repo_readable(viewer: Option<&User>, repo_id: &str, workspace: Option<&str>, private: Option<u8>) -> bool { |
| 184 | workspace.is_some_and(|namespace| { |
| 185 | access::can(viewer, RepoRef { id: repo_id, namespace, private: private != Some(0) }, Capability::Read) |
| 186 | }) |
| 187 | } |
| 188 | |
| 189 | pub(crate) fn no_item<T>() -> Outcome<T> { |
| 190 | Outcome::fail(FailureCode::NotFound, "Work item not found.") |
| 191 | } |
| 192 | |
| 193 | /// A workspace's slug as given, or `repo`'s, lowercase. |
| 194 | pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String { |
| 195 | let given = workspace.trim(); |
| 196 | match (given.is_empty(), repo) { |
| 197 | (true, Some(repo)) => repo.namespace.to_lowercase(), |
| 198 | _ => given.to_lowercase(), |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /// `owner/name` of a repository. |
| 203 | pub(crate) fn path_of(repo: &Repo) -> String { |
| 204 | format!("{}/{}", repo.namespace, repo.name) |
| 205 | } |
| 206 | |
| 207 | impl ItemRow { |
| 208 | /// The work item, once it has a workspace and a number: one moved from |
| 209 | /// a repository's issues has neither until the migration job runs. |
| 210 | pub(crate) fn held(self) -> Option<Held> { |
| 211 | let workspace = self.workspace?; |
| 212 | let number = self.number?; |
| 213 | let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(); |
| 214 | let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| { |
| 215 | let namespace = link.workspace.clone()?; |
| 216 | Some((link.repo_id.clone(), namespace, link.private != Some(0))) |
| 217 | }); |
| 218 | let known: Vec<(String, String, bool)> = links |
| 219 | .iter() |
| 220 | .filter_map(|link| Some((link.repo_id.clone(), link.workspace.clone()?, link.private != Some(0)))) |
| 221 | .collect(); |
| 222 | let mut repos: Vec<ItemRepo> = links |
| 223 | .into_iter() |
| 224 | .map(|link| ItemRepo { |
| 225 | repo_id: link.repo_id, |
| 226 | path: link.path, |
| 227 | role: link.role, |
| 228 | legacy_number: link.legacy_number, |
| 229 | landed: link.landed != 0, |
| 230 | }) |
| 231 | .collect(); |
| 232 | sort_repos(&mut repos); |
| 233 | let stored: Vec<StoredResolution> = |
| 234 | self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(); |
| 235 | let resolved_in: Vec<String> = stored.iter().map(|resolution| resolution.repo_id.clone()).collect(); |
| 236 | let resolutions = stored |
| 237 | .into_iter() |
| 238 | .map(|resolution| Resolution { |
| 239 | repo: repos |
| 240 | .iter() |
| 241 | .find(|repo| repo.repo_id == resolution.repo_id) |
| 242 | .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()), |
| 243 | pull: resolution.pull, |
| 244 | commit: resolution.commit, |
| 245 | pushed_by: resolution.pushed_by, |
| 246 | }) |
| 247 | .collect(); |
| 248 | Some(Held { |
| 249 | item: WorkItem { |
| 250 | id: self.id, |
| 251 | workspace, |
| 252 | number, |
| 253 | title: self.title, |
| 254 | body: self.body, |
| 255 | labels: serde_json::from_str(&self.labels).unwrap_or_default(), |
| 256 | state: self.state, |
| 257 | reason: self.reason, |
| 258 | resolutions, |
| 259 | author: user(self.author_id, self.author_name), |
| 260 | requested_by: requester(self.requested_by_id, self.requested_by_name), |
| 261 | created_at: self.created_at, |
| 262 | updated_at: self.updated_at, |
| 263 | closed_at: self.closed_at, |
| 264 | pull_count: self.pull_count, |
| 265 | comment_count: self.comment_count, |
| 266 | assignees: serde_json::from_str(&self.assignees).unwrap_or_default(), |
| 267 | blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(), |
| 268 | agent: self.agent, |
| 269 | milestone: milestone_ref(self.milestone, self.milestone_title), |
| 270 | repos, |
| 271 | }, |
| 272 | origin, |
| 273 | known, |
| 274 | resolved_in, |
| 275 | }) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | /// A work item of `acme` linked as `links` (`[repo_id, role, workspace, |
| 280 | /// private]`, a workspace of "" for a repository not known) and resolved |
| 281 | /// in `resolved` (repository ids), for tests. |
| 282 | #[cfg(test)] |
| 283 | pub(crate) fn held_for_test(number: u32, links: &[(&str, &str, &str, bool)], resolved: &[&str]) -> Held { |
| 284 | let links: Vec<serde_json::Value> = links |
| 285 | .iter() |
| 286 | .map(|(id, role, workspace, private)| { |
| 287 | serde_json::json!({ |
| 288 | "repo_id": id, "path": format!("{workspace}/{id}"), "role": role, "landed": 0, |
| 289 | "workspace": if workspace.is_empty() { serde_json::Value::Null } else { (*workspace).into() }, |
| 290 | "private": u8::from(*private), |
| 291 | }) |
| 292 | }) |
| 293 | .collect(); |
| 294 | let resolutions: Vec<serde_json::Value> = resolved.iter().map(|id| serde_json::json!({ "repo_id": id, "pull": 1 })).collect(); |
| 295 | let row: ItemRow = serde_json::from_value(serde_json::json!({ |
| 296 | "id": format!("wki_{number}"), "workspace": "acme", "number": number, "title": format!("Item {number}"), "body": "", |
| 297 | "labels": "[]", "state": "open", "reason": null, "resolutions": serde_json::to_string(&resolutions).unwrap(), |
| 298 | "author_id": "usr_1", "author_name": "ana", "created_at": "", "updated_at": "", "closed_at": null, "assignees": "[]", |
| 299 | "links": serde_json::to_string(&links).unwrap(), |
| 300 | })) |
| 301 | .unwrap(); |
| 302 | row.held().unwrap() |
| 303 | } |
| 304 | |
| 305 | /// A verified person, a member (with the default Write) of `member_of` |
| 306 | /// if given, for tests. |
| 307 | #[cfg(test)] |
| 308 | pub(crate) fn person_for_test(id: &str, member_of: Option<&str>) -> User { |
| 309 | User { |
| 310 | id: id.to_owned(), |
| 311 | username: id.trim_start_matches("usr_").to_owned(), |
| 312 | verified: true, |
| 313 | workspaces: member_of.map(g1t_contracts::Membership::member).into_iter().collect(), |
| 314 | ..User::default() |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | #[cfg(test)] |
| 319 | mod tests { |
| 320 | use super::*; |
| 321 | |
| 322 | #[test] |
| 323 | fn events_are_filed_under_the_origin_only() { |
| 324 | let filed = held_for_test(1, &[("rep_web", "origin", "acme", false), ("rep_api", "target", "acme", false)], &[]); |
| 325 | assert_eq!(filed.event_repo(), Some("rep_web")); |
| 326 | // Filed in the workspace: none, not the public repository it targets. |
| 327 | let members_only = held_for_test(2, &[("rep_public", "target", "acme", false)], &[]); |
| 328 | assert_eq!(members_only.event_repo(), None); |
| 329 | assert_eq!(members_only.repo_ids(), vec!["rep_public".to_owned()]); |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn a_viewer_is_shown_only_the_repositories_they_may_read() { |
| 334 | let held = held_for_test( |
| 335 | 3, |
| 336 | &[("rep_web", "origin", "acme", false), ("rep_secret", "target", "acme", true), ("rep_gone", "touched", "", false)], |
| 337 | &["rep_web", "rep_secret"], |
| 338 | ); |
| 339 | let shown = held.clone().shown(None); |
| 340 | assert_eq!(shown.repos.iter().map(|repo| repo.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_web"]); |
| 341 | assert_eq!(shown.resolutions.len(), 1); |
| 342 | assert_eq!(shown.resolutions[0].repo, "acme/rep_web"); |
| 343 | // A member who reads every repository sees them all, but for one |
| 344 | // that is not known. |
| 345 | let member = person_for_test("usr_2", Some("acme")); |
| 346 | let shown = held.shown(Some(&member)); |
| 347 | assert_eq!(shown.repos.len(), 2); |
| 348 | assert_eq!(shown.resolutions.len(), 2); |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn a_workspace_is_the_one_given_or_its_repositorys() { |
| 353 | assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme"); |
| 354 | assert_eq!(workspace_of("Globex", None), "globex"); |
| 355 | } |
| 356 | } |