Skip to content

g1t/services/work/src/labels.rs

572 lines23,504 bytesCodeBlame

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 bar1//! 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 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)]
537mod 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(&current, &names(&["question"]), LabelChange::Set), names(&["question"]));
548 assert_eq!(changed_labels(&current, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"]));
549 assert_eq!(changed_labels(&current, &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}