steid

@jamesgill /

Rust38.4 KBCode·Blame·History·Raw
1//! Labels: making them, changing them, deleting them, and putting them on an issue.
2//!
3//! The same split [`issue`](super::issue) sets, for the same reason: **reading follows
4//! the repository's visibility, and every write is owner-only.** Anyone who can see a
5//! repository can see what its labels mean — the pills are on the issues list already,
6//! and a legend nobody may read is a legend that does not work.
7//!
8//! Authorization is [`access`](super::access)'s `readable_repo` and `writable_repo`
9//! rather than a second copy of the same rule. Two copies would be two places for it to
10//! drift, and the answers have to agree: a label is only ever reached through the
11//! repository it belongs to.
12
13use std::{
14 collections::{HashMap, HashSet},
15 time::SystemTime,
16};
17
18use crate::domain::{
19 Actor, DomainError, IssueId, IssueNumber, Label, LabelColour, LabelId, OrgName, PullRequestId,
20 RepoName, Repository,
21 repository::{
22 IssueRepository, LabelRepository, MembershipRepository, OrgRepository,
23 PullRequestRepository, RepoRepository,
24 },
25};
26
27use super::{
28 access::{Stores, readable_repo, writable_repo},
29 error::{Error, Result},
30 issue::existing_issue,
31 pull_request::existing_pull,
32};
33
34/// A label as a page renders it.
35///
36/// The id travels with it because the checkbox list and the edit form both address one,
37/// and `created_at` does not because nothing shows it: a label has no history worth a
38/// timestamp.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LabelView {
41 pub id: LabelId,
42 pub name: String,
43 pub colour: LabelColour,
44 pub description: Option<String>,
45}
46
47impl From<Label> for LabelView {
48 fn from(label: Label) -> Self {
49 Self {
50 id: label.id,
51 name: label.name,
52 colour: label.colour,
53 description: label.description,
54 }
55 }
56}
57
58/// One row of the management page: the label, and how much use it is getting.
59///
60/// Two numbers rather than one total, because the row links to two lists and the delete
61/// confirmation says what the label comes off: a single number covering both would make
62/// that sentence wrong for whichever kind it was not counting.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct LabelRow {
65 pub label: LabelView,
66 pub issues: u64,
67 pub pulls: u64,
68}
69
70/// A repository's labels.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct LabelPage {
73 /// By name — the order both implementations promise, and the one you can scan.
74 pub labels: Vec<LabelRow>,
75 pub viewer_is_owner: bool,
76}
77
78/// What someone typed into the new-label form. Raw — validation is [`Label::new`]'s job.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct NewLabel {
81 pub name: String,
82 pub colour: LabelColour,
83 pub description: Option<String>,
84}
85
86/// A change to a label. The same shape as [`NewLabel`], kept separate so the two cannot
87/// be passed for one another.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct LabelEdit {
90 pub name: String,
91 pub colour: LabelColour,
92 pub description: Option<String>,
93}
94
95// --- Reads --------------------------------------------------------------------------
96
97/// A repository's labels, with a usage count on each.
98///
99/// `Ok(None)` means the repository is not there *or* the viewer may not see it, and the
100/// caller must render both identically — the rule
101/// [`view_repo`](super::view_repo) sets.
102pub async fn list_labels(
103 actor: &Actor,
104 handle: &OrgName,
105 name: &RepoName,
106 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
107 labels: &impl LabelRepository,
108) -> Result<Option<LabelPage>> {
109 let Some((repo, viewer_is_owner)) = readable_repo(actor, handle, name, stores).await? else {
110 return Ok(None);
111 };
112
113 // One query for every row's count rather than one per row, and one per kind — the
114 // two tables are two counts.
115 let counts: HashMap<LabelId, u64> = labels.issue_counts(&repo.id).await?.into_iter().collect();
116 let pulls: HashMap<LabelId, u64> = labels.pull_counts(&repo.id).await?.into_iter().collect();
117
118 let rows = labels
119 .list_by_repo(&repo.id)
120 .await?
121 .into_iter()
122 .map(|label| LabelRow {
123 issues: counts.get(&label.id).copied().unwrap_or(0),
124 pulls: pulls.get(&label.id).copied().unwrap_or(0),
125 label: label.into(),
126 })
127 .collect();
128
129 Ok(Some(LabelPage {
130 labels: rows,
131 viewer_is_owner,
132 }))
133}
134
135/// Every label on a repository, keyed by the issue wearing it.
136///
137/// The shape a listing needs: one query for a whole page of rows, rather than one per
138/// row. Pairs arrive grouped and name-ordered, so each vector is already in the order
139/// the pills are drawn in.
140pub(super) async fn labels_by_issue(
141 repo: &Repository,
142 labels: &impl LabelRepository,
143) -> Result<HashMap<IssueId, Vec<LabelView>>> {
144 let mut grouped: HashMap<IssueId, Vec<LabelView>> = HashMap::new();
145
146 for (issue_id, label) in labels.labels_for_repo(&repo.id).await? {
147 grouped.entry(issue_id).or_default().push(label.into());
148 }
149
150 Ok(grouped)
151}
152
153/// Every label on a repository, keyed by the pull request wearing it.
154///
155/// [`labels_by_issue`]'s counterpart, and the same shape for the same reason.
156pub(super) async fn labels_by_pull(
157 repo: &Repository,
158 labels: &impl LabelRepository,
159) -> Result<HashMap<PullRequestId, Vec<LabelView>>> {
160 let mut grouped: HashMap<PullRequestId, Vec<LabelView>> = HashMap::new();
161
162 for (pull_id, label) in labels.labels_for_repo_pulls(&repo.id).await? {
163 grouped.entry(pull_id).or_default().push(label.into());
164 }
165
166 Ok(grouped)
167}
168
169// --- Writes -------------------------------------------------------------------------
170
171/// Creates a label. Owner only.
172///
173/// The duplicate check is here rather than left to the unique index so the refusal is a
174/// message the form can show. The index is still the backstop: two creations racing
175/// would both pass this and one would fail its insert, which is the right way round —
176/// a storage error for a race nobody will ever see, and a sentence for the case that
177/// happens.
178pub async fn create_label(
179 actor: &Actor,
180 handle: &OrgName,
181 name: &RepoName,
182 spec: &NewLabel,
183 now: SystemTime,
184 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
185 labels: &impl LabelRepository,
186) -> Result<()> {
187 let repo = writable_repo(actor, handle, name, stores).await?;
188
189 // Validated before the uniqueness check, so `" bug "` is refused as a duplicate of
190 // `bug` rather than saved beside it.
191 let label = Label::new(
192 LabelId::generate(),
193 repo.id.clone(),
194 spec.name.clone(),
195 spec.colour,
196 spec.description.clone(),
197 now,
198 )?;
199
200 if labels.find_by_name(&repo.id, &label.name).await?.is_some() {
201 return Err(taken(&label.name));
202 }
203
204 labels.save(&label).await?;
205
206 Ok(())
207}
208
209/// Renames, recolours or redescribes a label. Owner only.
210///
211/// The id is checked against the repository in the path, not merely looked up: without
212/// that, an id from somebody else's repository would be a way to edit their label from
213/// a form on yours.
214pub async fn update_label(
215 actor: &Actor,
216 handle: &OrgName,
217 name: &RepoName,
218 id: &LabelId,
219 edit: &LabelEdit,
220 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
221 labels: &impl LabelRepository,
222) -> Result<()> {
223 let repo = writable_repo(actor, handle, name, stores).await?;
224 let mut label = owned_label(&repo, id, labels).await?;
225
226 // Validated before anything is written, so a rejected name leaves the label exactly
227 // as it was.
228 label.edit(edit.name.clone(), edit.colour, edit.description.clone())?;
229
230 // A label may keep its own name — recolouring is an edit too — so the clash is only
231 // with a *different* label.
232 if let Some(existing) = labels.find_by_name(&repo.id, &label.name).await?
233 && existing.id != label.id
234 {
235 return Err(taken(&label.name));
236 }
237
238 labels.save(&label).await?;
239
240 Ok(())
241}
242
243/// Deletes a label. Owner only.
244///
245/// The issues wearing it keep everything they say; they stop wearing it. That is what
246/// `issue_labels`' `on delete cascade` means, and it is why this needs no confirmation
247/// beyond the one the page asks for: nothing else is lost.
248pub async fn delete_label(
249 actor: &Actor,
250 handle: &OrgName,
251 name: &RepoName,
252 id: &LabelId,
253 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
254 labels: &impl LabelRepository,
255) -> Result<()> {
256 let repo = writable_repo(actor, handle, name, stores).await?;
257 let label = owned_label(&repo, id, labels).await?;
258
259 labels.delete(&label.id).await?;
260
261 Ok(())
262}
263
264/// Replaces an issue's labels with exactly this set. Owner only.
265///
266/// **The whole set, not an add and a remove**, because the control that submits it is a
267/// list of checkboxes: what arrives is what is ticked, and applying it as a replacement
268/// is the only reading that matches what the person saw. It also makes a double
269/// submission a no-op instead of a toggle.
270///
271/// An id that is not this repository's is `NotFound` rather than quietly dropped:
272/// dropping it would let a tampered form put somebody else's label on an issue and give
273/// no sign it had happened.
274///
275/// The issue's `updated_at` does **not** move. It marks when what the issue *says* last
276/// changed — an edit, a comment, a close — and a label is a way of describing an issue
277/// rather than part of what it says.
278#[allow(clippy::too_many_arguments)]
279pub async fn set_issue_labels(
280 actor: &Actor,
281 handle: &OrgName,
282 name: &RepoName,
283 number: IssueNumber,
284 chosen: &[LabelId],
285 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
286 issues: &impl IssueRepository,
287 labels: &impl LabelRepository,
288) -> Result<()> {
289 let repo = writable_repo(actor, handle, name, stores).await?;
290 let issue = existing_issue(&repo, number, issues).await?;
291
292 let wanted = chosen_labels(&repo, chosen, labels).await?;
293
294 labels.set_issue_labels(&issue.id, &wanted).await?;
295
296 Ok(())
297}
298
299/// The ids a checkbox list submitted, checked against the repository and de-duplicated.
300///
301/// An id that is not this repository's is `NotFound` rather than quietly dropped:
302/// dropping it would let a tampered form put somebody else's label on something and give
303/// no sign it had happened.
304async fn chosen_labels(
305 repo: &Repository,
306 chosen: &[LabelId],
307 labels: &impl LabelRepository,
308) -> Result<Vec<LabelId>> {
309 let known: HashSet<LabelId> = labels
310 .list_by_repo(&repo.id)
311 .await?
312 .into_iter()
313 .map(|label| label.id)
314 .collect();
315
316 let mut seen = HashSet::new();
317 let mut wanted = Vec::with_capacity(chosen.len());
318
319 for id in chosen {
320 if !known.contains(id) {
321 return Err(Error::Domain(DomainError::NotFound { entity: "label" }));
322 }
323
324 // A checkbox cannot be ticked twice, so a repeat is a hand-made submission. It
325 // means the same thing either way, and the store would collapse it anyway.
326 if seen.insert(id.clone()) {
327 wanted.push(id.clone());
328 }
329 }
330
331 Ok(wanted)
332}
333
334/// Replaces a pull request's labels with exactly this set. Owner only.
335///
336/// [`set_issue_labels`]'s counterpart, down to the refusal of an id from another
337/// repository, and it shares [`chosen_labels`] with it so the two cannot come to
338/// disagree about what a tampered form means.
339#[allow(clippy::too_many_arguments)]
340pub async fn set_pull_labels(
341 actor: &Actor,
342 handle: &OrgName,
343 name: &RepoName,
344 number: IssueNumber,
345 chosen: &[LabelId],
346 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
347 pulls: &impl PullRequestRepository,
348 labels: &impl LabelRepository,
349) -> Result<()> {
350 let repo = writable_repo(actor, handle, name, stores).await?;
351 let pull = existing_pull(&repo, number, pulls).await?;
352 let wanted = chosen_labels(&repo, chosen, labels).await?;
353
354 labels.set_pull_labels(&pull.id, &wanted).await?;
355
356 Ok(())
357}
358
359// --- Shared resolution ----------------------------------------------------------------
360
361/// The label with this id **on this repository**, or `NotFound`.
362async fn owned_label(
363 repo: &Repository,
364 id: &LabelId,
365 labels: &impl LabelRepository,
366) -> Result<Label> {
367 labels
368 .find_by_id(id)
369 .await?
370 .filter(|label| label.repo_id == repo.id)
371 .ok_or(Error::Domain(DomainError::NotFound { entity: "label" }))
372}
373
374/// The refusal a duplicate name earns, worded for the form that shows it.
375fn taken(name: &str) -> Error {
376 Error::Domain(DomainError::validation(
377 "name",
378 format!("this repository already has a label called {name:?}"),
379 ))
380}
381
382#[cfg(test)]
383mod tests {
384 use std::time::Duration;
385
386 use super::*;
387 use crate::{
388 domain::{
389 Email, Issue, IssueNumber, Membership, MembershipId, OrgId, Organization, PasswordHash,
390 RepoId, Role, User, UserId, Visibility,
391 repository::{MembershipRepository, OrgRepository, RepoRepository, UserRepository},
392 },
393 infrastructure::repository::{
394 InMemoryIssueRepo, InMemoryLabelRepo, InMemoryMembershipRepo, InMemoryOrgRepo,
395 InMemoryPullRequestRepo, InMemoryRepoRepo, InMemoryUserRepo,
396 },
397 };
398
399 fn at(seconds: u64) -> SystemTime {
400 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
401 }
402
403 struct Fixture {
404 orgs: InMemoryOrgRepo,
405 memberships: InMemoryMembershipRepo,
406 repos: InMemoryRepoRepo,
407 issues: InMemoryIssueRepo,
408 pulls: InMemoryPullRequestRepo,
409 users: InMemoryUserRepo,
410 labels: InMemoryLabelRepo,
411 handle: OrgName,
412 owner: Actor,
413 member: Actor,
414 stranger: Actor,
415 }
416
417 /// One organisation with an owner and a plain member, a public repository `steid`
418 /// and a second public one `other` — the second is what proves a label is scoped to
419 /// its repository rather than to the instance.
420 async fn fixture() -> Fixture {
421 let orgs = InMemoryOrgRepo::new();
422 let memberships = InMemoryMembershipRepo::new();
423 let repos = InMemoryRepoRepo::new();
424 let users = InMemoryUserRepo::new();
425
426 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
427 orgs.save(&org).await.expect("save org");
428
429 let mut actors = Vec::new();
430
431 for (email, role) in [
432 ("owner@example.com", Role::Owner),
433 ("member@example.com", Role::Member),
434 ] {
435 let user = User::new(
436 UserId::generate(),
437 Email::new(email).expect("valid email"),
438 PasswordHash::from_trusted("$argon2id$test"),
439 org.id.clone(),
440 );
441
442 users.save(&user).await.expect("save user");
443 memberships
444 .save(&Membership::new(
445 MembershipId::generate(),
446 org.id.clone(),
447 user.id.clone(),
448 role,
449 ))
450 .await
451 .expect("save membership");
452
453 actors.push(Actor::User(user.id));
454 }
455
456 for (name, visibility) in [
457 ("steid", Visibility::Public),
458 ("other", Visibility::Public),
459 ("secret", Visibility::Private),
460 ] {
461 repos
462 .save(
463 &Repository::new(
464 RepoId::generate(),
465 org.id.clone(),
466 name,
467 None,
468 visibility,
469 at(1_000),
470 )
471 .expect("valid repo"),
472 )
473 .await
474 .expect("save repo");
475 }
476
477 let member = actors.pop().expect("member");
478 let owner = actors.pop().expect("owner");
479
480 Fixture {
481 orgs,
482 memberships,
483 repos,
484 issues: InMemoryIssueRepo::new(),
485 pulls: InMemoryPullRequestRepo::new(),
486 users,
487 labels: InMemoryLabelRepo::new(),
488 handle: org.name.clone(),
489 owner,
490 member,
491 stranger: Actor::Anonymous,
492 }
493 }
494
495 fn repo(name: &str) -> RepoName {
496 RepoName::new(name).expect("valid name")
497 }
498
499 fn spec(name: &str, colour: LabelColour) -> NewLabel {
500 NewLabel {
501 name: name.to_owned(),
502 colour,
503 description: None,
504 }
505 }
506
507 async fn make(f: &Fixture, on: &str, name: &str, colour: LabelColour) -> Result<()> {
508 create_label(
509 &f.owner,
510 &f.handle,
511 &repo(on),
512 &spec(name, colour),
513 at(2_000),
514 &Stores::new(&f.orgs, &f.memberships, &f.repos),
515 &f.labels,
516 )
517 .await
518 }
519
520 async fn page(f: &Fixture, actor: &Actor, on: &str) -> Option<LabelPage> {
521 list_labels(
522 actor,
523 &f.handle,
524 &repo(on),
525 &Stores::new(&f.orgs, &f.memberships, &f.repos),
526 &f.labels,
527 )
528 .await
529 .expect("should list")
530 }
531
532 async fn id_of(f: &Fixture, on: &str, name: &str) -> LabelId {
533 page(f, &f.owner, on)
534 .await
535 .expect("visible")
536 .labels
537 .into_iter()
538 .find(|row| row.label.name == name)
539 .expect("label is there")
540 .label
541 .id
542 }
543
544 /// Opens an issue directly through the port: these tests are about labels, and
545 /// going through `create_issue` would only add its authorization to every one.
546 async fn open(f: &Fixture, on: &str, title: &str) -> Issue {
547 let repository = f
548 .repos
549 .find_by_org_and_name(
550 &f.orgs
551 .find_by_name(&f.handle)
552 .await
553 .expect("query")
554 .expect("org")
555 .id,
556 &repo(on),
557 )
558 .await
559 .expect("query")
560 .expect("repo");
561
562 let author = match &f.owner {
563 Actor::User(id) => id.clone(),
564 Actor::Anonymous => unreachable!("the owner is a user"),
565 };
566
567 let number = f.repos.next_number(&repository.id).await.expect("number");
568 let issue = Issue::new(
569 crate::domain::IssueId::generate(),
570 repository.id,
571 number,
572 title,
573 "",
574 author,
575 at(2_000),
576 )
577 .expect("valid issue");
578
579 f.issues.save(&issue).await.expect("save issue");
580
581 issue
582 }
583
584 async fn assign(f: &Fixture, on: &str, number: u64, chosen: &[LabelId]) -> Result<()> {
585 set_issue_labels(
586 &f.owner,
587 &f.handle,
588 &repo(on),
589 IssueNumber::from_trusted(number),
590 chosen,
591 &Stores::new(&f.orgs, &f.memberships, &f.repos),
592 &f.issues,
593 &f.labels,
594 )
595 .await
596 }
597
598 async fn listed(f: &Fixture, on: &str, filter: Option<&str>) -> crate::application::IssueList {
599 crate::application::list_issues(
600 &f.owner,
601 &f.handle,
602 &repo(on),
603 crate::domain::IssueState::Open,
604 filter,
605 1,
606 &Stores::new(&f.orgs, &f.memberships, &f.repos),
607 &f.issues,
608 &f.users,
609 &f.labels,
610 )
611 .await
612 .expect("should list")
613 .expect("visible")
614 }
615
616 // --- creating -------------------------------------------------------------------
617
618 #[tokio::test]
619 async fn a_created_label_is_listed_with_no_issues_on_it() {
620 let f = fixture().await;
621
622 make(&f, "steid", "bug", LabelColour::Red)
623 .await
624 .expect("should create");
625
626 let page = page(&f, &f.owner, "steid").await.expect("visible");
627
628 assert_eq!(page.labels.len(), 1);
629 assert_eq!(page.labels[0].label.name, "bug");
630 assert_eq!(page.labels[0].label.colour, LabelColour::Red);
631 assert_eq!(page.labels[0].issues, 0);
632 assert!(page.viewer_is_owner);
633 }
634
635 #[tokio::test]
636 async fn labels_are_listed_by_name() {
637 let f = fixture().await;
638
639 for name in ["docs", "bug", "chore"] {
640 make(&f, "steid", name, LabelColour::Gray)
641 .await
642 .expect("should create");
643 }
644
645 let names: Vec<String> = page(&f, &f.owner, "steid")
646 .await
647 .expect("visible")
648 .labels
649 .into_iter()
650 .map(|row| row.label.name)
651 .collect();
652
653 assert_eq!(names, ["bug", "chore", "docs"]);
654 }
655
656 #[tokio::test]
657 async fn a_name_is_unique_within_a_repository_and_free_on_another() {
658 let f = fixture().await;
659
660 make(&f, "steid", "bug", LabelColour::Red)
661 .await
662 .expect("should create");
663
664 // The same name again, including with the whitespace that trims to it.
665 assert!(matches!(
666 make(&f, "steid", "bug", LabelColour::Blue).await,
667 Err(Error::Domain(DomainError::Validation { .. }))
668 ));
669 assert!(matches!(
670 make(&f, "steid", " bug ", LabelColour::Blue).await,
671 Err(Error::Domain(DomainError::Validation { .. }))
672 ));
673
674 // A label belongs to its repository, so the same word is free next door.
675 make(&f, "other", "bug", LabelColour::Blue)
676 .await
677 .expect("should create");
678
679 assert_eq!(
680 page(&f, &f.owner, "steid")
681 .await
682 .expect("visible")
683 .labels
684 .len(),
685 1
686 );
687 assert_eq!(
688 page(&f, &f.owner, "other")
689 .await
690 .expect("visible")
691 .labels
692 .len(),
693 1
694 );
695 }
696
697 #[tokio::test]
698 async fn only_the_owner_writes_and_anyone_who_can_read_the_repository_reads() {
699 let f = fixture().await;
700
701 make(&f, "steid", "bug", LabelColour::Red)
702 .await
703 .expect("should create");
704
705 for actor in [&f.member, &f.stranger] {
706 assert!(page(&f, actor, "steid").await.is_some(), "readable");
707
708 assert!(matches!(
709 create_label(
710 actor,
711 &f.handle,
712 &repo("steid"),
713 &spec("wontfix", LabelColour::Gray),
714 at(3_000),
715 &Stores::new(&f.orgs, &f.memberships, &f.repos),
716 &f.labels,
717 )
718 .await,
719 Err(Error::Domain(DomainError::Forbidden))
720 ));
721 }
722
723 // A private repository is absent rather than locked, for everyone but its owner.
724 assert!(page(&f, &f.stranger, "secret").await.is_none());
725 assert!(page(&f, &f.member, "secret").await.is_some());
726 }
727
728 // --- editing --------------------------------------------------------------------
729
730 #[tokio::test]
731 async fn a_label_can_be_renamed_and_recoloured_but_not_onto_another_name() {
732 let f = fixture().await;
733
734 make(&f, "steid", "bug", LabelColour::Red)
735 .await
736 .expect("should create");
737 make(&f, "steid", "docs", LabelColour::Blue)
738 .await
739 .expect("should create");
740
741 let bug = id_of(&f, "steid", "bug").await;
742
743 let edit = |name: &str, colour| LabelEdit {
744 name: name.to_owned(),
745 colour,
746 description: None,
747 };
748
749 let apply = async |id: &LabelId, edit: LabelEdit| {
750 update_label(
751 &f.owner,
752 &f.handle,
753 &repo("steid"),
754 id,
755 &edit,
756 &Stores::new(&f.orgs, &f.memberships, &f.repos),
757 &f.labels,
758 )
759 .await
760 };
761
762 // Onto a name another label already has.
763 assert!(matches!(
764 apply(&bug, edit("docs", LabelColour::Red)).await,
765 Err(Error::Domain(DomainError::Validation { .. }))
766 ));
767
768 // Keeping its own name while changing the colour is not a clash with itself.
769 apply(&bug, edit("bug", LabelColour::Purple))
770 .await
771 .expect("should recolour");
772
773 apply(&bug, edit("defect", LabelColour::Purple))
774 .await
775 .expect("should rename");
776
777 let names: Vec<String> = page(&f, &f.owner, "steid")
778 .await
779 .expect("visible")
780 .labels
781 .into_iter()
782 .map(|row| row.label.name)
783 .collect();
784
785 assert_eq!(names, ["defect", "docs"]);
786 }
787
788 #[tokio::test]
789 async fn a_label_belonging_to_another_repository_is_not_found() {
790 let f = fixture().await;
791
792 make(&f, "other", "bug", LabelColour::Red)
793 .await
794 .expect("should create");
795 let elsewhere = id_of(&f, "other", "bug").await;
796
797 // Addressed through `steid`, which does not have it.
798 assert!(matches!(
799 delete_label(
800 &f.owner,
801 &f.handle,
802 &repo("steid"),
803 &elsewhere,
804 &Stores::new(&f.orgs, &f.memberships, &f.repos),
805 &f.labels,
806 )
807 .await,
808 Err(Error::Domain(DomainError::NotFound { entity: "label" }))
809 ));
810
811 assert_eq!(
812 page(&f, &f.owner, "other")
813 .await
814 .expect("visible")
815 .labels
816 .len(),
817 1
818 );
819 }
820
821 // --- assignment -----------------------------------------------------------------
822
823 #[tokio::test]
824 async fn setting_the_full_set_replaces_what_was_there_and_repeats_are_no_ops() {
825 let f = fixture().await;
826
827 for name in ["bug", "docs", "chore"] {
828 make(&f, "steid", name, LabelColour::Gray)
829 .await
830 .expect("should create");
831 }
832
833 let bug = id_of(&f, "steid", "bug").await;
834 let docs = id_of(&f, "steid", "docs").await;
835 let chore = id_of(&f, "steid", "chore").await;
836
837 open(&f, "steid", "Blame is slow").await;
838
839 assign(&f, "steid", 1, &[bug.clone(), docs.clone()])
840 .await
841 .expect("should assign");
842 assert_eq!(carried(&f, "steid", 1).await, ["bug", "docs"]);
843
844 // Submitting the same thing twice changes nothing.
845 assign(&f, "steid", 1, &[bug.clone(), docs.clone()])
846 .await
847 .expect("should assign");
848 assert_eq!(carried(&f, "steid", 1).await, ["bug", "docs"]);
849
850 // The whole set, so what is absent comes off.
851 assign(&f, "steid", 1, std::slice::from_ref(&chore))
852 .await
853 .expect("should assign");
854 assert_eq!(carried(&f, "steid", 1).await, ["chore"]);
855
856 // An empty set clears it.
857 assign(&f, "steid", 1, &[]).await.expect("should assign");
858 assert!(carried(&f, "steid", 1).await.is_empty());
859
860 // The same id twice is one assignment, not a failure.
861 assign(&f, "steid", 1, &[bug.clone(), bug])
862 .await
863 .expect("should assign");
864 assert_eq!(carried(&f, "steid", 1).await, ["bug"]);
865 }
866
867 #[tokio::test]
868 async fn a_label_from_another_repository_cannot_be_put_on_an_issue() {
869 let f = fixture().await;
870
871 make(&f, "other", "bug", LabelColour::Red)
872 .await
873 .expect("should create");
874 let elsewhere = id_of(&f, "other", "bug").await;
875
876 open(&f, "steid", "Blame is slow").await;
877
878 assert!(matches!(
879 assign(&f, "steid", 1, &[elsewhere]).await,
880 Err(Error::Domain(DomainError::NotFound { entity: "label" }))
881 ));
882 assert!(carried(&f, "steid", 1).await.is_empty());
883 }
884
885 #[tokio::test]
886 async fn deleting_a_label_takes_it_off_every_issue_and_leaves_the_issues_alone() {
887 let f = fixture().await;
888
889 make(&f, "steid", "bug", LabelColour::Red)
890 .await
891 .expect("should create");
892 make(&f, "steid", "docs", LabelColour::Blue)
893 .await
894 .expect("should create");
895
896 let bug = id_of(&f, "steid", "bug").await;
897 let docs = id_of(&f, "steid", "docs").await;
898
899 open(&f, "steid", "Blame is slow").await;
900 open(&f, "steid", "Docs are thin").await;
901
902 assign(&f, "steid", 1, &[bug.clone(), docs.clone()])
903 .await
904 .expect("should assign");
905 assign(&f, "steid", 2, std::slice::from_ref(&bug))
906 .await
907 .expect("should assign");
908
909 assert_eq!(
910 page(&f, &f.owner, "steid")
911 .await
912 .expect("visible")
913 .labels
914 .into_iter()
915 .map(|row| (row.label.name, row.issues))
916 .collect::<Vec<_>>(),
917 [("bug".to_owned(), 2), ("docs".to_owned(), 1)]
918 );
919
920 delete_label(
921 &f.owner,
922 &f.handle,
923 &repo("steid"),
924 &bug,
925 &Stores::new(&f.orgs, &f.memberships, &f.repos),
926 &f.labels,
927 )
928 .await
929 .expect("should delete");
930
931 assert_eq!(carried(&f, "steid", 1).await, ["docs"]);
932 assert!(carried(&f, "steid", 2).await.is_empty());
933 // Both issues are still there — a label is a way of describing an issue, not
934 // part of what it says.
935 assert_eq!(listed(&f, "steid", None).await.issues.items.len(), 2);
936 }
937
938 // --- filtering ------------------------------------------------------------------
939
940 #[tokio::test]
941 async fn the_issues_list_narrows_to_a_label_and_the_counts_follow() {
942 let f = fixture().await;
943
944 make(&f, "steid", "bug", LabelColour::Red)
945 .await
946 .expect("should create");
947 let bug = id_of(&f, "steid", "bug").await;
948
949 open(&f, "steid", "Blame is slow").await;
950 open(&f, "steid", "Docs are thin").await;
951 open(&f, "steid", "Search is slow").await;
952
953 assign(&f, "steid", 1, std::slice::from_ref(&bug))
954 .await
955 .expect("should assign");
956 assign(&f, "steid", 3, &[bug]).await.expect("should assign");
957
958 let all = listed(&f, "steid", None).await;
959 assert_eq!(all.issues.items.len(), 3);
960 assert_eq!(all.open, 3);
961 assert_eq!(all.filter, None);
962
963 let filtered = listed(&f, "steid", Some("bug")).await;
964 assert_eq!(
965 filtered
966 .issues
967 .items
968 .iter()
969 .map(|issue| issue.number.get())
970 .collect::<Vec<_>>(),
971 [3, 1]
972 );
973 // The toolbar's counts obey the filter, so "Open 2" is what is below it.
974 assert_eq!(filtered.open, 2);
975 assert_eq!(filtered.closed, 0);
976 assert_eq!(
977 filtered.filter.as_ref().map(|label| label.name.as_str()),
978 Some("bug")
979 );
980
981 // Every row carries its pills.
982 assert_eq!(
983 filtered.issues.items[0]
984 .labels
985 .iter()
986 .map(|label| label.name.as_str())
987 .collect::<Vec<_>>(),
988 ["bug"]
989 );
990 }
991
992 #[tokio::test]
993 async fn a_label_name_that_matches_nothing_filters_everything_out() {
994 let f = fixture().await;
995
996 open(&f, "steid", "Blame is slow").await;
997
998 // Not "show me everything": a filter that quietly failed to resolve would show
999 // a list that looks filtered and is not.
1000 let filtered = listed(&f, "steid", Some("nonesuch")).await;
1001
1002 assert!(filtered.issues.items.is_empty());
1003 assert_eq!(filtered.open, 0);
1004 assert_eq!(filtered.filter, None);
1005 }
1006
1007 /// The names of the labels on one issue, in the order they render.
1008 async fn carried(f: &Fixture, on: &str, number: u64) -> Vec<String> {
1009 crate::application::view_issue(
1010 &f.owner,
1011 &f.handle,
1012 &repo(on),
1013 IssueNumber::from_trusted(number),
1014 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1015 &f.issues,
1016 &f.users,
1017 &f.labels,
1018 )
1019 .await
1020 .expect("should read")
1021 .expect("visible")
1022 .labels
1023 .into_iter()
1024 .map(|label| label.name)
1025 .collect()
1026 }
1027
1028 // --- pull requests wear the same labels ------------------------------------------
1029
1030 /// Opens a pull request directly through the port, for the reason [`open`] does:
1031 /// these tests are about labels.
1032 async fn open_pull(f: &Fixture, on: &str, title: &str) -> crate::domain::PullRequest {
1033 let repository = f
1034 .repos
1035 .find_by_org_and_name(
1036 &f.orgs
1037 .find_by_name(&f.handle)
1038 .await
1039 .expect("query")
1040 .expect("org")
1041 .id,
1042 &repo(on),
1043 )
1044 .await
1045 .expect("query")
1046 .expect("repo");
1047
1048 let author = match &f.owner {
1049 Actor::User(id) => id.clone(),
1050 Actor::Anonymous => unreachable!("the owner is a user"),
1051 };
1052
1053 // The same allocator issues draw from, which is what makes `#7` one thing.
1054 let number = f.repos.next_number(&repository.id).await.expect("number");
1055 let pull = crate::domain::PullRequest::new(
1056 crate::domain::PullRequestId::generate(),
1057 repository.id,
1058 number,
1059 title,
1060 "",
1061 author,
1062 crate::domain::RefName::from_trusted("spike"),
1063 crate::domain::RefName::from_trusted("main"),
1064 at(2_000),
1065 )
1066 .expect("valid pull request");
1067
1068 f.pulls.save(&pull).await.expect("save pull request");
1069
1070 pull
1071 }
1072
1073 async fn assign_pull(f: &Fixture, on: &str, number: u64, chosen: &[LabelId]) -> Result<()> {
1074 set_pull_labels(
1075 &f.owner,
1076 &f.handle,
1077 &repo(on),
1078 IssueNumber::from_trusted(number),
1079 chosen,
1080 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1081 &f.pulls,
1082 &f.labels,
1083 )
1084 .await
1085 }
1086
1087 async fn listed_pulls(
1088 f: &Fixture,
1089 on: &str,
1090 filter: Option<&str>,
1091 ) -> crate::application::PullList {
1092 crate::application::list_pulls(
1093 &f.owner,
1094 &f.handle,
1095 &repo(on),
1096 crate::domain::PullRequestState::Open,
1097 filter,
1098 1,
1099 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1100 &f.pulls,
1101 &f.users,
1102 &f.labels,
1103 )
1104 .await
1105 .expect("should list")
1106 .expect("visible")
1107 }
1108
1109 #[tokio::test]
1110 async fn the_pull_request_list_narrows_to_a_label_and_the_counts_follow() {
1111 let f = fixture().await;
1112 make(&f, "steid", "bug", LabelColour::Red)
1113 .await
1114 .expect("made");
1115 let bug = id_of(&f, "steid", "bug").await;
1116
1117 let labelled = open_pull(&f, "steid", "Fix the parser").await;
1118 open_pull(&f, "steid", "Bump a dependency").await;
1119
1120 assign_pull(&f, "steid", labelled.number.get(), &[bug])
1121 .await
1122 .expect("assigned");
1123
1124 let all = listed_pulls(&f, "steid", None).await;
1125 assert_eq!(all.pulls.items.len(), 2);
1126 assert_eq!(all.open, 2);
1127
1128 let filtered = listed_pulls(&f, "steid", Some("bug")).await;
1129 assert_eq!(filtered.pulls.items.len(), 1);
1130 assert_eq!(filtered.pulls.items[0].number, labelled.number);
1131 // The toolbar must not say "Open 2" above one row.
1132 assert_eq!((filtered.open, filtered.merged, filtered.closed), (1, 0, 0));
1133 assert_eq!(
1134 filtered.filter.as_ref().map(|pill| pill.name.as_str()),
1135 Some("bug")
1136 );
1137 // The pills the row draws are the ones it wears.
1138 assert_eq!(
1139 filtered.pulls.items[0]
1140 .labels
1141 .iter()
1142 .map(|pill| pill.name.as_str())
1143 .collect::<Vec<_>>(),
1144 vec!["bug"]
1145 );
1146 }
1147
1148 #[tokio::test]
1149 async fn a_label_from_another_repository_cannot_be_put_on_a_pull_request() {
1150 let f = fixture().await;
1151 make(&f, "other", "bug", LabelColour::Red)
1152 .await
1153 .expect("made");
1154 let elsewhere = id_of(&f, "other", "bug").await;
1155 let pull = open_pull(&f, "steid", "Fix the parser").await;
1156
1157 assert!(matches!(
1158 assign_pull(&f, "steid", pull.number.get(), &[elsewhere]).await,
1159 Err(Error::Domain(DomainError::NotFound { entity: "label" }))
1160 ));
1161 }
1162
1163 #[tokio::test]
1164 async fn the_labels_page_counts_issues_and_pull_requests_apart() {
1165 // One number covering both would make the delete confirmation wrong for
1166 // whichever kind it was not counting.
1167 let f = fixture().await;
1168 make(&f, "steid", "bug", LabelColour::Red)
1169 .await
1170 .expect("made");
1171 let bug = id_of(&f, "steid", "bug").await;
1172
1173 let issue = open(&f, "steid", "It crashes").await;
1174 let pull = open_pull(&f, "steid", "Fix the crash").await;
1175
1176 assign(&f, "steid", issue.number.get(), std::slice::from_ref(&bug))
1177 .await
1178 .expect("assigned");
1179 assign_pull(&f, "steid", pull.number.get(), std::slice::from_ref(&bug))
1180 .await
1181 .expect("assigned");
1182
1183 let listed = page(&f, &f.owner, "steid").await.expect("visible");
1184 let row = &listed.labels[0];
1185
1186 assert_eq!((row.issues, row.pulls), (1, 1));
1187 }
1188
1189 #[tokio::test]
1190 async fn deleting_a_label_takes_it_off_pull_requests_too() {
1191 let f = fixture().await;
1192 make(&f, "steid", "bug", LabelColour::Red)
1193 .await
1194 .expect("made");
1195 let bug = id_of(&f, "steid", "bug").await;
1196 let pull = open_pull(&f, "steid", "Fix the parser").await;
1197
1198 assign_pull(&f, "steid", pull.number.get(), std::slice::from_ref(&bug))
1199 .await
1200 .expect("assigned");
1201
1202 delete_label(
1203 &f.owner,
1204 &f.handle,
1205 &repo("steid"),
1206 &bug,
1207 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1208 &f.labels,
1209 )
1210 .await
1211 .expect("deleted");
1212
1213 let listed = listed_pulls(&f, "steid", None).await;
1214 assert_eq!(listed.pulls.items.len(), 1);
1215 assert!(listed.pulls.items[0].labels.is_empty());
1216 }
1217}