diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 958b8fe0..03bf24b0 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -22,14 +22,12 @@ jobs: distribution: 'temurin' - name: Make gradlew executable run: chmod +x gradlew - - name: Run checkstyle - run: ./gradlew checkstyleMain - name: Build with Gradle uses: gradle/gradle-build-action@093dfe9d598ec5a42246855d09b49dc76803c005 with: arguments: shadowJar - name: Upload a Build Artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4 with: name: 'Successfully build DiscordOfficer JDK${{ matrix.jdk }}' path: build/libs/*.jar diff --git a/build.gradle.kts b/build.gradle.kts index 7031c81a..a02f8532 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `java-library` application idea - checkstyle id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -19,26 +18,6 @@ repositories { maven { url = uri("https://repo.eternalcode.pl/releases") } } -checkstyle { - toolVersion = "10.21.2" - - configFile = file("${rootDir}/checkstyle/checkstyle.xml") - - maxErrors = 0 - maxWarnings = 0 -} - -// https://github.com/JabRef/jabref/pull/10812/files#diff-49a96e7eea8a94af862798a45174e6ac43eb4f8b4bd40759b5da63ba31ec3ef7R267 -configurations.named("checkstyle") { - resolutionStrategy { - capabilitiesResolution { - withCapability("com.google.collections:google-collections") { - select("com.google.guava:guava:33.4.0-jre") - } - } - } -} - dependencies { // JDA implementation("net.dv8tion:JDA:5.3.0") { @@ -85,6 +64,8 @@ dependencies { // https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 implementation("org.apache.commons:commons-lang3:3.17.0") + + implementation("com.eternalcode:eternalcode-commons-shared:1.2.0") } tasks.getByName("test") { @@ -105,9 +86,6 @@ java { tasks.shadowJar { archiveFileName.set("DiscordOfficer v${project.version}.jar") - // dependsOn("checkstyleMain") - // dependsOn("test") - manifest { attributes( "Main-Class" to "com.eternalcode.discordapp.DiscordApp", diff --git a/checkstyle/checkstyle.xml b/checkstyle/checkstyle.xml deleted file mode 100644 index b3b149f2..00000000 --- a/checkstyle/checkstyle.xml +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/checkstyle/suppressions.xml b/checkstyle/suppressions.xml deleted file mode 100644 index ed8e187c..00000000 --- a/checkstyle/suppressions.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/src/main/java/com/eternalcode/discordapp/DiscordApp.java b/src/main/java/com/eternalcode/discordapp/DiscordApp.java index 2e27740a..e976e78b 100644 --- a/src/main/java/com/eternalcode/discordapp/DiscordApp.java +++ b/src/main/java/com/eternalcode/discordapp/DiscordApp.java @@ -1,5 +1,7 @@ package com.eternalcode.discordapp; +import com.eternalcode.discordapp.automessages.AutoMessageService; +import com.eternalcode.discordapp.automessages.AutoMessageTask; import com.eternalcode.discordapp.command.AvatarCommand; import com.eternalcode.discordapp.command.BanCommand; import com.eternalcode.discordapp.command.BotInfoCommand; @@ -42,14 +44,10 @@ import com.eternalcode.discordapp.review.database.GitHubReviewMentionRepositoryImpl; import com.eternalcode.discordapp.scheduler.Scheduler; import com.eternalcode.discordapp.scheduler.VirtualThreadSchedulerImpl; -import com.eternalcode.discordapp.user.UserRepositoryImpl; -import com.eternalcode.discordapp.automessages.AutoMessageService; -import com.eternalcode.discordapp.automessages.AutoMessageTask; import com.jagrosh.jdautilities.command.CommandClient; import com.jagrosh.jdautilities.command.CommandClientBuilder; import io.sentry.Sentry; import java.io.File; -import java.sql.SQLException; import java.time.Duration; import java.util.EnumSet; import net.dv8tion.jda.api.JDA; @@ -66,151 +64,175 @@ public class DiscordApp { private static final Logger LOGGER = LoggerFactory.getLogger(DiscordApp.class); + private static final Duration REMINDER_INTERVAL = Duration.ofHours(24); + private Scheduler scheduler; + private GitHubReviewReminderService reminderService; + private JDA jda; + private DatabaseManager databaseManager; + + public static void main(String[] args) { + new DiscordApp().start(); + } - private static ExperienceService experienceService; - private static LevelService levelService; - private static GitHubReviewService gitHubReviewService; - private static DatabaseManager databaseManager; - private static Scheduler scheduler; - private static GitHubReviewMentionRepository mentionRepository; + private void start() { + try { + LOGGER.info("Starting Discord Application..."); - public static void main(String... args) throws InterruptedException { - Runtime.getRuntime().addShutdownHook(new Thread(DiscordApp::shutdown)); + runApplication(); - ObserverRegistry observerRegistry = new ObserverRegistry(); - ConfigManager configManager = new ConfigManager("config"); + Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown)); + LOGGER.info("Discord Application started successfully!"); + Thread.sleep(Long.MAX_VALUE); + } + catch (Exception exception) { + LOGGER.error("Failed to start Discord Application", exception); + Sentry.captureException(exception); + System.exit(1); + } + } - AppConfig config = configManager.load(new AppConfig()); + private void runApplication() throws Exception { + LOGGER.info("Loading configurations..."); + ConfigManager configManager = new ConfigManager("config"); + AppConfig appConfig = configManager.load(new AppConfig()); DatabaseConfig databaseConfig = configManager.load(new DatabaseConfig()); ExperienceConfig experienceConfig = configManager.load(new ExperienceConfig()); LevelConfig levelConfig = configManager.load(new LevelConfig()); - if (!config.sentryDsn.isEmpty()) { + if (!appConfig.sentryDsn.isBlank()) { Sentry.init(options -> { - options.setDsn(config.sentryDsn); + options.setDsn(appConfig.sentryDsn); options.setTracesSampleRate(1.0); options.setDebug(true); options.setAttachStacktrace(true); }); + LOGGER.info("Sentry initialized"); } - try { - databaseManager = new DatabaseManager(databaseConfig, new File("database")); - databaseManager.connect(); - UserRepositoryImpl.create(databaseManager); - mentionRepository = GitHubReviewMentionRepositoryImpl.create(databaseManager); - - experienceService = new ExperienceService(databaseManager, observerRegistry); - levelService = new LevelService(databaseManager); - gitHubReviewService = new GitHubReviewService(config, configManager, mentionRepository); - } - catch (SQLException exception) { - Sentry.captureException(exception); - LOGGER.error("Failed to connect to database", exception); - } - - LeaderboardService leaderboardService = new LeaderboardService(levelService); - + LOGGER.info("Initializing core components..."); OkHttpClient httpClient = new OkHttpClient(); + Scheduler scheduler = new VirtualThreadSchedulerImpl(); + DatabaseManager databaseManager = new DatabaseManager(databaseConfig, new File("database")); + databaseManager.connect(); + ObserverRegistry observerRegistry = new ObserverRegistry(); + + LOGGER.info("Initializing repositories..."); + GitHubReviewMentionRepository mentionRepo = + GitHubReviewMentionRepositoryImpl.create(databaseManager, scheduler); - FilterService filterService = new FilterService() - .registerFilter(new RenovateForcedPushFilter()); + LOGGER.info("Initializing services..."); + ExperienceService experienceService = new ExperienceService(databaseManager, observerRegistry); + LevelService levelService = new LevelService(databaseManager); + GitHubReviewService reviewService = new GitHubReviewService(appConfig, configManager, mentionRepo); + LeaderboardService leaderboardService = new LeaderboardService(levelService); + LOGGER.info("Building command client..."); CommandClient commandClient = new CommandClientBuilder() - .setOwnerId(config.topOwnerId) + .setOwnerId(appConfig.topOwnerId) .setActivity(Activity.playing("IntelliJ IDEA")) .useHelpBuilder(false) - - // slash commands registry .addSlashCommands( - // Standard - new AvatarCommand(config), - new BanCommand(config), - new BotInfoCommand(config), - new ClearCommand(config), - new CooldownCommand(config), + new AvatarCommand(appConfig), + new BanCommand(appConfig), + new BotInfoCommand(appConfig), + new ClearCommand(appConfig), + new CooldownCommand(appConfig), new EmbedCommand(), - new KickCommand(config), + new KickCommand(appConfig), new MinecraftServerInfoCommand(httpClient), - new PingCommand(config), + new PingCommand(appConfig), new SayCommand(), - new ServerCommand(config), + new ServerCommand(appConfig), new XFixCommand(), - - // GitHub review - new GitHubReviewCommand(gitHubReviewService, config), - - // Leveling + new GitHubReviewCommand(reviewService, appConfig), new LevelCommand(levelService), new LeaderboardCommand(leaderboardService) ) .build(); - JDA jda = JDABuilder.createDefault(config.token) + LOGGER.info("Initializing Discord bot..."); + FilterService filterService = new FilterService().register(new RenovateForcedPushFilter()); + JDA jda = JDABuilder.createDefault(appConfig.token) .addEventListeners( - // Slash commands commandClient, - - // Experience system new ExperienceMessageListener(experienceConfig, experienceService), new ExperienceReactionListener(experienceConfig, experienceService), - - // Message filter new FilterMessageEmbedController(filterService), - - // leaderboard new LeaderboardButtonController(leaderboardService) ) - .setAutoReconnect(true) .setHttpClient(httpClient) - .enableIntents(EnumSet.allOf(GatewayIntent.class)) .setMemberCachePolicy(MemberCachePolicy.ALL) .enableCache(CacheFlag.ONLINE_STATUS) .setChunkingFilter(ChunkingFilter.ALL) - .build() .awaitReady(); - observerRegistry.observe(ExperienceChangeEvent.class, new LevelController(levelConfig, levelService, jda)); - GuildStatisticsService guildStatisticsService = new GuildStatisticsService(config, jda); + observerRegistry.observe( + ExperienceChangeEvent.class, + new LevelController(levelConfig, levelService, jda) + ); + + LOGGER.info("Initializing JDA-dependent services..."); + GuildStatisticsService guildStats = new GuildStatisticsService(appConfig, jda); + AutoMessageService autoMsgService = new AutoMessageService(jda, appConfig.autoMessagesConfig); + GitHubReviewReminderService reminderService = new GitHubReviewReminderService( + jda, + mentionRepo, + appConfig, + scheduler, + REMINDER_INTERVAL + ); + reminderService.start(); Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { Sentry.captureException(throwable); - LOGGER.error("Uncaught exception", throwable); + LOGGER.error("Uncaught exception in thread: {}", thread.getName(), throwable); }); - scheduler = new VirtualThreadSchedulerImpl(); - scheduler.schedule(new GuildStatisticsTask(guildStatisticsService), Duration.ofMinutes(5)); - scheduler.schedule(new GitHubReviewTask(gitHubReviewService, jda), Duration.ofMinutes(5)); + LOGGER.info("Starting scheduled tasks..."); + scheduler.schedule(new GuildStatisticsTask(guildStats), Duration.ofMinutes(5)); + new GitHubReviewTask(reviewService, jda, scheduler).start(); + scheduler.scheduleRepeating(new AutoMessageTask(autoMsgService), appConfig.autoMessagesConfig.interval); - // Initialize auto message system - AutoMessageService autoMessageService = new AutoMessageService(jda, config); - scheduler.scheduleRepeating(new AutoMessageTask(autoMessageService), config.autoMessages.interval); - LOGGER.info("Scheduled auto messages with interval {}", config.autoMessages.interval); + LOGGER.info("Auto messages scheduled with interval: {}", appConfig.autoMessagesConfig.interval); - // Initialize the reminder service - GitHubReviewReminderService reminderService = new GitHubReviewReminderService(jda, mentionRepository, config); - reminderService.start(); - - // Add shutdown hook to stop the reminder service - Runtime.getRuntime().addShutdownHook(new Thread(reminderService::stop)); + this.scheduler = scheduler; + this.reminderService = reminderService; + this.jda = jda; + this.databaseManager = databaseManager; } - private static void shutdown() { - try { - databaseManager.close(); - } - catch (Exception exception) { - throw new RuntimeException(exception); - } + private void shutdown() { + LOGGER.info("Initiating graceful shutdown..."); try { - scheduler.shutdown(); + if (scheduler != null) { + scheduler.shutdown(); + LOGGER.info("Scheduler stopped"); + } + + if (reminderService != null) { + reminderService.stop(); + LOGGER.info("GitHub review reminder service stopped"); + } + + if (jda != null) { + jda.shutdown(); + LOGGER.info("JDA stopped"); + } + + if (databaseManager != null) { + databaseManager.close(); + LOGGER.info("Database connections closed"); + } + + LOGGER.info("Graceful shutdown completed"); } - catch (InterruptedException exception) { - throw new RuntimeException(exception); + catch (Exception exception) { + LOGGER.error("Error during shutdown", exception); + Sentry.captureException(exception); } } } diff --git a/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageService.java b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageService.java index 21891171..c10a7e91 100644 --- a/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageService.java +++ b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageService.java @@ -1,79 +1,128 @@ package com.eternalcode.discordapp.automessages; -import com.eternalcode.discordapp.config.AppConfig; +import com.eternalcode.commons.concurrent.FutureHandler; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; - public class AutoMessageService { private static final Logger LOGGER = LoggerFactory.getLogger(AutoMessageService.class); - private static final int CHECK_LAST_MESSAGES_COUNT = 10; private final JDA jda; - private final AppConfig config; + private final AutoMessagesConfig config; + private final Map lastMessageTimes = new ConcurrentHashMap<>(); - public AutoMessageService(JDA jda, AppConfig config) { + public AutoMessageService(JDA jda, AutoMessagesConfig config) { this.jda = jda; this.config = config; + LOGGER.info("AutoMessageService initialized with {} entries", config.entries.size()); } - public void sendAutoMessages() { - if (config.autoMessages.entries.isEmpty()) { + public CompletableFuture sendAutoMessages() { + if (config.entries.isEmpty()) { LOGGER.debug("No auto messages configured"); - return; + return CompletableFuture.completedFuture(new AutoMessageResults(0, 0)); } - LOGGER.info("Starting auto message sending process..."); + LOGGER.info("Starting auto message sending - {} entries", config.entries.size()); - for (AppConfig.AutoMessages.AutoMessagesEntry entry : config.autoMessages.entries) { - try { - sendAutoMessage(entry); - } catch (Exception exception) { - LOGGER.error("Failed to send auto message to channel {}: {}", entry.channelId, exception.getMessage(), exception); - } - } + List> futures = config.entries.stream() + .map(this::sendAutoMessage) + .toList(); + + CompletableFuture allFutures = CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)); + + return allFutures.thenApply(ignored -> { + int successful = (int) futures.stream() + .map(CompletableFuture::join) + .filter(Boolean::booleanValue) + .count(); + + int failed = futures.size() - successful; + LOGGER.info("Auto messages completed: {}/{} successful", successful, futures.size()); + + return new AutoMessageResults(successful, failed); + }).exceptionally(FutureHandler::handleException); } - private void sendAutoMessage(AppConfig.AutoMessages.AutoMessagesEntry entry) { - TextChannel channel = jda.getTextChannelById(entry.channelId); - - if (channel == null) { - LOGGER.warn("Channel with ID {} not found", entry.channelId); - return; - } + private CompletableFuture sendAutoMessage(AutoMessagesConfig.AutoMessagesEntry entry) { + return CompletableFuture.supplyAsync(() -> { + try { + TextChannel channel = jda.getTextChannelById(entry.channelId); + if (channel == null) { + LOGGER.warn("Channel not found: {}", entry.channelId); + return false; + } - if (shouldSkipSending(channel, entry.message)) { - LOGGER.debug("Skipping auto message to channel {} - last messages are already auto messages", channel.getName()); - return; - } + if (shouldSkipSending(channel, entry)) { + LOGGER.debug("Skipping message to #{} - recent duplicate found", channel.getName()); + return false; + } - channel.sendMessage(entry.message).queue( - message -> LOGGER.info("Auto message sent to channel {}: {}", channel.getName(), entry.message), - error -> LOGGER.error("Failed to send auto message to channel {}: {}", channel.getName(), error.getMessage()) - ); + String messageToSend = selectRandomMessage(entry.messages); + + return channel.sendMessage(messageToSend) + .submit() + .thenApply(message -> { + lastMessageTimes.put(entry.channelId, Instant.now()); + LOGGER.info("✅ Auto message sent to #{}", channel.getName()); + return true; + }) + .exceptionally(error -> { + LOGGER.error("Failed to send message to #{}: {}", channel.getName(), error.getMessage()); + return false; + }) + .join(); + } + catch (Exception exception) { + LOGGER.error( + "Error processing auto message for channel {}: {}", + entry.channelId, + exception.getMessage()); + return false; + } + }).exceptionally(FutureHandler::handleException); } - private boolean shouldSkipSending(TextChannel channel, String messageContent) { + private boolean shouldSkipSending(TextChannel channel, AutoMessagesConfig.AutoMessagesEntry entry) { try { List recentMessages = channel.getHistory() - .retrievePast(CHECK_LAST_MESSAGES_COUNT) + .retrievePast(config.duplicateCheckCount) .complete(); - for (Message message : recentMessages) { - if (message.getAuthor().isBot() && message.getContentRaw().equals(messageContent)) { - return true; - } - } + String botId = jda.getSelfUser().getId(); + Set messagesToCheck = Set.of(entry.messages.toArray(String[]::new)); + return recentMessages.stream() + .filter(message -> message.getAuthor().getId().equals(botId)) + .anyMatch(message -> messagesToCheck.contains(message.getContentRaw())); + } + catch (Exception exception) { + LOGGER.warn("Failed to check recent messages in #{}: {}", channel.getName(), exception.getMessage()); return false; - } catch (Exception exception) { - LOGGER.warn("Failed to check recent messages in channel {}: {}", channel.getName(), exception.getMessage()); - return false; } } -} \ No newline at end of file + + private String selectRandomMessage(List messages) { + if (messages.size() == 1) { + return messages.get(0); + } + return messages.get(ThreadLocalRandom.current().nextInt(messages.size())); + } + + public record AutoMessageResults(int successful, int failed) { + public int total() { + return successful + failed; + } + } +} diff --git a/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageTask.java b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageTask.java index 6e5c5469..0383a4b8 100644 --- a/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageTask.java +++ b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessageTask.java @@ -1,7 +1,13 @@ package com.eternalcode.discordapp.automessages; +import com.eternalcode.discordapp.automessages.AutoMessageService.AutoMessageResults; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class AutoMessageTask implements Runnable { + private static final Logger LOGGER = LoggerFactory.getLogger(AutoMessageTask.class); + private final AutoMessageService autoMessageService; public AutoMessageTask(AutoMessageService autoMessageService) { @@ -10,6 +16,22 @@ public AutoMessageTask(AutoMessageService autoMessageService) { @Override public void run() { - this.autoMessageService.sendAutoMessages(); + try { + LOGGER.debug("Starting auto message task..."); + + AutoMessageResults results = autoMessageService.sendAutoMessages().join(); + + if (results.failed() > 0) { + LOGGER.warn( + "Auto message task completed with some failures: {}/{} successful", + results.successful(), results.total()); + } + else { + LOGGER.info("Auto message task completed successfully: {} messages sent", results.successful()); + } + } + catch (Exception exception) { + LOGGER.error("Auto message task failed", exception); + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/eternalcode/discordapp/automessages/AutoMessagesConfig.java b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessagesConfig.java new file mode 100644 index 00000000..f8ebe13a --- /dev/null +++ b/src/main/java/com/eternalcode/discordapp/automessages/AutoMessagesConfig.java @@ -0,0 +1,57 @@ +package com.eternalcode.discordapp.automessages; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import net.dzikoysk.cdn.entity.Contextual; +import net.dzikoysk.cdn.entity.Description; + +@Contextual +public class AutoMessagesConfig { + + @Description("# How often all auto messages should be sent (e.g. PT1H for 1 hour)") + public Duration interval = Duration.ofHours(1); + + @Description("# How many recent messages to check for duplicates (prevents spam)") + public int duplicateCheckCount = 10; + + @Description({ + "# List of automatic messages", + "# Each entry can have multiple message variants - bot will pick randomly" + }) + public List entries = new ArrayList<>(List.of( + new AutoMessagesEntry( + 1025826334435455047L, + List.of( + "🔥 Make your messages in Minecraft a MASTERPIECE - try the new notification generator now! 👉 https://www.eternalcode.pl/notification-generator", + "✨ Create stunning Minecraft notifications with our generator! Check it out: https://www.eternalcode.pl/notification-generator" + ) + ) + )); + + @Contextual + public static class AutoMessagesEntry { + @Description("# Discord channel ID where messages will be sent") + public long channelId; + + @Description({ + "# List of message variants - bot will randomly choose one", + "# Having multiple variants makes the bot feel more natural" + }) + public List messages; + + public AutoMessagesEntry() { + // Default constructor for CDN + } + + public AutoMessagesEntry(long channelId, List messages) { + this.channelId = channelId; + this.messages = new ArrayList<>(messages); + } + + // Backwards compatibility - single message + public AutoMessagesEntry(long channelId, String message) { + this(channelId, List.of(message)); + } + } +} diff --git a/src/main/java/com/eternalcode/discordapp/config/AppConfig.java b/src/main/java/com/eternalcode/discordapp/config/AppConfig.java index 7045b255..f1156aa0 100644 --- a/src/main/java/com/eternalcode/discordapp/config/AppConfig.java +++ b/src/main/java/com/eternalcode/discordapp/config/AppConfig.java @@ -1,5 +1,6 @@ package com.eternalcode.discordapp.config; +import com.eternalcode.discordapp.automessages.AutoMessagesConfig; import com.eternalcode.discordapp.review.GitHubReviewNotificationType; import com.eternalcode.discordapp.review.GitHubReviewUser; import net.dzikoysk.cdn.entity.Contextual; @@ -8,7 +9,6 @@ import net.dzikoysk.cdn.source.Source; import java.io.File; -import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -42,7 +42,7 @@ public class AppConfig implements CdnConfig { public ReviewSystem reviewSystem = new ReviewSystem(); @Description("# The settings of automatic messages") - public AutoMessages autoMessages = new AutoMessages(); + public AutoMessagesConfig autoMessagesConfig = new AutoMessagesConfig(); @Override public Resource resource(File folder) { @@ -117,36 +117,4 @@ public static class ReviewSystem { new GitHubReviewUser(852920601969950760L, "vluckyyy", GitHubReviewNotificationType.SERVER) )); } - - @Contextual - public static class AutoMessages { - @Description({ - "# List of automatic messages that will be sent at specified interval", - "# Messages will not be sent if the last few messages in the channel are already auto messages" - }) - public List entries = new ArrayList<>(Collections.singletonList( - new AutoMessagesEntry(1025826334435455047L, "🔥 Make your messages in Minecraft a MASTERPIECE - try the new notification generator now! 👉 https://www.eternalcode.pl/notification-generator") - )); - - @Description("# How often all auto messages should be sent. Default: 1 hour") - public Duration interval = Duration.ofHours(1); - - @Contextual - public static class AutoMessagesEntry { - @Description("# The ID of the channel where the message will be sent") - public long channelId; - - @Description("# The message content to be sent") - public String message; - - public AutoMessagesEntry() { - // Default constructor for CDN - } - - public AutoMessagesEntry(long channelId, String message) { - this.channelId = channelId; - this.message = message; - } - } - } } diff --git a/src/main/java/com/eternalcode/discordapp/config/composer/DurationComposer.java b/src/main/java/com/eternalcode/discordapp/config/composer/DurationComposer.java new file mode 100644 index 00000000..8a69e228 --- /dev/null +++ b/src/main/java/com/eternalcode/discordapp/config/composer/DurationComposer.java @@ -0,0 +1,18 @@ +package com.eternalcode.discordapp.config.composer; + +import panda.std.Result; + +import java.time.Duration; + +public class DurationComposer implements SimpleComposer { + + @Override + public Result deserialize(String value) { + return Result.supplyThrowing(() -> Duration.parse(value)); + } + + @Override + public Result serialize(Duration value) { + return Result.ok(value.toString()); + } +} \ No newline at end of file diff --git a/src/main/java/com/eternalcode/discordapp/filter/Filter.java b/src/main/java/com/eternalcode/discordapp/filter/Filter.java index 382bc6d6..82e7ec3e 100644 --- a/src/main/java/com/eternalcode/discordapp/filter/Filter.java +++ b/src/main/java/com/eternalcode/discordapp/filter/Filter.java @@ -2,7 +2,5 @@ @FunctionalInterface public interface Filter { - - FilterResult filter(String... source); - + FilterResult filter(String... sources); } diff --git a/src/main/java/com/eternalcode/discordapp/filter/FilterMessageEmbedController.java b/src/main/java/com/eternalcode/discordapp/filter/FilterMessageEmbedController.java index 50f096e9..f7ac6349 100644 --- a/src/main/java/com/eternalcode/discordapp/filter/FilterMessageEmbedController.java +++ b/src/main/java/com/eternalcode/discordapp/filter/FilterMessageEmbedController.java @@ -14,11 +14,7 @@ public FilterMessageEmbedController(FilterService filterService) { @Override public void onMessageReceived(MessageReceivedEvent event) { event.getMessage().getEmbeds().forEach(embed -> { - if (embed == null) { - return; - } - - if (embed.getAuthor() == null) { + if (embed == null || embed.getAuthor() == null) { return; } @@ -29,9 +25,7 @@ public void onMessageReceived(MessageReceivedEvent event) { return; } - FilterResult result = this.filterService.check(name, title); - - if (!result.isPassed()) { + if (!filterService.check(name, title).isPassed()) { event.getMessage().delete().queue(); } }); diff --git a/src/main/java/com/eternalcode/discordapp/filter/FilterResult.java b/src/main/java/com/eternalcode/discordapp/filter/FilterResult.java index 8e502d85..1be87429 100644 --- a/src/main/java/com/eternalcode/discordapp/filter/FilterResult.java +++ b/src/main/java/com/eternalcode/discordapp/filter/FilterResult.java @@ -22,5 +22,4 @@ public static FilterResult notPassed() { public boolean isPassed() { return this.passed; } - } diff --git a/src/main/java/com/eternalcode/discordapp/filter/FilterService.java b/src/main/java/com/eternalcode/discordapp/filter/FilterService.java index 222e0d97..d6761ce2 100644 --- a/src/main/java/com/eternalcode/discordapp/filter/FilterService.java +++ b/src/main/java/com/eternalcode/discordapp/filter/FilterService.java @@ -7,21 +7,16 @@ public class FilterService { private final List filters = new ArrayList<>(); - public FilterService registerFilter(Filter filter) { + public FilterService register(Filter filter) { this.filters.add(filter); return this; } public FilterResult check(String... sources) { - for (Filter filter : this.filters) { - FilterResult result = filter.filter(sources); - - if (!result.isPassed()) { - return FilterResult.notPassed(); - } - } - - return FilterResult.passed(); + return this.filters.stream() + .map(filter -> filter.filter(sources)) + .filter(result -> !result.isPassed()) + .findFirst() + .orElse(FilterResult.passed()); } - } diff --git a/src/main/java/com/eternalcode/discordapp/filter/renovate/RenovateForcedPushFilter.java b/src/main/java/com/eternalcode/discordapp/filter/renovate/RenovateForcedPushFilter.java index 911f2c8b..76e0734e 100644 --- a/src/main/java/com/eternalcode/discordapp/filter/renovate/RenovateForcedPushFilter.java +++ b/src/main/java/com/eternalcode/discordapp/filter/renovate/RenovateForcedPushFilter.java @@ -3,29 +3,24 @@ import com.eternalcode.discordapp.filter.Filter; import com.eternalcode.discordapp.filter.FilterResult; -import java.util.HashSet; import java.util.Set; public class RenovateForcedPushFilter implements Filter { - private static final Set WORDS_TO_NOT_PASS = Set.of( - "renovate[bot]", - "force-pushed" + private static final Set BLOCKED_PHRASES = Set.of( + "renovate[bot]", + "force-pushed" ); @Override public FilterResult filter(String... sources) { - Set toNotPassed = new HashSet<>(WORDS_TO_NOT_PASS); - for (String source : sources) { - for (String word : WORDS_TO_NOT_PASS) { - if (source.contains(word)) { - toNotPassed.remove(word); + for (String blocked : BLOCKED_PHRASES) { + if (source.contains(blocked)) { + return FilterResult.notPassed(); } } } - - return toNotPassed.isEmpty() ? FilterResult.notPassed() : FilterResult.passed(); + return FilterResult.passed(); } - } diff --git a/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsService.java b/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsService.java index ee74d083..fffe6f5b 100644 --- a/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsService.java +++ b/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsService.java @@ -1,15 +1,20 @@ package com.eternalcode.discordapp.guildstats; +import com.eternalcode.commons.concurrent.FutureHandler; import com.eternalcode.discordapp.config.AppConfig; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.OnlineStatus; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import panda.utilities.text.Formatter; -import java.util.Map; +public final class GuildStatisticsService { -public class GuildStatisticsService { + private static final Logger LOGGER = LoggerFactory.getLogger(GuildStatisticsService.class); private final AppConfig config; private final JDA jda; @@ -19,51 +24,77 @@ public GuildStatisticsService(AppConfig config, JDA jda) { this.jda = jda; } - public void displayStats() { - Guild guild = this.jda.getGuildById(this.config.guildId); - if (guild == null) { - return; - } + public CompletableFuture displayStats() { + return CompletableFuture.runAsync(() -> { + Guild guild = jda.getGuildById(config.guildId); + if (guild == null) { + LOGGER.warn("Guild not found with ID: {}", config.guildId); + return; + } - for (Map.Entry entry : this.config.voiceChannelStatistics.channelNames.entrySet()) { - Long key = entry.getKey(); - String value = entry.getValue(); + LOGGER.info("Updating guild statistics for guild: {}", guild.getName()); - VoiceChannel channel = guild.getVoiceChannelById(key); + for (Map.Entry entry : config.voiceChannelStatistics.channelNames.entrySet()) { + Long channelId = entry.getKey(); + String nameTemplate = entry.getValue(); - if (channel == null) { - continue; + this.updateChannelStatistics(guild, channelId, nameTemplate); } - Formatter formatter = new Formatter() - .register("{MEMBERS_SIZE}", guild.getMemberCache().stream() - .filter(member -> !member.getUser().isBot()) - .count() - ) - - .register("{ONLINE_MEMBERS_SIZE}", guild.getMemberCache().stream() - .filter(member -> member.getOnlineStatus() != OnlineStatus.OFFLINE) - .filter(member -> !member.getUser().isBot()) - .count() - ) - - .register("{BOT_MEMBERS_SIZE}", guild.getMembers().stream() - .filter(member -> member.getUser().isBot()) - .count() - ) - - .register("{CHANNELS_SIZE}", guild.getChannels().size()) - .register("{ROLES_SIZE}", guild.getRoles().size()) - .register("{TEXT_CHANNELS_SIZE}", guild.getTextChannels().size()) - .register("{VOICE_CHANNELS_SIZE}", guild.getVoiceChannels().size()) - .register("{CATEGORIES_SIZE}", guild.getCategories().size()) - .register("{EMOJIS_SIZE}", guild.getEmojis().size()) - .register("{BOOSTS_SIZE}", guild.getBoostCount()) - .register("{BOOST_TIER}", guild.getBoostTier().getKey()); - - - String stats = formatter.format(value); - channel.getManager().setName(stats).queue(); + LOGGER.info("Guild statistics update completed"); + }).exceptionally(FutureHandler::handleException); + } + + private void updateChannelStatistics(Guild guild, Long channelId, String nameTemplate) { + VoiceChannel channel = guild.getVoiceChannelById(channelId); + + if (channel == null) { + LOGGER.warn("Voice channel not found with ID: {}", channelId); + return; } + + try { + String formattedName = formatChannelName(guild, nameTemplate); + + channel.getManager() + .setName(formattedName) + .queue( + success -> LOGGER.debug("Updated channel '{}' statistics", channel.getName()), + error -> LOGGER.error("Failed to update channel '{}': {}", channel.getName(), error.getMessage()) + ); + } + catch (Exception exception) { + LOGGER.error("Error formatting statistics for channel '{}': {}", channel.getName(), exception.getMessage()); + } + } + + private String formatChannelName(Guild guild, String nameTemplate) { + long membersCount = guild.getMemberCache().stream() + .filter(member -> !member.getUser().isBot()) + .count(); + + long onlineMembersCount = guild.getMemberCache().stream() + .filter(member -> member.getOnlineStatus() != OnlineStatus.OFFLINE) + .filter(member -> !member.getUser().isBot()) + .count(); + + long botMembersCount = guild.getMembers().stream() + .filter(member -> member.getUser().isBot()) + .count(); + + Formatter formatter = new Formatter() + .register("{MEMBERS_SIZE}", membersCount) + .register("{ONLINE_MEMBERS_SIZE}", onlineMembersCount) + .register("{BOT_MEMBERS_SIZE}", botMembersCount) + .register("{CHANNELS_SIZE}", guild.getChannels().size()) + .register("{ROLES_SIZE}", guild.getRoles().size()) + .register("{TEXT_CHANNELS_SIZE}", guild.getTextChannels().size()) + .register("{VOICE_CHANNELS_SIZE}", guild.getVoiceChannels().size()) + .register("{CATEGORIES_SIZE}", guild.getCategories().size()) + .register("{EMOJIS_SIZE}", guild.getEmojis().size()) + .register("{BOOSTS_SIZE}", guild.getBoostCount()) + .register("{BOOST_TIER}", guild.getBoostTier().getKey()); + + return formatter.format(nameTemplate); } } diff --git a/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsTask.java b/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsTask.java index f5494bcc..210307db 100644 --- a/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsTask.java +++ b/src/main/java/com/eternalcode/discordapp/guildstats/GuildStatisticsTask.java @@ -1,6 +1,11 @@ package com.eternalcode.discordapp.guildstats; -public class GuildStatisticsTask implements Runnable { +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class GuildStatisticsTask implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(GuildStatisticsTask.class); private final GuildStatisticsService guildStatisticsService; @@ -10,7 +15,13 @@ public GuildStatisticsTask(GuildStatisticsService guildStatisticsService) { @Override public void run() { - this.guildStatisticsService.displayStats(); + try { + LOGGER.debug("Starting guild statistics update task"); + guildStatisticsService.displayStats().join(); + LOGGER.debug("Guild statistics update task completed"); + } + catch (Exception exception) { + LOGGER.error("Error during guild statistics update: {}", exception.getMessage(), exception); + } } - } diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubPullRequest.java b/src/main/java/com/eternalcode/discordapp/review/GitHubPullRequest.java index 42a689e1..7715539b 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubPullRequest.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubPullRequest.java @@ -1,14 +1,14 @@ package com.eternalcode.discordapp.review; -import panda.std.Result; - import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; +import panda.std.Result; public class GitHubPullRequest { - private static final Pattern GITHUB_PULL_REQUEST_REGEX = Pattern.compile("^https://github\\.com/([a-zA-Z0-9-_]+)/([a-zA-Z0-9-_]+)/pull/([0-9]+)$"); + private static final Pattern GITHUB_PULL_REQUEST_REGEX = + Pattern.compile("^https://github\\.com/([a-zA-Z0-9-_]+)/([a-zA-Z0-9-_]+)/pull/([0-9]+)$"); private static final String GITHUB_PULL_REQUEST_URL = "https://github.com/%s/%s/pull/%s"; private static final String GITHUB_PULL_REQUEST_API_URL = "https://api.github.com/repos/%s/%s/pulls/%d"; @@ -23,6 +23,20 @@ public GitHubPullRequest(String owner, String repository, int number) { this.number = number; } + public static Result fromUrl(String reviewUrl) { + Matcher matcher = GITHUB_PULL_REQUEST_REGEX.matcher(reviewUrl); + + if (!matcher.matches()) { + return Result.error(new IllegalArgumentException("Invalid GitHub pull request URL")); + } + + String owner = matcher.group(1); + String repository = matcher.group(2); + int number = Integer.parseInt(matcher.group(3)); + + return Result.ok(new GitHubPullRequest(owner, repository, number)); + } + public String getOwner() { return this.owner; } @@ -43,20 +57,6 @@ public String toApiUrl() { return String.format(GITHUB_PULL_REQUEST_API_URL, this.owner, this.repository, this.number); } - public static Result fromUrl(String reviewUrl) { - Matcher matcher = GITHUB_PULL_REQUEST_REGEX.matcher(reviewUrl); - - if (!matcher.matches()) { - return Result.error(new IllegalArgumentException("Invalid GitHub pull request URL")); - } - - String owner = matcher.group(1); - String repository = matcher.group(2); - int number = Integer.parseInt(matcher.group(3)); - - return Result.ok(new GitHubPullRequest(owner, repository, number)); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -67,7 +67,9 @@ public boolean equals(Object o) { return false; } - return this.number == that.number && Objects.equals(this.owner, that.owner) && Objects.equals(this.repository, that.repository); + return this.number == that.number && Objects.equals(this.owner, that.owner) && Objects.equals( + this.repository, + that.repository); } @Override diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubRateLimitInterceptor.java b/src/main/java/com/eternalcode/discordapp/review/GitHubRateLimitInterceptor.java new file mode 100644 index 00000000..a4b90998 --- /dev/null +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubRateLimitInterceptor.java @@ -0,0 +1,38 @@ +package com.eternalcode.discordapp.review; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import okhttp3.Interceptor; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +class GitHubRateLimitInterceptor implements Interceptor { + private static final Duration MIN_REQUEST_INTERVAL = Duration.ofMillis(100); + private volatile Instant lastRequest = Instant.MIN; + + @Override + public @NotNull Response intercept(@NotNull Chain chain) throws IOException { + synchronized (this) { + Instant now = Instant.now(); + Instant nextAllowed = lastRequest.plus(MIN_REQUEST_INTERVAL); + + if (now.isBefore(nextAllowed)) { + try { + long sleepMs = Duration.between(now, nextAllowed).toMillis(); + if (sleepMs > 0) { + Thread.sleep(sleepMs); + } + } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while rate limiting", exception); + } + } + + lastRequest = Instant.now(); + } + + return chain.proceed(chain.request()); + } +} diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubRetryInterceptor.java b/src/main/java/com/eternalcode/discordapp/review/GitHubRetryInterceptor.java new file mode 100644 index 00000000..0150b7bf --- /dev/null +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubRetryInterceptor.java @@ -0,0 +1,66 @@ +package com.eternalcode.discordapp.review; + +import java.io.IOException; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +class GitHubRetryInterceptor implements Interceptor { + private static final int MAX_RETRIES = 3; + private static final long RETRY_DELAY_MS = 1000; + + @Override + public @NotNull Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + Response response = null; + IOException lastException = null; + + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + if (response != null) { + response.close(); + } + + response = chain.proceed(request); + + if (response.isSuccessful() || response.code() == 404 || response.code() == 403) { + return response; + } + + if (response.code() >= 500 || response.code() == 429) { + if (attempt < MAX_RETRIES - 1) { + try { + Thread.sleep(RETRY_DELAY_MS * (attempt + 1)); + } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + } + + return response; + } + catch (IOException exception) { + lastException = exception; + if (attempt < MAX_RETRIES - 1) { + try { + Thread.sleep(RETRY_DELAY_MS * (attempt + 1)); + } + catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + if (response != null) { + return response; + } + + throw lastException != null ? lastException : new IOException("Max retries exceeded"); + } +} diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewReminderService.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewReminderService.java index 85280457..3a03f24e 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewReminderService.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewReminderService.java @@ -1,154 +1,266 @@ package com.eternalcode.discordapp.review; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.discordapp.config.AppConfig; import com.eternalcode.discordapp.review.database.GitHubReviewMentionRepository; +import com.eternalcode.discordapp.scheduler.Scheduler; import io.sentry.Sentry; +import java.io.IOException; import java.time.Duration; +import java.time.Instant; import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; -import com.eternalcode.discordapp.config.AppConfig; import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; -import java.io.IOException; public class GitHubReviewReminderService { private static final Logger LOGGER = Logger.getLogger(GitHubReviewReminderService.class.getName()); - private static final Duration DEFAULT_REMINDER_INTERVAL = Duration.ofHours(24); - + private static final Duration GITHUB_API_RATE_LIMIT = Duration.ofSeconds(1); private final JDA jda; private final GitHubReviewMentionRepository mentionRepository; - private final ScheduledExecutorService scheduler; + private final Scheduler scheduler; private final Duration reminderInterval; private final AppConfig appConfig; + private final AtomicBoolean isRunning = new AtomicBoolean(false); + private volatile Instant lastGitHubApiCall = Instant.MIN; public GitHubReviewReminderService( JDA jda, GitHubReviewMentionRepository mentionRepository, AppConfig appConfig, - Duration reminderInterval) { + Scheduler scheduler, + Duration reminderInterval + ) { this.jda = jda; this.mentionRepository = mentionRepository; this.reminderInterval = reminderInterval; - this.scheduler = Executors.newSingleThreadScheduledExecutor(); + this.scheduler = scheduler; this.appConfig = appConfig; } - public GitHubReviewReminderService(JDA jda, GitHubReviewMentionRepository mentionRepository, AppConfig appConfig) { - this(jda, mentionRepository, appConfig, DEFAULT_REMINDER_INTERVAL); - } - public void start() { - this.scheduler.scheduleAtFixedRate(this::sendReminders, 1, this.reminderInterval.toMinutes(), TimeUnit.MINUTES); - LOGGER.info("GitHub review reminder service started"); + if (this.isRunning.compareAndSet(false, true)) { + LOGGER.info("Starting GitHub review reminder service with interval: " + this.reminderInterval); + + this.scheduler.scheduleRepeating( + this::sendRemindersWithErrorHandling, + Duration.ofMinutes(1), + this.reminderInterval + ); + + LOGGER.info("GitHub review reminder service started"); + } + else { + LOGGER.warning("GitHub review reminder service is already running"); + } } public void stop() { - this.scheduler.shutdown(); - try { - if (!this.scheduler.awaitTermination(60, TimeUnit.SECONDS)) { - this.scheduler.shutdownNow(); - } + if (this.isRunning.compareAndSet(true, false)) { + LOGGER.info("GitHub review reminder service stopped"); } - catch (InterruptedException e) { - this.scheduler.shutdownNow(); - Thread.currentThread().interrupt(); - } - LOGGER.info("GitHub review reminder service stopped"); } - private void sendReminders() { + private void sendRemindersWithErrorHandling() { + if (!this.isRunning.get()) { + return; + } + try { - this.mentionRepository.getReviewersNeedingReminders(this.reminderInterval) - .thenAccept(this::processReminders) - .exceptionally(throwable -> { - Sentry.captureException(throwable); - LOGGER.log(Level.SEVERE, "Error sending reminders", throwable); - return null; - }); + this.sendReminders().exceptionally(FutureHandler::handleException); } catch (Exception exception) { Sentry.captureException(exception); - LOGGER.log(Level.SEVERE, "Error scheduling reminders", exception); + LOGGER.log(Level.SEVERE, "Unexpected error in reminder scheduling", exception); } } - private void processReminders(List reminders) { - for (GitHubReviewMentionRepository.ReviewerReminder reminder : reminders) { - this.sendReminder(reminder); + private CompletableFuture sendReminders() { + return this.mentionRepository.getReviewersNeedingReminders(this.reminderInterval) + .thenCompose(this::processReminders) + .exceptionally(throwable -> { + Sentry.captureException(throwable); + LOGGER.log(Level.SEVERE, "Error sending reminders", throwable); + return null; + }); + } + + private CompletableFuture processReminders(List reminders) { + if (reminders == null || reminders.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + LOGGER.info("Processing " + reminders.size() + " reminders"); + + List> reminderFutures = reminders.stream() + .map(this::sendReminderAsync) + .toList(); + + return CompletableFuture.allOf(reminderFutures.toArray(new CompletableFuture[0])) + .thenRun(() -> LOGGER.info("Completed processing all reminders")) + .exceptionally(throwable -> { + Sentry.captureException(throwable); + LOGGER.log(Level.SEVERE, "Error processing reminders", throwable); + return null; + }); + } + + private CompletableFuture sendReminderAsync(GitHubReviewMentionRepository.ReviewerReminder reminder) { + return CompletableFuture.runAsync(() -> this.scheduler.schedule( + () -> { + try { + this.sendReminder(reminder); + } + catch (Exception exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error sending individual reminder", exception); + } + }, this.calculateDelayForRateLimit())); + } + + private Duration calculateDelayForRateLimit() { + Instant now = Instant.now(); + Instant nextAllowed = this.lastGitHubApiCall.plus(GITHUB_API_RATE_LIMIT); + + if (now.isBefore(nextAllowed)) { + return Duration.between(now, nextAllowed); } + + return Duration.ZERO; } private void sendReminder(GitHubReviewMentionRepository.ReviewerReminder reminder) { - long userId = reminder.userId(); - String pullRequestUrl = reminder.pullRequestUrl(); - long threadId = reminder.threadId(); + if (!isRunning.get()) { + return; + } - GitHubPullRequest pullRequest = GitHubPullRequest.fromUrl(pullRequestUrl).orNull(); + String reviewUrl = reminder.pullRequestUrl(); + GitHubPullRequest pullRequest = GitHubPullRequest.fromUrl(reviewUrl).orNull(); if (pullRequest == null) { - LOGGER.warning("Invalid pull request URL: " + pullRequestUrl); + LOGGER.warning("Invalid pull request URL: " + reviewUrl); return; } - boolean isMerged = false; - boolean isClosed = false; + lastGitHubApiCall = Instant.now(); + try { - isMerged = GitHubReviewUtil.isPullRequestMerged(pullRequest, this.appConfig.githubToken); - isClosed = GitHubReviewUtil.isPullRequestClosed(pullRequest, this.appConfig.githubToken); - } catch (IOException e) { - Sentry.captureException(e); - LOGGER.log(Level.SEVERE, "Error checking PR status", e); - return; + boolean isMerged = GitHubReviewUtil.isPullRequestMerged(pullRequest, appConfig.githubToken); + + scheduler.schedule( + () -> { + try { + boolean isClosed = GitHubReviewUtil.isPullRequestClosed(pullRequest, appConfig.githubToken); + handlePRStatusCheck(reminder, pullRequest, isMerged, isClosed); + } + catch (IOException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error checking PR closed status: " + reviewUrl, exception); + } + }, GITHUB_API_RATE_LIMIT); + } + catch (IOException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error checking PR merged status: " + reviewUrl, exception); } + } + private void handlePRStatusCheck( + GitHubReviewMentionRepository.ReviewerReminder reminder, + GitHubPullRequest pullRequest, boolean isMerged, boolean isClosed) { if (isMerged || isClosed) { - ThreadChannel thread = this.jda.getThreadChannelById(threadId); - if (thread != null) { - AppConfig.ReviewSystem reviewSystem = this.appConfig.reviewSystem; - if (isMerged) { - thread.getManager() - .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.mergedTagId)) - .setLocked(true) - .setArchived(true) - .queue(); - } else if (isClosed) { - thread.getManager() - .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.closedTagId)) - .setLocked(true) - .setArchived(true) - .queue(); - } - } - LOGGER.info("PR is merged or closed, skipping reminder for thread: " + threadId); + this.handleClosedOrMergedPR(reminder.threadId(), isMerged); + LOGGER.info( + "PR is " + (isMerged ? "merged" : "closed") + ", skipping reminder for thread: " + reminder.threadId()); return; } - this.jda.retrieveUserById(userId).queue( - user -> { - if (user == null) { - LOGGER.warning("Could not find user with ID " + userId); - return; - } + String githubUsername = this.findGithubUsernameByDiscordId(reminder.userId()); + if (githubUsername == null) { + LOGGER.warning("Could not find GitHub username for Discord userId " + reminder.userId()); + return; + } - ThreadChannel thread = this.jda.getThreadChannelById(threadId); - if (thread == null) { - LOGGER.warning("Could not find thread with ID " + threadId); - return; + this.scheduler.schedule( + () -> { + try { + boolean alreadyReviewed = + GitHubReviewUtil.hasUserReviewed(pullRequest, this.appConfig.githubToken, githubUsername); + if (alreadyReviewed) { + LOGGER.info("User " + githubUsername + " already reviewed PR, skipping reminder."); + return; + } + + this.jda.retrieveUserById(reminder.userId()).queue( + user -> this.handleUserRetrieved( + user, + reminder.threadId(), + reminder.pullRequestUrl(), + pullRequest), + throwable -> { + Sentry.captureException(throwable); + LOGGER.log(Level.SEVERE, "Error retrieving user: " + reminder.userId(), throwable); + } + ); + } + catch (Exception exception) { + Sentry.captureException(exception); + LOGGER.log(Level.WARNING, "Error checking if user reviewed PR: " + reminder.pullRequestUrl(), exception); } + }, GITHUB_API_RATE_LIMIT); + } - this.sendReminderMessage(user, thread, pullRequestUrl); - }, throwable -> { - Sentry.captureException(throwable); - LOGGER.log(Level.SEVERE, "Error retrieving user", throwable); - }); + private void handleClosedOrMergedPR(long threadId, boolean isMerged) { + ThreadChannel thread = this.jda.getThreadChannelById(threadId); + if (thread != null) { + AppConfig.ReviewSystem reviewSystem = this.appConfig.reviewSystem; + long tagId = isMerged ? reviewSystem.mergedTagId : reviewSystem.closedTagId; + + thread.getManager() + .setAppliedTags(ForumTagSnowflake.fromId(tagId)) + .setLocked(true) + .setArchived(true) + .queue( + success -> LOGGER.info( + "Successfully archived " + (isMerged ? "merged" : "closed") + " thread: " + threadId), + failure -> LOGGER.log(Level.WARNING, "Failed to archive thread: " + threadId, failure) + ); + } + } + + private String findGithubUsernameByDiscordId(long userId) { + return this.appConfig.reviewSystem.reviewers.stream() + .filter(user -> user.getDiscordId() != null && user.getDiscordId() == userId) + .map(GitHubReviewUser::getGithubUsername) + .findFirst() + .orElse(null); + } + + private void handleUserRetrieved(User user, long threadId, String pullRequestUrl, GitHubPullRequest pullRequest) { + if (user == null) { + LOGGER.warning("User is null for thread: " + threadId); + return; + } + + ThreadChannel thread = this.jda.getThreadChannelById(threadId); + if (thread == null) { + LOGGER.warning("Could not find thread with ID " + threadId); + return; + } + + this.sendReminderMessage(user, thread, pullRequestUrl, pullRequest); } - private void sendReminderMessage(User user, ThreadChannel thread, String pullRequestUrl) { + private void sendReminderMessage( + User user, + ThreadChannel thread, + String pullRequestUrl, + GitHubPullRequest pullRequest) { String message = String.format( "Hey %s! you have been assigned as a reviewer for this pull request: <%s>.", user.getAsMention(), @@ -157,15 +269,14 @@ private void sendReminderMessage(User user, ThreadChannel thread, String pullReq thread.sendMessage(message).queue( success -> { - GitHubPullRequest pullRequest = GitHubPullRequest.fromUrl(pullRequestUrl).orNull(); - if (pullRequest != null) { - this.mentionRepository.recordReminderSent(pullRequest, user.getIdLong()); - } + LOGGER.info("Reminder sent to " + user.getName() + " for PR: " + pullRequestUrl); + this.mentionRepository.recordReminderSent(pullRequest, user.getIdLong()) + .exceptionally(FutureHandler::handleException); }, throwable -> { Sentry.captureException(throwable); - LOGGER.log(Level.SEVERE, "Error sending reminder message", throwable); + LOGGER.log(Level.SEVERE, "Error sending reminder message to " + user.getName(), throwable); } ); } -} +} diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewService.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewService.java index 08e3ba03..002dd57b 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewService.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewService.java @@ -1,13 +1,18 @@ package com.eternalcode.discordapp.review; +import com.eternalcode.commons.concurrent.FutureHandler; import com.eternalcode.discordapp.config.AppConfig; import com.eternalcode.discordapp.config.ConfigManager; import com.eternalcode.discordapp.review.database.GitHubReviewMentionRepository; import io.sentry.Sentry; import java.io.IOException; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.logging.Level; import java.util.logging.Logger; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; @@ -15,6 +20,7 @@ import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; +import net.dv8tion.jda.api.exceptions.RateLimitedException; import net.dv8tion.jda.api.utils.messages.MessageCreateData; import panda.std.Result; @@ -26,9 +32,11 @@ public class GitHubReviewService { private static final String SERVER_REVIEW_MESSAGE = "%s, you have been assigned as a reviewer for this pull request: %s"; + private static final Duration GITHUB_API_RATE_LIMIT = Duration.ofSeconds(1); private final AppConfig appConfig; private final ConfigManager configManager; private final GitHubReviewMentionRepository mentionRepository; + private volatile Instant lastGitHubApiCall = Instant.MIN; public GitHubReviewService( AppConfig appConfig, @@ -40,35 +48,42 @@ public GitHubReviewService( this.mentionRepository = mentionRepository; } - public String createReview(Guild guild, String url, JDA jda) { - try { - if (this.isReviewPostCreatedInGuild(guild, url)) { - return "Review already exists"; - } + public CompletableFuture createReview(Guild guild, String url, JDA jda) { + return CompletableFuture.supplyAsync(() -> { + try { + if (this.isReviewPostCreatedInGuild(guild, url)) { + return "Review already exists"; + } - Result result = GitHubPullRequest.fromUrl(url); - if (result.isErr()) { - return "URL is not a valid, please provide a valid GitHub pull request URL"; - } + Result result = GitHubPullRequest.fromUrl(url); + if (result.isErr()) { + return "URL is not a valid, please provide a valid GitHub pull request URL"; + } - GitHubPullRequest pullRequest = result.get(); - if (!this.checkPullRequestTitle(pullRequest)) { - return "Pull request title is not valid, please use GH- as title and keep it under 100 characters"; - } + GitHubPullRequest pullRequest = result.get(); + if (!this.checkPullRequestTitle(pullRequest)) { + return "Pull request title is not valid, please use GH- as title and keep it under 100 characters"; + } - long messageId = this.createReviewForumPost(guild, pullRequest); - this.mentionReviewers(jda, pullRequest, messageId); + long messageId = this.createReviewForumPost(guild, pullRequest); + this.mentionReviewers(jda, pullRequest, messageId); - return "Review created"; - } - catch (IOException exception) { - Sentry.captureException(exception); - exception.printStackTrace(); - return "Something went wrong"; - } + return "Review created"; + } + catch (IOException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error creating review", exception); + throw new CompletionException(exception); + } + }).exceptionally(throwable -> { + Sentry.captureException(throwable); + LOGGER.log(Level.SEVERE, "Failed to create review", throwable); + return "Something went wrong while creating review"; + }); } public boolean checkPullRequestTitle(GitHubPullRequest url) throws IOException { + this.waitForRateLimit(); String pullRequestTitleFromUrl = GitHubReviewUtil.getPullRequestTitleFromUrl(url, this.appConfig.githubToken); return GitHubReviewUtil.isPullRequestTitleValid(pullRequestTitleFromUrl) && @@ -76,14 +91,16 @@ public boolean checkPullRequestTitle(GitHubPullRequest url) throws IOException { } public long createReviewForumPost(Guild guild, GitHubPullRequest pullRequest) throws IOException { + this.waitForRateLimit(); String pullRequestTitleFromUrl = GitHubReviewUtil.getPullRequestTitleFromUrl(pullRequest, this.appConfig.githubToken); ForumChannel forumChannel = guild.getForumChannelById(this.appConfig.reviewSystem.reviewForumId); - MessageCreateData createData = MessageCreateData.fromContent(GitHubReviewUtil.getPullRequestTitleFromUrl( - pullRequest, - this.appConfig.githubToken - )); + if (forumChannel == null) { + throw new IOException("Forum channel not found with ID: " + this.appConfig.reviewSystem.reviewForumId); + } + + MessageCreateData createData = MessageCreateData.fromContent(pullRequestTitleFromUrl); return forumChannel.createForumPost(pullRequestTitleFromUrl, createData) .setName(pullRequest.toUrl()) @@ -93,119 +110,188 @@ public long createReviewForumPost(Guild guild, GitHubPullRequest pullRequest) th .getIdLong(); } - public void mentionReviewers(JDA jda, GitHubPullRequest pullRequest, long forumId) { - List assignedReviewers = GitHubReviewUtil.getReviewers(pullRequest, this.appConfig.githubToken); - - if (assignedReviewers.isEmpty()) { - return; - } - - StringBuilder reviewersMention = new StringBuilder(); - CompletableFuture mentionFuture = CompletableFuture.completedFuture(null); + public CompletableFuture mentionReviewers(JDA jda, GitHubPullRequest pullRequest, long forumId) { + return CompletableFuture.runAsync(this::waitForRateLimit).thenCompose(v -> CompletableFuture.supplyAsync(() -> { + try { + return GitHubReviewUtil.getReviewers(pullRequest, this.appConfig.githubToken); + } + catch (Exception exception) { + LOGGER.log(Level.WARNING, "Failed to get reviewers for PR: " + pullRequest.toUrl(), exception); + return new ArrayList(); + } + })).thenCompose(assignedReviewers -> { + if (assignedReviewers.isEmpty()) { + return CompletableFuture.completedFuture(null); + } - for (String reviewer : assignedReviewers) { - GitHubReviewUser gitHubReviewUser = this.getReviewUserByUsername(reviewer); + List> mentionFutures = new ArrayList<>(); + StringBuilder reviewersMention = new StringBuilder(); - if (gitHubReviewUser == null) { - continue; - } + for (String reviewer : assignedReviewers) { + GitHubReviewUser gitHubReviewUser = this.getReviewUserByUsername(reviewer); - Long discordId = gitHubReviewUser.getDiscordId(); + if (gitHubReviewUser == null || gitHubReviewUser.getDiscordId() == null) { + continue; + } - if (discordId != null) { - mentionFuture = mentionFuture.thenComposeAsync(v -> - this.mentionRepository.isMentioned(pullRequest, discordId) - ).thenAcceptAsync(isMentioned -> { - if (!isMentioned) { - User user = jda.getUserById(discordId); - GitHubReviewNotificationType notificationType = gitHubReviewUser.getNotificationType(); + Long discordId = gitHubReviewUser.getDiscordId(); - if (user == null) { - return; + CompletableFuture mentionFuture = this.mentionRepository.isMentioned(pullRequest, discordId) + .thenCompose(isMentioned -> { + if (isMentioned) { + return CompletableFuture.completedFuture(null); } - String message = String.format(DM_REVIEW_MESSAGE, pullRequest.toUrl()); - - if (notificationType.isDmNotify()) { + return CompletableFuture.runAsync(() -> { try { - LOGGER.info("Sending message to: " + user.getName()); - user.openPrivateChannel().queue( - privateChannel -> privateChannel.sendMessage(message).queue(), - throwable -> LOGGER.warning("Cannot send message to: " + user.getName())); + User user = jda.getUserById(discordId); + if (user == null) { + LOGGER.warning("User not found with ID: " + discordId); + return; + } + + GitHubReviewNotificationType notificationType = gitHubReviewUser.getNotificationType(); + String message = String.format(DM_REVIEW_MESSAGE, pullRequest.toUrl()); + + if (notificationType.isDmNotify()) { + this.sendDirectMessage(user, message); + } + + if (notificationType.isServerNotify()) { + synchronized (reviewersMention) { + reviewersMention.append(user.getAsMention()).append(" "); + } + } + + this.mentionRepository.markReviewerAsMentioned(pullRequest, discordId, forumId) + .exceptionally(FutureHandler::handleException); } catch (Exception exception) { Sentry.captureException(exception); - LOGGER.warning("Cannot send message to: " + user.getName()); + LOGGER.log(Level.SEVERE, "Error mentioning reviewer: " + discordId, exception); } + }); + }) + .exceptionally(FutureHandler::handleException); + + mentionFutures.add(mentionFuture); + } + + return CompletableFuture.allOf(mentionFutures.toArray(new CompletableFuture[0])) + .thenRun(() -> { + if (!reviewersMention.isEmpty()) { + String message = String.format( + SERVER_REVIEW_MESSAGE, + reviewersMention.toString().trim(), + pullRequest.toUrl()); + ThreadChannel threadChannel = jda.getThreadChannelById(forumId); + + if (threadChannel != null) { + threadChannel.sendMessage(message).queue( + success -> LOGGER.info("Server mention sent for PR: " + pullRequest.toUrl()), + failure -> { + Sentry.captureException(failure); + LOGGER.log(Level.WARNING, "Failed to send server mention", failure); + } + ); } - if (notificationType.isServerNotify()) { - reviewersMention.append(user.getAsMention()).append(" "); + else { + LOGGER.warning("Thread channel not found with ID: " + forumId); } - - this.mentionRepository.markReviewerAsMentioned(pullRequest, discordId, forumId); } }); - } - } - - mentionFuture.thenRunAsync(() -> { - if (!reviewersMention.isEmpty()) { - String message = String.format(SERVER_REVIEW_MESSAGE, reviewersMention, pullRequest.toUrl()); - ThreadChannel threadChannel = jda.getThreadChannelById(forumId); + }).exceptionally(throwable -> { + Sentry.captureException(throwable); + LOGGER.log(Level.SEVERE, "Error in mentionReviewers", throwable); + return null; + }); + } - if (threadChannel != null) { - threadChannel.sendMessage(message).queue(); + private void sendDirectMessage(User user, String message) { + user.openPrivateChannel().queue( + privateChannel -> privateChannel.sendMessage(message).queue( + success -> LOGGER.info("DM sent to: " + user.getName()), + failure -> { + if (failure instanceof RateLimitedException) { + LOGGER.warning("Rate limited when sending DM to: " + user.getName()); + } + else { + LOGGER.warning("Cannot send DM to: " + user.getName() + " - " + failure.getMessage()); + } } - } - }); + ), + throwable -> LOGGER.warning( + "Cannot open private channel with: " + user.getName() + " - " + throwable.getMessage()) + ); } - public void mentionReviewersOnAllReviewChannels(JDA jda) { - Guild guild = jda.getGuildById(this.appConfig.guildId); + public CompletableFuture mentionReviewersOnAllReviewChannels(JDA jda) { + return CompletableFuture.runAsync(() -> { + Guild guild = jda.getGuildById(this.appConfig.guildId); - if (guild == null) { - return; - } + if (guild == null) { + LOGGER.warning("Guild not found with ID: " + this.appConfig.guildId); + return; + } - for (ForumChannel forumChannel : guild.getForumChannels()) { - for (ThreadChannel threadChannel : forumChannel.getThreadChannels()) { - Result result = - GitHubPullRequest.fromUrl(threadChannel.getName()); + List> channelFutures = new ArrayList<>(); - if (result.isErr()) { - continue; - } + for (ForumChannel forumChannel : guild.getForumChannels()) { + for (ThreadChannel threadChannel : forumChannel.getThreadChannels()) { + Result result = + GitHubPullRequest.fromUrl(threadChannel.getName()); + + if (result.isErr()) { + continue; + } + + CompletableFuture channelFuture = + this.mentionReviewers(jda, result.get(), threadChannel.getIdLong()) + .exceptionally(FutureHandler::handleException); - this.mentionReviewers(jda, result.get(), threadChannel.getIdLong()); + channelFutures.add(channelFuture); + } } - } + + CompletableFuture.allOf(channelFutures.toArray(new CompletableFuture[0])) + .exceptionally(FutureHandler::handleException) + .join(); + }).exceptionally(FutureHandler::handleException); } public boolean isReviewPostCreatedInGuild(Guild guild, String url) { - List threadChannels = new ArrayList<>(); - - for (ForumChannel forumChannel : guild.getForumChannels()) { - threadChannels.addAll(forumChannel.getThreadChannels()); + if (guild == null) { + LOGGER.warning("Guild is null when checking for existing review post"); + return false; } - for (ThreadChannel threadChannel : threadChannels) { - if (threadChannel.getName().equals(url)) { - return true; + try { + for (ForumChannel forumChannel : guild.getForumChannels()) { + for (ThreadChannel threadChannel : forumChannel.getThreadChannels()) { + if (url.equals(threadChannel.getName())) { + return true; + } + } } } + catch (Exception exception) { + LOGGER.log(Level.WARNING, "Error checking if review post exists", exception); + } return false; } - public void archiveMergedPullRequest(JDA jda) { - try { + public CompletableFuture archiveMergedPullRequest(JDA jda) { + return CompletableFuture.runAsync(() -> { Guild guild = jda.getGuildById(this.appConfig.guildId); if (guild == null) { + LOGGER.warning("Guild not found with ID: " + this.appConfig.guildId); return; } AppConfig.ReviewSystem reviewSystem = this.appConfig.reviewSystem; + List> archiveFutures = new ArrayList<>(); for (ForumChannel forumChannel : guild.getForumChannels()) { for (ThreadChannel threadChannel : forumChannel.getThreadChannels()) { @@ -218,28 +304,59 @@ public void archiveMergedPullRequest(JDA jda) { GitHubPullRequest pullRequest = result.get(); - if (GitHubReviewUtil.isPullRequestMerged(pullRequest, this.appConfig.githubToken)) { - threadChannel.getManager() - .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.mergedTagId)) - .setLocked(true) - .setArchived(true) - .queue(); - } + CompletableFuture archiveFuture = CompletableFuture.runAsync(() -> { + try { + this.waitForRateLimit(); + + boolean isMerged = + GitHubReviewUtil.isPullRequestMerged(pullRequest, this.appConfig.githubToken); + boolean isClosed = + GitHubReviewUtil.isPullRequestClosed(pullRequest, this.appConfig.githubToken); + + if (isMerged) { + threadChannel.getManager() + .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.mergedTagId)) + .setLocked(true) + .setArchived(true) + .queue( + success -> LOGGER.info("Archived merged PR: " + pullRequest.toUrl()), + failure -> LOGGER.log( + Level.WARNING, + "Failed to archive merged PR: " + pullRequest.toUrl(), + failure) + ); + } + else if (isClosed) { + threadChannel.getManager() + .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.closedTagId)) + .setLocked(true) + .setArchived(true) + .queue( + success -> LOGGER.info("Archived closed PR: " + pullRequest.toUrl()), + failure -> LOGGER.log( + Level.WARNING, + "Failed to archive closed PR: " + pullRequest.toUrl(), + failure) + ); + } + } + catch (IOException exception) { + Sentry.captureException(exception); + LOGGER.log( + Level.WARNING, + "Error checking PR status for archival: " + pullRequest.toUrl(), + exception); + } + }); - if (GitHubReviewUtil.isPullRequestClosed(pullRequest, this.appConfig.githubToken)) { - threadChannel.getManager() - .setAppliedTags(ForumTagSnowflake.fromId(reviewSystem.closedTagId)) - .setLocked(true) - .setArchived(true) - .queue(); - } + archiveFutures.add(archiveFuture); } } - } - catch (IOException exception) { - Sentry.captureException(exception); - exception.printStackTrace(); - } + + CompletableFuture.allOf(archiveFutures.toArray(new CompletableFuture[0])) + .exceptionally(FutureHandler::handleException) + .join(); + }).exceptionally(FutureHandler::handleException); } public boolean addUserToSystem(GitHubReviewUser gitHubReviewUser) { @@ -266,11 +383,9 @@ public boolean removeUserFromSystem(Long discordId) { public void updateUserNotificationType(Long discordId, GitHubReviewNotificationType newNotificationType) { for (GitHubReviewUser user : this.appConfig.reviewSystem.reviewers) { - if (user.getDiscordId().equals(discordId)) { user.setNotificationType(newNotificationType); this.configManager.save(this.appConfig); - return; } } @@ -296,4 +411,22 @@ public GitHubReviewUser getReviewUserByUsername(String githubUsername) { .findFirst() .orElse(null); } + + private synchronized void waitForRateLimit() { + Instant now = Instant.now(); + Instant nextAllowed = this.lastGitHubApiCall.plus(GITHUB_API_RATE_LIMIT); + + if (now.isBefore(nextAllowed)) { + try { + long sleepMs = Duration.between(now, nextAllowed).toMillis(); + Thread.sleep(sleepMs); + } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + LOGGER.warning("Interrupted while waiting for rate limit"); + } + } + + this.lastGitHubApiCall = Instant.now(); + } } diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewStatus.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewStatus.java index 1df441c1..8d73e388 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewStatus.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewStatus.java @@ -19,7 +19,7 @@ public static GitHubReviewStatus fromString(String status) { if (status == null) { return PENDING; } - + return switch (status.toLowerCase()) { case "pending" -> PENDING; case "approved" -> APPROVED; @@ -42,7 +42,7 @@ public static GitHubReviewStatus fromString(String status) { public String getDisplayName() { return this.displayName; } - + @Override public String toString() { return this.displayName; diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewTask.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewTask.java index 61dedbc0..b3d34b9f 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewTask.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewTask.java @@ -1,30 +1,105 @@ package com.eternalcode.discordapp.review; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.discordapp.scheduler.Scheduler; import io.sentry.Sentry; -import net.dv8tion.jda.api.JDA; -import java.util.logging.Logger; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; +import java.util.logging.Logger; +import net.dv8tion.jda.api.JDA; -public class GitHubReviewTask implements Runnable { +public class GitHubReviewTask { private static final Logger LOGGER = Logger.getLogger(GitHubReviewTask.class.getName()); + private static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(30); + private static final Duration TASK_INTERVAL = Duration.ofMinutes(15); + private final GitHubReviewService gitHubReviewService; private final JDA jda; + private final Scheduler scheduler; + private final AtomicBoolean isRunning = new AtomicBoolean(false); - public GitHubReviewTask(GitHubReviewService gitHubReviewService, JDA jda) { + public GitHubReviewTask(GitHubReviewService gitHubReviewService, JDA jda, Scheduler scheduler) { this.gitHubReviewService = gitHubReviewService; this.jda = jda; + this.scheduler = scheduler; + } + + public void start() { + if (this.isRunning.compareAndSet(false, true)) { + LOGGER.info("Starting GitHub review task with interval: " + TASK_INTERVAL); + this.scheduler.scheduleRepeating(this::executeTask, Duration.ofMinutes(1), TASK_INTERVAL); + } + else { + LOGGER.warning("GitHub review task is already running"); + } } - @Override - public void run() { + public void stop() { + if (this.isRunning.compareAndSet(true, false)) { + LOGGER.info("Stopping GitHub review task"); + } + } + + private void executeTask() { + if (!this.isRunning.get()) { + return; + } + + LOGGER.info("Starting GitHub review task execution"); + try { - this.gitHubReviewService.archiveMergedPullRequest(this.jda); - this.gitHubReviewService.mentionReviewersOnAllReviewChannels(this.jda); + if (this.jda.getStatus() != JDA.Status.CONNECTED) { + LOGGER.warning("JDA is not connected, skipping task execution. Status: " + this.jda.getStatus()); + return; + } + + CompletableFuture archiveTask = + CompletableFuture.runAsync(() -> this.scheduler.schedule(() -> this.gitHubReviewService.archiveMergedPullRequest( + this.jda) + .orTimeout(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .exceptionally(throwable -> { + if (throwable instanceof TimeoutException) { + LOGGER.log(Level.WARNING, "Archive task timed out after " + OPERATION_TIMEOUT); + } + else { + LOGGER.log(Level.SEVERE, "Error in archiveMergedPullRequest", throwable); + } + Sentry.captureException(throwable); + return null; + }) + .join())); + + CompletableFuture mentionTask = CompletableFuture.runAsync(() -> this.scheduler.schedule(() -> { + this.gitHubReviewService.mentionReviewersOnAllReviewChannels(this.jda) + .orTimeout(OPERATION_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .exceptionally(throwable -> { + if (throwable instanceof TimeoutException) { + LOGGER.log(Level.WARNING, "Mention task timed out after " + OPERATION_TIMEOUT); + } + else { + LOGGER.log(Level.SEVERE, "Error in mentionReviewersOnAllReviewChannels", throwable); + } + Sentry.captureException(throwable); + return null; + }) + .join(); + })); + + CompletableFuture.allOf(archiveTask, mentionTask) + .whenComplete(FutureHandler.whenSuccess(result -> + LOGGER.info("GitHub review task completed successfully") + )) + .exceptionally(FutureHandler::handleException) + .join(); } catch (Exception exception) { Sentry.captureException(exception); - LOGGER.log(Level.SEVERE, "Error in GitHubReviewTask", exception); + LOGGER.log(Level.SEVERE, "Unexpected error in GitHubReviewTask", exception); } } } diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUser.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUser.java index 4a426dee..31fa7350 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUser.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUser.java @@ -34,7 +34,7 @@ public Long getDiscordId() { public String getGithubUsername() { return this.githubUsername; } - + @Override public String toString() { return """ @@ -43,9 +43,9 @@ public String toString() { githubUsername=%s, notificationType=%s ]""".formatted( - this.discordId, - this.githubUsername, - this.notificationType - ); + this.discordId, + this.githubUsername, + this.notificationType + ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUtil.java b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUtil.java index d6d03a4a..4efe41b0 100644 --- a/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUtil.java +++ b/src/main/java/com/eternalcode/discordapp/review/GitHubReviewUtil.java @@ -4,120 +4,320 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import io.sentry.Sentry; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; public final class GitHubReviewUtil { + private static final Logger LOGGER = Logger.getLogger(GitHubReviewUtil.class.getName()); + private static final String GITHUB_PULL_REQUEST_TITLE_CONVENTION = "^(GH)-\\d+ .+$"; private static final int MAX_TITLE_LENGTH = 100; - private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); private static final Gson GSON = new Gson(); private static final String AUTHORIZATION = "Authorization"; private static final String TOKEN = "token "; private static final String HTTP_ERROR = "HTTP Error: "; + private static final String USER_AGENT = "EternalCode-DiscordBot/1.0"; + + private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(new GitHubRetryInterceptor()) + .addInterceptor(new GitHubRateLimitInterceptor()) + .build(); private GitHubReviewUtil() {} public static boolean isPullRequestTitleValid(String title) { + if (title == null || title.trim().isEmpty()) { + return false; + } return title.matches(GITHUB_PULL_REQUEST_TITLE_CONVENTION); } public static boolean isTitleLengthValid(String title) { + if (title == null) { + return false; + } return title.length() <= MAX_TITLE_LENGTH; } public static List getReviewers(GitHubPullRequest pullRequest, String githubToken) { + if (pullRequest == null || githubToken == null || githubToken.trim().isEmpty()) { + LOGGER.warning("Invalid parameters for getReviewers"); + return Collections.emptyList(); + } + Request request = new Request.Builder() - .url(pullRequest.toApiUrl()) - .header(AUTHORIZATION, TOKEN + githubToken) - .build(); + .url(pullRequest.toApiUrl()) + .header(AUTHORIZATION, TOKEN + githubToken) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/vnd.github.v3+json") + .build(); - try { - Response response = HTTP_CLIENT.newCall(request).execute(); - String responseBody = response.body().string(); + try (Response response = HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorMsg = HTTP_ERROR + response.code() + " for URL: " + pullRequest.toApiUrl(); + if (response.code() == 404) { + LOGGER.warning("Pull request not found: " + pullRequest.toApiUrl()); + } + else if (response.code() == 403) { + LOGGER.warning("GitHub API rate limit exceeded or access denied"); + } + else { + LOGGER.warning(errorMsg); + Sentry.captureException(new IOException(errorMsg)); + } + return Collections.emptyList(); + } - JsonObject json = GSON.fromJson(responseBody, JsonObject.class); - JsonArray requestedReviewers = json.getAsJsonArray("requested_reviewers"); + String responseBody = response.body() != null ? response.body().string() : ""; + if (responseBody.isEmpty()) { + LOGGER.warning("Empty response body from GitHub API"); + return Collections.emptyList(); + } + JsonObject json; + try { + json = GSON.fromJson(responseBody, JsonObject.class); + } + catch (JsonSyntaxException exception) { + LOGGER.log(Level.WARNING, "Invalid JSON response from GitHub API", exception); + return Collections.emptyList(); + } + + if (json == null || !json.has("requested_reviewers")) { + return Collections.emptyList(); + } + + JsonArray requestedReviewers = json.getAsJsonArray("requested_reviewers"); List reviewers = new ArrayList<>(); - response.close(); for (int i = 0; i < requestedReviewers.size(); i++) { - JsonObject reviewer = requestedReviewers.get(i).getAsJsonObject(); - String reviewerLogin = reviewer.get("login").getAsString(); - reviewers.add(reviewerLogin); + try { + JsonObject reviewer = requestedReviewers.get(i).getAsJsonObject(); + if (reviewer != null && reviewer.has("login")) { + String reviewerLogin = reviewer.get("login").getAsString(); + if (reviewerLogin != null && !reviewerLogin.trim().isEmpty()) { + reviewers.add(reviewerLogin); + } + } + } + catch (Exception exception) { + LOGGER.log(Level.WARNING, "Error parsing reviewer at index " + i, exception); + } } return reviewers; } catch (IOException exception) { Sentry.captureException(exception); - exception.printStackTrace(); + LOGGER.log(Level.SEVERE, "IOException in getReviewers for " + pullRequest.toApiUrl(), exception); return Collections.emptyList(); } } - public static String getPullRequestTitleFromUrl(GitHubPullRequest pullRequest, String githubToken) throws IOException { + public static String getPullRequestTitleFromUrl(GitHubPullRequest pullRequest, String githubToken) + throws IOException { + if (pullRequest == null || githubToken == null || githubToken.trim().isEmpty()) { + throw new IOException("Invalid parameters for getPullRequestTitleFromUrl"); + } + Request request = new Request.Builder() - .url(pullRequest.toApiUrl()) - .header(AUTHORIZATION, TOKEN + githubToken) - .build(); + .url(pullRequest.toApiUrl()) + .header(AUTHORIZATION, TOKEN + githubToken) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/vnd.github.v3+json") + .build(); - Response response = HTTP_CLIENT.newCall(request).execute(); + try (Response response = HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException(HTTP_ERROR + response.code() + " for URL: " + pullRequest.toApiUrl()); + } - if (!response.isSuccessful()) { - throw new IOException(HTTP_ERROR + response.code()); - } + String responseBody = response.body() != null ? response.body().string() : ""; + if (responseBody.isEmpty()) { + throw new IOException("Empty response body from GitHub API for URL: " + pullRequest.toApiUrl()); + } + + JsonObject jsonObject; + try { + jsonObject = JsonParser.parseString(responseBody).getAsJsonObject(); + } + catch (JsonSyntaxException exception) { + throw new IOException("Invalid JSON response from GitHub API for URL: " + pullRequest.toApiUrl(), exception); + } - String string = response.body().string(); - JsonObject jsonObject = JsonParser.parseString(string).getAsJsonObject(); - response.close(); + if (jsonObject == null || !jsonObject.has("title")) { + throw new IOException("Missing 'title' in API response for URL: " + pullRequest.toApiUrl()); + } - return jsonObject.get("title").getAsString(); + String title = jsonObject.get("title").getAsString(); + return title != null ? title : ""; + } } public static boolean isPullRequestMerged(GitHubPullRequest pullRequest, String githubToken) throws IOException { - Request request = new Request.Builder() - .url(pullRequest.toApiUrl()) - .header(AUTHORIZATION, TOKEN + githubToken) - .build(); + return checkPullRequestBooleanField(pullRequest, githubToken, "merged"); + } - Response response = HTTP_CLIENT.newCall(request).execute(); - if (!response.isSuccessful()) { - throw new IOException(HTTP_ERROR + response.code()); + public static boolean isPullRequestClosed(GitHubPullRequest pullRequest, String githubToken) throws IOException { + if (pullRequest == null || githubToken == null || githubToken.trim().isEmpty()) { + throw new IOException("Invalid parameters for isPullRequestClosed"); } - JsonObject json = GSON.fromJson(response.body().string(), JsonObject.class); - response.close(); + Request request = new Request.Builder() + .url(pullRequest.toApiUrl()) + .header(AUTHORIZATION, TOKEN + githubToken) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/vnd.github.v3+json") + .build(); + + try (Response response = HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException(HTTP_ERROR + response.code() + " for URL: " + pullRequest.toApiUrl()); + } + + String responseBody = response.body() != null ? response.body().string() : ""; + JsonObject json; + try { + json = GSON.fromJson(responseBody, JsonObject.class); + } + catch (JsonSyntaxException exception) { + throw new IOException("Invalid JSON response from GitHub API", exception); + } + + if (json == null || !json.has("state")) { + throw new IOException("Missing 'state' in API response for URL: " + pullRequest.toApiUrl()); + } - return json.get("merged").getAsBoolean(); + String state = json.get("state").getAsString(); + return "closed".equalsIgnoreCase(state); + } } - public static boolean isPullRequestClosed(GitHubPullRequest pullRequest, String githubToken) throws IOException { + private static boolean checkPullRequestBooleanField( + GitHubPullRequest pullRequest, + String githubToken, + String fieldName + ) throws IOException { + if (pullRequest == null || githubToken == null || githubToken.trim().isEmpty() || fieldName == null) { + throw new IOException("Invalid parameters for checkPullRequestBooleanField"); + } + Request request = new Request.Builder() .url(pullRequest.toApiUrl()) .header(AUTHORIZATION, TOKEN + githubToken) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/vnd.github.v3+json") .build(); - Response response = HTTP_CLIENT.newCall(request).execute(); - if (!response.isSuccessful()) { - throw new IOException(HTTP_ERROR + response.code()); + try (Response response = HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException(HTTP_ERROR + response.code() + " for URL: " + pullRequest.toApiUrl()); + } + + String responseBody = response.body() != null ? response.body().string() : ""; + JsonObject json; + try { + json = GSON.fromJson(responseBody, JsonObject.class); + } + catch (JsonSyntaxException exception) { + throw new IOException("Invalid JSON response from GitHub API", exception); + } + + if (json == null || !json.has(fieldName)) { + throw new IOException("Missing '" + fieldName + "' in API response for URL: " + pullRequest.toApiUrl()); + } + return json.get(fieldName).getAsBoolean(); + } + } + + public static boolean hasUserReviewed(GitHubPullRequest pullRequest, String githubToken, String githubUsername) { + if (pullRequest == null || githubToken == null || githubToken.trim().isEmpty() || + githubUsername == null || githubUsername.trim().isEmpty()) { + LOGGER.warning("Invalid parameters for hasUserReviewed"); + return false; } - JsonObject json = GSON.fromJson(response.body().string(), JsonObject.class); - String state = json.get("state").getAsString(); - response.close(); + String reviewsUrl = pullRequest.toApiUrl() + "/reviews"; + Request request = new Request.Builder() + .url(reviewsUrl) + .header(AUTHORIZATION, TOKEN + githubToken) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/vnd.github.v3+json") + .build(); + + try (Response response = HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorMsg = HTTP_ERROR + response.code() + " for URL: " + reviewsUrl; + if (response.code() == 404) { + LOGGER.warning("Reviews not found for PR: " + pullRequest.toUrl()); + } + else { + LOGGER.warning(errorMsg); + Sentry.captureException(new IOException(errorMsg)); + } + return false; + } + + String responseBody = response.body() != null ? response.body().string() : ""; + if (responseBody.isEmpty()) { + LOGGER.warning("Empty response body for reviews"); + return false; + } + JsonArray reviews; + try { + reviews = JsonParser.parseString(responseBody).getAsJsonArray(); + } + catch (JsonSyntaxException exception) { + LOGGER.log(Level.WARNING, "Invalid JSON response for reviews", exception); + return false; + } - return "closed".equalsIgnoreCase(state); + for (int i = 0; i < reviews.size(); i++) { + try { + JsonObject review = reviews.get(i).getAsJsonObject(); + if (review != null && review.has("user")) { + JsonObject user = review.getAsJsonObject("user"); + if (user != null && user.has("login")) { + String login = user.get("login").getAsString(); + if (githubUsername.equalsIgnoreCase(login)) { + if (review.has("state")) { + String state = review.get("state").getAsString(); + if ("APPROVED".equalsIgnoreCase(state) || + "CHANGES_REQUESTED".equalsIgnoreCase(state) || + "COMMENTED".equalsIgnoreCase(state)) { + return true; + } + } + } + } + } + } + catch (Exception exception) { + LOGGER.log(Level.WARNING, "Error parsing review at index " + i, exception); + } + } + return false; + } + catch (IOException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "IOException in hasUserReviewed", exception); + return false; + } } } diff --git a/src/main/java/com/eternalcode/discordapp/review/command/child/RequestChild.java b/src/main/java/com/eternalcode/discordapp/review/command/child/RequestChild.java index 4c485e9f..4929a5a7 100644 --- a/src/main/java/com/eternalcode/discordapp/review/command/child/RequestChild.java +++ b/src/main/java/com/eternalcode/discordapp/review/command/child/RequestChild.java @@ -1,14 +1,14 @@ package com.eternalcode.discordapp.review.command.child; +import com.eternalcode.commons.concurrent.FutureHandler; import com.eternalcode.discordapp.review.GitHubReviewService; import com.jagrosh.jdautilities.command.SlashCommand; import com.jagrosh.jdautilities.command.SlashCommandEvent; +import java.util.List; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; -import java.util.List; - public class RequestChild extends SlashCommand { private final GitHubReviewService gitHubReviewService; @@ -17,11 +17,11 @@ public RequestChild(GitHubReviewService gitHubReviewService) { this.name = "request"; this.help = "Request a review"; - this.userPermissions = new Permission[]{ Permission.MESSAGE_MANAGE }; + this.userPermissions = new Permission[] {Permission.MESSAGE_MANAGE}; this.options = List.of( - new OptionData(OptionType.STRING, "url", "The URL of the pull request") - .setRequired(true) + new OptionData(OptionType.STRING, "url", "The URL of the pull request") + .setRequired(true) ); this.gitHubReviewService = gitHubReviewService; @@ -31,7 +31,10 @@ public RequestChild(GitHubReviewService gitHubReviewService) { public void execute(SlashCommandEvent event) { String url = event.getOption("url").getAsString(); - String review = this.gitHubReviewService.createReview(event.getGuild(), url, event.getJDA()); - event.reply(review).setEphemeral(true).queue(); + this.gitHubReviewService.createReview(event.getGuild(), url, event.getJDA()) + .thenAccept(message -> event.reply(message) + .setEphemeral(true) + .queue()) + .exceptionally(FutureHandler::handleException); } } diff --git a/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionRepositoryImpl.java b/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionRepositoryImpl.java index cd5bc4de..27a56a1b 100644 --- a/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionRepositoryImpl.java +++ b/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionRepositoryImpl.java @@ -1,11 +1,14 @@ package com.eternalcode.discordapp.review.database; +import com.eternalcode.commons.concurrent.FutureHandler; import com.eternalcode.discordapp.database.DataAccessException; import com.eternalcode.discordapp.database.DatabaseManager; import com.eternalcode.discordapp.database.repository.AbstractRepository; import com.eternalcode.discordapp.review.GitHubPullRequest; import com.eternalcode.discordapp.review.GitHubReviewMention; import com.eternalcode.discordapp.review.GitHubReviewStatus; +import com.eternalcode.discordapp.scheduler.Scheduler; +import com.j256.ormlite.stmt.DeleteBuilder; import com.j256.ormlite.table.TableUtils; import io.sentry.Sentry; import java.sql.SQLException; @@ -14,106 +17,209 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; public class GitHubReviewMentionRepositoryImpl extends AbstractRepository implements GitHubReviewMentionRepository { + private static final Logger LOGGER = Logger.getLogger(GitHubReviewMentionRepositoryImpl.class.getName()); private static final Duration MENTION_INTERVAL = Duration.ofHours(12); + private static final Duration CLEANUP_INTERVAL = Duration.ofDays(7); - public GitHubReviewMentionRepositoryImpl(DatabaseManager databaseManager) { + private final Scheduler scheduler; + + public GitHubReviewMentionRepositoryImpl(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, GitHubReviewMentionWrapper.class); + this.scheduler = scheduler; + + this.scheduler.scheduleRepeating( + this::performCleanup, + Duration.ofHours(1), + Duration.ofHours(24) + ); } - public static GitHubReviewMentionRepository create(DatabaseManager databaseManager) { + public static GitHubReviewMentionRepository create(DatabaseManager databaseManager, Scheduler scheduler) { try { TableUtils.createTableIfNotExists(databaseManager.getConnectionSource(), GitHubReviewMentionWrapper.class); + LOGGER.info("GitHubReviewMentionRepository initialized successfully"); } catch (SQLException sqlException) { Sentry.captureException(sqlException); - throw new DataAccessException("Failed to create table", sqlException); + throw new DataAccessException("Failed to create github_review_mentions table", sqlException); } - return new GitHubReviewMentionRepositoryImpl(databaseManager); + return new GitHubReviewMentionRepositoryImpl(databaseManager, scheduler); } @Override public CompletableFuture markReviewerAsMentioned(GitHubPullRequest pullRequest, long userId, long threadId) { - return CompletableFuture.runAsync(() -> { - GitHubReviewMentionWrapper mention = - GitHubReviewMentionWrapper.create( - pullRequest.toUrl(), - userId, - Instant.now(), - GitHubReviewStatus.PENDING, - threadId); - this.save(mention); - }); + if (pullRequest == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException("PullRequest cannot be null")); + } + + return CompletableFuture.runAsync(() -> this.scheduler.schedule(() -> { + try { + String pullRequestKey = this.createPullRequestKey(pullRequest); + + GitHubReviewMentionWrapper existingMention = this.select(pullRequestKey).join().orElse(null); + + GitHubReviewMentionWrapper mention; + if (existingMention != null) { + mention = new GitHubReviewMentionWrapper( + pullRequestKey, + userId, + Instant.now().toEpochMilli(), + GitHubReviewStatus.PENDING.name(), + threadId, + 0 + ); + } + else { + mention = new GitHubReviewMentionWrapper( + pullRequestKey, + userId, + Instant.now().toEpochMilli(), + GitHubReviewStatus.PENDING.name(), + threadId, + 0 + ); + } + + this.save(mention); + LOGGER.info("Marked reviewer as mentioned: userId=" + userId + ", PR=" + pullRequest.toUrl()); + } + catch (Exception exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error marking reviewer as mentioned", exception); + throw new DataAccessException("Failed to mark reviewer as mentioned", exception); + } + })).exceptionally(FutureHandler::handleException); } @Override public CompletableFuture isMentioned(GitHubPullRequest pullRequest, long userId) { - return this.select(pullRequest.toUrl()) - .thenApply(mentionOptional -> mentionOptional - .map(mention -> { - Instant lastMention = mention.getLastMention(); - Instant nextMention = lastMention.plus(MENTION_INTERVAL); - return nextMention.isAfter(Instant.now()); - }) - .orElse(false) - ); + if (pullRequest == null) { + return CompletableFuture.completedFuture(false); + } + + return CompletableFuture.supplyAsync(() -> { + try { + String pullRequestKey = this.createPullRequestKey(pullRequest); + GitHubReviewMentionWrapper mention = this.select(pullRequestKey).join().orElse(null); + + if (mention == null) { + return false; + } + + Instant lastMention = mention.getLastMention(); + Instant nextMention = lastMention.plus(MENTION_INTERVAL); + boolean isMentioned = nextMention.isAfter(Instant.now()); + + LOGGER.fine("Checked mention status: userId=" + userId + ", PR=" + pullRequest.toUrl() + ", mentioned=" + + isMentioned); + return isMentioned; + } + catch (Exception exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error checking if user is mentioned", exception); + return false; + } + }).exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Exception in isMentioned", throwable); + return false; + }); } @Override public CompletableFuture recordReminderSent(GitHubPullRequest pullRequest, long userId) { - return this.select(pullRequest.toUrl()) - .thenCompose(mentionOptional -> { - if (mentionOptional.isPresent()) { - GitHubReviewMentionWrapper mention = mentionOptional.get(); - mention.setLastReminderSent(Instant.now()); - return this.save(mention).thenApply(status -> null); + if (pullRequest == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException("PullRequest cannot be null")); + } + + return CompletableFuture.runAsync(() -> { + this.scheduler.schedule(() -> { + try { + String pullRequestKey = this.createPullRequestKey(pullRequest); + GitHubReviewMentionWrapper mention = this.select(pullRequestKey).join().orElse(null); + + if (mention != null) { + mention.setLastReminderSent(Instant.now()); + this.save(mention); + LOGGER.info("Recorded reminder sent: userId=" + userId + ", PR=" + pullRequest.toUrl()); + } + else { + LOGGER.warning("Mention not found when recording reminder: userId=" + userId + ", PR=" + + pullRequest.toUrl()); + } + } + catch (Exception exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error recording reminder sent", exception); + throw new DataAccessException("Failed to record reminder sent", exception); } - return CompletableFuture.completedFuture(null); }); + }).exceptionally(FutureHandler::handleException); } @Override public CompletableFuture> getReviewersNeedingReminders(Duration reminderInterval) { return CompletableFuture.supplyAsync(() -> { List reminders = new ArrayList<>(); - Instant now = Instant.now(); - Instant cutoffTime = now.minus(reminderInterval); try { + Instant now = Instant.now(); + Instant cutoffTime = now.minus(reminderInterval); long cutoffTimeMillis = cutoffTime.toEpochMilli(); - + List filteredMentions = this.databaseManager.getDao(GitHubReviewMentionWrapper.class) .queryBuilder() .where() - .isNull("lastReminderSent") - .or() - .lt("lastReminderSent", cutoffTimeMillis) + .eq("reviewStatus", GitHubReviewStatus.PENDING.name()) + .and() + .raw("(lastReminderSent IS NULL OR lastReminderSent = 0 OR lastReminderSent < " + + cutoffTimeMillis + ")") .query(); for (GitHubReviewMentionWrapper mention : filteredMentions) { - reminders.add(new ReviewerReminder( - mention.getUserId(), - mention.getPullRequest(), - mention.getThreadId() - )); + try { + reminders.add(new ReviewerReminder( + mention.getUserId(), + mention.getPullRequest(), + mention.getThreadId() + )); + } + catch (Exception exception) { + LOGGER.log( + Level.WARNING, + "Error creating reminder for mention: " + mention.getPullRequest(), + exception); + } } + + LOGGER.info("Found " + reminders.size() + " reviewers needing reminders"); } - catch (SQLException e) { - Sentry.captureException(e); - throw new DataAccessException("Failed to get reviewers needing reminders", e); + catch (SQLException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Database error getting reviewers needing reminders", exception); + throw new DataAccessException("Failed to get reviewers needing reminders", exception); } return reminders; + }).exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Exception in getReviewersNeedingReminders", throwable); + return new ArrayList<>(); }); } @Override public CompletableFuture find(String pullRequest, long userId) { + if (pullRequest == null || pullRequest.trim().isEmpty()) { + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.supplyAsync(() -> { try { List mentions = @@ -125,12 +231,68 @@ public CompletableFuture find(String pullRequest, long user .eq("userId", userId) .query(); - return mentions.isEmpty() ? null : mentions.get(0).toMention(); + if (mentions.isEmpty()) { + return null; + } + + GitHubReviewMentionWrapper wrapper = mentions.getFirst(); + return wrapper.toMention(); } - catch (SQLException e) { - Sentry.captureException(e); - throw new DataAccessException("Failed to find review mention", e); + catch (SQLException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Database error finding review mention", exception); + throw new DataAccessException("Failed to find review mention", exception); } + }).exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Exception in find", throwable); + return null; }); } + + private String createPullRequestKey(GitHubPullRequest pullRequest) { + return pullRequest.toUrl(); + } + + public CompletableFuture cleanupOldMentions(Duration maxAge) { + return CompletableFuture.supplyAsync(() -> { + try { + Instant cutoffTime = Instant.now().minus(maxAge); + long cutoffTimeMillis = cutoffTime.toEpochMilli(); + + DeleteBuilder deleteBuilder = + this.databaseManager.getDao(GitHubReviewMentionWrapper.class).deleteBuilder(); + + deleteBuilder.where() + .lt("lastMention", cutoffTimeMillis) + .and() + .in("reviewStatus", GitHubReviewStatus.MERGED.name(), GitHubReviewStatus.CLOSED.name()); + + int deletedCount = deleteBuilder.delete(); + + if (deletedCount > 0) { + LOGGER.info("Cleaned up " + deletedCount + " old mentions"); + } + + return deletedCount; + } + catch (SQLException exception) { + Sentry.captureException(exception); + LOGGER.log(Level.SEVERE, "Error cleaning up old mentions", exception); + throw new DataAccessException("Failed to cleanup old mentions", exception); + } + }).exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Exception in cleanupOldMentions", throwable); + return 0; + }); + } + + private void performCleanup() { + try { + this.cleanupOldMentions(CLEANUP_INTERVAL) + .exceptionally(FutureHandler::handleException); + } + catch (Exception exception) { + LOGGER.log(Level.WARNING, "Error during periodic cleanup", exception); + } + } } diff --git a/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionWrapper.java b/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionWrapper.java index 448f016c..aa87e415 100644 --- a/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionWrapper.java +++ b/src/main/java/com/eternalcode/discordapp/review/database/GitHubReviewMentionWrapper.java @@ -1,10 +1,11 @@ package com.eternalcode.discordapp.review.database; -import com.eternalcode.discordapp.review.GitHubReviewStatus; import com.eternalcode.discordapp.review.GitHubReviewMention; +import com.eternalcode.discordapp.review.GitHubReviewStatus; import com.j256.ormlite.field.DatabaseField; import com.j256.ormlite.table.DatabaseTable; import java.time.Instant; +import java.util.Objects; @DatabaseTable(tableName = "github_review_mentions") public final class GitHubReviewMentionWrapper { @@ -31,7 +32,7 @@ public GitHubReviewMentionWrapper() { // ORMLite requires a no-arg constructor } - private GitHubReviewMentionWrapper( + public GitHubReviewMentionWrapper( String pullRequest, long userId, long lastMention, @@ -74,34 +75,14 @@ public Instant getLastMention() { return Instant.ofEpochMilli(this.lastMention); } - public GitHubReviewStatus getReviewStatus() { - try { - return GitHubReviewStatus.fromString(this.reviewStatus); - } - catch (Exception exception) { - return GitHubReviewStatus.PENDING; - } - } - - public void setReviewStatus(GitHubReviewStatus reviewStatus) { - if (reviewStatus == null) { - throw new IllegalArgumentException("Review status cannot be null"); - } - this.reviewStatus = reviewStatus.name(); - } - public long getThreadId() { return this.threadId; } - public Instant getLastReminderSent() { - return this.lastReminderSent == 0 ? null : Instant.ofEpochMilli(this.lastReminderSent); - } - public void setLastReminderSent(Instant lastReminderSent) { this.lastReminderSent = lastReminderSent == null ? 0 : lastReminderSent.toEpochMilli(); } - + public GitHubReviewMention toMention() { return new GitHubReviewMention( this.pullRequest, @@ -112,4 +93,34 @@ public GitHubReviewMention toMention() { this.lastReminderSent ); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GitHubReviewMentionWrapper that)) { + return false; + } + return this.userId == that.userId && + this.pullRequest != null && + this.pullRequest.equals(that.pullRequest); + } + + @Override + public int hashCode() { + return Objects.hash(this.pullRequest, this.userId); + } + + @Override + public String toString() { + return "GitHubReviewMentionWrapper{" + + "pullRequest='" + pullRequest + '\'' + + ", userId=" + userId + + ", lastMention=" + lastMention + + ", reviewStatus='" + reviewStatus + '\'' + + ", threadId=" + threadId + + ", lastReminderSent=" + lastReminderSent + + '}'; + } } diff --git a/src/main/java/com/eternalcode/discordapp/scheduler/VirtualThreadSchedulerImpl.java b/src/main/java/com/eternalcode/discordapp/scheduler/VirtualThreadSchedulerImpl.java index 517db149..58ff7e57 100644 --- a/src/main/java/com/eternalcode/discordapp/scheduler/VirtualThreadSchedulerImpl.java +++ b/src/main/java/com/eternalcode/discordapp/scheduler/VirtualThreadSchedulerImpl.java @@ -3,47 +3,72 @@ import java.time.Duration; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class VirtualThreadSchedulerImpl implements Scheduler { private static final Logger LOGGER = LoggerFactory.getLogger(VirtualThreadSchedulerImpl.class); - private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + + private final ScheduledExecutorService scheduledExecutor; + private final ExecutorService virtualExecutor; + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + + public VirtualThreadSchedulerImpl() { + this.scheduledExecutor = Executors.newScheduledThreadPool( + 2, + Thread.ofPlatform() + .name("scheduler-", 0) + .daemon(true) + .factory() + ); + + this.virtualExecutor = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual() + .name("task-", 0) + .factory() + ); + } @Override public void schedule(Runnable task, Duration delay) { - if (delay.isNegative() || delay.isZero()) { - this.schedule(task); + if (isShutdown.get()) { + LOGGER.warn("Scheduler is shutdown, ignoring task scheduling"); return; } - this.executorService.submit(() -> { - try { - Thread.sleep(delay.toMillis()); - task.run(); - } - catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - LOGGER.warn("Task interrupted during delay", exception); - } - catch (Exception exception) { - LOGGER.error("Task execution failed", exception); - } - }); + if (delay.isNegative()) { + throw new IllegalArgumentException("Delay cannot be negative"); + } + + if (delay.isZero()) { + schedule(task); + return; + } + + scheduledExecutor.schedule( + () -> { + if (!isShutdown.get()) { + virtualExecutor.submit(wrapTask(task, "delayed")); + } + }, delay.toMillis(), TimeUnit.MILLISECONDS); + + LOGGER.debug("Scheduled task with delay: {}", delay); } @Override public void schedule(Runnable task) { - this.executorService.submit(() -> { - try { - task.run(); - } - catch (Exception exception) { - LOGGER.error("Immediate task execution failed", exception); - } - }); + if (isShutdown.get()) { + LOGGER.warn("Scheduler is shutdown, ignoring task scheduling"); + return; + } + + virtualExecutor.submit(wrapTask(task, "immediate")); + LOGGER.debug("Scheduled immediate task"); } @Override @@ -53,65 +78,108 @@ public void scheduleRepeating(Runnable task, Duration interval) { @Override public void scheduleRepeating(Runnable task, Duration initialDelay, Duration interval) { - this.executorService.submit(() -> { - try { - // Początkowe opóźnienie - if (!initialDelay.isZero()) { - Thread.sleep(initialDelay.toMillis()); + if (isShutdown.get()) { + LOGGER.warn("Scheduler is shutdown, ignoring repeating task scheduling"); + return; + } + + if (interval.isNegative() || interval.isZero()) { + throw new IllegalArgumentException("Interval must be positive"); + } + + if (initialDelay.isNegative()) { + throw new IllegalArgumentException("Initial delay cannot be negative"); + } + + ScheduledFuture future = scheduledExecutor.scheduleAtFixedRate( + () -> { + if (!isShutdown.get()) { + virtualExecutor.submit(wrapTask(task, "repeating")); } + }, + initialDelay.toMillis(), + interval.toMillis(), + TimeUnit.MILLISECONDS + ); - // Cykliczne wykonywanie zadania - while (!Thread.currentThread().isInterrupted()) { - try { - task.run(); - Thread.sleep(interval.toMillis()); - } - catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - LOGGER.warn("Repeating task interrupted", exception); - break; - } - catch (Exception exception) { - LOGGER.error("Repeating task execution failed", exception); - // Kontynuuj mimo błędu - try { - Thread.sleep(interval.toMillis()); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } + LOGGER.debug( + "Scheduled repeating task with initial delay: {} and interval: {}", + initialDelay, interval); + + virtualExecutor.submit(() -> { + try { + while (!isShutdown.get()) { + Thread.sleep(Duration.ofSeconds(1)); } + future.cancel(false); + LOGGER.debug("Cancelled repeating task due to shutdown"); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - LOGGER.warn("Repeating task interrupted during initial delay", exception); - } - catch (Exception exception) { - LOGGER.error("Failed to start repeating task", exception); + future.cancel(true); } }); } @Override - public void shutdown() { + public void shutdown() throws InterruptedException { + if (!isShutdown.compareAndSet(false, true)) { + LOGGER.warn("Scheduler already shutdown"); + return; + } + + LOGGER.info("Initiating scheduler shutdown..."); + try { - LOGGER.info("Initiating scheduler shutdown..."); - this.executorService.shutdown(); + scheduledExecutor.shutdown(); + + virtualExecutor.shutdown(); - if (!this.executorService.awaitTermination(60, TimeUnit.SECONDS)) { - LOGGER.warn("Scheduler did not terminate within 60 seconds, forcing shutdown..."); - this.executorService.shutdownNow(); + if (!scheduledExecutor.awaitTermination(30, TimeUnit.SECONDS)) { + LOGGER.warn("Scheduled executor did not terminate within 30 seconds, forcing shutdown"); + scheduledExecutor.shutdownNow(); + + if (!scheduledExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + LOGGER.error("Scheduled executor did not terminate after forced shutdown"); + } } - else { - LOGGER.info("Scheduler shut down successfully."); + + if (!virtualExecutor.awaitTermination(30, TimeUnit.SECONDS)) { + LOGGER.warn("Virtual executor did not terminate within 30 seconds, forcing shutdown"); + virtualExecutor.shutdownNow(); + + if (!virtualExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + LOGGER.error("Virtual executor did not terminate after forced shutdown"); + } } + + LOGGER.info("Scheduler shutdown completed successfully"); } catch (InterruptedException exception) { - LOGGER.error("Shutdown interrupted", exception); - this.executorService.shutdownNow(); + LOGGER.error("Shutdown interrupted, forcing immediate shutdown", exception); + scheduledExecutor.shutdownNow(); + virtualExecutor.shutdownNow(); Thread.currentThread().interrupt(); + throw exception; } } + + private Runnable wrapTask(Runnable task, String taskType) { + return () -> { + long startTime = System.nanoTime(); + String threadName = Thread.currentThread().getName(); + + try { + LOGGER.trace("Starting {} task on thread: {}", taskType, threadName); + task.run(); + + long durationMs = (System.nanoTime() - startTime) / 1_000_000; + LOGGER.trace("Completed {} task on thread: {} in {}ms", taskType, threadName, durationMs); + } + catch (Exception exception) { + long durationMs = (System.nanoTime() - startTime) / 1_000_000; + LOGGER.error("Task failed on thread: {} after {}ms", threadName, durationMs, exception); + } + }; + } } diff --git a/src/main/java/com/eternalcode/discordapp/user/User.java b/src/main/java/com/eternalcode/discordapp/user/User.java deleted file mode 100644 index e211750a..00000000 --- a/src/main/java/com/eternalcode/discordapp/user/User.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.eternalcode.discordapp.user; - -public class User { - - private Long id; - - public User(Long id) { - this.id = id; - } - - public Long getId() { - return this.id; - } - - public void setId(Long id) { - this.id = id; - } -} diff --git a/src/main/java/com/eternalcode/discordapp/user/UserRepository.java b/src/main/java/com/eternalcode/discordapp/user/UserRepository.java deleted file mode 100644 index 86d3e7c3..00000000 --- a/src/main/java/com/eternalcode/discordapp/user/UserRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.eternalcode.discordapp.user; - -import com.j256.ormlite.dao.Dao; - -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; - -public interface UserRepository { - - CompletableFuture> findUser(Long id); - - CompletableFuture saveUser(User user); - - CompletableFuture deleteUser(User user); - - CompletableFuture> selectAllUsers(); - - CompletableFuture deleteUserById(Long id); -} diff --git a/src/main/java/com/eternalcode/discordapp/user/UserRepositoryImpl.java b/src/main/java/com/eternalcode/discordapp/user/UserRepositoryImpl.java deleted file mode 100644 index ba0fefea..00000000 --- a/src/main/java/com/eternalcode/discordapp/user/UserRepositoryImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.eternalcode.discordapp.user; - -import com.eternalcode.discordapp.database.DataAccessException; -import com.eternalcode.discordapp.database.DatabaseManager; -import com.eternalcode.discordapp.database.repository.AbstractRepository; -import com.j256.ormlite.dao.Dao; -import com.j256.ormlite.table.TableUtils; -import io.sentry.Sentry; - -import java.sql.SQLException; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; - -public class UserRepositoryImpl extends AbstractRepository implements UserRepository { - - protected UserRepositoryImpl(DatabaseManager databaseManager) { - super(databaseManager, UserWrapper.class); - } - - public static void create(DatabaseManager databaseManager) { - try { - TableUtils.createTableIfNotExists(databaseManager.getConnectionSource(), UserWrapper.class); - } - catch (SQLException sqlException) { - Sentry.captureException(sqlException); - throw new DataAccessException("Failed to create table", sqlException); - } - - new UserRepositoryImpl(databaseManager); - } - - @Override - public CompletableFuture> findUser(Long id) { - return this.select(id).thenApply(userWrapperOptional -> userWrapperOptional.map(UserWrapper::toUser)); - } - - @Override - public CompletableFuture saveUser(User userWrapper) { - return this.save(UserWrapper.from(userWrapper)); - } - - @Override - public CompletableFuture deleteUser(User userWrapper) { - return this.delete(UserWrapper.from(userWrapper)); - } - - @Override - public CompletableFuture> selectAllUsers() { - return this.selectAll().thenApply(users -> users.stream().map(UserWrapper::toUser).toList()); - } - - @Override - public CompletableFuture deleteUserById(Long id) { - return this.deleteById(id); - } - - -} diff --git a/src/main/java/com/eternalcode/discordapp/user/UserWrapper.java b/src/main/java/com/eternalcode/discordapp/user/UserWrapper.java deleted file mode 100644 index f497fe8b..00000000 --- a/src/main/java/com/eternalcode/discordapp/user/UserWrapper.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.eternalcode.discordapp.user; - -import com.j256.ormlite.field.DatabaseField; -import com.j256.ormlite.table.DatabaseTable; - -@DatabaseTable(tableName = "officer_users") -class UserWrapper { - - @DatabaseField(id = true) - private Long id; - - public UserWrapper() { - } - - public UserWrapper(Long id) { - this.id = id; - } - - public static UserWrapper from(User user) { - return new UserWrapper(user.getId()); - } - - public User toUser() { - return new User(this.id); - } -}