Skip to content

Commit 20b0898

Browse files
committed
Implement PR notifications for channels
1 parent f49d430 commit 20b0898

File tree

5 files changed

+323
-0
lines changed

5 files changed

+323
-0
lines changed

application/src/main/java/org/togetherjava/tjbot/features/Features.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
import org.togetherjava.tjbot.features.code.CodeMessageManualDetection;
2323
import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener;
2424
import org.togetherjava.tjbot.features.github.GitHubCommand;
25+
import org.togetherjava.tjbot.features.github.GitHubLinkCommand;
2526
import org.togetherjava.tjbot.features.github.GitHubReference;
27+
import org.togetherjava.tjbot.features.github.GitHubUnlinkCommand;
28+
import org.togetherjava.tjbot.features.github.PullRequestNotificationRoutine;
2629
import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener;
2730
import org.togetherjava.tjbot.features.help.HelpSystemHelper;
2831
import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater;
@@ -136,6 +139,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
136139
features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener));
137140
features.add(new MemberCountDisplayRoutine(config));
138141
features.add(new RSSHandlerRoutine(config, database));
142+
features.add(new PullRequestNotificationRoutine(database, config));
139143

140144
// Message receivers
141145
features.add(new TopHelpersMessageListener(database, config));
@@ -192,6 +196,8 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
192196
features.add(new BookmarksCommand(bookmarksSystem));
193197
features.add(new ChatGptCommand(chatGptService, helpSystemHelper));
194198
features.add(new JShellCommand(jshellEval));
199+
features.add(new GitHubLinkCommand(database, config));
200+
features.add(new GitHubUnlinkCommand(database));
195201

