Skip to content

Commit 97736f5

Browse files
committed
Move label matching logic to new module with tests
1 parent 3cefbd9 commit 97736f5

File tree

6 files changed

+175
-100
lines changed

6 files changed

+175
-100
lines changed

src/github.rs

Lines changed: 8 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
1+
use crate::labels;
12
use crate::team_data::TeamClient;
23
use anyhow::{Context, anyhow};
34
use async_trait::async_trait;
45
use bytes::Bytes;
56
use chrono::{DateTime, FixedOffset, Utc};
67
use futures::{FutureExt, future::BoxFuture};
7-
use itertools::Itertools;
88
use octocrab::models::{Author, AuthorAssociation};
99
use regex::Regex;
1010
use reqwest::header::{AUTHORIZATION, USER_AGENT};
1111
use reqwest::{Client, Request, RequestBuilder, Response, StatusCode};
1212
use std::collections::{HashMap, HashSet};
13-
use std::sync::{LazyLock, OnceLock};
13+
use std::sync::OnceLock;
1414
use std::{
1515
fmt,
1616
time::{Duration, SystemTime},
1717
};
1818
use tracing as log;
1919

20-
static EMOJI_REGEX: LazyLock<Regex> =
21-
LazyLock::new(|| Regex::new(r"[\p{Emoji}\p{Emoji_Presentation}]").unwrap());
22-
2320
pub type UserId = u64;
2421
pub type PullRequestNumber = u64;
2522

@@ -578,40 +575,6 @@ impl IssueRepository {
578575
}
579576
}
580577

