From 74c1309e34406211993980978bfe8215ef81171d Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Mon, 29 Jul 2024 21:57:29 +0100 Subject: [PATCH 1/5] Create table to store linked projects --- .../src/main/resources/db/V16__GH_Linked_Projects.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 application/src/main/resources/db/V16__GH_Linked_Projects.sql diff --git a/application/src/main/resources/db/V16__GH_Linked_Projects.sql b/application/src/main/resources/db/V16__GH_Linked_Projects.sql new file mode 100644 index 0000000000..70211a6935 --- /dev/null +++ b/application/src/main/resources/db/V16__GH_Linked_Projects.sql @@ -0,0 +1,6 @@ +CREATE TABLE gh_linked_projects +( + channelId TEXT NOT NULL PRIMARY KEY, + repositoryOwner TEXT NOT NULL, + repositoryName TEXT NOT NULL +) From dd861ea01eff340573b41f2814c6604f4362a85c Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Mon, 29 Jul 2024 21:58:25 +0100 Subject: [PATCH 2/5] Create pull request fetcher --- .../projectnotification/PullRequest.java | 27 ++++ .../PullRequestFetcher.java | 132 ++++++++++++++++++ .../github/projectnotification/User.java | 15 ++ 3 files changed, 174 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequest.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequestFetcher.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/User.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequest.java b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequest.java new file mode 100644 index 0000000000..78532f0b77 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequest.java @@ -0,0 +1,27 @@ +package org.togetherjava.tjbot.features.github.projectnotification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; + +/** + * This record is a container for the pull request information received when calling the GitHub pull + * requests endpoint. + * + * @param htmlUrl The user-friendly link to the PR + * @param number The pull request number + * @param state The state that the PR is in for example "opened", "closed", "merged" + * @param title The title of the PR + * @param user The user object representing the pull request author + * @param body The PR description + * @param createdAt The time that the PR was created + * @param draft True if the PR is in draft otherwise false + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PullRequest(@JsonProperty("html_url") String htmlUrl, + @JsonProperty("number") int number, @JsonProperty("state") String state, + @JsonProperty("title") String title, @JsonProperty("user") User user, + @JsonProperty("body") String body, @JsonProperty("created_at") OffsetDateTime createdAt, + @JsonProperty("draft") boolean draft) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequestFetcher.java b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequestFetcher.java new file mode 100644 index 0000000000..d348e309a7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/PullRequestFetcher.java @@ -0,0 +1,132 @@ +package org.togetherjava.tjbot.features.github.projectnotification; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; + +/** + * This class is used to fetch pull requests for a given repository. When using the GitHub API a + * valid PAT (personal access token) is required, and it must not be expired. Expired PAT will + * result in HTTP 401. + * + * @author Suraj Kumar + */ +public class PullRequestFetcher { + private static final Logger logger = LoggerFactory.getLogger(PullRequestFetcher.class); + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().registerModule(new JavaTimeModule()); + private static final String GITHUB_API_URL = "https://api.github.com/repos/%s/%s/pulls"; + + private final String githubPersonalAccessToken; + private final HttpClient httpClient; + + /** + * Constructs a PullRequestFetcher + * + * @param githubPersonalAccessToken The PAT used to authenticate against the GitHub API + */ + public PullRequestFetcher(String githubPersonalAccessToken) { + this.githubPersonalAccessToken = githubPersonalAccessToken; + this.httpClient = HttpClient.newBuilder().build(); + } + + /** + * Makes a request to the https://api.github.com/repos/%s/%s/pulls API and returns the result as + * a List of PullRequest objects. + * + * On any API error, this code will not throw. Instead, a warning/error level log message is + * sent. In this situation an empty List will be returned. + * + * @param repositoryOwner The owner of the GitHub repository + * @param repositoryName The repository name + * @return A List of PullRequest objects + */ + public List fetchPullRequests(String repositoryOwner, String repositoryName) { + logger.trace( + "Entry PullRequestFetcher#fetchPullRequests repositoryOwner={}, repositoryName={}", + repositoryOwner, repositoryName); + List pullRequests = new ArrayList<>(); + HttpResponse response = callGitHubRepoAPI(repositoryOwner, repositoryName); + + if (response == null) { + logger.warn( + "Failed to make the request to the GitHub API which resulted in a null response"); + logger.trace("Exit PullRequestFetcher#fetchPullRequests"); + return pullRequests; + } + + int statusCode = response.statusCode(); + logger.debug("Received http status {}", statusCode); + + if (statusCode == 200) { + try { + pullRequests = OBJECT_MAPPER.readValue(response.body(), new TypeReference<>() {}); + } catch (JsonProcessingException jpe) { + logger.error("Failed to parse JSON", jpe); + } + } else { + logger.warn("Unexpected HTTP status {} while fetching pull requests for {}, body={}", + statusCode, repositoryName, response.body()); + } + + logger.trace("Exit PullRequestFetcher#fetchPullRequests"); + return pullRequests; + } + + private HttpResponse callGitHubRepoAPI(String repositoryOwner, String repositoryName) { + logger.trace( + "Entry PullRequestFetcher#callGitHubRepoAPI repositoryOwner={}, repositoryName={}", + repositoryOwner, repositoryName); + String apiURL = GITHUB_API_URL.formatted(repositoryOwner, repositoryName); + + HttpResponse response; + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(apiURL)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", githubPersonalAccessToken) + .build(); + + try { + logger.trace("Sending request to {}", apiURL); + response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + logger.debug("Received response httpStatus={} body={}", response.statusCode(), + response.body()); + } catch (IOException | InterruptedException e) { + logger.error("Failed to fetch pull request from discord for {}/{}: {}", repositoryOwner, + repositoryName, e); + response = null; + } + + logger.trace("Exit PullRequestFetcher#callGitHubRepoAPI"); + return response; + } + + /** + * Check if we can read a repositories pull request + * + * @param repositoryOwner The repository owner name + * @param repositoryName The repository name + * @return True if we can access the pull requests + */ + public boolean isRepositoryAccessible(String repositoryOwner, String repositoryName) { + logger.trace( + "Entry isRepositoryAccessible#isRepositoryAccessible repositoryOwner={}, repositoryName={}", + repositoryOwner, repositoryName); + HttpResponse response = callGitHubRepoAPI(repositoryOwner, repositoryName); + logger.trace("Exit isRepositoryAccessible#isRepositoryAccessible"); + return response != null && response.statusCode() == 200; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/User.java b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/User.java new file mode 100644 index 0000000000..87a090324c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/User.java @@ -0,0 +1,15 @@ +package org.togetherjava.tjbot.features.github.projectnotification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * This record represents an author of a pull request + * + * @param name The GitHub username of the PR author + * @param avatarUrl The GitHub users profile picture/avatar URL + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record User(@JsonProperty("name") String name, + @JsonProperty("avatar_url") String avatarUrl) { +} From 9fa5455155f09bb1f69ca992c36b44555a1fe15a Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Mon, 29 Jul 2024 21:58:46 +0100 Subject: [PATCH 3/5] Create '/link-gh-project' command --- .../LinkGHProjectCommand.java | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/LinkGHProjectCommand.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/LinkGHProjectCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/LinkGHProjectCommand.java new file mode 100644 index 0000000000..a991810220 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/LinkGHProjectCommand.java @@ -0,0 +1,133 @@ +package org.togetherjava.tjbot.features.github.projectnotification; + +import net.dv8tion.jda.api.entities.channel.Channel; +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.generated.tables.GhLinkedProjects; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +/** + * This slash command (/link-gh-project) is used to link a project posted in #projects to a GitHub + * repository associated with the project. + * + * The association created is: 1. Channel ID 2. GitHub repository details (owner, name) + * + * These details are stored within the GH_LINKED_PROJECTS table. + * + * @author Suraj Kumar + */ +public class LinkGHProjectCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(LinkGHProjectCommand.class); + private static final String COMMAND_NAME = "link-gh-project"; + private static final String REPOSITORY_OWNER_OPTION = "Repository Owner"; + private static final String REPOSITORY_NAME_OPTION = "Repository Name"; + private final Database database; + private final PullRequestFetcher pullRequestFetcher; + + /** + * Creates a new LinkGHProjectCommand. + * + * There are 2 required options which are bound to this command: + *
    + *
  • "Repository Owner" the owner/organisation name that owns the repository
  • + *
  • "Repository Name" the name of the repository as seen on GitHub
  • + *
+ * + * @param githubPersonalAccessToken A personal access token used to authenticate against the + * GitHub API + * @param database the database to store linked projects + */ + public LinkGHProjectCommand(String githubPersonalAccessToken, Database database) { + super(COMMAND_NAME, "description", CommandVisibility.GUILD); + + this.database = database; + this.pullRequestFetcher = new PullRequestFetcher(githubPersonalAccessToken); + + getData().addOption(OptionType.STRING, REPOSITORY_OWNER_OPTION, + "The repository owner/organisation name", true, false); + + getData().addOption(OptionType.STRING, REPOSITORY_NAME_OPTION, "The repository name", true, + false); + } + + /** + * The slash command event handler. When a user initiates the /link-gh-project command in the + * server this method is invoked. + * + * The following happens when the command is invoked: + *
    + *
  • Try fetch the current PRs for the given repository. If that is unsuccessful an error + * message is returned back to the user.
  • + *
  • The project details are saved to the GH_LINKED_PROJECTS table. If a record already exists + * for the given project, the value is updated with the new repository details.
  • + *
  • A confirmation message is sent within the project thread
  • + *
+ * + * @param event the event that triggered this + */ + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + logger.trace("Entry LinkGHProjectCommand#onSlashCommand"); + OptionMapping repositoryOwner = event.getOption(REPOSITORY_OWNER_OPTION); + OptionMapping repositoryName = event.getOption(REPOSITORY_NAME_OPTION); + Channel channel = event.getChannel(); + + if (repositoryOwner == null || repositoryName == null) { + event.reply("The repository owner and repository name must both have values").queue(); + return; + } + + logger.trace("Received repositoryOwner={} repositoryName={} in channel {}", repositoryOwner, + repositoryName, channel.getName()); + + String repositoryOwnerValue = repositoryOwner.getAsString(); + String repositoryNameValue = repositoryName.getAsString(); + + if (!pullRequestFetcher.isRepositoryAccessible(repositoryOwnerValue, repositoryNameValue)) { + logger.info("Repository {}/{} cannot be linked as the repository is not accessible", + repositoryOwnerValue, repositoryNameValue); + event.reply("Unable to access {}/{}. To link a project please ensure it is public.") + .queue(); + logger.trace("Exit LinkGHProjectCommand#onSlashCommand"); + return; + } + + logger.trace("Saving project details to database"); + saveProjectToDatabase(repositoryOwner.getAsString(), repositoryName.getAsString(), + channel.getId()); + event.reply(repositoryName.getAsString() + " has been linked to this project").queue(); + + logger.trace("Exit LinkGHProjectCommand#onSlashCommand"); + } + + /** Saves project details to the GH_LINKED_PROJECTS, replacing the value if it already exists */ + private void saveProjectToDatabase(String repositoryOwner, String repositoryName, + String channelId) { + + logger.trace( + "Entry LinkGHProjectCommand#saveProjectToDatabase repositoryOwner={} repositoryName={} channelId={}", + repositoryOwner, repositoryName, channelId); + + GhLinkedProjects table = GhLinkedProjects.GH_LINKED_PROJECTS; + + logger.info("Saving {}/{} to database", repositoryOwner, repositoryName); + + database.write(context -> context.insertInto(table) + .set(table.REPOSITORYNAME, repositoryName) + .set(table.REPOSITORYOWNER, repositoryOwner) + .set(table.CHANNELID, channelId) + .onConflict(table.CHANNELID) + .doUpdate() + .set(table.REPOSITORYNAME, repositoryName) + .set(table.REPOSITORYOWNER, repositoryOwner) + .execute()); + + logger.trace("Exit LinkGHProjectCommand#saveProjectToDatabase"); + } +} From 182b6df80d248f1df162b7a4a1cfc5874056da49 Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Mon, 29 Jul 2024 21:59:42 +0100 Subject: [PATCH 4/5] Create Routine --- .../ProjectPRNotifierRoutine.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/ProjectPRNotifierRoutine.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/ProjectPRNotifierRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/ProjectPRNotifierRoutine.java new file mode 100644 index 0000000000..c8eeff0fc1 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/projectnotification/ProjectPRNotifierRoutine.java @@ -0,0 +1,106 @@ +package org.togetherjava.tjbot.features.github.projectnotification; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.GhLinkedProjects; +import org.togetherjava.tjbot.db.generated.tables.records.GhLinkedProjectsRecord; +import org.togetherjava.tjbot.features.Routine; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * The regularly, scheduled routine that checks for pull requests and reports their status. The way + * this works is that we poll the GitHub API and send a message to the discord project channel with + * the information. Any PRs that were created before the `lastRun` time are ignored. We only notify + * on newly created PRs since the last run of this routine. + * + * @author Suraj Kumar + */ +public class ProjectPRNotifierRoutine implements Routine { + private static final Logger logger = LoggerFactory.getLogger(ProjectPRNotifierRoutine.class); + private static final int SCHEDULE_TIME_IN_MINUTES = 10; + private final Database database; + private final PullRequestFetcher pullRequestFetcher; + private OffsetDateTime lastRun; + + /** + * Constructs a new ProjectPRNotifierRoutine + * + * @param githubPersonalAccessToken The PAT used to authenticate against the GitHub API + * @param database The database object to store project information in + */ + public ProjectPRNotifierRoutine(String githubPersonalAccessToken, Database database) { + this.database = database; + this.pullRequestFetcher = new PullRequestFetcher(githubPersonalAccessToken); + this.lastRun = OffsetDateTime.now(); + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, SCHEDULE_TIME_IN_MINUTES, TimeUnit.SECONDS); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + logger.trace("Entry ProjectPRNotifierRoutine#runRoutine"); + List projects = getAllProjects(); + logger.trace("Running routine, against {} projects", projects.size()); + for (GhLinkedProjectsRecord project : projects) { + String channelId = project.getChannelid(); + String repositoryOwner = project.getRepositoryowner(); + String repositoryName = project.getRepositoryname(); + logger.debug("Searching for pull requests for {}/{} for channel {}", repositoryOwner, + repositoryName, channelId); + if (pullRequestFetcher.isRepositoryAccessible(repositoryOwner, repositoryName)) { + List pullRequests = + pullRequestFetcher.fetchPullRequests(repositoryOwner, repositoryName); + logger.debug("Found {} pull requests in {}/{}", pullRequests.size(), + repositoryOwner, repositoryName); + for (PullRequest pullRequest : pullRequests) { + if (pullRequest.createdAt().isAfter(lastRun)) { + logger.info("Found new PR for {}, sending information to discord", + channelId); + sendNotificationToProject(channelId, jda, pullRequest); + } + } + } else { + logger.warn("{}/{} is not accessible", repositoryOwner, repositoryName); + } + } + lastRun = OffsetDateTime.now(); + logger.debug("lastRun has been set to {}", lastRun); + logger.trace("Exit ProjectPRNotifierRoutine#runRoutine"); + } + + private void sendNotificationToProject(String channelId, JDA jda, PullRequest pullRequest) { + logger.trace( + "Entry ProjectPRNotifierRoutine#sendNotificationToProject, channelId={}, pullRequest={}", + channelId, pullRequest); + TextChannel channel = jda.getTextChannelById(channelId); + if (channel != null) { + logger.trace("Sending PR notification to channel {}", channel); + channel.sendMessage("PR from " + pullRequest.user().name()).queue(); + } else { + logger.warn("No channel found for channelId {}, pull request {}", channelId, + pullRequest.htmlUrl()); + } + logger.trace("Exit ProjectPRNotifierRoutine#sendNotificationToProject"); + } + + private List getAllProjects() { + logger.trace("Entry ProjectPRNotifierRoutine#getAllProjects"); + try { + return database + .read(dsl -> dsl.selectFrom(GhLinkedProjects.GH_LINKED_PROJECTS).fetch()); + } finally { + logger.trace("Exit ProjectPRNotifierRoutine#getAllProjects"); + } + } +} From 324370c89bfcd8a4f6897c8fccadf427f9773851 Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Mon, 29 Jul 2024 21:59:54 +0100 Subject: [PATCH 5/5] Add command + routine to Features --- .../main/java/org/togetherjava/tjbot/features/Features.java | 4 ++++ 1 file changed, 4 insertions(+) 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..285bbe5b6f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -23,6 +23,8 @@ import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.github.GitHubCommand; import org.togetherjava.tjbot.features.github.GitHubReference; +import org.togetherjava.tjbot.features.github.projectnotification.LinkGHProjectCommand; +import org.togetherjava.tjbot.features.github.projectnotification.ProjectPRNotifierRoutine; import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener; import org.togetherjava.tjbot.features.help.HelpSystemHelper; import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater; @@ -136,6 +138,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 ProjectPRNotifierRoutine(config.getGitHubApiKey(), database)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -192,6 +195,7 @@ 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 LinkGHProjectCommand(config.getGitHubApiKey(), database)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList();