| 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 | } |
| 114 | |
| 115 | impl Held { |
| 116 | pub(crate) fn origin(&self) -> Option<RepoRef<'_>> { |
| 117 | self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private }) |
| 118 | } |
| 119 | |
| 120 | /// Every repository it is linked to, its origin first. |
| 121 | pub(crate) fn repo_ids(&self) -> Vec<String> { |
| 122 | self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect() |
| 123 | } |
| 124 | |
| 125 | /// The repository its events are filed under: its origin, else the |
| 126 | /// first it is linked to. |
| 127 | pub(crate) fn event_repo(&self) -> Option<&str> { |
| 128 | self.item.repos.first().map(|repo| repo.repo_id.as_str()) |
| 129 | } |
| 130 | |
| 131 | pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool { |
| 132 | access::can_item(viewer, &self.item.workspace, self.origin(), capability) |
| 133 | } |
| 134 | |
| 135 | /// `Ok` when `viewer` may do `capability` on it; not found when they |
| 136 | /// may not see it. |
| 137 | pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> { |
| 138 | match access::check_item(viewer, &self.item.workspace, self.origin(), capability) { |
| 139 | Ok(()) => Outcome::Ok(()), |
| 140 | Err(Denied::NotFound) => no_item(), |
| 141 | Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)), |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | pub(crate) fn no_item<T>() -> Outcome<T> { |
| 147 | Outcome::fail(FailureCode::NotFound, "Work item not found.") |
| 148 | } |
| 149 | |
| 150 | /// A workspace's slug as given, or `repo`'s, lowercase. |
| 151 | pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String { |
| 152 | let given = workspace.trim(); |
| 153 | match (given.is_empty(), repo) { |
| 154 | (true, Some(repo)) => repo.namespace.to_lowercase(), |
| 155 | _ => given.to_lowercase(), |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// `owner/name` of a repository. |
| 160 | pub(crate) fn path_of(repo: &Repo) -> String { |
| 161 | format!("{}/{}", repo.namespace, repo.name) |
| 162 | } |
| 163 | |
| 164 | impl ItemRow { |
| 165 | /// The work item, once it has a workspace and a number: one moved from |
| 166 | /// a repository's issues has neither until the migration job runs. |
| 167 | pub(crate) fn held(self) -> Option<Held> { |
| 168 | let workspace = self.workspace?; |
| 169 | let number = self.number?; |
| 170 | let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(); |
| 171 | let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| { |
| 172 | let namespace = link.workspace.clone()?; |
| 173 | Some((link.repo_id.clone(), namespace, link.private != Some(0))) |
| 174 | }); |
| 175 | let mut repos: Vec<ItemRepo> = links |
| 176 | .into_iter() |
| 177 | .map(|link| ItemRepo { |
| 178 | repo_id: link.repo_id, |
| 179 | path: link.path, |
| 180 | role: link.role, |
| 181 | legacy_number: link.legacy_number, |
| 182 | landed: link.landed != 0, |
| 183 | }) |
| 184 | .collect(); |
| 185 | sort_repos(&mut repos); |
| 186 | let stored: Vec<StoredResolution> = |
| 187 | self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(); |
| 188 | let resolutions = stored |
| 189 | .into_iter() |
| 190 | .map(|resolution| Resolution { |
| 191 | repo: repos |
| 192 | .iter() |
| 193 | .find(|repo| repo.repo_id == resolution.repo_id) |
| 194 | .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()), |
| 195 | pull: resolution.pull, |
| 196 | commit: resolution.commit, |
| 197 | pushed_by: resolution.pushed_by, |
| 198 | }) |
| 199 | .collect(); |
| 200 | Some(Held { |
| 201 | item: WorkItem { |
| 202 | id: self.id, |
| 203 | workspace, |
| 204 | number, |
| 205 | title: self.title, |
| 206 | body: self.body, |
| 207 | labels: serde_json::from_str(&self.labels).unwrap_or_default(), |
| 208 | state: self.state, |
| 209 | reason: self.reason, |
| 210 | resolutions, |
| 211 | author: user(self.author_id, self.author_name), |
| 212 | requested_by: requester(self.requested_by_id, self.requested_by_name), |
| 213 | created_at: self.created_at, |
| 214 | updated_at: self.updated_at, |
| 215 | closed_at: self.closed_at, |
| 216 | pull_count: self.pull_count, |
| 217 | comment_count: self.comment_count, |
| 218 | assignees: serde_json::from_str(&self.assignees).unwrap_or_default(), |
| 219 | blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(), |
| 220 | agent: self.agent, |
| 221 | milestone: milestone_ref(self.milestone, self.milestone_title), |
| 222 | repos, |
| 223 | }, |
| 224 | origin, |
| 225 | }) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | #[cfg(test)] |
| 230 | mod tests { |
| 231 | use super::*; |
| 232 | |
| 233 | #[test] |
| 234 | fn a_workspace_is_the_one_given_or_its_repositorys() { |
| 235 | assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme"); |
| 236 | assert_eq!(workspace_of("Globex", None), "globex"); |
| 237 | } |
| 238 | } |