Skip to content
Closed
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,41 @@
package org.keycloak.gh.bot.security.email;

import jakarta.inject.Singleton;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

/** Loads and serves auto-reply message templates for email responses. */
@Singleton
public class AutoReplyMessages {

private static final String RESOURCE = "auto-reply-messages.properties";

private final Properties properties;

public AutoReplyMessages() {
this.properties = loadProperties();
}

public String getMessage(AutoReplyType type) {
String value = properties.getProperty(type.name());
if (value == null) {
throw new IllegalArgumentException("No auto-reply template for " + type.name());
}
return value;
}

private static Properties loadProperties() {
Properties props = new Properties();
try (InputStream stream = AutoReplyMessages.class.getResourceAsStream(RESOURCE)) {
if (stream == null) {
throw new IllegalStateException(RESOURCE + " not found on classpath");
}
props.load(stream);
} catch (IOException e) {
throw new IllegalStateException("Failed to load " + RESOURCE, e);
}
return props;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.keycloak.gh.bot.security.email;

/** Types of automated email replies sent by the bot when an action cannot be performed. */
public enum AutoReplyType {

ISSUE_RESOLVED

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHIssueComment;
import org.kohsuke.github.GHIssueState;
import org.kohsuke.github.GHIssueStateReason;
import org.kohsuke.github.GHLabel;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
Expand Down Expand Up @@ -56,7 +57,13 @@ public class MailProcessor {
GitHubInstallationProvider gitHubInstallationProvider;

@Inject
EmailBodySanitizer bodySanitizer; // Extracted parsing logic dependency
AutoReplyMessages autoReplyMessages;

@Inject
MailSender mailSender;

@Inject
EmailBodySanitizer bodySanitizer;

private TargetGroup targetGroup;

Expand Down Expand Up @@ -138,9 +145,7 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor
if (issueOpt.isPresent()) {
var issue = issueOpt.get();
if (issue.getState() == GHIssueState.CLOSED) {
issue.reopen();
issue.addLabels(Labels.STATUS_TRIAGE, Labels.REOPENED_BY_BOT);
LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId);
handleClosedIssue(issue, fromSecAlert, threadId);
}
appendComment(issue, from, body, attachmentSection);

Expand Down Expand Up @@ -241,6 +246,25 @@ private boolean isFromSecAlert(String from, String replyTo) {
.anyMatch(header -> header.toLowerCase().contains(needle));
}

private void handleClosedIssue(GHIssue issue, boolean fromSecAlert, String threadId) throws IOException {
if (shouldReopenClosedIssue(fromSecAlert, issue.getStateReason())) {
issue.reopen();
issue.addLabels(Labels.STATUS_TRIAGE, Labels.REOPENED_BY_BOT);
LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId);
return;
}
String reply = autoReplyMessages.getMessage(AutoReplyType.ISSUE_RESOLVED);
issue.comment(reply);
mailSender.sendReply(threadId, reply, targetGroup.email());
LOGGER.infof("Issue #%d is completed — posted auto-reply and sent email for thread %s",
issue.getNumber(), threadId);
}

boolean shouldReopenClosedIssue(boolean fromSecAlert, GHIssueStateReason stateReason) {
if (fromSecAlert) return true;
return stateReason != GHIssueStateReason.COMPLETED;
}

private Optional<GHIssue> resolveIssueBySecAlertThreadId(GitHub github, GHRepository repository, String threadId) {
try {
var expectedMarker = Constants.SECALERT_THREAD_ID_PREFIX + " " + threadId;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ISSUE_RESOLVED=\
Thank you for your message. This security issue has been resolved and the \
corresponding tracking issue has been closed.\n\
\n\
If you believe this requires further attention or have new information, \
please submit a new report via the Keycloak security mailing list.\n\
\n\
Best regards,\n\
Keycloak Security Team
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.keycloak.gh.bot.security.email;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/** Verifies auto-reply message templates load correctly and contain expected content. */
public class AutoReplyMessagesTest {

@Test
void getMessage_returnsNonNullForIssueResolved() {
AutoReplyMessages messages = new AutoReplyMessages();
String result = messages.getMessage(AutoReplyType.ISSUE_RESOLVED);
assertNotNull(result);
}

@Test
void getMessage_containsExpectedContent() {
AutoReplyMessages messages = new AutoReplyMessages();
String result = messages.getMessage(AutoReplyType.ISSUE_RESOLVED);
assertTrue(result.contains("resolved"));
assertTrue(result.contains("Keycloak Security Team"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHIssueComment;
import org.kohsuke.github.GHIssueCommentQueryBuilder;
import org.kohsuke.github.GHIssueStateReason;
import org.kohsuke.github.GHLabel;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.PagedIterable;
Expand All @@ -20,6 +21,7 @@
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
Expand Down Expand Up @@ -276,6 +278,32 @@ void recordSecAlertThreadIdIfMissing_usesCache() throws Exception {
verify(issue.queryComments(), org.mockito.Mockito.times(1)).list();
}

// --- shouldReopenClosedIssue ---

@Test
void shouldReopenClosedIssue_returnsTrueForSecAlert() {
MailProcessor processor = new MailProcessor();
assertTrue(processor.shouldReopenClosedIssue(true, GHIssueStateReason.COMPLETED));
}

@Test
void shouldReopenClosedIssue_returnsFalseForCompleted() {
MailProcessor processor = new MailProcessor();
assertFalse(processor.shouldReopenClosedIssue(false, GHIssueStateReason.COMPLETED));
}

@Test
void shouldReopenClosedIssue_returnsTrueForNotPlanned() {
MailProcessor processor = new MailProcessor();
assertTrue(processor.shouldReopenClosedIssue(false, GHIssueStateReason.NOT_PLANNED));
}

@Test
void shouldReopenClosedIssue_returnsTrueForNull() {
MailProcessor processor = new MailProcessor();
assertTrue(processor.shouldReopenClosedIssue(false, null));
}

@SuppressWarnings("unchecked")
private void stubIssueComments(GHIssue issue, String... commentBodies) throws IOException {
GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class);
Expand Down
Loading