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
50 changes: 42 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,45 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: temurin
cache: maven
- name: Test
run: ./mvnw -B package -Dquarkus.native.container-build=true
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: temurin
cache: maven
- name: Test
run: ./mvnw -B package
env:
GMAIL_CLIENT_ID: dummy
GMAIL_CLIENT_SECRET: dummy
GMAIL_REFRESH_TOKEN: dummy
GMAIL_USER_EMAIL: bot@test.com
QUARKUS_GITHUB_APP_APP_ID: 12345
QUARKUS_GITHUB_APP_PRIVATE_KEY: |
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxlVR3TIkouAOvH79vaJTgFhpfvVKQIeVkFRZPVXK/zY0Gvrh
4JAqGjJoW/PfrQv5sdD36qtHH3a+G5hLZ6Ni+t/mtfjucxZfuLGC3kmJ1T3XqEKZ
gXXI2IR7vVSoImREvDQGEDyJwtHzLANlkbGg0cghVhWZSCAndO8BenalC2v94/rt
DfkPekH6dgU3Sf40T0sBSeSY94mOzTaqOR2pfV1rWlLRdWmo33zeHBv52Rlbt0dM
uXAureXWiHztkm5GCBC1dgM+CaxNtizNEgC91KcD0xuRCCM2WxH+r1lpszyIJDct
YbrFmVEYl/kjQpafhy7Nsk1fqSTyRdriZSYmTQIDAQABAoIBAQC+kJgaCuX8wYAn
SXWQ0fmdZlXnMNRpcF0a0pD0SAzGb1RdYBXMaXiqtyhiwc53PPxsCDdNecjayIMd
jJVXPTwLhTruOgMS/bp3gcgWwV34UHV4LJXGOGAE+jbS0hbDBMiudOYmj6RmVshp
z9G1zZCSQNMXHaWsEYkX59XpzzoB384nRul2QgEtwzUNR9XlpzgtJBLk3SACkvsN
mQ/DW8IWHXLg8vLn1LzVJ2e3B16H4MoE2TCHxqfMgr03IDRRJogkenQuQsFhevYT
o/mJyHSWavVgzMHG9I5m+eepF4Wyhj1Y4WyKAuMI+9dHAX/h7Lt8XFCQCh5DbkVG
zGr34sWBAoGBAOs7n7YZqNaaguovfIdRRsxxZr1yJAyDsr6w3yGImDZYju4c4WY9
5esO2kP3FA4p0c7FhQF5oOb1rBuHEPp36cpL4aGeK87caqTfq63WZAujoTZpr9Lp
BRbkL7w/xG7jpQ/clpA8sHzHGQs/nelxoOtC7E118FiRgvD/jdhlMyL9AoGBANfX
vyoN1pplfT2xR8QOjSZ+Q35S/+SAtMuBnHx3l0qH2bbBjcvM1MNDWjnRDyaYhiRu
i+KA7tqfib09+XpB3g5D6Ov7ls/Ldx0S/VcmVWtia2HK8y8iLGtokoBZKQ5AaFX2
iQU8+tC4h69GnJYQKqNwgCUzh8+gHX5Y46oDiTmRAoGAYpOx8lX+czB8/Da6MNrW
mIZNT8atZLEsDs2ANEVRxDSIcTCZJId7+m1W+nRoaycLTWNowZ1+2ErLvR10+AGY
b7Ys79Wg9idYaY9yGn9lnZsMzAiuLeyIvXcSqgjvAKlVWrhOQFOughvNWvFl85Yy
oWSCMlPiTLtt7CCsCKsgKuECgYBgdIp6GZsIfkgclKe0hqgvRoeU4TR3gcjJlM9A
lBTo+pKhaBectplx9RxR8AnsPobbqwcaHnIfAuKDzjk5mEvKZjClnFXF4HAHbyAF
nRzZEy9XkWFhc80T5rRpZO7C7qdxmu2aiKixM3V3L3/0U58qULEDbubHMw9bEhAT
PudI8QKBgHEEiMm/hr9T41hbQi/LYanWnlFw1ue+osKuF8bXQuxnnHNuFT/c+9/A
vWhgqG6bOEHu+p/IPrYm4tBMYlwsyh4nXCyGgDJLbLIfzKwKAWCtH9LwnyDVhOow
GH9shdR+sW3Ew97xef02KAH4VlNANEmBV4sQNqWWvsYrcFm2rOdL
-----END RSA PRIVATE KEY-----
29 changes: 29 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-gmail</artifactId>
<version>v1-rev20220404-1.32.1</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.19.0</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client-gson</artifactId>
<version>1.43.3</version>
</dependency>
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>angus-mail</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
Expand All @@ -76,6 +100,11 @@
<version>${mockito-core.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
Expand Down
94 changes: 94 additions & 0 deletions src/main/java/org/keycloak/gh/bot/email/CommandParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package org.keycloak.gh.bot.email;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.keycloak.gh.bot.GitHubInstallationProvider;

import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Responsible for parsing raw text from GitHub comments into Command objects.
*/
@ApplicationScoped
public class CommandParser {

@Inject
GitHubInstallationProvider gitHubProvider;

private Pattern mentionStartOfLine;
private Pattern replySecurity;
private Pattern newSecAlert;
private String cachedBotName;

public enum CommandType {
REPLY_KEYCLOAK_SECURITY,
NEW_SECALERT,
UNKNOWN
}

public record Command(CommandType type, Optional<String> subject, String body) {}

public Optional<Command> parse(String text) {
if (text == null || text.isBlank()) return Optional.empty();

ensurePatternsInitialized();
String trimmedText = text.trim();

if (!mentionStartOfLine.matcher(trimmedText).find()) {
return Optional.empty();
}

Matcher mSecurity = replySecurity.matcher(trimmedText);
if (mSecurity.find()) {
String body = mSecurity.group(1).trim();
return body.isEmpty() ? Optional.empty() : Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), body));
}

