| 1 | //! A workspace's labels, and putting them on work items and pull requests. |
| 2 | //! |
| 3 | //! Labels live in the `labels` table, one row per name in a workspace; a |
| 4 | //! work item or a pull request carries them by name in its `labels` JSON |
| 5 | //! array. Renaming or deleting a label rewrites those arrays, so a name on |
| 6 | //! one is always one its workspace has. |
| 7 | |
| 8 | use g1t_contracts::access::{self, Capability, RepoRef}; |
| 9 | use g1t_contracts::events::{EventLabel, PullEvent, WorkItemEvent}; |
| 10 | use g1t_contracts::repos::Repo; |
| 11 | use g1t_contracts::time::rfc3339; |
| 12 | use g1t_contracts::work::*; |
| 13 | use g1t_contracts::{FailureCode, Outcome, User, Viewer}; |
| 14 | use g1t_kit::now_ms; |
| 15 | use serde::Deserialize; |
| 16 | use worker::Result; |
| 17 | |
| 18 | use super::rows::{Held, workspace_of}; |
| 19 | use crate::retired::writable; |
| 20 | use crate::{On, Work}; |
| 21 | |
| 22 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 23 | macro_rules! check { |
| 24 | ($outcome:expr) => { |
| 25 | match $outcome { |
| 26 | Outcome::Ok(value) => value, |
| 27 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 28 | } |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | /// A label as stored, with how many work items and pull requests carry it. |
| 33 | #[derive(Deserialize)] |
| 34 | struct LabelRow { |
| 35 | name: String, |
| 36 | color: String, |
| 37 | description: String, |
| 38 | #[serde(default)] |
| 39 | items: u32, |
| 40 | #[serde(default)] |
| 41 | pulls: u32, |
| 42 | } |
| 43 | |
| 44 | impl From<LabelRow> for Label { |
| 45 | fn from(row: LabelRow) -> Self { |
| 46 | Label { name: row.name, color: row.color, description: row.description, items: row.items, pulls: row.pulls } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// A workspace's labels with their counts, by name. `?1` is its slug. |
| 51 | const LABELS: &str = "SELECT name, color, description, |
| 52 | (SELECT count(*) FROM work_items, json_each(work_items.labels) |
| 53 | WHERE work_items.workspace = labels.workspace AND json_each.value = labels.name) AS items, |
| 54 | (SELECT count(*) FROM pulls, json_each(pulls.labels) |
| 55 | WHERE pulls.repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = labels.workspace) |
| 56 | AND json_each.value = labels.name) AS pulls |
| 57 | FROM labels WHERE workspace = ?1"; |
| 58 | |
| 59 | /// The most labels a workspace can have. |
| 60 | const MAX_WORKSPACE_LABELS: u32 = 500; |
| 61 | |
| 62 | /// A work item or a pull request, as labels and milestones change on it. |
| 63 | #[derive(Clone, Debug)] |
| 64 | #[allow(clippy::large_enum_variant)] |
| 65 | pub(crate) enum Labeled { |
| 66 | Item(Held), |
| 67 | Pull(Repo, Pull), |
| 68 | } |
| 69 | |
| 70 | impl Labeled { |
| 71 | /// The workspace whose labels and milestones it takes. |
| 72 | pub(crate) fn workspace(&self) -> String { |
| 73 | match self { |
| 74 | Labeled::Item(held) => held.item.workspace.clone(), |
| 75 | Labeled::Pull(repo, _) => repo.namespace.to_lowercase(), |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// The repository whose role counts besides a membership of the |
| 80 | /// workspace: a work item's origin, or the pull request's own. |
| 81 | pub(crate) fn repo(&self) -> Option<RepoRef<'_>> { |
| 82 | match self { |
| 83 | Labeled::Item(held) => held.origin(), |
| 84 | Labeled::Pull(repo, _) => Some(RepoRef::from(repo)), |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | pub(crate) fn on(&self) -> On<'_> { |
| 89 | match self { |
| 90 | Labeled::Item(held) => On::Item(&held.item.id), |
| 91 | Labeled::Pull(_, pull) => On::Pull(&pull.repo_id, pull.number), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | pub(crate) fn labels(&self) -> &[String] { |
| 96 | match self { |
| 97 | Labeled::Item(held) => &held.item.labels, |
| 98 | Labeled::Pull(_, pull) => &pull.labels, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | pub(crate) fn milestone(&self) -> Option<&MilestoneRef> { |
| 103 | match self { |
| 104 | Labeled::Item(held) => held.item.milestone.as_ref(), |
| 105 | Labeled::Pull(_, pull) => pull.milestone.as_ref(), |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Who may manage it without the Triage role. |
| 110 | pub(crate) fn owned_by(&self, id: &str) -> bool { |
| 111 | match self { |
| 112 | Labeled::Item(held) => held.item.owner().id == id, |
| 113 | Labeled::Pull(_, pull) => pull.is_owned_by(id), |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// Whether `actor` may do `capability` on it. |
| 118 | pub(crate) fn may(&self, actor: &User, capability: Capability) -> bool { |
| 119 | access::can_item(Some(actor), &self.workspace(), self.repo(), capability) |
| 120 | } |
| 121 | |
| 122 | /// Its row: the table and its id. |
| 123 | pub(crate) fn row(&self) -> (&'static str, &str) { |
| 124 | match self { |
| 125 | Labeled::Item(held) => ("work_items", &held.item.id), |
| 126 | Labeled::Pull(_, pull) => ("pulls", &pull.id), |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// The event `what` happened to it: `work_item.labeled` or `pull.labeled`. |
| 131 | pub(crate) fn kind(&self, what: &str) -> &'static str { |
| 132 | match (self, what) { |
| 133 | (Labeled::Item(_), "labeled") => "work_item.labeled", |
| 134 | (Labeled::Item(_), "unlabeled") => "work_item.unlabeled", |
| 135 | (Labeled::Item(_), "milestoned") => "work_item.milestoned", |
| 136 | (Labeled::Item(_), _) => "work_item.demilestoned", |
| 137 | (Labeled::Pull(..), "labeled") => "pull.labeled", |
| 138 | (Labeled::Pull(..), "unlabeled") => "pull.unlabeled", |
| 139 | (Labeled::Pull(..), "milestoned") => "pull.milestoned", |
| 140 | (Labeled::Pull(..), _) => "pull.demilestoned", |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// What was added and what taken away, going from `before` to `after`. |
| 146 | pub(crate) fn label_changes(before: &[String], after: &[String]) -> (Vec<String>, Vec<String>) { |
| 147 | let added = after.iter().filter(|name| !before.contains(name)).cloned().collect(); |
| 148 | let removed = before.iter().filter(|name| !after.contains(name)).cloned().collect(); |
| 149 | (added, removed) |
| 150 | } |
| 151 | |
| 152 | /// What an item's labels become under `change`. |
| 153 | pub(crate) fn changed_labels(current: &[String], given: &[String], change: LabelChange) -> Vec<String> { |
| 154 | match change { |
| 155 | LabelChange::Set => given.to_vec(), |
| 156 | LabelChange::Add => current.iter().chain(given).cloned().collect(), |
| 157 | LabelChange::Remove => current.iter().filter(|name| !given.contains(name)).cloned().collect(), |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// A sentence for the conversation, about the labels added or removed: |
| 162 | /// "added the bug and docs labels". |
| 163 | pub(crate) fn label_note(verb: &str, names: &[String]) -> String { |
| 164 | let quoted: Vec<String> = names.iter().map(|name| format!("`{name}`")).collect(); |
| 165 | let list = match quoted.as_slice() { |
| 166 | [one] => one.clone(), |
| 167 | [rest @ .., last] => format!("{} and {last}", rest.join(", ")), |
| 168 | [] => String::new(), |
| 169 | }; |
| 170 | let noun = if names.len() == 1 { "label" } else { "labels" }; |
| 171 | format!("{verb} the {list} {noun}") |
| 172 | } |
| 173 | |
| 174 | /// One label name, tidied as [`normalize_labels`] tidies them, or why not. |
| 175 | fn one_name(name: &str) -> Result<String, &'static str> { |
| 176 | match normalize_labels(&[name.to_owned()]).and_then(|mut names| names.pop()) { |
| 177 | Some(name) => Ok(name), |
| 178 | None if name.trim().is_empty() => Err("A label needs a name."), |
| 179 | None => Err("A label's name can be at most 50 characters."), |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | fn tidy_description(description: &str) -> Result<String, &'static str> { |
| 184 | let description = description.trim(); |
| 185 | if description.chars().count() > MAX_LABEL_DESCRIPTION_CHARS { |
| 186 | return Err("A label's description can be at most 100 characters."); |
| 187 | } |
| 188 | Ok(description.to_owned()) |
| 189 | } |
| 190 | |
| 191 | /// Refused while issues are still being moved into work items: what is |
| 192 | /// numbered or kept by workspace is not all there yet. |
| 193 | pub(crate) fn still_moving<T>() -> Outcome<T> { |
| 194 | Outcome::fail( |
| 195 | FailureCode::Conflict, |
| 196 | "Issues are moving into work items, with their labels and milestones, and new numbers wait for them; try again in a few minutes.", |
| 197 | ) |
| 198 | } |
| 199 | |
| 200 | impl Work { |
| 201 | /// The workspace, if `actor` may create, edit and delete its labels |
| 202 | /// and milestones: Write in it, as on GitHub. Applying them is Triage. |
| 203 | pub(crate) async fn labelled_workspace(&self, actor: &User, workspace: &str) -> Result<Outcome<String>> { |
| 204 | let workspace = workspace_of(workspace, None); |
| 205 | if !access::can_item(Some(actor), &workspace, None, Capability::ManageLabels) { |
| 206 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::ManageLabels, &workspace))); |
| 207 | } |
| 208 | if !self.items_moved().await? { |
| 209 | return Ok(still_moving()); |
| 210 | } |
| 211 | Ok(Outcome::Ok(workspace)) |
| 212 | } |
| 213 | |
| 214 | /// Whether `viewer` may see what a workspace keeps: a member, someone |
| 215 | /// given a role on one of its repositories, or anyone where one of its |
| 216 | /// repositories is public. |
| 217 | pub(crate) async fn workspace_visible(&self, viewer: &Viewer, workspace: &str) -> Result<bool> { |
| 218 | if viewer.as_ref().is_some_and(|user| access::has_access_in(user, workspace)) { |
| 219 | return Ok(true); |
| 220 | } |
| 221 | Ok(self |
| 222 | .db |
| 223 | .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE workspace = ? AND private = 0 LIMIT 1") |
| 224 | .bind(&[workspace.into()])? |
| 225 | .first::<crate::rows::ValueRow>(None) |
| 226 | .await? |
| 227 | .is_some()) |
| 228 | } |
| 229 | |
| 230 | async fn labels_in(&self, workspace: &str, only: Option<&str>) -> Result<Vec<Label>> { |
| 231 | let rows = match only { |
| 232 | Some(name) => { |
| 233 | self.db |
| 234 | .prepare(format!("{LABELS} AND name = ?2")) |
| 235 | .bind(&[workspace.into(), name.into()])? |
| 236 | .all() |
| 237 | .await? |
| 238 | } |
| 239 | None => { |
| 240 | self.db |
| 241 | .prepare(format!("{LABELS} ORDER BY name LIMIT {MAX_WORKSPACE_LABELS}")) |
| 242 | .bind(&[workspace.into()])? |
| 243 | .all() |
| 244 | .await? |
| 245 | } |
| 246 | }; |
| 247 | Ok(rows.results::<LabelRow>()?.into_iter().map(Label::from).collect()) |
| 248 | } |
| 249 | |
| 250 | /// A workspace's labels, by name, with how many work items and pull |
| 251 | /// requests carry each. |
| 252 | pub(crate) async fn list_labels(&self, a: WorkspaceViewArgs) -> Result<Outcome<Vec<Label>>> { |
| 253 | let workspace = workspace_of(&a.workspace, None); |
| 254 | if !self.workspace_visible(&a.viewer, &workspace).await? { |
| 255 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 256 | } |
| 257 | Ok(Outcome::Ok(self.labels_in(&workspace, None).await?)) |
| 258 | } |
| 259 | |
| 260 | /// Gives a workspace the default labels it does not have yet. |
| 261 | pub(crate) async fn seed_labels(&self, workspace: &str) -> Result<()> { |
| 262 | let now = rfc3339(now_ms()); |
| 263 | let statements = DEFAULT_LABELS |
| 264 | .iter() |
| 265 | .map(|(name, color, description)| { |
| 266 | self.db |
| 267 | .prepare( |
| 268 | "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at) |
| 269 | VALUES (?, ?, ?, ?, ?)", |
| 270 | ) |
| 271 | .bind(&[workspace.into(), (*name).into(), (*color).into(), (*description).into(), now.as_str().into()]) |
| 272 | }) |
| 273 | .collect::<Result<Vec<_>>>()?; |
| 274 | self.db.batch(statements).await?; |
| 275 | Ok(()) |
| 276 | } |
| 277 | |
| 278 | pub(crate) async fn add_default_labels(&self, a: WorkspaceActorArgs) -> Result<Outcome<Vec<Label>>> { |
| 279 | let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?); |
| 280 | self.seed_labels(&workspace).await?; |
| 281 | Ok(Outcome::Ok(self.labels_in(&workspace, None).await?)) |
| 282 | } |
| 283 | |
| 284 | /// Creates a label, or changes one: its name (on every work item and |
| 285 | /// pull request that carries it), color or description. |
| 286 | pub(crate) async fn save_label(&self, a: SaveLabelArgs) -> Result<Outcome<Label>> { |
| 287 | let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?); |
| 288 | let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message)); |
| 289 | let new_name = match a.new_name.as_deref().map(one_name) { |
| 290 | Some(Err(message)) => return invalid(message), |
| 291 | Some(Ok(name)) => Some(name), |
| 292 | None => None, |
| 293 | }; |
| 294 | let color = match a.color.as_deref().map(tidy_color) { |
| 295 | Some(None) => return invalid("A color is six hex digits, such as d73a4a."), |
| 296 | Some(Some(color)) => Some(color), |
| 297 | None => None, |
| 298 | }; |
| 299 | let description = match a.description.as_deref().map(tidy_description) { |
| 300 | Some(Err(message)) => return invalid(message), |
| 301 | Some(Ok(description)) => Some(description), |
| 302 | None => None, |
| 303 | }; |
| 304 | let taken = async |name: &str| -> Result<bool> { Ok(!self.labels_in(&workspace, Some(name)).await?.is_empty()) }; |
| 305 | |
| 306 | let Some(name) = a.name.as_deref().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()) else { |
| 307 | // A new label. |
| 308 | let Some(name) = new_name else { |
| 309 | return invalid("A label needs a name."); |
| 310 | }; |
| 311 | if taken(&name).await? { |
| 312 | return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {name} already exists."))); |
| 313 | } |
| 314 | let count = self |
| 315 | .db |
| 316 | .prepare("SELECT count(*) AS n FROM labels WHERE workspace = ?") |
| 317 | .bind(&[workspace.as_str().into()])? |
| 318 | .first::<crate::rows::NumberRow>(None) |
| 319 | .await? |
| 320 | .map_or(0, |row| row.n); |
| 321 | if count >= MAX_WORKSPACE_LABELS { |
| 322 | return invalid("A workspace can have at most 500 labels."); |
| 323 | } |
| 324 | self.db |
| 325 | .prepare("INSERT INTO labels (workspace, name, color, description, created_at) VALUES (?, ?, ?, ?, ?)") |
| 326 | .bind(&[ |
| 327 | workspace.as_str().into(), |
| 328 | name.as_str().into(), |
| 329 | color.unwrap_or_else(|| label_color_for(&name)).into(), |
| 330 | description.unwrap_or_default().into(), |
| 331 | rfc3339(now_ms()).into(), |
| 332 | ])? |
| 333 | .run() |
| 334 | .await?; |
| 335 | return self.label_now(&workspace, &name).await; |
| 336 | }; |
| 337 | if !taken(&name).await? { |
| 338 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}."))); |
| 339 | } |
| 340 | let renamed = new_name.filter(|new_name| *new_name != name); |
| 341 | if let Some(new_name) = &renamed |
| 342 | && taken(new_name).await? |
| 343 | { |
| 344 | return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {new_name} already exists."))); |
| 345 | } |
| 346 | let to = renamed.clone().unwrap_or_else(|| name.clone()); |
| 347 | let mut statements = vec![self |
| 348 | .db |
| 349 | .prepare( |
| 350 | "UPDATE labels SET name = ?3, color = COALESCE(?4, color), description = COALESCE(?5, description) |
| 351 | WHERE workspace = ?1 AND name = ?2", |
| 352 | ) |
| 353 | .bind(&[ |
| 354 | workspace.as_str().into(), |
| 355 | name.as_str().into(), |
| 356 | to.as_str().into(), |
| 357 | crate::optional(&color), |
| 358 | crate::optional(&description), |
| 359 | ])?]; |
| 360 | if renamed.is_some() { |
| 361 | for (table, scope) in WHERE_IN_WORKSPACE { |
| 362 | statements.push( |
| 363 | self.db |
| 364 | .prepare(format!( |
| 365 | "UPDATE {table} |
| 366 | SET labels = (SELECT json_group_array(CASE WHEN value = ?2 THEN ?3 ELSE value END) |
| 367 | FROM json_each({table}.labels)) |
| 368 | WHERE {scope} |
| 369 | AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)" |
| 370 | )) |
| 371 | .bind(&[workspace.as_str().into(), name.as_str().into(), to.as_str().into()])?, |
| 372 | ); |
| 373 | } |
| 374 | } |
| 375 | self.db.batch(statements).await?; |
| 376 | self.label_now(&workspace, &to).await |
| 377 | } |
| 378 | |
| 379 | async fn label_now(&self, workspace: &str, name: &str) -> Result<Outcome<Label>> { |
| 380 | Ok(match self.labels_in(workspace, Some(name)).await?.pop() { |
| 381 | Some(label) => Outcome::Ok(label), |
| 382 | None => Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}.")), |
| 383 | }) |
| 384 | } |
| 385 | |
| 386 | /// Removes a label from the workspace and from everything carrying it. |
| 387 | pub(crate) async fn delete_label(&self, a: DeleteLabelArgs) -> Result<Outcome<bool>> { |
| 388 | let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?); |
| 389 | let name = a.name.trim().to_lowercase(); |
| 390 | if self.labels_in(&workspace, Some(&name)).await?.is_empty() { |
| 391 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}."))); |
| 392 | } |
| 393 | let mut statements = vec![self |
| 394 | .db |
| 395 | .prepare("DELETE FROM labels WHERE workspace = ?1 AND name = ?2") |
| 396 | .bind(&[workspace.as_str().into(), name.as_str().into()])?]; |
| 397 | for (table, scope) in WHERE_IN_WORKSPACE { |
| 398 | statements.push( |
| 399 | self.db |
| 400 | .prepare(format!( |
| 401 | "UPDATE {table} |
| 402 | SET labels = (SELECT json_group_array(value) FROM json_each({table}.labels) WHERE value != ?2) |
| 403 | WHERE {scope} AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)" |
| 404 | )) |
| 405 | .bind(&[workspace.as_str().into(), name.as_str().into()])?, |
| 406 | ); |
| 407 | } |
| 408 | self.db.batch(statements).await?; |
| 409 | Ok(Outcome::Ok(true)) |
| 410 | } |
| 411 | |
| 412 | /// Makes sure every label in `names` exists in the workspace: those |
| 413 | /// missing are created when `actor` may manage labels (through the |
| 414 | /// workspace, or their role on `repo`), and refused otherwise. Returns |
| 415 | /// their colors, by name. |
| 416 | pub(crate) async fn ensure_labels( |
| 417 | &self, |
| 418 | actor: &User, |
| 419 | workspace: &str, |
| 420 | repo: Option<RepoRef<'_>>, |
| 421 | names: &[String], |
| 422 | ) -> Result<Outcome<Vec<(String, String)>>> { |
| 423 | if names.is_empty() { |
| 424 | return Ok(Outcome::Ok(Vec::new())); |
| 425 | } |
| 426 | let existing: Vec<Label> = self.labels_in(workspace, None).await?; |
| 427 | let missing: Vec<&String> = names.iter().filter(|name| !existing.iter().any(|label| label.name == **name)).collect(); |
| 428 | if !missing.is_empty() { |
| 429 | if !access::can_item(Some(actor), workspace, repo, Capability::ManageLabels) { |
| 430 | let list = missing.iter().map(|name| name.as_str()).collect::<Vec<_>>().join(", "); |
| 431 | return Ok(Outcome::fail( |
| 432 | FailureCode::Invalid, |
| 433 | format!("{workspace} has no label named {list}. Someone with the Write role can create it on the labels page."), |
| 434 | )); |
| 435 | } |
| 436 | if !self.items_moved().await? { |
| 437 | return Ok(still_moving()); |
| 438 | } |
| 439 | let now = rfc3339(now_ms()); |
| 440 | let statements = missing |
| 441 | .iter() |
| 442 | .map(|name| { |
| 443 | self.db |
| 444 | .prepare( |
| 445 | "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at) |
| 446 | VALUES (?, ?, ?, '', ?)", |
| 447 | ) |
| 448 | .bind(&[workspace.into(), name.as_str().into(), label_color_for(name).into(), now.as_str().into()]) |
| 449 | }) |
| 450 | .collect::<Result<Vec<_>>>()?; |
| 451 | self.db.batch(statements).await?; |
| 452 | } |
| 453 | Ok(Outcome::Ok( |
| 454 | names |
| 455 | .iter() |
| 456 | .map(|name| { |
| 457 | let color = existing |
| 458 | .iter() |
| 459 | .find(|label| label.name == *name) |
| 460 | .map_or_else(|| label_color_for(name), |label| label.color.clone()); |
| 461 | (name.clone(), color) |
| 462 | }) |
| 463 | .collect(), |
| 464 | )) |
| 465 | } |
| 466 | |
| 467 | /// Gives a work item or a pull request the labels `wanted`, creating |
| 468 | /// the missing ones as [`ensure_labels`](Work::ensure_labels) does, and |
| 469 | /// says what changed: in its conversation, and as one `labeled` or |
| 470 | /// `unlabeled` event for each label. Returns its labels now. |
| 471 | pub(crate) async fn relabel(&self, actor: &User, labeled: &Labeled, wanted: &[String]) -> Result<Outcome<Vec<String>>> { |
| 472 | let Some(after) = normalize_labels(wanted) else { |
| 473 | return Ok(Outcome::fail( |
| 474 | FailureCode::Invalid, |
| 475 | format!("A work item or pull request can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."), |
| 476 | )); |
| 477 | }; |
| 478 | let colors = check!(self.ensure_labels(actor, &labeled.workspace(), labeled.repo(), &after).await?); |
| 479 | let before = labeled.labels().to_vec(); |
| 480 | let (added, removed) = label_changes(&before, &after); |
| 481 | if added.is_empty() && removed.is_empty() { |
| 482 | return Ok(Outcome::Ok(after)); |
| 483 | } |
| 484 | let (table, id) = labeled.row(); |
| 485 | self.db |
| 486 | .prepare(format!("UPDATE {table} SET labels = ?, updated_at = ? WHERE id = ?")) |
| 487 | .bind(&[serde_json::to_string(&after)?.into(), rfc3339(now_ms()).into(), id.into()])? |
| 488 | .run() |
| 489 | .await?; |
| 490 | self.announce_labels(actor, labeled, &added, &removed, &colors).await?; |
| 491 | Ok(Outcome::Ok(after)) |
| 492 | } |
| 493 | |
| 494 | /// Notes and events for labels put on and taken off a work item or |
| 495 | /// pull request. |
| 496 | pub(crate) async fn announce_labels( |
| 497 | &self, |
| 498 | actor: &User, |
| 499 | labeled: &Labeled, |
| 500 | added: &[String], |
| 501 | removed: &[String], |
| 502 | colors: &[(String, String)], |
| 503 | ) -> Result<()> { |
| 504 | if !added.is_empty() { |
| 505 | self.note_on(labeled.on(), actor.into(), &label_note("added", added)).await?; |
| 506 | } |
| 507 | if !removed.is_empty() { |
| 508 | self.note_on(labeled.on(), actor.into(), &label_note("removed", removed)).await?; |
| 509 | } |
| 510 | let color_of = |name: &String| { |
| 511 | colors.iter().find(|(known, _)| known == name).map_or_else(|| label_color_for(name), |(_, color)| color.clone()) |
| 512 | }; |
| 513 | for (what, names) in [("labeled", added), ("unlabeled", removed)] { |
| 514 | for name in names { |
| 515 | let label = Some(EventLabel { name: name.clone(), color: color_of(name) }); |
| 516 | match labeled { |
| 517 | Labeled::Item(held) => { |
| 518 | self.publish_item(labeled.kind(what), held, Some(actor), WorkItemEvent { label, ..Self::item_event(held) }) |
| 519 | .await?; |
| 520 | } |
| 521 | Labeled::Pull(_, pull) => { |
| 522 | self.publish(labeled.kind(what), &pull.repo_id, actor, PullEvent { label, ..Self::pull_event(pull) }) |
| 523 | .await?; |
| 524 | } |
| 525 | } |
| 526 | } |
| 527 | } |
| 528 | Ok(()) |
| 529 | } |
| 530 | |
| 531 | /// `set_labels`: the labels of a work item or a pull request, replaced, |
| 532 | /// added to or taken from. Its owner may use the workspace's labels on |
| 533 | /// it; anyone else needs the Triage role. |
| 534 | pub(crate) async fn set_labels(&self, a: SetLabelsArgs) -> Result<Outcome<Vec<String>>> { |
| 535 | let labeled = check!(self.labeled(&a.actor, a.repo.as_ref(), &a.workspace, a.number).await?); |
| 536 | let given = match a.change { |
| 537 | // Taking off works on names as given, tidied the same way. |
| 538 | LabelChange::Remove => normalize_labels(&a.labels).unwrap_or_default(), |
| 539 | _ => a.labels.clone(), |
| 540 | }; |
| 541 | let wanted = changed_labels(labeled.labels(), &given, a.change); |
| 542 | let labels = check!(self.relabel(&a.actor, &labeled, &wanted).await?); |
| 543 | Ok(Outcome::Ok(labels)) |
| 544 | } |
| 545 | |
| 546 | /// The thread `actor` changes the labels or milestone of: theirs, or |
| 547 | /// one they may triage. |
| 548 | pub(crate) async fn labeled(&self, actor: &User, repo: Option<&g1t_contracts::repos::RepoPath>, workspace: &str, number: u32) -> Result<Outcome<Labeled>> { |
| 549 | let labeled = match check!(self.thread(&Some(actor.clone()), repo, workspace, number).await?) { |
| 550 | crate::Thread::Pull(repo, pull) => { |
| 551 | check!(writable(&repo)); |
| 552 | Labeled::Pull(repo, pull) |
| 553 | } |
| 554 | crate::Thread::Item(held) => Labeled::Item(held), |
| 555 | }; |
| 556 | if !labeled.owned_by(&actor.id) && !labeled.may(actor, Capability::Triage) { |
| 557 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::Triage, &labeled.workspace()))); |
| 558 | } |
| 559 | Ok(Outcome::Ok(labeled)) |
| 560 | } |
| 561 | } |
| 562 | |
| 563 | /// The rows of a workspace (`?1`) that carry labels: its work items, and |
| 564 | /// its repositories' pull requests. |
| 565 | const WHERE_IN_WORKSPACE: [(&str, &str); 2] = [ |
| 566 | ("work_items", "workspace = ?1"), |
| 567 | ("pulls", "repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?1)"), |
| 568 | ]; |
| 569 | |
| 570 | #[cfg(test)] |
| 571 | mod tests { |
| 572 | use super::*; |
| 573 | |
| 574 | fn names(list: &[&str]) -> Vec<String> { |
| 575 | list.iter().map(|name| (*name).to_owned()).collect() |
| 576 | } |
| 577 | |
| 578 | #[test] |
| 579 | fn labels_are_set_added_to_and_taken_off() { |
| 580 | let current = names(&["bug", "docs"]); |
| 581 | assert_eq!(changed_labels(¤t, &names(&["question"]), LabelChange::Set), names(&["question"])); |
| 582 | assert_eq!(changed_labels(¤t, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"])); |
| 583 | assert_eq!(changed_labels(¤t, &names(&["bug"]), LabelChange::Remove), names(&["docs"])); |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn what_changed_is_said_in_a_sentence() { |
| 588 | let (added, removed) = label_changes(&names(&["bug", "docs"]), &names(&["docs", "security", "wontfix"])); |
| 589 | assert_eq!(added, names(&["security", "wontfix"])); |
| 590 | assert_eq!(removed, names(&["bug"])); |
| 591 | assert_eq!(label_note("added", &added), "added the `security` and `wontfix` labels"); |
| 592 | assert_eq!(label_note("removed", &removed), "removed the `bug` label"); |
| 593 | assert_eq!( |
| 594 | label_note("added", &names(&["a", "b", "c"])), |
| 595 | "added the `a`, `b` and `c` labels" |
| 596 | ); |
| 597 | } |
| 598 | |
| 599 | #[test] |
| 600 | fn a_name_is_tidied_or_refused() { |
| 601 | assert_eq!(one_name(" Good First Issue ").unwrap(), "good first issue"); |
| 602 | assert!(one_name(" ").is_err()); |
| 603 | assert!(one_name(&"x".repeat(51)).is_err()); |
| 604 | assert!(tidy_description(&"x".repeat(101)).is_err()); |
| 605 | } |
| 606 | } |