Skip to content

Commit 72dbff7

Browse files
authored
Merge pull request #2128 from kailan/normalize-labels
Normalize labels before addition or removal
2 parents 4c371f1 + 01409ef commit 72dbff7

File tree

5 files changed

+220
-70
lines changed

5 files changed

+220
-70
lines changed

src/github.rs

Lines changed: 54 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use std::{
1616
};
1717
use tracing as log;
1818

19+
pub mod labels;
20+
1921
pub type UserId = u64;
2022
pub type PullRequestNumber = u64;
2123

@@ -565,38 +567,15 @@ impl IssueRepository {
565567
format!("{}/{}", self.organization, self.repository)
566568
}
567569

568-
async fn has_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<bool> {
569-
#[allow(clippy::redundant_pattern_matching)]
570-
let url = format!("{}/labels/{}", self.url(client), label);
571-
match client.send_req(client.get(&url)).await {
572-
Ok(_) => Ok(true),
573-
Err(e) => {
574-
if e.downcast_ref::<reqwest::Error>()
575-
.map_or(false, |e| e.status() == Some(StatusCode::NOT_FOUND))
576-
{
577-
Ok(false)
578-
} else {
579-
Err(e)
580-
}
581-
}
582-
}
583-
}
584-
}
585-
586-
#[derive(Debug)]
587-
pub(crate) struct UnknownLabels {
588-
labels: Vec<String>,
589-
}
590-
591-
// NOTE: This is used to post the Github comment; make sure it's valid markdown.
592-
impl fmt::Display for UnknownLabels {
593-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
594-
write!(f, "Unknown labels: {}", &self.labels.join(", "))
570+
async fn labels(&self, client: &GithubClient) -> anyhow::Result<Vec<Label>> {
571+
let url = format!("{}/labels", self.url(client));
572+
client
573+
.json(client.get(&url))
574+
.await
575+
.context("failed to get labels")
595576
}
596577
}
597578