Matcher mSecAlert = newSecAlert.matcher(trimmedText);
if (mSecAlert.find()) {
String subject = mSecAlert.group(1).trim();
String body = mSecAlert.group(2).trim();
if (subject.isEmpty() || body.isEmpty()) return Optional.empty();
return Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of(subject), body));
}

return Optional.of(new Command(CommandType.UNKNOWN, Optional.empty(), trimmedText));
}

public String getBotName() {
String login = gitHubProvider.getBotLogin();
if (login == null) return "unknown-bot";
return login.endsWith("[bot]") ? login.replace("[bot]", "") : login;
}

public String getHelpMessage() {
String n = getBotName();
return String.format("""
I don't know this command or the format is incorrect.
**Rule:** Commands must be on their own line. The message body starts on the next line.

**Available Commands:**

`@%s /reply keycloak-security`
`REPLY BODY (Sent to: Sender + Keycloak Security List)`

`@%s /new secalert "Subject"`
`EMAIL BODY (Sent to: SecAlert + Keycloak Security List)`
""", n, n);
}

private synchronized void ensurePatternsInitialized() {
String currentBotName = getBotName();

if (cachedBotName == null || !cachedBotName.equals(currentBotName)) {
cachedBotName = currentBotName;
String quotedName = Pattern.quote(currentBotName);

mentionStartOfLine = Pattern.compile("(?mi)^@" + quotedName + "\\b");
replySecurity = Pattern.compile("(?msi)^@" + quotedName + "\\s+/reply\\s+keycloak-security\\s*[\r\n]+(.*)");
newSecAlert = Pattern.compile("(?msi)^@" + quotedName + "\\s+/new\\s+secalert\\s+\"([^\"]+)\"\\s*[\r\n]+(.*)");
}
}
}
183 changes: 183 additions & 0 deletions src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package org.keycloak.gh.bot.email;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHIssueComment;
import org.kohsuke.github.GHReaction;
import org.kohsuke.github.ReactionContent;

import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Handles the processing of GitHub comments to identify and execute bot commands
*/
@ApplicationScoped
public class CommandProcessor {

private static final Logger LOG = Logger.getLogger(CommandProcessor.class);
private static final Pattern VISIBLE_MARKER_PATTERN = Pattern.compile("\\*\\*Gmail-Thread-ID:\\*\\*\\s*([a-f0-9]+)");
private static final Pattern RAW_HEX_PATTERN = Pattern.compile("\\b([a-f0-9]{16})\\b");

private static final int MAX_PROCESSED_HISTORY = 10000;

@ConfigProperty(name = "google.group.target")
String targetGroup;

@ConfigProperty(name = "email.target.secalert")
String secAlertEmail;

@Inject GitHubAdapter github;
@Inject CommandParser parser;
@Inject MailSender mailSender;

private final Set<Long> processedComments = Collections.synchronizedSet(Collections.newSetFromMap(
new LinkedHashMap<Long, Boolean>(MAX_PROCESSED_HISTORY + 1, .75F, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Boolean> eldest) {
return size() > MAX_PROCESSED_HISTORY;
}
}));

