Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package io.quarkus.bot;

import static io.quarkus.bot.util.Strings.SIMILAR_GITHUB_HANDLE_COMMENT_MARKER;

import java.io.IOException;
import java.util.Optional;
import java.util.Set;

import jakarta.inject.Inject;

import org.jboss.logging.Logger;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHPullRequest;

import io.quarkiverse.githubapp.ConfigFile;
import io.quarkiverse.githubapp.event.PullRequest;
import io.quarkus.bot.config.Feature;
import io.quarkus.bot.config.QuarkusGitHubBotConfig;
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile;
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.SimilarGitHubHandleCheck;
import io.quarkus.bot.service.TeamMemberService;
import io.quarkus.bot.util.GitHubHandleSimilarity;
import io.quarkus.bot.util.Strings;

class CheckPullRequestSimilarGitHubHandle {

private static final Logger LOG = Logger.getLogger(CheckPullRequestSimilarGitHubHandle.class);

private static final String WARNING_COMMENT = """
> [!WARNING]
> We noticed that your GitHub handle is very similar to that of a team member.
>
> To avoid any confusion, we wanted to flag this so that reviewers can double-check.
> No action is needed on your part. Thank you for your understanding!""";

@Inject
QuarkusGitHubBotConfig quarkusBotConfig;

@Inject
TeamMemberService teamMemberService;

void checkPullRequestSimilarGitHubHandle(
@PullRequest.Opened GHEventPayload.PullRequest pullRequestPayload,
@ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException {
if (!Feature.CHECK_SIMILAR_GITHUB_HANDLES.isEnabled(quarkusBotConfigFile)) {
return;
}

SimilarGitHubHandleCheck config = quarkusBotConfigFile.similarGitHubHandleCheck;
if (config.org == null || config.teams.isEmpty()) {
return;
}

GHPullRequest pullRequest = pullRequestPayload.getPullRequest();
String login = pullRequest.getUser().getLogin();

Set<String> teamMembers = teamMemberService.getTeamMembers(
pullRequest.getRepository().getRoot(),
config.org,
config.teams);

if (teamMembers.isEmpty()) {
return;
}

Optional<String> similarMember = GitHubHandleSimilarity.findSimilarTeamMember(login, teamMembers);
if (similarMember.isEmpty()) {
return;
}

LOG.info("Pull Request #" + pullRequest.getNumber()
+ " - GitHub handle " + login + " is similar to a team member GitHub handle");

String comment = Strings.commentByBot(WARNING_COMMENT + "\n\n/cc @" + similarMember.get())
+ SIMILAR_GITHUB_HANDLE_COMMENT_MARKER;

if (!quarkusBotConfig.isDryRun()) {
pullRequest.comment(comment);
} else {
LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add comment: " + comment);
}
}
}
3 changes: 2 additions & 1 deletion src/main/java/io/quarkus/bot/config/Feature.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public enum Feature {
TRIAGE_ISSUES_AND_PULL_REQUESTS,
TRIAGE_DISCUSSIONS,
PUSH_TO_PROJECTS,
APPROVE_WORKFLOWS;
APPROVE_WORKFLOWS,
CHECK_SIMILAR_GITHUB_HANDLES;

public boolean isEnabled(QuarkusGitHubBotConfigFile quarkusBotConfigFile) {
if (quarkusBotConfigFile == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public class QuarkusGitHubBotConfigFile {

public Develocity develocity = new Develocity();

public SimilarGitHubHandleCheck similarGitHubHandleCheck = new SimilarGitHubHandleCheck();

public static class TriageConfig {

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

public static class SimilarGitHubHandleCheck {

public String org;

@JsonDeserialize(as = TreeSet.class)
public Set<String> teams = new TreeSet<>();
}

boolean isFeatureEnabled(Feature feature) {
return features.contains(Feature.ALL) || features.contains(feature);
}
Expand Down
51 changes: 51 additions & 0 deletions src/main/java/io/quarkus/bot/service/TeamMemberService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.quarkus.bot.service;

import java.io.IOException;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;

import jakarta.inject.Singleton;

import org.jboss.logging.Logger;
import org.kohsuke.github.GHOrganization;
import org.kohsuke.github.GHTeam;
import org.kohsuke.github.GHUser;
import org.kohsuke.github.GitHub;

import io.quarkus.cache.CacheKey;
import io.quarkus.cache.CacheResult;

@Singleton
public class TeamMemberService {

private static final Logger LOG = Logger.getLogger(TeamMemberService.class);

@CacheResult(cacheName = "team-members-cache")
public Set<String> getTeamMembers(GitHub gitHub, @CacheKey String org, Set<String> teamSlugs) {
Set<String> members = new TreeSet<>();

try {
GHOrganization ghOrg = gitHub.getOrganization(org);

for (String teamSlug : teamSlugs) {
try {
GHTeam team = ghOrg.getTeamBySlug(teamSlug);
if (team == null) {
LOG.warn("Team " + teamSlug + " not found in organization " + org);
continue;
}
for (GHUser member : team.getMembers()) {
members.add(member.getLogin().toLowerCase(Locale.ROOT));
}
} catch (IOException e) {
LOG.error("Error fetching members for team " + teamSlug + " in organization " + org, e);
}
}
} catch (IOException e) {
LOG.error("Error fetching organization " + org, e);
}

return members;
}
}
80 changes: 80 additions & 0 deletions src/main/java/io/quarkus/bot/util/GitHubHandleSimilarity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package io.quarkus.bot.util;

import java.util.Locale;
import java.util.Optional;
import java.util.Set;

public final class GitHubHandleSimilarity {

private GitHubHandleSimilarity() {
}

/**
* Finds a team member whose handle is suspiciously similar to the given handle.
* Returns empty if the handle is an exact match (the person IS the team member)
* or if no team member is close enough.
* <p>
* Team members are expected to be already lowercased.
*/
public static Optional<String> findSimilarTeamMember(String handle, Set<String> teamMembers) {
String lowerHandle = handle.toLowerCase(Locale.ROOT);

for (String member : teamMembers) {
if (lowerHandle.equals(member)) {
return Optional.empty();
}

int threshold = member.length() < 6 ? 1 : 2;
int distance = damerauLevenshteinDistance(lowerHandle, member, threshold);

if (distance <= threshold) {
return Optional.of(member);
}
}

return Optional.empty();
}

/**
* Computes the Damerau-Levenshtein distance between two strings,
* with an early exit if the distance exceeds the given threshold.
* Supports insertions, deletions, substitutions, and adjacent transpositions.
*/
static int damerauLevenshteinDistance(String source, String target, int threshold) {
int sourceLength = source.length();
int targetLength = target.length();

if (Math.abs(sourceLength - targetLength) > threshold) {
return threshold + 1;
}

int[][] distance = new int[sourceLength + 1][targetLength + 1];

for (int i = 0; i <= sourceLength; i++) {
distance[i][0] = i;
}
for (int j = 0; j <= targetLength; j++) {
distance[0][j] = j;
}

for (int i = 1; i <= sourceLength; i++) {
for (int j = 1; j <= targetLength; j++) {
int cost = source.charAt(i - 1) == target.charAt(j - 1) ? 0 : 1;

distance[i][j] = Math.min(
Math.min(
distance[i - 1][j] + 1,
distance[i][j - 1] + 1),
distance[i - 1][j - 1] + cost);

if (i > 1 && j > 1
&& source.charAt(i - 1) == target.charAt(j - 2)
&& source.charAt(i - 2) == target.charAt(j - 1)) {
distance[i][j] = Math.min(distance[i][j], distance[i - 2][j - 2] + cost);
}
}
}

return distance[sourceLength][targetLength];
}
}
1 change: 1 addition & 0 deletions src/main/java/io/quarkus/bot/util/Strings.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

public static boolean isNotBlank(String string) {
return string != null && !string.trim().isEmpty();
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ quarkus.cache.caffeine."PushToProject.getStatusFieldValue".expire-after-write=2H

quarkus.cache.caffeine."contributor-cache".expire-after-write=P2D
quarkus.cache.caffeine."stats-cache".expire-after-write=P2D
quarkus.cache.caffeine."team-members-cache".expire-after-write=P1D

quarkus.openshift.labels."app"=quarkus-bot
quarkus.openshift.annotations."kubernetes.io/tls-acme"=true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package io.quarkus.bot.it;

import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given;
import static io.quarkus.bot.it.MockHelper.mockUser;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

import java.util.Set;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.kohsuke.github.GHEvent;
import org.kohsuke.github.GHOrganization;
import org.kohsuke.github.GHTeam;
import org.kohsuke.github.GHUser;
import org.mockito.junit.jupiter.MockitoExtension;

import io.quarkiverse.githubapp.testing.GitHubAppTest;
import io.quarkiverse.githubapp.testing.dsl.GitHubMockSetupContext;
import io.quarkus.cache.CacheInvalidateAll;
import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
@GitHubAppTest
@ExtendWith(MockitoExtension.class)
public class CheckPullRequestSimilarGitHubHandleTest {

private static final String CONFIG = """
features: [ CHECK_SIMILAR_GITHUB_HANDLES ]
similarGitHubHandleCheck:
org: quarkusio
teams: [quarkus-admin, quarkus-push]
""";

private static final String EXPECTED_COMMENT = """
> [!WARNING]
> We noticed that your GitHub handle is very similar to that of a team member.
>
> To avoid any confusion, we wanted to flag this so that reviewers can double-check.
> No action is needed on your part. Thank you for your understanding!

/cc @gsmet

<sub>This message is automatically generated by a bot.</sub>
<!-- Quarkus-Bot-Similar-GitHub-Handle -->""";

@CacheInvalidateAll(cacheName = "team-members-cache")
void setupTeamMocks(GitHubMockSetupContext mocks) throws Exception {
GHOrganization org = mock(GHOrganization.class);
when(mocks.installationClient(13173124L).getOrganization("quarkusio")).thenReturn(org);

GHTeam adminTeam = mock(GHTeam.class);
GHTeam pushTeam = mock(GHTeam.class);
when(org.getTeamBySlug("quarkus-admin")).thenReturn(adminTeam);
when(org.getTeamBySlug("quarkus-push")).thenReturn(pushTeam);

GHUser gsmet = mockUser("gsmet");
GHUser maxandersen = mockUser("maxandersen");
when(adminTeam.getMembers()).thenReturn(Set.of(gsmet, maxandersen));
when(pushTeam.getMembers()).thenReturn(Set.of(gsmet));
}

@Test
void similarHandleShouldPostComment() throws Exception {
given().github(mocks -> {
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
setupTeamMocks(mocks);
})
.when().payloadFromClasspath("/pullrequest-opened-similar-github-handle.json")
.event(GHEvent.PULL_REQUEST)
.then().github(mocks -> {
verify(mocks.pullRequest(527350930)).comment(EXPECTED_COMMENT);
});
}

@Test
void exactTeamMemberHandleShouldNotPostComment() throws Exception {
given().github(mocks -> {
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
setupTeamMocks(mocks);
})
.when().payloadFromClasspath("/pullrequest-opened-team-member.json")
.event(GHEvent.PULL_REQUEST)
.then().github(mocks -> {
verifyNoMoreInteractions(mocks.ghObjects());
});
}

@Test
void unrelatedHandleShouldNotPostComment() throws Exception {
given().github(mocks -> {
mocks.configFile("quarkus-github-bot.yml").fromString(CONFIG);
setupTeamMocks(mocks);
})
// yrodiere is unrelated to any team member
.when().payloadFromClasspath("/pullrequest-opened-title-ends-with-dot.json")
.event(GHEvent.PULL_REQUEST)
.then().github(mocks -> {
verifyNoMoreInteractions(mocks.ghObjects());
});
}

@Test
void featureDisabledShouldNotPostComment() throws Exception {
given().github(mocks -> {
mocks.configFile("quarkus-github-bot.yml")
.fromString("features: [ CHECK_EDITORIAL_RULES ]\n");
})
.when().payloadFromClasspath("/pullrequest-opened-similar-github-handle.json")
.event(GHEvent.PULL_REQUEST)
.then().github(mocks -> {
verifyNoMoreInteractions(mocks.ghObjects());
});
}
}
Loading
Loading