598-
impl std::error::Error for UnknownLabels {}
599-
600579
impl Issue {
601580
pub fn to_zulip_github_reference(&self) -> ZulipGitHubReference {
602581
ZulipGitHubReference {
@@ -730,8 +709,39 @@ impl Issue {
730709
Ok(())
731710
}
732711

712+
async fn normalize_and_match_labels(
713+
&self,
714+
client: &GithubClient,
715+
requested_labels: &[&str],
716+
) -> anyhow::Result<Vec<String>> {
717+
let available_labels = self
718+
.repository()
719+
.labels(client)
720+
.await
721+
.context("unable to retrieve the repository labels")?;
722+
723+
labels::normalize_and_match_labels(
724+
&available_labels
725+
.iter()
726+
.map(|l| l.name.as_str())
727+
.collect::<Vec<_>>(),
728+
requested_labels,
729+
)
730+
}
731+
733732
pub async fn remove_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<()> {
734733
log::info!("remove_label from {}: {:?}", self.global_id(), label);
734+
735+
let normalized_labels = self.normalize_and_match_labels(client, &[label]).await?;
736+
let label = normalized_labels
737+
.first()
738+
.context("failed to find label on repository")?;
739+
log::info!(
740+
"remove_label from {}: matched label to {:?}",
741+
self.global_id(),
742+
label
743+
);
744+
735745
// DELETE /repos/:owner/:repo/issues/:number/labels/{name}
736746
let url = format!(
737747
"{repo_url}/issues/{number}/labels/{name}",
@@ -767,6 +777,19 @@ impl Issue {
767777
labels: Vec<Label>,
768778
) -> anyhow::Result<()> {
769779
log::info!("add_labels: {} +{:?}", self.global_id(), labels);
780+
781+
let labels = self
782+
.normalize_and_match_labels(
783+
client,
784+
&labels.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
785+
)
786+
.await?;
787+
log::info!(
788+
"add_labels: {} matched requested labels to +{:?}",
789+
self.global_id(),
790+
labels
791+
);
792+
770793
// POST /repos/:owner/:repo/issues/:number/labels
771794
// repo_url = https://api.github.com/repos/Codertocat/Hello-World
772795
let url = format!(
@@ -778,8 +801,7 @@ impl Issue {
778801
// Don't try to add labels already present on this issue.
779802
let labels = labels
780803
.into_iter()
781-
.filter(|l| !self.labels().contains(&l))
782-
.map(|l| l.name)
804+
.filter(|l| !self.labels().iter().any(|existing| existing.name == *l))
783805
.collect::<Vec<_>>();
784806

785807
log::info!("add_labels: {} filtered to {:?}", self.global_id(), labels);
@@ -788,32 +810,13 @@ impl Issue {
788810
return Ok(());
789811
}
790812

791-
let mut unknown_labels = vec![];
792-
let mut known_labels = vec![];
793-
for label in labels {
794-
if !self.repository().has_label(client, &label).await? {
795-
unknown_labels.push(label);
796-
} else {
797-
known_labels.push(label);
798-
}
799-
}
800-
801-
if !unknown_labels.is_empty() {
802-
return Err(UnknownLabels {
803-
labels: unknown_labels,
804-
}
805-
.into());
806-
}
807-
808813
#[derive(serde::Serialize)]
809814
struct LabelsReq {
810815
labels: Vec<String>,
811816
}
812817

813818
client
814-
.send_req(client.post(&url).json(&LabelsReq {
815-
labels: known_labels,
816-
}))
819+
.send_req(client.post(&url).json(&LabelsReq { labels }))
817820
.await
818821
.context("failed to add labels")?;
819822

@@ -3217,16 +3220,3 @@ impl Submodule {
32173220
client.repository(fullname).await
32183221
}
32193222
}
3220-
3221-
#[cfg(test)]
3222-
mod tests {
3223-
use super::*;
3224-
3225-
#[test]
3226-
fn display_labels() {
3227-
let x = UnknownLabels {
3228-
labels: vec!["A-bootstrap".into(), "xxx".into()],
3229-
};
3230-
assert_eq!(x.to_string(), "Unknown labels: A-bootstrap, xxx");
3231-
}
3232-
}

src/github/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/handlers/assign.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
2323
use crate::db::issue_data::IssueData;
2424
use crate::db::review_prefs::{RotationMode, get_review_prefs_batch};
25-
use crate::github::UserId;
25+
use crate::github::{UserId, labels};
2626
use crate::handlers::pr_tracking::ReviewerWorkqueue;
2727
use crate::{
2828
config::AssignConfig,
@@ -563,7 +563,7 @@ pub(super) async fn handle_command(
563563
.add_labels(&ctx.github, vec![github::Label { name: t_label }])
564564
.await
565565
{
566-
if let Some(github::UnknownLabels { .. }) = err.downcast_ref() {
566+
if let Some(labels::UnknownLabels { .. }) = err.downcast_ref() {
567567
log::warn!("Error assigning label: {}", err);
568568
} else {
569569
return Err(err);

src/handlers/autolabel.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::{
22
config::AutolabelConfig,
3-
github::{IssuesAction, IssuesEvent, Label},
3+
github::{IssuesAction, IssuesEvent, Label, labels::UnknownLabels},
44
handlers::Context,
55
};
66
use anyhow::Context as _;
@@ -209,7 +209,6 @@ 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;
213212
if let Some(err @ UnknownLabels { .. }) = e.downcast_ref() {
214213
event
215214
.issue

src/handlers/relabel.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
use crate::team_data::TeamClient;
1212
use crate::{
1313
config::RelabelConfig,
14-
github::UnknownLabels,
15-
github::{self, Event},
14+
github::{self, Event, labels::UnknownLabels},
1615
handlers::Context,
1716
interactions::ErrorComment,
1817
};

0 commit comments

Comments
 (0)