Skip to content

Commit 1ea060e

Browse files
authored
Merge pull request #614 from gsmet/warn-if-similar-handle
Post a comment with a warning when a similar handle is used
2 parents cf29c33 + 2e9a863 commit 1ea060e

11 files changed

Lines changed: 1411 additions & 1 deletion
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package io.quarkus.bot;
2+
3+
import static io.quarkus.bot.util.Strings.SIMILAR_GITHUB_HANDLE_COMMENT_MARKER;
4+
5+
import java.io.IOException;
6+
import java.util.Optional;
7+
import java.util.Set;
8+
9+
import jakarta.inject.Inject;
10+
11+
import org.jboss.logging.Logger;
12+
import org.kohsuke.github.GHEventPayload;
13+
import org.kohsuke.github.GHPullRequest;
14+
15+
import io.quarkiverse.githubapp.ConfigFile;
16+
import io.quarkiverse.githubapp.event.PullRequest;
17+
import io.quarkus.bot.config.Feature;
18+
import io.quarkus.bot.config.QuarkusGitHubBotConfig;
19+
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile;
20+
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.SimilarGitHubHandleCheck;
21+
import io.quarkus.bot.service.TeamMemberService;
22+
import io.quarkus.bot.util.GitHubHandleSimilarity;
23+
import io.quarkus.bot.util.Strings;
24+
25+
class CheckPullRequestSimilarGitHubHandle {
26+
27+
private static final Logger LOG = Logger.getLogger(CheckPullRequestSimilarGitHubHandle.class);
28+
29+
private static final String WARNING_COMMENT = """
30+
> [!WARNING]
31+
> We noticed that your GitHub handle is very similar to that of a team member.
32+
>
33+
> To avoid any confusion, we wanted to flag this so that reviewers can double-check.
34+
> No action is needed on your part. Thank you for your understanding!""";
35+
36+
@Inject
37+
QuarkusGitHubBotConfig quarkusBotConfig;
38+
39+
@Inject
40+
TeamMemberService teamMemberService;
41+
42+
void checkPullRequestSimilarGitHubHandle(
43+
@PullRequest.Opened GHEventPayload.PullRequest pullRequestPayload,
44+
@ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException {
45+
if (!Feature.CHECK_SIMILAR_GITHUB_HANDLES.isEnabled(quarkusBotConfigFile)) {
46+
return;
47+
}
48+
49+
SimilarGitHubHandleCheck config = quarkusBotConfigFile.similarGitHubHandleCheck;
50+
if (config.org == null || config.teams.isEmpty()) {
51+
return;
52+
}
53+
54+
GHPullRequest pullRequest = pullRequestPayload.getPullRequest();
55+
String login = pullRequest.getUser().getLogin();
56+
57+
Set<String> teamMembers = teamMemberService.getTeamMembers(
58+
pullRequest.getRepository().getRoot(),
59+
config.org,
60+
config.teams);
61+
62+
if (teamMembers.isEmpty()) {
63+
return;
64+
}
65+
66+
Optional<String> similarMember = GitHubHandleSimilarity.findSimilarTeamMember(login, teamMembers);
67+
if (similarMember.isEmpty()) {
68+
return;
69+
}
70+
71+
LOG.info("Pull Request #" + pullRequest.getNumber()
72+
+ " - GitHub handle " + login + " is similar to a team member GitHub handle");
73+
74+
String comment = Strings.commentByBot(WARNING_COMMENT + "\n\n/cc @" + similarMember.get())
75+
+ SIMILAR_GITHUB_HANDLE_COMMENT_MARKER;
76+
77+
if (!quarkusBotConfig.isDryRun()) {
78+
pullRequest.comment(comment);
79+
} else {
80+
LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add comment: " + comment);
81+
}
82+
}
83+
}

src/main/java/io/quarkus/bot/config/Feature.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public enum Feature {
1515
TRIAGE_ISSUES_AND_PULL_REQUESTS,
1616
TRIAGE_DISCUSSIONS,
1717
PUSH_TO_PROJECTS,
18-
APPROVE_WORKFLOWS;
18+
APPROVE_WORKFLOWS,
19+
CHECK_SIMILAR_GITHUB_HANDLES;
1920

2021
public boolean isEnabled(QuarkusGitHubBotConfigFile quarkusBotConfigFile) {
2122
if (quarkusBotConfigFile == null) {

src/main/java/io/quarkus/bot/config/QuarkusGitHubBotConfigFile.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public class QuarkusGitHubBotConfigFile {
2929

3030
public Develocity develocity = new Develocity();
3131

32+
public SimilarGitHubHandleCheck similarGitHubHandleCheck = new SimilarGitHubHandleCheck();
33+
3234
public static class TriageConfig {
3335

3436
public List<TriageRule> rules = new ArrayList<>();
@@ -166,6 +168,14 @@ public static class GuardedBranch {
166168
public Set<String> notify = new TreeSet<>();
167169
}
168170

171+
public static class SimilarGitHubHandleCheck {
172+
173+
public String org;
174+
175+
@JsonDeserialize(as = TreeSet.class)
176+
public Set<String> teams = new TreeSet<>();
177+
}
178+
169179
boolean isFeatureEnabled(Feature feature) {
170180
return features.contains(Feature.ALL) || features.contains(feature);
171181
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.quarkus.bot.service;
2+
3+
import java.io.IOException;
4+
import java.util.Locale;
5+
import java.util.Set;
6+
import java.util.TreeSet;
7+
8+
import jakarta.inject.Singleton;
9+
10+
import org.jboss.logging.Logger;
11+
import org.kohsuke.github.GHOrganization;
12+
import org.kohsuke.github.GHTeam;
13+
import org.kohsuke.github.GHUser;
14+
import org.kohsuke.github.GitHub;
15+
16+
import io.quarkus.cache.CacheKey;
17+
import io.quarkus.cache.CacheResult;
18+
19+
@Singleton
20+
public class TeamMemberService {
21+
22+
private static final Logger LOG = Logger.getLogger(TeamMemberService.class);
23+
24+
@CacheResult(cacheName = "team-members-cache")
25+
public Set<String> getTeamMembers(GitHub gitHub, @CacheKey String org, Set<String> teamSlugs) {
26+
Set<String> members = new TreeSet<>();
27+
28+
try {
29+
GHOrganization ghOrg = gitHub.getOrganization(org);
30+
31+
for (String teamSlug : teamSlugs) {
32+
try {
33+
GHTeam team = ghOrg.getTeamBySlug(teamSlug);
34+
if (team == null) {
35+
LOG.warn("Team " + teamSlug + " not found in organization " + org);
36+
continue;
37+
}
38+
for (GHUser member : team.getMembers()) {
39+
members.add(member.getLogin().toLowerCase(Locale.ROOT));
40+
}
41+
} catch (IOException e) {
42+
LOG.error("Error fetching members for team " + teamSlug + " in organization " + org, e);
43+
}
44+
}
45+
} catch (IOException e) {
46+
LOG.error("Error fetching organization " + org, e);
47+
}
48+
49+
return members;
50+
}
51+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.quarkus.bot.util;
2+
3+
import java.util.Locale;
4+
import java.util.Optional;
5+
import java.util.Set;
6+
7+
public final class GitHubHandleSimilarity {
8+
9+
private GitHubHandleSimilarity() {
10+
}
11+
12+
/**
13+
* Finds a team member whose handle is suspiciously similar to the given handle.
14+
* Returns empty if the handle is an exact match (the person IS the team member)
15+
* or if no team member is close enough.
16+
* <p>
17+
* Team members are expected to be already lowercased.
18+
*/
19+
public static Optional<String> findSimilarTeamMember(String handle, Set<String> teamMembers) {
20+
String lowerHandle = handle.toLowerCase(Locale.ROOT);
21+
22+
for (String member : teamMembers) {
23+
if (lowerHandle.equals(member)) {
24+
return Optional.empty();
25+
}
26+
27+
int threshold = member.length() < 6 ? 1 : 2;
28+
int distance = damerauLevenshteinDistance(lowerHandle, member, threshold);
29+
30+
if (distance <= threshold) {
31+
return Optional.of(member);
32+
}
33+
}
34+
35+
return Optional.empty();
36+
}
37+
38+
/**
39+
* Computes the Damerau-Levenshtein distance between two strings,
40+
* with an early exit if the distance exceeds the given threshold.
41+
* Supports insertions, deletions, substitutions, and adjacent transpositions.
42+
*/
43+
static int damerauLevenshteinDistance(String source, String target, int threshold) {
44+
int sourceLength = source.length();
45+
int targetLength = target.length();
46+
47+
if (Math.abs(sourceLength - targetLength) > threshold) {
48+
return threshold + 1;
49+
}
50+
51+
int[][] distance = new int[sourceLength + 1][targetLength + 1];
52+
53+
for (int i = 0; i <= sourceLength; i++) {
54+
distance[i][0] = i;
55+
}
56+
for (int j = 0; j <= targetLength; j++) {
57+
distance[0][j] = j;
58+
}
59+
60+
for (int i = 1; i <= sourceLength; i++) {
61+
for (int j = 1; j <= targetLength; j++) {
62+
int cost = source.charAt(i - 1) == target.charAt(j - 1) ? 0 : 1;
63+
64+
distance[i][j] = Math.min(
65+
Math.min(
66+
distance[i - 1][j] + 1,
67+
distance[i][j - 1] + 1),
68+
distance[i - 1][j - 1] + cost);
69+
70+
if (i > 1 && j > 1
71+
&& source.charAt(i - 1) == target.charAt(j - 2)
72+
&& source.charAt(i - 2) == target.charAt(j - 1)) {
73+
distance[i][j] = Math.min(distance[i][j], distance[i - 2][j - 2] + cost);
74+
}
75+
}
76+
}
77+
78+
return distance[sourceLength][targetLength];
79+
}
80+
}

src/main/java/io/quarkus/bot/util/Strings.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
public class Strings {
44
public static final String EDITORIAL_RULES_COMMENT_MARKER = "\n<!-- Quarkus-Bot-Editor-Rules -->";
5+
public static final String SIMILAR_GITHUB_HANDLE_COMMENT_MARKER = "\n<!-- Quarkus-Bot-Similar-GitHub-Handle -->";
56

67
public static boolean isNotBlank(String string) {
78
return string != null && !string.trim().isEmpty();

src/main/resources/application.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ quarkus.cache.caffeine."PushToProject.getStatusFieldValue".expire-after-write=2H
1414

1515
quarkus.cache.caffeine."contributor-cache".expire-after-write=P2D
1616
quarkus.cache.caffeine."stats-cache".expire-after-write=P2D
17+
quarkus.cache.caffeine."team-members-cache".expire-after-write=P1D
1718

1819
quarkus.openshift.labels."app"=quarkus-bot
1920
quarkus.openshift.annotations."kubernetes.io/tls-acme"=true
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package io.quarkus.bot.it;
2+
3+
import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given;
4+
import static io.quarkus.bot.it.MockHelper.mockUser;
5+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.verify;
7+
import static org.mockito.Mockito.verifyNoMoreInteractions;
8+
import static org.mockito.Mockito.when;
9+
10+
import java.util.Set;
11+
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.extension.ExtendWith;
14+
import org.kohsuke.github.GHEvent;
15+
import org.kohsuke.github.GHOrganization;
16+
import org.kohsuke.github.GHTeam;
17+
import org.kohsuke.github.GHUser;
18+
import org.mockito.junit.jupiter.MockitoExtension;
19+
20+
import io.quarkiverse.githubapp.testing.GitHubAppTest;
21+
import io.quarkiverse.githubapp.testing.dsl.GitHubMockSetupContext;
22+
import io.quarkus.cache.CacheInvalidateAll;
23+
import io.quarkus.test.junit.QuarkusTest;
24+
25+
@QuarkusTest
26+
@GitHubAppTest
27+
@ExtendWith(MockitoExtension.class)
28+
public class CheckPullRequestSimilarGitHubHandleTest {
29+
30+
private static final String CONFIG = """
31+
features: [ CHECK_SIMILAR_GITHUB_HANDLES ]
32+
similarGitHubHandleCheck:
33+
org: quarkusio
34+
teams: [quarkus-admin, quarkus-push]
35+
""";
36+
37+
private static final String EXPECTED_COMMENT = """
38+
> [!WARNING]
39+
> We noticed that your GitHub handle is very similar to that of a team member.
40+
>
41+
> To avoid any confusion, we wanted to flag this so that reviewers can double-check.
42+
> No action is needed on your part. Thank you for your understanding!
43+
44+
/cc @gsmet
45+
46+
<sub>This message is automatically generated by a bot.</sub>
47+
<!-- Quarkus-Bot-Similar-GitHub-Handle -->""";
48+
49+
@CacheInvalidateAll(cacheName = "team-members-cache")
50+
void setupTeamMocks(GitHubMockSetupContext mocks) throws Exception {
51+
GHOrganization org = mock(GHOrganization.class);
52+
when(mocks.installationClient(13173124L).getOrganization("quarkusio")).thenReturn(org);
53+
54+
GHTeam adminTeam = mock(GHTeam.class);
55+
GHTeam pushTeam = mock(GHTeam.class);
56+
when(org.getTeamBySlug("quarkus-admin")).thenReturn(adminTeam);
57+
when(org.getTeamBySlug("quarkus-push")).thenReturn(pushTeam);
58+
59+
GHUser gsmet = mockUser("gsmet");
60+
GHUser maxandersen = mockUser("maxandersen");
61+
when(adminTeam.getMembers()).thenReturn(Set.of(gsmet, maxandersen));
62+
when(pushTeam.getMembers()).thenReturn(Set.of(gsmet));
63+
}
64+
65+
@Test
66+
void similarHandleShouldPostComment() throws Exception {
67+
given().github(mocks -> {
68+
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
69+
setupTeamMocks(mocks);
70+
})
71+
.when().payloadFromClasspath("/pullrequest-opened-similar-github-handle.json")
72+
.event(GHEvent.PULL_REQUEST)
73+
.then().github(mocks -> {
74+
verify(mocks.pullRequest(527350930)).comment(EXPECTED_COMMENT);
75+
});
76+
}
77+
78+
@Test
79+
void exactTeamMemberHandleShouldNotPostComment() throws Exception {
80+
given().github(mocks -> {
81+
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
82+
setupTeamMocks(mocks);
83+
})
84+
.when().payloadFromClasspath("/pullrequest-opened-team-member.json")
85+
.event(GHEvent.PULL_REQUEST)
86+
.then().github(mocks -> {
87+
verifyNoMoreInteractions(mocks.ghObjects());
88+
});
89+
}
90+
91+
@Test
92+
void unrelatedHandleShouldNotPostComment() throws Exception {
93+
given().github(mocks -> {
94+
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
95+
setupTeamMocks(mocks);
96+
})
97+
// yrodiere is unrelated to any team member
98+
.when().payloadFromClasspath("/pullrequest-opened-title-ends-with-dot.json")
99+
.event(GHEvent.PULL_REQUEST)
100+
.then().github(mocks -> {
101+
verifyNoMoreInteractions(mocks.ghObjects());
102+
});
103+
}
104+
105+
@Test
106+
void featureDisabledShouldNotPostComment() throws Exception {
107+
given().github(mocks -> {
108+
mocks.configFile("quarkus-github-bot.yml")
109+
.fromString("features: [ CHECK_EDITORIAL_RULES ]\n");
110+
})
111+
.when().payloadFromClasspath("/pullrequest-opened-similar-github-handle.json")
112+
.event(GHEvent.PULL_REQUEST)
113+
.then().github(mocks -> {
114+
verifyNoMoreInteractions(mocks.ghObjects());
115+
});
116+
}
117+
}

0 commit comments

Comments
 (0)