Skip to content

Commit

Permalink
✨ check organization/team before labeling
Browse files Browse the repository at this point in the history
  • Loading branch information
ebullient committed Dec 18, 2024
1 parent 47ab78c commit 2759176
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,8 @@ public int getNumber() {
public boolean isClosed() {
return commonItem == null ? false : commonItem.closedAt != null;
}

public DataActor getAuthor() {
return commonItem == null ? null : commonItem.author;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.commonhaus.automation.github.rules;

import java.util.ArrayList;
import java.util.List;

import org.commonhaus.automation.github.EventQueryContext;
import org.commonhaus.automation.github.context.DataActor;
import org.commonhaus.automation.github.context.EventData;
import org.kohsuke.github.GHOrganization;
import org.kohsuke.github.GHUser;

public class MatchMember {
public final List<String> include = new ArrayList<>();
public final List<String> exclude = new ArrayList<>();

public MatchMember(List<String> groups) {
groups.forEach(x -> {
if (x.startsWith("!")) {
exclude.add(x.substring(1));
} else {
include.add(x);
}
});
}

public boolean matches(EventQueryContext qc) {
EventData eventData = qc.getEventData();
if (eventData == null) {
return false; // unlikely/bad event; fail match
}
DataActor author = eventData.getAuthor();
if (author == null) {
return false; // unlikely/bad event; fail match
}
GHUser user = qc.getUser(author.login); // cached lookup

if (!exclude.isEmpty() && exclude.stream().anyMatch(group -> userIsMember(qc, user, group))) {
return false;
}

return include.isEmpty() || include.stream().anyMatch(group -> userIsMember(qc, user, group));
}

private boolean userIsMember(EventQueryContext qc, GHUser user, String group) {
if (group.contains("/")) {
// Check for team membership
return qc.isTeamMember(user, group);
}
// Check for org membership
GHOrganization org = qc.getOrganization(group);
return org.hasMember(user);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class Rule {
MatchPaths paths;
MatchLabel label;
MatchChangedLabel changedLabel;
MatchMember member;

public List<String> then;

Expand All @@ -45,6 +46,9 @@ public boolean matches(EventQueryContext qc) {
if (matches && changedLabel != null) {
matches = changedLabel.matches(qc);
}
if (matches && member != null) {
matches = member.matches(qc);
}
return matches;
}

Expand Down Expand Up @@ -90,6 +94,11 @@ public Rule deserialize(com.fasterxml.jackson.core.JsonParser p,
rule.changedLabel = new MatchChangedLabel(mapper.convertValue(child, LIST_STRING));
}

child = RuleType.member.getFrom(node);
if (child != null) {
rule.member = new MatchMember(mapper.convertValue(child, LIST_STRING));
}

child = RuleType.paths.getFrom(node);
if (child != null) {
rule.paths = new MatchPaths(mapper.convertValue(child, LIST_STRING));
Expand All @@ -109,6 +118,7 @@ enum RuleType {
category,
label,
label_change,
member,
paths,
then;

Expand Down
3 changes: 0 additions & 3 deletions commonhaus-bot/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ [email protected]

%dev.automation.error-email-address=[email protected]

%dev.quarkus.keycloak.devservices.enabled=false
%test.quarkus.keycloak.devservices.enabled=false

# For use with mailpit
# %dev.quarkus.mailer.host=localhost
# %dev.quarkus.mailer.port=56504
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,142 @@ void discussionCreatedReviewsLabeled() throws Exception {
verifyLabelCache(discussionId, 1, List.of("notice"));
}

@Test
void discussionCreatedOrganizationMember() throws Exception {
// When a discussion is created in announcements
// - labels are fetched (label rule): no notice label
// - organization membership is checked: pass
// - the notice label is added

// from src/test/resources/github/eventDiscussionCreatedAnnouncements.json
String discussionId = "D_kwDOLDuJqs4AXaZM";

setLabels(repositoryId, notice);
setLabels(discussionId, bug);

Response modifiedLabel = mockResponse(Path.of("src/test/resources/github/mutableAddLabelsToLabelable.json"));

given()
.github(mocks -> {
mocks.configFile(RepositoryConfigFile.NAME).fromClasspath("/cf-notice-label-organization.yml");
setupMockTeam(mocks);
setupAuthor(mocks, true, false);

when(mocks.installationGraphQLClient(installationId)
.executeSync(contains("addLabelsToLabelable("), anyMap()))
.thenReturn(modifiedLabel);
})
.when().payloadFromClasspath("/github/eventDiscussionCreatedAnnouncements.json")
.event(GHEvent.DISCUSSION)
.then().github(mocks -> {
verifyOrganizationMember(mocks);

verify(mocks.installationGraphQLClient(installationId), timeout(500))
.executeSync(contains("addLabelsToLabelable("), anyMap());

verifyNoMoreInteractions(mocks.installationGraphQLClient(installationId));
verifyNoMoreInteractions(mocks.ghObjects());
});
verifyLabelCache(discussionId, 1, List.of("notice"));
}

@Test
void discussionCreatedNotOrganizationMember() throws Exception {
// When a discussion is created in announcements
// - labels are fetched (label rule): no notice label
// - organization membership is checked: fail
// - the notice label is not added

// from src/test/resources/github/eventDiscussionCreatedAnnouncements.json
String discussionId = "D_kwDOLDuJqs4AXaZM";

setLabels(repositoryId, notice);
setLabels(discussionId, bug);

given()
.github(mocks -> {
mocks.configFile(RepositoryConfigFile.NAME).fromClasspath("/cf-notice-label-organization.yml");
setupMockTeam(mocks);
setupAuthor(mocks, false, false);
})
.when().payloadFromClasspath("/github/eventDiscussionCreatedAnnouncements.json")
.event(GHEvent.DISCUSSION)
.then().github(mocks -> {
verifyOrganizationMember(mocks);

verifyNoMoreInteractions(mocks.installationGraphQLClient(installationId));
verifyNoMoreInteractions(mocks.ghObjects());
});

verifyLabelCache(discussionId, 1, List.of("bug"));
}

@Test
void discussionCreatedTeamMember() throws Exception {
// When a discussion is created in announcements
// - labels are fetched (label rule): no notice label
// - team membership is checked: pass
// - the notice label is added

// from src/test/resources/github/eventDiscussionCreatedAnnouncements.json
String discussionId = "D_kwDOLDuJqs4AXaZM";

setLabels(repositoryId, notice);
setLabels(discussionId, bug);

Response modifiedLabel = mockResponse(Path.of("src/test/resources/github/mutableAddLabelsToLabelable.json"));

given()
.github(mocks -> {
mocks.configFile(RepositoryConfigFile.NAME).fromClasspath("/cf-notice-label-team.yml");
setupMockTeam(mocks);
setupAuthor(mocks, false, true);

when(mocks.installationGraphQLClient(installationId)
.executeSync(contains("addLabelsToLabelable("), anyMap()))
.thenReturn(modifiedLabel);
})
.when().payloadFromClasspath("/github/eventDiscussionCreatedAnnouncements.json")
.event(GHEvent.DISCUSSION)
.then().github(mocks -> {
verify(mocks.installationGraphQLClient(installationId), timeout(500))
.executeSync(contains("addLabelsToLabelable("), anyMap());

verifyNoMoreInteractions(mocks.installationGraphQLClient(installationId));
verifyNoMoreInteractions(mocks.ghObjects());
});

verifyLabelCache(discussionId, 1, List.of("notice"));
}

@Test
void discussionCreatedNotTeamMember() throws Exception {
// When a discussion is created in announcements
// - labels are fetched (label rule): no notice label
// - team membership is checked: fail
// - the notice label is not added

// from src/test/resources/github/eventDiscussionCreatedAnnouncements.json
String discussionId = "D_kwDOLDuJqs4AXaZM";

setLabels(repositoryId, notice);
setLabels(discussionId, bug);

given()
.github(mocks -> {
mocks.configFile(RepositoryConfigFile.NAME).fromClasspath("/cf-notice-label-team.yml");
setupMockTeam(mocks);
setupAuthor(mocks, false, false);
})
.when().payloadFromClasspath("/github/eventDiscussionCreatedAnnouncements.json")
.event(GHEvent.DISCUSSION)
.then().github(mocks -> {
verifyNoMoreInteractions(mocks.installationGraphQLClient(installationId));
verifyNoMoreInteractions(mocks.ghObjects());
});
verifyLabelCache(discussionId, 1, List.of("bug"));
}

@Test
void discussionCategoryChangedLabeled() throws Exception {
// When a discussion is created and it already has the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;
Expand Down Expand Up @@ -31,6 +32,7 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.kohsuke.github.GHContent;
import org.kohsuke.github.GHOrganization;
import org.kohsuke.github.GHPullRequestFileDetail;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GHUser;
Expand All @@ -40,6 +42,7 @@
import org.mockito.Mockito;

import io.quarkiverse.githubapp.testing.dsl.GitHubMockSetupContext;
import io.quarkiverse.githubapp.testing.dsl.GitHubMockVerificationContext;
import io.smallrye.graphql.client.GraphQLError;
import io.smallrye.graphql.client.Response;

Expand Down Expand Up @@ -89,22 +92,26 @@ public void setLogin(String login) {
}

public void setupMockTeam(GitHubMockSetupContext mocks, GHUser... users) throws Exception {
mockGHUser("user4");
mockGHUser(mocks, "user4");

if (users.length == 0) {
GHUser user1 = mockGHUser("user1");
GHUser user2 = mockGHUser("user2");
GHUser user3 = mockGHUser("user3");
GHUser user1 = mockGHUser(mocks, "user1");
GHUser user2 = mockGHUser(mocks, "user2");
GHUser user3 = mockGHUser(mocks, "user3");
BaseQueryCache.TEAM_MEMBERS.put("commonhaus/test-quorum-default", Set.of(user1, user2, user3));
} else {
BaseQueryCache.TEAM_MEMBERS.put("commonhaus/test-quorum-default", Set.of(users));
}

GHUser second = mockGHUser("second");
GHUser second = mockGHUser(mocks, "second");
BaseQueryCache.TEAM_MEMBERS.put("commonhaus/test-quorum-seconds", Set.of(second));

GitHub gh = mocks.installationClient(installationId);

GHOrganization org = mocks.ghObject(GHOrganization.class, organizationId);
when(org.getLogin()).thenReturn("commonhaus");
when(gh.getOrganization("commonhaus")).thenReturn(org);

GHContent content = mock(GHContent.class);
when(content.read()).thenReturn(Files.newInputStream(Path.of("src/test/resources/CONTACTS.yaml")));

Expand All @@ -116,6 +123,24 @@ public void setupMockTeam(String teamName, Set<GHUser> users) {
BaseQueryCache.TEAM_MEMBERS.put(teamName, users);
}

public void setupAuthor(GitHubMockSetupContext mocks, boolean inOrg, boolean inTeam) throws Exception {
GHUser user = mockGHUser(mocks, "ebullient");
GitHub gh = mocks.installationClient(installationId);
GHOrganization org = gh.getOrganization("commonhaus");
when(org.hasMember(user)).thenReturn(inOrg);
if (inTeam) {
BaseQueryCache.TEAM_MEMBERS.put("commonhaus/test-quorum-default", Set.of(user));
}
}

public void verifyOrganizationMember(GitHubMockVerificationContext mocks) throws Exception {
GitHub gh = mocks.installationClient(installationId);
GHOrganization org = gh.getOrganization("commonhaus");
GHUser ebullient = gh.getUser("ebullient");

verify(org).hasMember(ebullient);
}

public void setLabels(String id, DataLabel... labels) {
BaseQueryCache.LABELS.computeIfAbsent(id, (k) -> new HashSet<>()).addAll(List.of(labels));
}
Expand Down Expand Up @@ -212,7 +237,7 @@ public String stringify(Collection<DataLabel> labels) {
return labels.stream().map(label -> label.name).collect(Collectors.joining(", "));
}

public static GHUser mockGHUser(String login) {
public static GHUser mockGHUser(GitHubMockSetupContext mocks, String login) throws IOException {
final URL url = mock(URL.class);
lenient().when(url.toString()).thenReturn("");
GHUser mock = mock(GHUser.class);
Expand All @@ -222,6 +247,11 @@ public static GHUser mockGHUser(String login) {
lenient().when(mock.getHtmlUrl()).thenReturn(url);
lenient().when(mock.getUrl()).thenReturn(url);
lenient().when(mock.getAvatarUrl()).thenReturn("");

if (mocks != null) {
GitHub gh = mocks.installationClient(installationId);
lenient().when(gh.getUser(login)).thenReturn(mock);
}
return mock;
}

Expand Down
Loading

0 comments on commit 2759176

Please sign in to comment.