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.

623 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 super::rows::{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/// Whether `actor` may make labels in `workspace`, which are the whole
192/// workspace's: through their membership of it alone, never their role on
193/// one repository (an outside collaborator's Write on a public repository
194/// makes no workspace labels).
195pub(crate) fn may_create_labels(actor: &User, workspace: &str) -> bool {
196 access::can_item(Some(actor), workspace, None, Capability::ManageLabels)
197}
198
199/// Refused while issues are still being moved into work items: what is
200/// numbered or kept by workspace is not all there yet.
201pub(crate) fn still_moving<T>() -> Outcome<T> {
202 Outcome::fail(
203 FailureCode::Conflict,
204 "Work items are still being moved into this workspace; try again in a few minutes.",
205 )
206}
207
208impl Work {
209 /// The workspace, if `actor` may create, edit and delete its labels
210 /// and milestones: Write in it, as on GitHub. Applying them is Triage.
211 pub(crate) async fn labelled_workspace(&self, actor: &User, workspace: &str) -> Result<Outcome<String>> {
212 let workspace = workspace_of(workspace, None);
213 if !access::can_item(Some(actor), &workspace, None, Capability::ManageLabels) {
214 return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::ManageLabels, &workspace)));
215 }
216 if !self.items_moved().await? {
217 return Ok(still_moving());
218 }
219 Ok(Outcome::Ok(workspace))
220 }
221
222 /// Whether `viewer` may see what a workspace keeps: a member, someone
223 /// given a role on one of its repositories, or anyone where one of its
224 /// repositories is public.
225 pub(crate) async fn workspace_visible(&self, viewer: &Viewer, workspace: &str) -> Result<bool> {
226 if viewer.as_ref().is_some_and(|user| access::has_access_in(user, workspace)) {
227 return Ok(true);
228 }
229 Ok(self
230 .db
231 .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE workspace = ? AND private = 0 LIMIT 1")
232 .bind(&[workspace.into()])?
233 .first::<crate::rows::ValueRow>(None)
234 .await?
235 .is_some())
236 }
237
238 async fn labels_in(&self, workspace: &str, only: Option<&str>) -> Result<Vec<Label>> {
239 let rows = match only {
240 Some(name) => {
241 self.db
242 .prepare(format!("{LABELS} AND name = ?2"))
243 .bind(&[workspace.into(), name.into()])?
244 .all()
245 .await?
246 }
247 None => {
248 self.db
249 .prepare(format!("{LABELS} ORDER BY name LIMIT {MAX_WORKSPACE_LABELS}"))
250 .bind(&[workspace.into()])?
251 .all()
252 .await?
253 }
254 };
255 Ok(rows.results::<LabelRow>()?.into_iter().map(Label::from).collect())
256 }
257
258 /// A workspace's labels, by name, with how many work items and pull
259 /// requests carry each.
260 pub(crate) async fn list_labels(&self, a: WorkspaceViewArgs) -> Result<Outcome<Vec<Label>>> {
261 let workspace = workspace_of(&a.workspace, None);
262 if !self.workspace_visible(&a.viewer, &workspace).await? {
263 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
264 }
265 Ok(Outcome::Ok(self.labels_in(&workspace, None).await?))
266 }
267
268 /// Gives a workspace the default labels it does not have yet.
269 pub(crate) async fn seed_labels(&self, workspace: &str) -> Result<()> {
270 let now = rfc3339(now_ms());
271 let statements = DEFAULT_LABELS
272 .iter()
273 .map(|(name, color, description)| {
274 self.db
275 .prepare(
276 "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at)
277 VALUES (?, ?, ?, ?, ?)",
278 )
279 .bind(&[workspace.into(), (*name).into(), (*color).into(), (*description).into(), now.as_str().into()])
280 })
281 .collect::<Result<Vec<_>>>()?;
282 self.db.batch(statements).await?;
283 Ok(())
284 }
285
286 pub(crate) async fn add_default_labels(&self, a: WorkspaceActorArgs) -> Result<Outcome<Vec<Label>>> {
287 let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?);
288 self.seed_labels(&workspace).await?;
289 Ok(Outcome::Ok(self.labels_in(&workspace, None).await?))
290 }
291
292 /// Creates a label, or changes one: its name (on every work item and
293 /// pull request that carries it), color or description.
294 pub(crate) async fn save_label(&self, a: SaveLabelArgs) -> Result<Outcome<Label>> {
295 let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?);
296 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
297 let new_name = match a.new_name.as_deref().map(one_name) {
298 Some(Err(message)) => return invalid(message),
299 Some(Ok(name)) => Some(name),
300 None => None,
301 };
302 let color = match a.color.as_deref().map(tidy_color) {
303 Some(None) => return invalid("A color is six hex digits, such as d73a4a."),
304 Some(Some(color)) => Some(color),
305 None => None,
306 };
307 let description = match a.description.as_deref().map(tidy_description) {
308 Some(Err(message)) => return invalid(message),
309 Some(Ok(description)) => Some(description),
310 None => None,
311 };
312 let taken = async |name: &str| -> Result<bool> { Ok(!self.labels_in(&workspace, Some(name)).await?.is_empty()) };
313
314 let Some(name) = a.name.as_deref().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()) else {
315 // A new label.
316 let Some(name) = new_name else {
317 return invalid("A label needs a name.");
318 };
319 if taken(&name).await? {
320 return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {name} already exists.")));
321 }
322 let count = self
323 .db
324 .prepare("SELECT count(*) AS n FROM labels WHERE workspace = ?")
325 .bind(&[workspace.as_str().into()])?
326 .first::<crate::rows::NumberRow>(None)
327 .await?
328 .map_or(0, |row| row.n);
329 if count >= MAX_WORKSPACE_LABELS {
330 return invalid("A workspace can have at most 500 labels.");
331 }
332 self.db
333 .prepare("INSERT INTO labels (workspace, name, color, description, created_at) VALUES (?, ?, ?, ?, ?)")
334 .bind(&[
335 workspace.as_str().into(),
336 name.as_str().into(),
337 color.unwrap_or_else(|| label_color_for(&name)).into(),
338 description.unwrap_or_default().into(),
339 rfc3339(now_ms()).into(),
340 ])?
341 .run()
342 .await?;
343 return self.label_now(&workspace, &name).await;
344 };
345 if !taken(&name).await? {
346 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}.")));
347 }
348 let renamed = new_name.filter(|new_name| *new_name != name);
349 if let Some(new_name) = &renamed
350 && taken(new_name).await?
351 {
352 return Ok(Outcome::fail(FailureCode::Conflict, format!("A label named {new_name} already exists.")));
353 }
354 let to = renamed.clone().unwrap_or_else(|| name.clone());
355 let mut statements = vec![self
356 .db
357 .prepare(
358 "UPDATE labels SET name = ?3, color = COALESCE(?4, color), description = COALESCE(?5, description)
359 WHERE workspace = ?1 AND name = ?2",
360 )
361 .bind(&[
362 workspace.as_str().into(),
363 name.as_str().into(),
364 to.as_str().into(),
365 crate::optional(&color),
366 crate::optional(&description),
367 ])?];
368 if renamed.is_some() {
369 for (table, scope) in WHERE_IN_WORKSPACE {
370 statements.push(
371 self.db
372 .prepare(format!(
373 "UPDATE {table}
374 SET labels = (SELECT json_group_array(CASE WHEN value = ?2 THEN ?3 ELSE value END)
375 FROM json_each({table}.labels))
376 WHERE {scope}
377 AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)"
378 ))
379 .bind(&[workspace.as_str().into(), name.as_str().into(), to.as_str().into()])?,
380 );
381 }
382 }
383 self.db.batch(statements).await?;
384 self.label_now(&workspace, &to).await
385 }
386
387 async fn label_now(&self, workspace: &str, name: &str) -> Result<Outcome<Label>> {
388 Ok(match self.labels_in(workspace, Some(name)).await?.pop() {
389 Some(label) => Outcome::Ok(label),
390 None => Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}.")),
391 })
392 }
393
394 /// Removes a label from the workspace and from everything carrying it.
395 pub(crate) async fn delete_label(&self, a: DeleteLabelArgs) -> Result<Outcome<bool>> {
396 let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?);
397 let name = a.name.trim().to_lowercase();
398 if self.labels_in(&workspace, Some(&name)).await?.is_empty() {
399 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no label named {name}.")));
400 }
401 let mut statements = vec![self
402 .db
403 .prepare("DELETE FROM labels WHERE workspace = ?1 AND name = ?2")
404 .bind(&[workspace.as_str().into(), name.as_str().into()])?];
405 for (table, scope) in WHERE_IN_WORKSPACE {
406 statements.push(
407 self.db
408 .prepare(format!(
409 "UPDATE {table}
410 SET labels = (SELECT json_group_array(value) FROM json_each({table}.labels) WHERE value != ?2)
411 WHERE {scope} AND EXISTS (SELECT 1 FROM json_each({table}.labels) WHERE value = ?2)"
412 ))
413 .bind(&[workspace.as_str().into(), name.as_str().into()])?,
414 );
415 }
416 self.db.batch(statements).await?;
417 Ok(Outcome::Ok(true))
418 }
419
420 /// Makes sure every label in `names` exists in the workspace: those
421 /// missing are created when `actor` may manage the workspace's labels
422 /// ([`may_create_labels`]), and refused otherwise. Returns their
423 /// colors, by name.
424 pub(crate) async fn ensure_labels(&self, actor: &User, workspace: &str, names: &[String]) -> Result<Outcome<Vec<(String, String)>>> {
425 if names.is_empty() {
426 return Ok(Outcome::Ok(Vec::new()));
427 }
428 let existing: Vec<Label> = self.labels_in(workspace, None).await?;
429 let missing: Vec<&String> = names.iter().filter(|name| !existing.iter().any(|label| label.name == **name)).collect();
430 if !missing.is_empty() {
431 if !may_create_labels(actor, workspace) {
432 let list = missing.iter().map(|name| name.as_str()).collect::<Vec<_>>().join(", ");
433 return Ok(Outcome::fail(
434 FailureCode::Invalid,
435 format!("{workspace} has no label named {list}. Someone with the Write role can create it on the labels page."),
436 ));
437 }
438 if !self.items_moved().await? {
439 return Ok(still_moving());
440 }
441 let now = rfc3339(now_ms());
442 let statements = missing
443 .iter()
444 .map(|name| {
445 self.db
446 .prepare(
447 "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at)
448 VALUES (?, ?, ?, '', ?)",
449 )
450 .bind(&[workspace.into(), name.as_str().into(), label_color_for(name).into(), now.as_str().into()])
451 })
452 .collect::<Result<Vec<_>>>()?;
453 self.db.batch(statements).await?;
454 }
455 Ok(Outcome::Ok(
456 names
457 .iter()
458 .map(|name| {
459 let color = existing
460 .iter()
461 .find(|label| label.name == *name)
462 .map_or_else(|| label_color_for(name), |label| label.color.clone());
463 (name.clone(), color)
464 })
465 .collect(),
466 ))
467 }
468
469 /// Gives a work item or a pull request the labels `wanted`, creating
470 /// the missing ones as [`ensure_labels`](Work::ensure_labels) does, and
471 /// says what changed: in its conversation, and as one `labeled` or
472 /// `unlabeled` event for each label. Returns its labels now.
473 pub(crate) async fn relabel(&self, actor: &User, labeled: &Labeled, wanted: &[String]) -> Result<Outcome<Vec<String>>> {
474 let Some(after) = normalize_labels(wanted) else {
475 return Ok(Outcome::fail(
476 FailureCode::Invalid,
477 format!("A work item or pull request can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
478 ));
479 };
480 let colors = check!(self.ensure_labels(actor, &labeled.workspace(), &after).await?);
481 let before = labeled.labels().to_vec();
482 let (added, removed) = label_changes(&before, &after);
483 if added.is_empty() && removed.is_empty() {
484 return Ok(Outcome::Ok(after));
485 }
486 let (table, id) = labeled.row();
487 self.db
488 .prepare(format!("UPDATE {table} SET labels = ?, updated_at = ? WHERE id = ?"))
489 .bind(&[serde_json::to_string(&after)?.into(), rfc3339(now_ms()).into(), id.into()])?
490 .run()
491 .await?;
492 self.announce_labels(actor, labeled, &added, &removed, &colors).await?;
493 Ok(Outcome::Ok(after))
494 }
495
496 /// Notes and events for labels put on and taken off a work item or
497 /// pull request.
498 pub(crate) async fn announce_labels(
499 &self,
500 actor: &User,
501 labeled: &Labeled,
502 added: &[String],
503 removed: &[String],
504 colors: &[(String, String)],
505 ) -> Result<()> {
506 if !added.is_empty() {
507 self.note_on(labeled.on(), actor.into(), &label_note("added", added)).await?;
508 }
509 if !removed.is_empty() {
510 self.note_on(labeled.on(), actor.into(), &label_note("removed", removed)).await?;
511 }
512 let color_of = |name: &String| {
513 colors.iter().find(|(known, _)| known == name).map_or_else(|| label_color_for(name), |(_, color)| color.clone())
514 };
515 for (what, names) in [("labeled", added), ("unlabeled", removed)] {
516 for name in names {
517 let label = Some(EventLabel { name: name.clone(), color: color_of(name) });
518 match labeled {
519 Labeled::Item(held) => {
520 self.publish_item(labeled.kind(what), held, Some(actor), WorkItemEvent { label, ..Self::item_event(held) })
521 .await?;
522 }
523 Labeled::Pull(_, pull) => {
524 self.publish(labeled.kind(what), &pull.repo_id, actor, PullEvent { label, ..Self::pull_event(pull) })
525 .await?;
526 }
527 }
528 }
529 }
530 Ok(())
531 }
532
533 /// `set_labels`: the labels of a work item or a pull request, replaced,
534 /// added to or taken from. Its owner may use the workspace's labels on
535 /// it; anyone else needs the Triage role.
536 pub(crate) async fn set_labels(&self, a: SetLabelsArgs) -> Result<Outcome<Vec<String>>> {
537 let labeled = check!(self.labeled(&a.actor, a.repo.as_ref(), &a.workspace, a.number).await?);
538 let given = match a.change {
539 // Taking off works on names as given, tidied the same way.
540 LabelChange::Remove => normalize_labels(&a.labels).unwrap_or_default(),
541 _ => a.labels.clone(),
542 };
543 let wanted = changed_labels(labeled.labels(), &given, a.change);
544 let labels = check!(self.relabel(&a.actor, &labeled, &wanted).await?);
545 Ok(Outcome::Ok(labels))
546 }
547
548 /// The thread `actor` changes the labels or milestone of: theirs, or
549 /// one they may triage.
550 pub(crate) async fn labeled(&self, actor: &User, repo: Option<&g1t_contracts::repos::RepoPath>, workspace: &str, number: u32) -> Result<Outcome<Labeled>> {
551 let labeled = match check!(self.thread(&Some(actor.clone()), repo, workspace, number).await?) {
552 crate::Thread::Pull(repo, pull) => {
553 check!(writable(&repo));
554 Labeled::Pull(repo, pull)
555 }
556 crate::Thread::Item(held) => Labeled::Item(held),
557 };
558 if !labeled.owned_by(&actor.id) && !labeled.may(actor, Capability::Triage) {
559 return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::Triage, &labeled.workspace())));
560 }
561 Ok(Outcome::Ok(labeled))
562 }
563}
564
565/// The rows of a workspace (`?1`) that carry labels: its work items, and
566/// its repositories' pull requests.
567const WHERE_IN_WORKSPACE: [(&str, &str); 2] = [
568 ("work_items", "workspace = ?1"),
569 ("pulls", "repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?1)"),
570];
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn names(list: &[&str]) -> Vec<String> {
577 list.iter().map(|name| (*name).to_owned()).collect()
578 }
579
580 #[test]
581 fn only_the_workspaces_members_make_its_labels() {
582 use crate::items::rows_for_test::person_for_test;
583 // An outside collaborator with Write on one of acme's repositories.
584 let mut outsider = person_for_test("usr_9", None);
585 outsider.grants.push(g1t_contracts::access::RepoGrant {
586 repo_id: "rep_web".into(),
587 workspace: "acme".into(),
588 role: g1t_contracts::access::RepoRole::Write,
589 team: None,
590 });
591 assert!(!may_create_labels(&outsider, "acme"));
592 assert!(may_create_labels(&person_for_test("usr_2", Some("acme")), "acme"));
593 }
594
595 #[test]
596 fn labels_are_set_added_to_and_taken_off() {
597 let current = names(&["bug", "docs"]);
598 assert_eq!(changed_labels(&current, &names(&["question"]), LabelChange::Set), names(&["question"]));
599 assert_eq!(changed_labels(&current, &names(&["question"]), LabelChange::Add), names(&["bug", "docs", "question"]));
600 assert_eq!(changed_labels(&current, &names(&["bug"]), LabelChange::Remove), names(&["docs"]));
601 }
602
603 #[test]
604 fn what_changed_is_said_in_a_sentence() {
605 let (added, removed) = label_changes(&names(&["bug", "docs"]), &names(&["docs", "security", "wontfix"]));
606 assert_eq!(added, names(&["security", "wontfix"]));
607 assert_eq!(removed, names(&["bug"]));
608 assert_eq!(label_note("added", &added), "added the `security` and `wontfix` labels");
609 assert_eq!(label_note("removed", &removed), "removed the `bug` label");
610 assert_eq!(
611 label_note("added", &names(&["a", "b", "c"])),
612 "added the `a`, `b` and `c` labels"
613 );
614 }
615
616 #[test]
617 fn a_name_is_tidied_or_refused() {
618 assert_eq!(one_name(" Good First Issue ").unwrap(), "good first issue");
619 assert!(one_name(" ").is_err());
620 assert!(one_name(&"x".repeat(51)).is_err());
621 assert!(tidy_description(&"x".repeat(101)).is_err());
622 }
623}