Skip to content

g1t/services/work/src/labels.rs

573 lines23,586 bytesCodeBlame
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
8use g1t_contracts::access::Capability;
9use g1t_contracts::events::{EventLabel, IssueEvent, PullEvent};
10use g1t_contracts::repos::{Repo, RepoPath};
11use g1t_contracts::time::rfc3339;
12use g1t_contracts::work::*;
13use g1t_contracts::{FailureCode, Outcome, User};
14use g1t_kit::now_ms;
15use serde::Deserialize;
16use worker::Result;
17
18use crate::retired::writable;
19use crate::{Work, allowed};
20
21/// Unwraps an `Outcome`, returning its failure from the enclosing method.
22macro_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)]
33struct LabelRow {
34 name: String,
35 color: String,
36 description: String,
37 #[serde(default)]
38 issues: u32,
39 #[serde(default)]
40 pulls: u32,
41}
42
43impl 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.
50const 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.
58const MAX_REPO_LABELS: u32 = 500;
59
60/// An issue or a pull request, as labels and milestones change on it.
61pub(crate) enum Item {
62 Issue(Issue),
63 Pull(Pull),
64}
65
66impl 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`.
126pub(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`.
133pub(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".
143pub(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.
155fn 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
163fn 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
171impl 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)]
538mod 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(&current, &names(&["question"]), LabelChange::Set), names(&["question"]));
549 assert_eq!(changed_labels(&current, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"]));
550 assert_eq!(changed_labels(&current, &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}