Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

606 linesCodeBlameRaw
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
8use g1t_contracts::access::{self, Capability, RepoRef};
9use g1t_contracts::events::{EventLabel, PullEvent, WorkItemEvent};
10use g1t_contracts::repos::Repo;
11use g1t_contracts::time::rfc3339;
12use g1t_contracts::work::*;
13use g1t_contracts::{FailureCode, Outcome, User, Viewer};
14use g1t_kit::now_ms;
15use serde::Deserialize;
16use worker::Result;
17
18use crate::items::{Held, workspace_of};
19use crate::retired::writable;
20use crate::{On, Work};
21
22/// Unwraps an `Outcome`, returning its failure from the enclosing method.
23macro_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)]
34struct LabelRow {
35 name: String,
36 color: String,
37 description: String,
38 #[serde(default)]
39 items: u32,
40 #[serde(default)]
41 pulls: u32,
42}
43
44impl 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.
51const 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.
60const 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)]
65pub(crate) enum Labeled {
66 Item(Held),
67 Pull(Repo, Pull),
68}
69
70impl 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`.
146pub(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`.
153pub(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".
163pub(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.
175fn 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
183fn 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.
193pub(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
200impl 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.
565const 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)]
571mod 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(&current, &names(&["question"]), LabelChange::Set), names(&["question"]));
582 assert_eq!(changed_labels(&current, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"]));
583 assert_eq!(changed_labels(&current, &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}