196202
FeatureBlacklist<Class<?>> blacklist = blacklistConfig.normal();
197203
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package org.togetherjava.tjbot.features.github;
2+
3+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
4+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
5+
import net.dv8tion.jda.api.interactions.commands.OptionType;
6+
import org.kohsuke.github.GHRepository;
7+
import org.kohsuke.github.GitHub;
8+
import org.kohsuke.github.GitHubBuilder;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
12+
import org.togetherjava.tjbot.config.Config;
13+
import org.togetherjava.tjbot.db.Database;
14+
import org.togetherjava.tjbot.db.DatabaseException;
15+
import org.togetherjava.tjbot.db.generated.tables.PrNotifications;
16+
import org.togetherjava.tjbot.db.generated.tables.records.PrNotificationsRecord;
17+
import org.togetherjava.tjbot.features.CommandVisibility;
18+
import org.togetherjava.tjbot.features.SlashCommandAdapter;
19+
20+
import java.io.IOException;
21+
22+
/**
23+
* Slash command used to link a GitHub project to a discord channel to post pull request
24+
* notifications.
25+
*/
26+
public class GitHubLinkCommand extends SlashCommandAdapter {
27+
28+
private static final Logger logger = LoggerFactory.getLogger(GitHubLinkCommand.class);
29+
30+
private static final String REPOSITORY_OWNER_OPTION = "owner";
31+
private static final String REPOSITORY_NAME_OPTION = "name";
32+
33+
private final Database database;
34+
private final String githubApiKey;
35+
36+
/**
37+
* Creates new GitHub link command.
38+
*
39+
* @param database the database to store the new linked pull request notifications
40+
* @param config the config to get the GitHub API key
41+
*/
42+
public GitHubLinkCommand(Database database, Config config) {
43+
super("link-gh-project",
44+
"Links a GitHub repository to this project post to receive pull request notifications",
45+
CommandVisibility.GUILD);
46+
this.database = database;
47+
this.githubApiKey = config.getGitHubApiKey();
48+
49+
getData()
50+
.addOption(OptionType.STRING, REPOSITORY_OWNER_OPTION,
51+
"The owner of the repository to be linked", true)
52+
.addOption(OptionType.STRING, REPOSITORY_NAME_OPTION,
53+
"The name of the repository to be linked", true);
54+
}
55+
56+
@Override
57+
public void onSlashCommand(SlashCommandInteractionEvent event) {
58+
OptionMapping repositoryOwnerOption = event.getOption(REPOSITORY_OWNER_OPTION);
59+
OptionMapping repositoryNameOption = event.getOption(REPOSITORY_NAME_OPTION);
60+
61+
if (repositoryOwnerOption == null || repositoryNameOption == null) {
62+
event.reply("You must specify a repository owner and a repository name")
63+
.setEphemeral(true)
64+
.queue();
65+
return;
66+
}
67+
68+
long channelId = event.getChannelIdLong();
69+
String repositoryOwner = repositoryOwnerOption.getAsString();
70+
String repositoryName = repositoryNameOption.getAsString();
71+
72+
GitHub github;
73+
try {
74+
github = new GitHubBuilder().withOAuthToken(githubApiKey).build();
75+
} catch (IOException e) {
76+
logger.error("Failed to initialize GitHub API wrapper.", e);
77+
event.reply("Failed to initialize GitHub API wrapper.").setEphemeral(true).queue();
78+
return;
79+
}
80+
81+
try {
82+
if (!isRepositoryAccessible(github, repositoryOwner, repositoryName)) {
83+
event.reply("Repository is not publicly available.").setEphemeral(true).queue();
84+
logger.info("Repository {}/{} is not accessible.", repositoryOwner, repositoryName);
85+
return;
86+
}
87+
} catch (IOException e) {
88+
logger.error("Failed to check if GitHub repository is available.", e);
89+
event.reply("Failed to link repository.").setEphemeral(true).queue();
90+
return;
91+
}
92+
93+
try {
94+
saveNotificationToDatabase(channelId, repositoryOwner, repositoryName);
95+
event.reply("Successfully linked repository.").setEphemeral(true).queue();
96+
} catch (DatabaseException e) {
97+
logger.error("Failed to save pull request notification to database.", e);
98+
event.reply("Failed to link repository.").setEphemeral(true).queue();
99+
}
100+
}
101+
102+
private boolean isRepositoryAccessible(GitHub github, String owner, String name)
103+
throws IOException {
104+
GHRepository repository = github.getRepository(owner + "/" + name);
105+
return repository != null;
106+
}
107+
108+
private void saveNotificationToDatabase(long channelId, String repositoryOwner,
109+
String repositoryName) {
110+
database.write(context -> {
111+
PrNotificationsRecord prNotificationsRecord =
112+
context.newRecord(PrNotifications.PR_NOTIFICATIONS);
113+
prNotificationsRecord.setChannelId(channelId);
114+
prNotificationsRecord.setRepositoryOwner(repositoryOwner);
115+
prNotificationsRecord.setRepositoryName(repositoryName);
116+
prNotificationsRecord.insert();
117+
});
118+
}
119+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package org.togetherjava.tjbot.features.github;
2+
3+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
4+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
5+
import net.dv8tion.jda.api.interactions.commands.OptionType;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import org.togetherjava.tjbot.db.Database;
10+
import org.togetherjava.tjbot.db.DatabaseException;
11+
import org.togetherjava.tjbot.db.generated.tables.PrNotifications;
12+
import org.togetherjava.tjbot.features.CommandVisibility;
13+
import org.togetherjava.tjbot.features.SlashCommandAdapter;
14+
15+
/**
16+
* Slash command to unlink a project from a channel.
17+
*/
18+
public class GitHubUnlinkCommand extends SlashCommandAdapter {
19+
20+
private static final Logger logger = LoggerFactory.getLogger(GitHubUnlinkCommand.class);
21+
22+
private static final String REPOSITORY_OWNER_OPTION = "owner";
23+
private static final String REPOSITORY_NAME_OPTION = "name";
24+
25+
private final Database database;
26+
27+
/**
28+
* Creates new GitHub unlink command.
29+
*
30+
* @param database the database to remove linked pull request notifications
31+
*/
32+
public GitHubUnlinkCommand(Database database) {
33+
super("unlink-gh-project", "Unlinks a GitHub repository", CommandVisibility.GUILD);
34+
this.database = database;
35+
36+
getData()
37+
.addOption(OptionType.STRING, REPOSITORY_OWNER_OPTION,
38+
"The owner of the repository to get unlinked", true)
39+
.addOption(OptionType.STRING, REPOSITORY_NAME_OPTION,
40+
"The name of the repository to get unlinked", true);
41+
}
42+
43+
@Override
44+
public void onSlashCommand(SlashCommandInteractionEvent event) {
45+
OptionMapping repositoryOwnerOption = event.getOption(REPOSITORY_OWNER_OPTION);
46+
OptionMapping repositoryNameOption = event.getOption(REPOSITORY_NAME_OPTION);
47+
48+
if (repositoryOwnerOption == null || repositoryNameOption == null) {
49+
event.reply("You must specify a repository owner and a repository name")
50+
.setEphemeral(true)
51+
.queue();
52+
return;
53+
}
54+
55+
long channelId = event.getChannelIdLong();
56+
String repositoryOwner = repositoryOwnerOption.getAsString();
57+
String repositoryName = repositoryNameOption.getAsString();
58+
59+
try {
60+
int deleted = deleteNotification(channelId, repositoryOwner, repositoryName);
61+
62+
if (deleted == 0) {
63+
event.reply("The provided repository wasn't linked to this channel previously.")
64+
.setEphemeral(true)
65+
.queue();
66+
} else {
67+
event.reply("Successfully unlinked repository.").setEphemeral(true).queue();
68+
}
69+
} catch (DatabaseException e) {
70+
logger.error("Failed to delete pull request notification link from database.", e);
71+
event.reply("Failed to unlink repository.").setEphemeral(true).queue();
72+
}
73+
}
74+
75+
private int deleteNotification(long channelId, String repositoryOwner, String repositoryName) {
76+
return database
77+
.writeAndProvide(context -> context.deleteFrom(PrNotifications.PR_NOTIFICATIONS)
78+
.where(PrNotifications.PR_NOTIFICATIONS.CHANNEL_ID.eq(channelId))
79+
.and(PrNotifications.PR_NOTIFICATIONS.REPOSITORY_OWNER.eq(repositoryOwner))
80+
.and(PrNotifications.PR_NOTIFICATIONS.REPOSITORY_NAME.eq(repositoryName))
81+
.execute());
82+
}
83+
84+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package org.togetherjava.tjbot.features.github;
2+
3+
import net.dv8tion.jda.api.JDA;
4+
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
5+
import org.kohsuke.github.GHIssueState;
6+
import org.kohsuke.github.GHPullRequest;
7+
import org.kohsuke.github.GHRepository;
8+
import org.kohsuke.github.GitHub;
9+
import org.kohsuke.github.GitHubBuilder;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
import org.togetherjava.tjbot.config.Config;
14+
import org.togetherjava.tjbot.db.Database;
15+
import org.togetherjava.tjbot.db.generated.tables.PrNotifications;
16+
import org.togetherjava.tjbot.db.generated.tables.records.PrNotificationsRecord;
17+
import org.togetherjava.tjbot.features.Routine;
18+
19+
import java.io.IOException;
20+
import java.util.Date;
21+
import java.util.List;
22+
import java.util.concurrent.TimeUnit;
23+
24+
/**
25+
* Routine to send a notification about new pull request.
26+
*/
27+
public class PullRequestNotificationRoutine implements Routine {
28+
29+
private static final Logger logger =
30+
LoggerFactory.getLogger(PullRequestNotificationRoutine.class);
31+
32+
private final Database database;
33+
private final String githubApiKey;
34+
private Date lastExecution;
35+
36+
/**
37+
* Creates new notification routine.
38+
*
39+
* @param database the database to get the pull request notifications
40+
* @param config the config to get the GitHub API key
41+
*/
42+
public PullRequestNotificationRoutine(Database database, Config config) {
43+
this.database = database;
44+
this.githubApiKey = config.getGitHubApiKey();
45+
this.lastExecution = new Date();
46+
}
47+
48+
@Override
49+
public Schedule createSchedule() {
50+
return new Schedule(ScheduleMode.FIXED_RATE, 0, 15, TimeUnit.MINUTES);
51+
}
52+
53+
@Override
54+
public void runRoutine(JDA jda) {
55+
GitHub github;
56+
try {
57+
github = new GitHubBuilder().withOAuthToken(githubApiKey).build();
58+
} catch (IOException e) {
59+
logger.error("Failed to initialize GitHub API wrapper.", e);
60+
return;
61+
}
62+
63+
for (PrNotificationsRecord notification : getAllNotifications()) {
64+
long channelId = notification.getChannelId();
65+
String repositoryOwner = notification.getRepositoryOwner();
66+
String repositoryName = notification.getRepositoryName();
67+
68+
try {
69+
GHRepository repository =
70+
github.getRepository(repositoryOwner + "/" + repositoryName);
71+
72+
if (repository == null) {
73+
logger.info("Failed to find repository {}/{}.", repositoryOwner,
74+
repositoryName);
75+
continue;
76+
}
77+
78+
List<GHPullRequest> pullRequests = repository.getPullRequests(GHIssueState.OPEN);
79+
for (GHPullRequest pr : pullRequests) {
80+
if (pr.getCreatedAt().after(lastExecution)) {
81+
sendNotification(jda, channelId, pr);
82+
}
83+
}
84+
} catch (IOException e) {
85+
logger.error("Failed to send notification for repository {}/{}.", repositoryOwner,
86+
repositoryName, e);
87+
}
88+
}
89+
90+
lastExecution = new Date();
91+
}
92+
93+
private List<PrNotificationsRecord> getAllNotifications() {
94+
return database
95+
.read(context -> context.selectFrom(PrNotifications.PR_NOTIFICATIONS).fetch());
96+
}
97+
98+
private void sendNotification(JDA jda, long channelId, GHPullRequest pr) throws IOException {
99+
ThreadChannel channel = jda.getThreadChannelById(channelId);
100+
if (channel == null) {
101+
logger.info("Failed to find channel {} to send pull request notification.", channelId);
102+
return;
103+
}
104+
channel.sendMessage("New pull request from " + pr.getUser().getLogin() + ".").queue();
105+
}
106+
107+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE pr_notifications
2+
(
3+
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4+
channel_id BIGINT NOT NULL,
5+
repository_owner TEXT NOT NULL,
6+
repository_name TEXT NOT NULL
7+
)

0 commit comments

Comments
 (0)