581-
#[derive(Debug)]
582-
pub(crate) struct UnknownLabels {
583-
labels: Vec<String>,
584-
}
585-
586-
// NOTE: This is used to post the Github comment; make sure it's valid markdown.
587-
impl fmt::Display for UnknownLabels {
588-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
589-
write!(f, "Unknown labels: {}", &self.labels.join(", "))
590-
}
591-
}
592-
593-
impl std::error::Error for UnknownLabels {}
594-
595-
#[derive(Debug)]
596-
pub(crate) struct AmbiguousLabelMatch {
597-
pub requested_label: String,
598-
pub labels: Vec<String>,
599-
}
600-
601-
// NOTE: This is used to post the Github comment; make sure it's valid markdown.
602-
impl fmt::Display for AmbiguousLabelMatch {
603-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
604-
write!(
605-
f,
606-
"Unsure which label to use for `{}` - could be one of: {}",
607-
self.requested_label,
608-
self.labels.iter().map(|l| format!("`{}`", l)).join(", ")
609-
)
610-
}
611-
}
612-
613-
impl std::error::Error for AmbiguousLabelMatch {}
614-
615578
impl Issue {
616579
pub fn to_zulip_github_reference(&self) -> ZulipGitHubReference {
617580
ZulipGitHubReference {
@@ -756,52 +719,13 @@ impl Issue {
756719
.await
757720
.context("unable to retrieve the repository labels")?;
758721

759-
let normalize = |s: &str| EMOJI_REGEX.replace_all(s, "").trim().to_lowercase();
760-
761-
let mut found_labels = Vec::with_capacity(requested_labels.len());
762-
let mut unknown_labels = Vec::new();
763-
764-
for requested_label in requested_labels {
765-
// First look for an exact match
766-
if let Some(found) = available_labels.iter().find(|l| l.name == *requested_label) {
767-
found_labels.push(found.name.clone());
768-
continue;
769-
}
770-
771-
// Try normalizing requested label (remove emoji, case insensitive, trim whitespace)
772-
let normalized_requested: String = normalize(requested_label);
773-
774-
// Find matching labels by normalized name
775-
let found = available_labels
722+
labels::normalize_and_match_labels(
723+
&available_labels
776724
.iter()
777-
.filter(|l| normalize(&l.name) == normalized_requested)
778-
.collect::<Vec<_>>();
779-
780-
match found[..] {
781-
[] => {
782-
unknown_labels.push(requested_label);
783-
}
784-
[label] => {
785-
found_labels.push(label.name.clone());
786-
}
787-
[..] => {
788-
return Err(AmbiguousLabelMatch {
789-
requested_label: requested_label.to_string(),
790-
labels: found.into_iter().map(|l| l.name.clone()).collect(),
791-
}
792-
.into());
793-
}
794-
};
795-
}
796-
797-
if !unknown_labels.is_empty() {
798-
return Err(UnknownLabels {
799-
labels: unknown_labels.into_iter().map(|s| s.to_string()).collect(),
800-
}
801-
.into());
802-
}
803-
804-
Ok(found_labels)
725+
.map(|l| l.name.as_str())
726+
.collect::<Vec<_>>(),
727+
requested_labels,
728+
)
805729
}
806730

807731
pub async fn remove_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<()> {
@@ -3295,16 +3219,3 @@ impl Submodule {
32953219
client.repository(fullname).await
32963220
}
32973221
}
3298-
3299-
#[cfg(test)]
3300-
mod tests {
3301-
use super::*;
3302-
3303-
#[test]
3304-
fn display_labels() {
3305-
let x = UnknownLabels {
3306-
labels: vec!["A-bootstrap".into(), "xxx".into()],
3307-
};
3308-
assert_eq!(x.to_string(), "Unknown labels: A-bootstrap, xxx");
3309-
}
3310-
}

src/handlers/assign.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::db::issue_data::IssueData;
2424
use crate::db::review_prefs::{RotationMode, get_review_prefs_batch};
2525
use crate::github::UserId;
2626
use crate::handlers::pr_tracking::ReviewerWorkqueue;
27+
use crate::labels;
2728
use crate::{
2829
config::AssignConfig,
2930
github::{self, Event, FileDiff, Issue, IssuesAction, Selection},
@@ -563,7 +564,7 @@ pub(super) async fn handle_command(
563564
.add_labels(&ctx.github, vec![github::Label { name: t_label }])
564565
.await
565566
{
566-
if let Some(github::UnknownLabels { .. }) = err.downcast_ref() {
567+
if let Some(labels::UnknownLabels { .. }) = err.downcast_ref() {
567568
log::warn!("Error assigning label: {}", err);
568569
} else {
569570
return Err(err);

src/handlers/autolabel.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ pub(super) async fn handle_input(
209209
match event.issue.add_labels(&ctx.github, input.add).await {
210210
Ok(()) => {}
211211
Err(e) => {
212-
use crate::github::UnknownLabels;
212+
use crate::labels::UnknownLabels;
213213
if let Some(err @ UnknownLabels { .. }) = e.downcast_ref() {
214214
event
215215
.issue

src/handlers/relabel.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
use crate::team_data::TeamClient;
1212
use crate::{
1313
config::RelabelConfig,
14-
github::UnknownLabels,
1514
github::{self, Event},
1615
handlers::Context,
1716
interactions::ErrorComment,
17+
labels::UnknownLabels,
1818
};
1919
use parser::command::relabel::{LabelDelta, RelabelCommand};
2020

src/labels.rs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
use std::{fmt, sync::LazyLock};
2+
3+
use itertools::Itertools;
4+
use regex::Regex;
5+
6+
static EMOJI_REGEX: LazyLock<Regex> =
7+
LazyLock::new(|| Regex::new(r"[\p{Emoji}\p{Emoji_Presentation}]").unwrap());
8+
9+
pub(crate) fn normalize_and_match_labels(
10+
available_labels: &[&str],
11+
requested_labels: &[&str],
12+
) -> anyhow::Result<Vec<String>> {
13+
let normalize = |s: &str| EMOJI_REGEX.replace_all(s, "").trim().to_lowercase();
14+
15+
let mut found_labels = Vec::<String>::with_capacity(requested_labels.len());
16+
let mut unknown_labels = Vec::new();
17+
18+
for requested_label in requested_labels {
19+
// First look for an exact match
20+
if let Some(found) = available_labels.iter().find(|l| **l == *requested_label) {
21+
found_labels.push((*found).into());
22+
continue;
23+
}
24+
25+
// Try normalizing requested label (remove emoji, case insensitive, trim whitespace)
26+
let normalized_requested: String = normalize(requested_label);
27+
28+
// Find matching labels by normalized name
29+
let found = available_labels
30+
.iter()
31+
.filter(|l| normalize(l) == normalized_requested)
32+
.collect::<Vec<_>>();
33+
34+
match found[..] {
35+
[] => {
36+
unknown_labels.push(requested_label);
37+
}
38+
[label] => {
39+
found_labels.push((*label).into());
40+
}
41+
[..] => {
42+
return Err(AmbiguousLabelMatch {
43+
requested_label: requested_label.to_string(),
44+
labels: found.into_iter().map(|l| (*l).into()).collect(),
45+
}
46+
.into());
47+
}
48+
};
49+
}
50+
51+
if !unknown_labels.is_empty() {
52+
return Err(UnknownLabels {
53+
labels: unknown_labels.iter().map(|s| s.to_string()).collect(),
54+
}
55+
.into());
56+
}
57+
58+
Ok(found_labels)
59+
}
60+
61+
#[derive(Debug)]
62+
pub(crate) struct UnknownLabels {
63+
labels: Vec<String>,
64+
}
65+
66+
// NOTE: This is used to post the Github comment; make sure it's valid markdown.
67+
impl fmt::Display for UnknownLabels {
68+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
69+
write!(f, "Unknown labels: {}", &self.labels.join(", "))
70+
}
71+
}
72+
73+
impl std::error::Error for UnknownLabels {}
74+
75+
#[derive(Debug)]
76+
pub(crate) struct AmbiguousLabelMatch {
77+
pub requested_label: String,
78+
pub labels: Vec<String>,
79+
}
80+
81+
// NOTE: This is used to post the Github comment; make sure it's valid markdown.
82+
impl fmt::Display for AmbiguousLabelMatch {
83+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
84+
write!(
85+
f,
86+
"Unsure which label to use for `{}` - could be one of: {}",
87+
self.requested_label,
88+
self.labels.iter().map(|l| format!("`{}`", l)).join(", ")
89+
)
90+
}
91+
}
92+
93+
impl std::error::Error for AmbiguousLabelMatch {}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::*;
98+
99+
#[test]
100+
fn display_unknown_labels_error() {
101+
let x = UnknownLabels {
102+
labels: vec!["A-bootstrap".into(), "xxx".into()],
103+
};
104+
assert_eq!(x.to_string(), "Unknown labels: A-bootstrap, xxx");
105+
}
106+
107+
#[test]
108+
fn display_ambiguous_label_error() {
109+
let x = AmbiguousLabelMatch {
110+
requested_label: "A-bootstrap".into(),
111+
labels: vec!["A-bootstrap".into(), "A-bootstrap-2".into()],
112+
};
113+
assert_eq!(
114+
x.to_string(),
115+
"Unsure which label to use for `A-bootstrap` - could be one of: `A-bootstrap`, `A-bootstrap-2`"
116+
);
117+
}
118+
119+
#[test]
120+
fn normalize_and_match_labels_happy_path() {
121+
let available_labels = vec!["A-bootstrap 😺", "B-foo 👾", "C-bar", "C-bar 😦"];
122+
let requested_labels = vec!["A-bootstrap", "B-foo", "C-bar"];
123+
124+
let result = normalize_and_match_labels(&available_labels, &requested_labels);
125+
126+
assert!(result.is_ok());
127+
let found_labels = result.unwrap();
128+
assert_eq!(found_labels.len(), 3);
129+
assert_eq!(found_labels[0], "A-bootstrap 😺");
130+
assert_eq!(found_labels[1], "B-foo 👾");
131+
assert_eq!(found_labels[2], "C-bar");
132+
}
133+
134+
#[test]
135+
fn normalize_and_match_labels_no_match() {
136+
let available_labels = vec!["A-bootstrap", "B-foo"];
137+
let requested_labels = vec!["A-bootstrap", "C-bar"];
138+
139+
let result = normalize_and_match_labels(&available_labels, &requested_labels);
140+
141+
assert!(result.is_err());
142+
let err = result.unwrap_err();
143+
assert!(err.is::<UnknownLabels>());
144+
let unknown = err.downcast::<UnknownLabels>().unwrap();
145+
assert_eq!(unknown.labels, vec!["C-bar"]);
146+
}
147+
148+
#[test]
149+
fn normalize_and_match_labels_ambiguous_match() {
150+
let available_labels = vec!["A-bootstrap 😺", "A-bootstrap 👾"];
151+
let requested_labels = vec!["A-bootstrap"];
152+
153+
let result = normalize_and_match_labels(&available_labels, &requested_labels);
154+
155+
assert!(result.is_err());
156+
let err = result.unwrap_err();
157+
assert!(err.is::<AmbiguousLabelMatch>());
158+
let ambiguous = err.downcast::<AmbiguousLabelMatch>().unwrap();
159+
assert_eq!(ambiguous.requested_label, "A-bootstrap");
160+
assert_eq!(ambiguous.labels, vec!["A-bootstrap 😺", "A-bootstrap 👾"]);
161+
}
162+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod github;
1818
pub mod handlers;
1919
mod interactions;
2020
pub mod jobs;
21+
pub mod labels;
2122
pub mod notification_listing;
2223
pub mod payload;
2324
mod rfcbot;

0 commit comments

Comments
 (0)