Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1142] PR notifications for projects #1143

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -136,6 +138,7 @@ public static Collection<Feature> 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));
Expand Down Expand Up @@ -192,6 +195,7 @@ public static Collection<Feature> 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<Class<?>> blacklist = blacklistConfig.normal();
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
* <ul>
* <li>"Repository Owner" the owner/organisation name that owns the repository</li>
* <li>"Repository Name" the name of the repository as seen on GitHub</li>
* </ul>
*
* @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:
* <ul>
* <li>Try fetch the current PRs for the given repository. If that is unsuccessful an error
* message is returned back to the user.</li>
* <li>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.</li>
* <li>A confirmation message is sent within the project thread</li>
* </ul>
*
* @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");
}
}
Original file line number Diff line number Diff line change
@@ -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<GhLinkedProjectsRecord> 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<PullRequest> 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<GhLinkedProjectsRecord> getAllProjects() {
logger.trace("Entry ProjectPRNotifierRoutine#getAllProjects");
try {
return database
.read(dsl -> dsl.selectFrom(GhLinkedProjects.GH_LINKED_PROJECTS).fetch());
} finally {
logger.trace("Exit ProjectPRNotifierRoutine#getAllProjects");
}
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Loading
Loading