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