diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 893adbc00f..4adda0e249 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -22,7 +22,10 @@ import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.github.GitHubCommand; +import org.togetherjava.tjbot.features.github.GitHubLinkCommand; import org.togetherjava.tjbot.features.github.GitHubReference; +import org.togetherjava.tjbot.features.github.GitHubUnlinkCommand; +import org.togetherjava.tjbot.features.github.PullRequestNotificationRoutine; import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener; import org.togetherjava.tjbot.features.help.HelpSystemHelper; import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater; @@ -136,6 +139,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener)); features.add(new MemberCountDisplayRoutine(config)); features.add(new RSSHandlerRoutine(config, database)); + features.add(new PullRequestNotificationRoutine(database, config)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -192,6 +196,8 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new GitHubLinkCommand(database, config)); + features.add(new GitHubUnlinkCommand(database)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubLinkCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubLinkCommand.java new file mode 100644 index 0000000000..fd06ad886b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubLinkCommand.java @@ -0,0 +1,119 @@ +package org.togetherjava.tjbot.features.github; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.DatabaseException; +import org.togetherjava.tjbot.db.generated.tables.PrNotifications; +import org.togetherjava.tjbot.db.generated.tables.records.PrNotificationsRecord; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +import java.io.IOException; + +/** + * Slash command used to link a GitHub project to a discord channel to post pull request + * notifications. + */ +public class GitHubLinkCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(GitHubLinkCommand.class); + + private static final String REPOSITORY_OWNER_OPTION = "owner"; + private static final String REPOSITORY_NAME_OPTION = "name"; + + private final Database database; + private final String githubApiKey; + + /** + * Creates new GitHub link command. + * + * @param database the database to store the new linked pull request notifications + * @param config the config to get the GitHub API key + */ + public GitHubLinkCommand(Database database, Config config) { + super("link-gh-project", + "Links a GitHub repository to this project post to receive pull request notifications", + CommandVisibility.GUILD); + this.database = database; + this.githubApiKey = config.getGitHubApiKey(); + + getData() + .addOption(OptionType.STRING, REPOSITORY_OWNER_OPTION, + "The owner of the repository to be linked", true) + .addOption(OptionType.STRING, REPOSITORY_NAME_OPTION, + "The name of the repository to be linked", true); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + OptionMapping repositoryOwnerOption = event.getOption(REPOSITORY_OWNER_OPTION); + OptionMapping repositoryNameOption = event.getOption(REPOSITORY_NAME_OPTION); + + if (repositoryOwnerOption == null || repositoryNameOption == null) { + event.reply("You must specify a repository owner and a repository name") + .setEphemeral(true) + .queue(); + return; + } + + long channelId = event.getChannelIdLong(); + String repositoryOwner = repositoryOwnerOption.getAsString(); + String repositoryName = repositoryNameOption.getAsString(); + + GitHub github; + try { + github = new GitHubBuilder().withOAuthToken(githubApiKey).build(); + } catch (IOException e) { + logger.error("Failed to initialize GitHub API wrapper.", e); + event.reply("Failed to initialize GitHub API wrapper.").setEphemeral(true).queue(); + return; + } + + try { + if (!isRepositoryAccessible(github, repositoryOwner, repositoryName)) { + event.reply("Repository is not publicly available.").setEphemeral(true).queue(); + logger.info("Repository {}/{} is not accessible.", repositoryOwner, repositoryName); + return; + } + } catch (IOException e) { + logger.error("Failed to check if GitHub repository is available.", e); + event.reply("Failed to link repository.").setEphemeral(true).queue(); + return; + } + + try { + saveNotificationToDatabase(channelId, repositoryOwner, repositoryName); + event.reply("Successfully linked repository.").setEphemeral(true).queue(); + } catch (DatabaseException e) { + logger.error("Failed to save pull request notification to database.", e); + event.reply("Failed to link repository.").setEphemeral(true).queue(); + } + } + + private boolean isRepositoryAccessible(GitHub github, String owner, String name) + throws IOException { + GHRepository repository = github.getRepository(owner + "/" + name); + return repository != null; + } + + private void saveNotificationToDatabase(long channelId, String repositoryOwner, + String repositoryName) { + database.write(context -> { + PrNotificationsRecord prNotificationsRecord = + context.newRecord(PrNotifications.PR_NOTIFICATIONS); + prNotificationsRecord.setChannelId(channelId); + prNotificationsRecord.setRepositoryOwner(repositoryOwner); + prNotificationsRecord.setRepositoryName(repositoryName); + prNotificationsRecord.insert(); + }); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubUnlinkCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubUnlinkCommand.java new file mode 100644 index 0000000000..26a91afbf3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubUnlinkCommand.java @@ -0,0 +1,84 @@ +package org.togetherjava.tjbot.features.github; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.DatabaseException; +import org.togetherjava.tjbot.db.generated.tables.PrNotifications; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +/** + * Slash command to unlink a project from a channel. + */ +public class GitHubUnlinkCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(GitHubUnlinkCommand.class); + + private static final String REPOSITORY_OWNER_OPTION = "owner"; + private static final String REPOSITORY_NAME_OPTION = "name"; + + private final Database database; + + /** + * Creates new GitHub unlink command. + * + * @param database the database to remove linked pull request notifications + */ + public GitHubUnlinkCommand(Database database) { + super("unlink-gh-project", "Unlinks a GitHub repository", CommandVisibility.GUILD); + this.database = database; + + getData() + .addOption(OptionType.STRING, REPOSITORY_OWNER_OPTION, + "The owner of the repository to get unlinked", true) + .addOption(OptionType.STRING, REPOSITORY_NAME_OPTION, + "The name of the repository to get unlinked", true); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + OptionMapping repositoryOwnerOption = event.getOption(REPOSITORY_OWNER_OPTION); + OptionMapping repositoryNameOption = event.getOption(REPOSITORY_NAME_OPTION); + + if (repositoryOwnerOption == null || repositoryNameOption == null) { + event.reply("You must specify a repository owner and a repository name") + .setEphemeral(true) + .queue(); + return; + } + + long channelId = event.getChannelIdLong(); + String repositoryOwner = repositoryOwnerOption.getAsString(); + String repositoryName = repositoryNameOption.getAsString(); + + try { + int deleted = deleteNotification(channelId, repositoryOwner, repositoryName); + + if (deleted == 0) { + event.reply("The provided repository wasn't linked to this channel previously.") + .setEphemeral(true) + .queue(); + } else { + event.reply("Successfully unlinked repository.").setEphemeral(true).queue(); + } + } catch (DatabaseException e) { + logger.error("Failed to delete pull request notification link from database.", e); + event.reply("Failed to unlink repository.").setEphemeral(true).queue(); + } + } + + private int deleteNotification(long channelId, String repositoryOwner, String repositoryName) { + return database + .writeAndProvide(context -> context.deleteFrom(PrNotifications.PR_NOTIFICATIONS) + .where(PrNotifications.PR_NOTIFICATIONS.CHANNEL_ID.eq(channelId)) + .and(PrNotifications.PR_NOTIFICATIONS.REPOSITORY_OWNER.eq(repositoryOwner)) + .and(PrNotifications.PR_NOTIFICATIONS.REPOSITORY_NAME.eq(repositoryName)) + .execute()); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/PullRequestNotificationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/github/PullRequestNotificationRoutine.java new file mode 100644 index 0000000000..3528440e2b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/PullRequestNotificationRoutine.java @@ -0,0 +1,107 @@ +package org.togetherjava.tjbot.features.github; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import org.kohsuke.github.GHIssueState; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.PrNotifications; +import org.togetherjava.tjbot.db.generated.tables.records.PrNotificationsRecord; +import org.togetherjava.tjbot.features.Routine; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Routine to send a notification about new pull request. + */ +public class PullRequestNotificationRoutine implements Routine { + + private static final Logger logger = + LoggerFactory.getLogger(PullRequestNotificationRoutine.class); + + private final Database database; + private final String githubApiKey; + private Date lastExecution; + + /** + * Creates new notification routine. + * + * @param database the database to get the pull request notifications + * @param config the config to get the GitHub API key + */ + public PullRequestNotificationRoutine(Database database, Config config) { + this.database = database; + this.githubApiKey = config.getGitHubApiKey(); + this.lastExecution = new Date(); + } + + @Override + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 15, TimeUnit.MINUTES); + } + + @Override + public void runRoutine(JDA jda) { + GitHub github; + try { + github = new GitHubBuilder().withOAuthToken(githubApiKey).build(); + } catch (IOException e) { + logger.error("Failed to initialize GitHub API wrapper.", e); + return; + } + + for (PrNotificationsRecord notification : getAllNotifications()) { + long channelId = notification.getChannelId(); + String repositoryOwner = notification.getRepositoryOwner(); + String repositoryName = notification.getRepositoryName(); + + try { + GHRepository repository = + github.getRepository(repositoryOwner + "/" + repositoryName); + + if (repository == null) { + logger.info("Failed to find repository {}/{}.", repositoryOwner, + repositoryName); + continue; + } + + List pullRequests = repository.getPullRequests(GHIssueState.OPEN); + for (GHPullRequest pr : pullRequests) { + if (pr.getCreatedAt().after(lastExecution)) { + sendNotification(jda, channelId, pr); + } + } + } catch (IOException e) { + logger.error("Failed to send notification for repository {}/{}.", repositoryOwner, + repositoryName, e); + } + } + + lastExecution = new Date(); + } + + private List getAllNotifications() { + return database + .read(context -> context.selectFrom(PrNotifications.PR_NOTIFICATIONS).fetch()); + } + + private void sendNotification(JDA jda, long channelId, GHPullRequest pr) throws IOException { + ThreadChannel channel = jda.getThreadChannelById(channelId); + if (channel == null) { + logger.info("Failed to find channel {} to send pull request notification.", channelId); + return; + } + channel.sendMessage("New pull request from " + pr.getUser().getLogin() + ".").queue(); + } + +} diff --git a/application/src/main/resources/db/V16__Add_Pull_Request_Notification.sql b/application/src/main/resources/db/V16__Add_Pull_Request_Notification.sql new file mode 100644 index 0000000000..e2d97192a1 --- /dev/null +++ b/application/src/main/resources/db/V16__Add_Pull_Request_Notification.sql @@ -0,0 +1,7 @@ +CREATE TABLE pr_notifications +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + channel_id BIGINT NOT NULL, + repository_owner TEXT NOT NULL, + repository_name TEXT NOT NULL +)