Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 manage its labels and milestones. | |
| 173 | pub(crate) async fn triaged_repo(&self, actor: &User, path: &RepoPath) -> Result<Outcome<Repo>> { | |
| 174 | let repo = check!(self.repo(path, &Some(actor.clone())).await?); | |
| 175 | check!(writable(&repo)); | |
| 176 | check!(allowed(Some(actor), &repo, Capability::Triage)); | |
| 177 | Ok(Outcome::Ok(repo)) | |
| 178 | } | |
| 179 | ||
| 180 | async fn labels_in(&self, repo_id: &str, only: Option<&str>) -> Result<Vec<Label>> { | |
| 181 | let rows = match only { | |
| 182 | Some(name) => { | |
| 183 | self.db | |
| 184 | .prepare(format!("{LABELS} AND name = ?2")) | |
| 185 | .bind(&[repo_id.into(), name.into()])? | |
| 186 | .all() | |
| 187 | .await? | |
| 188 | } | |
| 189 | None => { | |
| 190 | self.db | |
| 191 | .prepare(format!("{LABELS} ORDER BY name LIMIT {MAX_REPO_LABELS}")) | |
| 192 | .bind(&[repo_id.into()])? | |
| 193 | .all() | |
| 194 | .await? | |
| 195 | } | |
| 196 | }; | |
| 197 | Ok(rows.results::<LabelRow>()?.into_iter().map(Label::from).collect()) | |
| 198 | } | |
| 199 | ||
| 200 | /// A repository's labels, by name, with how many issues and pull | |
| 201 | /// requests carry each. | |
| 202 | pub(crate) async fn list_labels(&self, a: ViewArgs) -> Result<Outcome<Vec<Label>>> { | |
| 203 | let read = |repo_id: String| async move { | |
| 204 | let query = self | |
| 205 | .db | |
| 206 | .prepare(format!("{LABELS} ORDER BY name LIMIT {MAX_REPO_LABELS}")) | |
| 207 | .bind(&[repo_id.into()])?; | |
| 208 | self.timing.db(1, query.all()).await?.results::<LabelRow>() | |
| 209 | }; | |
| 210 | let (_, rows) = check!(self.repo_then(&a.repo, &a.viewer, read).await?); | |
| 211 | Ok(Outcome::Ok(rows.into_iter().map(Label::from).collect())) | |
| 212 | } | |
| 213 | ||
| 214 | /// Gives a repository the default labels it does not have yet. | |
| 215 | pub(crate) async fn seed_labels(&self, repo_id: &str) -> Result<()> { | |
| 216 | let now = rfc3339(now_ms()); | |
| 217 | let statements = DEFAULT_LABELS | |
| 218 | .iter() | |
| 219 | .map(|(name, color, description)| { | |
| 220 | self.db | |
| 221 | .prepare( | |
| 222 | "INSERT OR IGNORE INTO labels (repo_id, name, color, description, created_at) | |
| 223 | VALUES (?, ?, ?, ?, ?)", | |
| 224 | ) | |
| 225 | .bind(&[repo_id.into(), (*name).into(), (*color).into(), (*description).into(), now.as_str().into()]) | |
| 226 | }) | |
| 227 | .collect::<Result<Vec<_>>>()?; | |
| 228 | self.db.batch(statements).await?; | |
| 229 | Ok(()) | |
| 230 | } | |
| 231 | ||
| 232 | pub(crate) async fn add_default_labels(&self, a: RepoActorArgs) -> Result<Outcome<Vec<Label>>> { | |
| 233 | let repo = check!(self.triaged_repo(&a.actor, &a.repo).await?); | |
| 234 | self.seed_labels(&repo.id).await?; | |
| 235 | Ok(Outcome::Ok(self.labels_in(&repo.id, None).await?)) | |
| 236 | } | |
| 237 | ||
| 238 | /// Creates a label, or changes one: its name (on every issue and pull | |
| 239 | /// request that carries it), color or description. | |
| 240 | pub(crate) async fn save_label(&self, a: SaveLabelArgs) -> Result<Outcome<Label>> { | |
| 241 | let repo = check!(self.triaged_repo(&a.actor, &a.repo).await?); | |
| 242 | let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message)); | |
| 243 | let new_name = match a.new_name.as_deref().map(one_name) { | |
| 244 | Some(Err(message)) => return invalid(message), | |
| 245 | Some(Ok(name)) => Some(name), | |
| 246 | None => None, | |
| 247 | }; | |
| 248 | let color = match a.color.as_deref().map(tidy_color) { | |
| 249 | Some(None) => return invalid("A color is six hex digits, such as d73a4a."), | |
| 250 | Some(Some(color)) => Some(color), | |
| 251 | None => None, | |
| 252 | }; | |
| 253 | let description = match a.description.as_deref().map(tidy_description) { | |
| 254 | Some(Err(message)) => return invalid(message), | |
| 255 | Some(Ok(description)) => Some(description), | |
| 256 | None => None, | |
| 257 | }; | |
| 258 | let taken = async |name: &str| -> Result<bool> { Ok(!self.labels_in(&repo.id, Some(name)).await?.is_empty()) }; | |
| 259 | ||
| 260 | let Some(name) = a.name.as_deref().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()) else { | |
| 261 | // A new label. | |
| 262 | let Some(name) = new_name else { | |
| 263 | return invalid("A label needs a name."); | |
| 264 | }; | |
| 265 | if taken(&name).await? { | |
| 266 | return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {name} already exists."))); | |
| 267 | } | |
| 268 | let count = self | |
| 269 | .db | |
| 270 | .prepare("SELECT count(*) AS n FROM labels WHERE repo_id = ?") | |
| 271 | .bind(&[repo.id.as_str().into()])? | |
| 272 | .first::<crate::rows::NumberRow>(None) | |
| 273 | .await? | |
| 274 | .map_or(0, |row| row.n); | |
| 275 | if count >= MAX_REPO_LABELS { | |
| 276 | return invalid("A repository can have at most 500 labels."); | |
| 277 | } | |
| 278 | self.db | |
| 279 | .prepare( | |
| 280 | "INSERT INTO labels (repo_id, name, color, description, created_at) VALUES (?, ?, ?, ?, ?)", | |
| 281 | ) | |
| 282 | .bind(&[ | |
| 283 | repo.id.as_str().into(), | |
| 284 | name.as_str().into(), | |
| 285 | color.unwrap_or_else(|| label_color_for(&name)).into(), | |
| 286 | description.unwrap_or_default().into(), | |
| 287 | rfc3339(now_ms()).into(), | |
| 288 | ])? | |
| 289 | .run() | |
| 290 | .await?; | |
| 291 | return self.label_now(&repo.id, &name).await; | |
| 292 | }; | |
| 293 | if !taken(&name).await? { | |
| 294 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}."))); | |
| 295 | } | |
| 296 | let renamed = new_name.filter(|new_name| *new_name != name); | |
| 297 | if let Some(new_name) = &renamed | |
| 298 | && taken(new_name).await? | |
| 299 | { | |
| 300 | return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {new_name} already exists."))); | |
| 301 | } | |
| 302 | let to = renamed.clone().unwrap_or_else(|| name.clone()); | |
| 303 | let mut statements = vec![ | |
| 304 | self.db | |
| 305 | .prepare( | |
| 306 | "UPDATE labels SET name = ?3, color = COALESCE(?4, color), description = COALESCE(?5, description) | |
| 307 | WHERE repo_id = ?1 AND name = ?2", | |
| 308 | ) | |
| 309 | .bind(&[ | |
| 310 | repo.id.as_str().into(), | |
| 311 | name.as_str().into(), | |
| 312 | to.as_str().into(), | |
| 313 | crate::optional(&color), | |
| 314 | crate::optional(&description), | |
| 315 | ])?, | |
| 316 | ]; | |
| 317 | if renamed.is_some() { | |
| 318 | for table in ["issues", "pulls"] { | |
| 319 | statements.push( | |
| 320 | self.db | |
| 321 | .prepare(format!( | |
| 322 | "UPDATE {table} | |
| 323 | SET labels = (SELECT json_group_array(CASE WHEN value = ?2 THEN ?3 ELSE value END) | |
| 324 | FROM json_each({table}.labels)) | |
| 325 | WHERE repo_id = ?1 | |
| 326 | AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)" | |
| 327 | )) | |
| 328 | .bind(&[repo.id.as_str().into(), name.as_str().into(), to.as_str().into()])?, | |
| 329 | ); | |
| 330 | } | |
| 331 | } | |
| 332 | self.db.batch(statements).await?; | |
| 333 | self.label_now(&repo.id, &to).await | |
| 334 | } | |
| 335 | ||
| 336 | async fn label_now(&self, repo_id: &str, name: &str) -> Result<Outcome<Label>> { | |
| 337 | Ok(match self.labels_in(repo_id, Some(name)).await?.pop() { | |
| 338 | Some(label) => Outcome::Ok(label), | |
| 339 | None => Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}.")), | |
| 340 | }) | |
| 341 | } | |
| 342 | ||
| 343 | /// Removes a label from the repository and from everything carrying it. | |
| 344 | pub(crate) async fn delete_label(&self, a: DeleteLabelArgs) -> Result<Outcome<bool>> { | |
| 345 | let repo = check!(self.triaged_repo(&a.actor, &a.repo).await?); | |
| 346 | let name = a.name.trim().to_lowercase(); | |
| 347 | if self.labels_in(&repo.id, Some(&name)).await?.is_empty() { | |
| 348 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}."))); | |
| 349 | } | |
| 350 | let mut statements = vec![ | |
| 351 | self.db | |
| 352 | .prepare("DELETE FROM labels WHERE repo_id = ?1 AND name = ?2") | |
| 353 | .bind(&[repo.id.as_str().into(), name.as_str().into()])?, | |
| 354 | ]; | |
| 355 | for table in ["issues", "pulls"] { | |
| 356 | statements.push( | |
| 357 | self.db | |
| 358 | .prepare(format!( | |
| 359 | "UPDATE {table} | |
| 360 | SET labels = (SELECT json_group_array(value) FROM json_each({table}.labels) WHERE value != ?2) | |
| 361 | WHERE repo_id = ?1 AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)" | |
| 362 | )) | |
| 363 | .bind(&[repo.id.as_str().into(), name.as_str().into()])?, | |
| 364 | ); | |
| 365 | } | |
| 366 | self.db.batch(statements).await?; | |
| 367 | Ok(Outcome::Ok(true)) | |
| 368 | } | |
| 369 | ||
| 370 | /// Makes sure every label in `names` exists on the repository: those | |
| 371 | /// missing are created when `actor` may triage it, and refused | |
| 372 | /// otherwise. Returns their colors, by name. | |
| 373 | pub(crate) async fn ensure_labels( | |
| 374 | &self, | |
| 375 | actor: &User, | |
| 376 | repo: &Repo, | |
| 377 | names: &[String], | |
| 378 | ) -> Result<Outcome<Vec<(String, String)>>> { | |
| 379 | if names.is_empty() { | |
| 380 | return Ok(Outcome::Ok(Vec::new())); | |
| 381 | } | |
| 382 | let existing: Vec<Label> = self.labels_in(&repo.id, None).await?; | |
| 383 | let missing: Vec<&String> = names.iter().filter(|name| !existing.iter().any(|label| label.name == **name)).collect(); | |
| 384 | if !missing.is_empty() { | |
| 385 | if !g1t_contracts::access::check(Some(actor), repo, Capability::Triage).is_ok() { | |
| 386 | let list = missing.iter().map(|name| name.as_str()).collect::<Vec<_>>().join(", "); | |
| 387 | return Ok(Outcome::fail( | |
| 388 | FailureCode::Invalid, | |
| 389 | format!( | |
| 390 | "{} has no label named {list}. Someone with the Triage role can create it on the labels page.", | |
| 391 | repo.name | |
| 392 | ), | |
| 393 | )); | |
| 394 | } | |
| 395 | let now = rfc3339(now_ms()); | |
| 396 | let statements = missing | |
| 397 | .iter() | |
| 398 | .map(|name| { | |
| 399 | self.db | |
| 400 | .prepare( | |
| 401 | "INSERT OR IGNORE INTO labels (repo_id, name, color, description, created_at) | |
| 402 | VALUES (?, ?, ?, '', ?)", | |
| 403 | ) | |
| 404 | .bind(&[ | |
| 405 | repo.id.as_str().into(), | |
| 406 | name.as_str().into(), | |
| 407 | label_color_for(name).into(), | |
| 408 | now.as_str().into(), | |
| 409 | ]) | |
| 410 | }) | |
| 411 | .collect::<Result<Vec<_>>>()?; | |
| 412 | self.db.batch(statements).await?; | |
| 413 | } | |
| 414 | Ok(Outcome::Ok( | |
| 415 | names | |
| 416 | .iter() | |
| 417 | .map(|name| { | |
| 418 | let color = existing | |
| 419 | .iter() | |
| 420 | .find(|label| label.name == *name) | |
| 421 | .map_or_else(|| label_color_for(name), |label| label.color.clone()); | |
| 422 | (name.clone(), color) | |
| 423 | }) | |
| 424 | .collect(), | |
| 425 | )) | |
| 426 | } | |
| 427 | ||
| 428 | /// Gives an issue or a pull request the labels `wanted`, creating the | |
| 429 | /// missing ones as [`ensure_labels`](Work::ensure_labels) does, and says | |
| 430 | /// what changed: in its conversation, and as one `labeled` or | |
| 431 | /// `unlabeled` event for each label. Returns its labels now. | |
| 432 | pub(crate) async fn relabel( | |
| 433 | &self, | |
| 434 | actor: &User, | |
| 435 | repo: &Repo, | |
| 436 | item: &Item, | |
| 437 | wanted: &[String], | |
| 438 | ) -> Result<Outcome<Vec<String>>> { | |
| 439 | let Some(after) = normalize_labels(wanted) else { | |
| 440 | return Ok(Outcome::fail( | |
| 441 | FailureCode::Invalid, | |
| 442 | format!("An issue or pull request can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."), | |
| 443 | )); | |
| 444 | }; | |
| 445 | let colors = check!(self.ensure_labels(actor, repo, &after).await?); | |
| 446 | let before = item.labels().to_vec(); | |
| 447 | let (added, removed) = label_changes(&before, &after); | |
| 448 | if added.is_empty() && removed.is_empty() { | |
| 449 | return Ok(Outcome::Ok(after)); | |
| 450 | } | |
| 451 | self.db | |
| 452 | .prepare(format!("UPDATE {} SET labels = ?, updated_at = ? WHERE repo_id = ? AND number = ?", item.table())) | |
| 453 | .bind(&[ | |
| 454 | serde_json::to_string(&after)?.into(), | |
| 455 | rfc3339(now_ms()).into(), | |
| 456 | repo.id.as_str().into(), | |
| 457 | item.number().into(), | |
| 458 | ])? | |
| 459 | .run() | |
| 460 | .await?; | |
| 461 | self.announce_labels(actor, item, &added, &removed, &colors).await?; | |
| 462 | Ok(Outcome::Ok(after)) | |
| 463 | } | |
| 464 | ||
| 465 | /// Notes and events for labels put on and taken off an item. | |
| 466 | pub(crate) async fn announce_labels( | |
| 467 | &self, | |
| 468 | actor: &User, | |
| 469 | item: &Item, | |
| 470 | added: &[String], | |
| 471 | removed: &[String], | |
| 472 | colors: &[(String, String)], | |
| 473 | ) -> Result<()> { | |
| 474 | let who = (actor.id.as_str(), actor.username.as_str()); | |
| 475 | if !added.is_empty() { | |
| 476 | self.note(item.repo_id(), item.number(), who, &label_note("added", added)).await?; | |
| 477 | } | |
| 478 | if !removed.is_empty() { | |
| 479 | self.note(item.repo_id(), item.number(), who, &label_note("removed", removed)).await?; | |
| 480 | } | |
| 481 | let color_of = |name: &String| { | |
| 482 | colors.iter().find(|(known, _)| known == name).map_or_else(|| label_color_for(name), |(_, color)| color.clone()) | |
| 483 | }; | |
| 484 | for (what, names) in [("labeled", added), ("unlabeled", removed)] { | |
| 485 | for name in names { | |
| 486 | let label = Some(EventLabel { name: name.clone(), color: color_of(name) }); | |
| 487 | match item { | |
| 488 | Item::Issue(issue) => { | |
| 489 | self.publish(item.kind(what), &issue.repo_id, actor, IssueEvent { label, ..Self::issue_event(issue) }) | |
| 490 | .await?; | |
| 491 | } | |
| 492 | Item::Pull(pull) => { | |
| 493 | self.publish(item.kind(what), &pull.repo_id, actor, PullEvent { label, ..Self::pull_event(pull) }) | |
| 494 | .await?; | |
| 495 | } | |
| 496 | } | |
| 497 | } | |
| 498 | } | |
| 499 | Ok(()) | |
| 500 | } | |
| 501 | ||
| 502 | /// `set_labels`: the labels of an issue or a pull request, replaced, | |
| 503 | /// added to or taken from. Its owner may use the repository's labels | |
| 504 | /// on it; anyone else needs the Triage role. | |
| 505 | pub(crate) async fn set_labels(&self, a: SetLabelsArgs) -> Result<Outcome<Vec<String>>> { | |
| 506 | let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?); | |
| 507 | check!(writable(&repo)); | |
| 508 | let item = match self.issue(&repo.id, a.number).await? { | |
| 509 | Some(issue) => Item::Issue(issue), | |
| 510 | None => match self.pull(&repo.id, a.number).await? { | |
| 511 | Some(pull) => Item::Pull(pull), | |
| 512 | None => { | |
| 513 | return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request has that number.")); | |
| 514 | } | |
| 515 | }, | |
| 516 | }; | |
| 517 | if !item.owned_by(&a.actor.id) { | |
| 518 | check!(allowed(Some(&a.actor), &repo, Capability::Triage)); | |
| 519 | } | |
| 520 | let given = match a.change { | |
| 521 | // Taking off works on names as given, tidied the same way. | |
| 522 | LabelChange::Remove => normalize_labels(&a.labels).unwrap_or_default(), | |
| 523 | _ => a.labels.clone(), | |
| 524 | }; | |
| 525 | let wanted = changed_labels(item.labels(), &given, a.change); | |
| 526 | let labels = check!(self.relabel(&a.actor, &repo, &item, &wanted).await?); | |
| 527 | if let Item::Issue(issue) = &item | |
| 528 | && let Some(now) = self.issue(&repo.id, issue.number).await? | |
| 529 | { | |
| 530 | self.apply_label_rule(&a.actor, &now, &issue.labels).await?; | |
| 531 | } | |
| 532 | Ok(Outcome::Ok(labels)) | |
| 533 | } | |
| 534 | } | |
| 535 | ||
| 536 | #[cfg(test)] | |
| 537 | mod tests { | |
| 538 | use super::*; | |
| 539 | ||
| 540 | fn names(list: &[&str]) -> Vec<String> { | |
| 541 | list.iter().map(|name| (*name).to_owned()).collect() | |
| 542 | } | |
| 543 | ||
| 544 | #[test] | |
| 545 | fn labels_are_set_added_to_and_taken_off() { | |
| 546 | let current = names(&["bug", "docs"]); | |
| 547 | assert_eq!(changed_labels(¤t, &names(&["question"]), LabelChange::Set), names(&["question"])); | |
| 548 | assert_eq!(changed_labels(¤t, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"])); | |
| 549 | assert_eq!(changed_labels(¤t, &names(&["bug"]), LabelChange::Remove), names(&["docs"])); | |
| 550 | } | |
| 551 | ||
| 552 | #[test] | |
| 553 | fn what_changed_is_said_in_a_sentence() { | |
| 554 | let (added, removed) = label_changes(&names(&["bug", "docs"]), &names(&["docs", "security", "wontfix"])); | |
| 555 | assert_eq!(added, names(&["security", "wontfix"])); | |
| 556 | assert_eq!(removed, names(&["bug"])); | |
| 557 | assert_eq!(label_note("added", &added), "added the `security` and `wontfix` labels"); | |
| 558 | assert_eq!(label_note("removed", &removed), "removed the `bug` label"); | |
| 559 | assert_eq!( | |
| 560 | label_note("added", &names(&["a", "b", "c"])), | |
| 561 | "added the `a`, `b` and `c` labels" | |
| 562 | ); | |
| 563 | } | |
| 564 | ||
| 565 | #[test] | |
| 566 | fn a_name_is_tidied_or_refused() { | |
| 567 | assert_eq!(one_name(" Good First Issue ").unwrap(), "good first issue"); | |
| 568 | assert!(one_name(" ").is_err()); | |
| 569 | assert!(one_name(&"x".repeat(51)).is_err()); | |
| 570 | assert!(tidy_description(&"x".repeat(101)).is_err()); | |
| 571 | } | |
| 572 | } |