private Instant lastPollTime = Instant.now().minus(10, ChronoUnit.MINUTES);

public void processCommands() {
try {
String myLogin = parser.getBotName();
if (myLogin == null || myLogin.isEmpty()) return;

Instant executionStart = Instant.now();

Date querySince = Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES));
List<GHIssue> updatedIssues = github.getIssuesUpdatedSince(querySince);

for (GHIssue issue : updatedIssues) {
try {
scanIssue(issue, myLogin);
} catch (Exception e) {
LOG.errorf(e, "Failed to scan issue #%d", issue.getNumber());
}
}

lastPollTime = executionStart;
} catch (Exception e) {
LOG.error("Fatal error fetching updated issues", e);
}
}

private void scanIssue(GHIssue issue, String myLogin) throws IOException {
Optional<String> threadIdOpt = findThreadIdInComments(issue);

List<GHIssueComment> recentComments = issue.queryComments()
.since(Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES)))
.list()
.toList();

for (GHIssueComment comment : recentComments) {
if (hasAlreadyProcessed(comment, myLogin)) continue;

parser.parse(comment.getBody()).ifPresent(cmd -> executeCommand(issue, comment, cmd, threadIdOpt));
}
}

private void executeCommand(GHIssue issue, GHIssueComment comment, CommandParser.Command cmd, Optional<String> threadId) {
boolean success = false;
ReactionContent reaction = ReactionContent.EYES;

switch (cmd.type()) {
case NEW_SECALERT:
success = mailSender.sendNewEmail(secAlertEmail, targetGroup, cmd.subject().orElse("No Subject"), cmd.body());
if (!success) {
replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
reaction = ReactionContent.CONFUSED;
}
break;
case REPLY_KEYCLOAK_SECURITY:
if (threadId.isPresent()) {
success = mailSender.sendReply(threadId.get(), issue.getTitle(), cmd.body(), targetGroup);
if (!success) {
replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
reaction = ReactionContent.CONFUSED;
}
} else {
replyWithError(issue, comment, "❌ Error: Gmail Thread ID not found.");
success = true;
reaction = ReactionContent.CONFUSED;
}
break;
case UNKNOWN:
sendHelpMessage(issue, comment);
success = true;
reaction = ReactionContent.CONFUSED;
break;
}

if (success) {
processedComments.add(comment.getId());
addReaction(comment, reaction);
LOG.debugf("✅ Command executed: %s", cmd.type());
}
}

private void addReaction(GHIssueComment comment, ReactionContent reaction) {
try {
comment.createReaction(reaction);
} catch (IOException e) {
LOG.errorf("Failed to react to comment %d", comment.getId());
}
}

private void replyWithError(GHIssue issue, GHIssueComment comment, String message) {
try {
github.commentOnIssue(issue, "@" + comment.getUser().getLogin() + " " + message);
} catch (IOException e) {
LOG.error("Failed to send error reply", e);
}
}

private void sendHelpMessage(GHIssue issue, GHIssueComment comment) {
try {
String body = "@" + comment.getUser().getLogin() + " " + parser.getHelpMessage();
github.commentOnIssue(issue, body);
} catch (IOException e) {
LOG.error("Failed to send help message", e);
}
}

private boolean hasAlreadyProcessed(GHIssueComment comment, String myLogin) throws IOException {
if (processedComments.contains(comment.getId())) return true;

for (GHReaction reaction : comment.listReactions()) {
String user = reaction.getUser().getLogin();
if ((reaction.getContent() == ReactionContent.EYES || reaction.getContent() == ReactionContent.CONFUSED) &&
(user.equalsIgnoreCase(myLogin) || user.equalsIgnoreCase(myLogin + "[bot]"))) {
processedComments.add(comment.getId());
return true;
}
}
return false;
}

private Optional<String> findThreadIdInComments(GHIssue issue) throws IOException {
for (GHIssueComment comment : issue.getComments()) {
Matcher m = VISIBLE_MARKER_PATTERN.matcher(comment.getBody());
if (m.find()) return Optional.of(m.group(1).trim());
Matcher raw = RAW_HEX_PATTERN.matcher(comment.getBody());
if (raw.find()) return Optional.of(raw.group(1).trim());
}
return Optional.empty();
}
}
Loading
Loading