Skip to content

Commit c63add7

Browse files
ankitsmt211Tais993Taz03illuminator3
authored
Continue/feature/reference gh (#981)
* Bugfix In BotCore: Added onCommandAutoCompleteInteraction listener, this way autocompletion events will actually get forwarded. Funny, didnt think of this during the previous PR, was probably too hasty. * Update application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java Co-authored-by: Tanish Azad <73871477+Taz03@users.noreply.github.com> * fixed debug message * add github referencing + github command * make codeql and sonarcloud happy * spotless, *sigh* * forgot these two * remove mention when reference * aaaaaaaaaa * fix compilation * fix compilation x2 * apply spotless * fix doc * requested changes * requested changes * requested changes * Update application/config.json.template Co-authored-by: Tanish Azad <73871477+Taz03@users.noreply.github.com> * Update application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubCommand.java Co-authored-by: Tanish Azad <73871477+Taz03@users.noreply.github.com> * Update application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubReference.java Co-authored-by: Tanish Azad <73871477+Taz03@users.noreply.github.com> * resolve conflicts * sonar fix * adding back suspicousKeywords * requested changes in old PR * java doc fixes * avatar of author in embed * refactor embed reply for clarity, add date of creation * sonar fix * refactor date to a better format * upgrade from 1.313->1.315 * remove duplicate * requested changes * refactor date using calendar api to java time api & remove months array * get rid of redundant modifier and an extra line of space * making formatter a constant field instead of local var * update config template and verify allowed channels * adds few channel patterns to be allowed in template * refactors pattern matching for allowed channels * helper method to match allowed channels * replacing parallelstream with sequential * adding repository Ids for all TJ repos * changes to find issue method * for github search, instead of finding by issue we also wanna match title for correct match * method overloading for defaulting to tj-bot repo for reference feature * sonar and better var name * sonar fix * remove unnecessary use of strip --------- Co-authored-by: Tijs <tijs@familiebeek.eu> Co-authored-by: Tais993 <49957334+Tais993@users.noreply.github.com> Co-authored-by: Tanish Azad <73871477+Taz03@users.noreply.github.com> Co-authored-by: Taz03 <tanishazad03@gmail.com> Co-authored-by: illuminator3 <hardt-j@web.de>
1 parent 65007f2 commit c63add7

File tree

7 files changed

+411
-14
lines changed

7 files changed

+411
-14
lines changed

application/config.json.template

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"token": "<put_your_token_here>",
3-
"gistApiKey": "<your_gist_personal_access_token>",
3+
"githubApiKey": "<your_github_personal_access_token>",
44
"databasePath": "local-database.db",
55
"projectWebsite": "https://github.com/Together-Java/TJ-Bot",
66
"discordGuildInvite": "https://discord.com/invite/XXFUXzK",
@@ -86,6 +86,8 @@
8686
"wsf",
8787
"wsh"
8888
],
89+
"githubReferencingEnabledChannelPattern": "server-suggestions|tjbot-discussion|modernjava-discussion",
90+
"githubRepositories": [403389278,587644974,601602394],
8991
"logInfoChannelWebhook": "<put_your_webhook_here>",
9092
"logErrorChannelWebhook": "<put_your_webhook_here>",
9193
"openaiApiKey": "<check pins in #tjbot_discussion for the key>",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package org.togetherjava.tjbot.commands.github;
2+
3+
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
4+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
5+
import net.dv8tion.jda.api.interactions.commands.OptionType;
6+
import org.kohsuke.github.GHIssue;
7+
import org.kohsuke.github.GHIssueState;
8+
9+
import org.togetherjava.tjbot.features.CommandVisibility;
10+
import org.togetherjava.tjbot.features.SlashCommandAdapter;
11+
import org.togetherjava.tjbot.features.utils.StringDistances;
12+
13+
import java.io.IOException;
14+
import java.io.UncheckedIOException;
15+
import java.time.Duration;
16+
import java.time.Instant;
17+
import java.util.*;
18+
import java.util.function.ToIntFunction;
19+
import java.util.regex.Matcher;
20+
import java.util.stream.Stream;
21+
22+
/**
23+
* Slash command (/github-search) used to search for an issue in one of the repositories listed in
24+
* the config. It also auto suggests issues/PRs on trigger.
25+
*/
26+
public final class GitHubCommand extends SlashCommandAdapter {
27+
private static final Duration CACHE_EXPIRES_AFTER = Duration.ofMinutes(1);
28+
29+
/**
30+
* Compares two GitHub Issues ascending by the time they have been updated at.
31+
*/
32+
private static final Comparator<GHIssue> GITHUB_ISSUE_TIME_COMPARATOR = (i1, i2) -> {
33+
try {
34+
return i2.getUpdatedAt().compareTo(i1.getUpdatedAt());
35+
} catch (IOException ex) {
36+
throw new UncheckedIOException(ex);
37+
}
38+
};
39+
40+
private static final String TITLE_OPTION = "title";
41+
42+
private final GitHubReference reference;
43+
44+
private Instant lastCacheUpdate;
45+
private List<String> autocompleteGHIssueCache;
46+
47+
public GitHubCommand(GitHubReference reference) {
48+
super("github-search", "Search configured GitHub repositories for an issue/pull request",
49+
CommandVisibility.GUILD);
50+
51+
this.reference = reference;
52+
53+
getData().addOption(OptionType.STRING, TITLE_OPTION,
54+
"Title of the issue you're looking for", true, true);
55+
56+
updateCache();
57+
}
58+
59+
@Override
60+
public void onSlashCommand(SlashCommandInteractionEvent event) {
61+
String titleOption = event.getOption(TITLE_OPTION).getAsString();
62+
Matcher matcher = GitHubReference.ISSUE_REFERENCE_PATTERN.matcher(titleOption);
63+
64+
if (!matcher.find()) {
65+
event.reply(
66+
"Could not parse your query. Was not able to find an issue number in it (e.g. #207).")
67+
.setEphemeral(true)
68+
.queue();
69+
70+
return;
71+
}
72+
73+
int issueId = Integer.parseInt(matcher.group(GitHubReference.ID_GROUP));
74+
// extracting issue title from "[#10] add more stuff"
75+
String[] issueData = titleOption.split(" ", 2);
76+
String targetIssueTitle = issueData[1];
77+
78+
reference.findIssue(issueId, targetIssueTitle)
79+
.ifPresentOrElse(issue -> event.replyEmbeds(reference.generateReply(issue)).queue(),
80+
() -> event.reply("Could not find the issue you are looking for.")
81+
.setEphemeral(true)
82+
.queue());
83+
}
84+
85+
@Override
86+
public void onAutoComplete(CommandAutoCompleteInteractionEvent event) {
87+
String title = event.getOption(TITLE_OPTION).getAsString();
88+
89+
if (title.isEmpty()) {
90+
event.replyChoiceStrings(autocompleteGHIssueCache.stream().limit(25).toList()).queue();
91+
} else {
92+
Queue<String> closestSuggestions =
93+
new PriorityQueue<>(Comparator.comparingInt(suggestionScorer(title)));
94+
95+
closestSuggestions.addAll(autocompleteGHIssueCache);
96+
97+
List<String> choices = Stream.generate(closestSuggestions::poll).limit(25).toList();
98+
event.replyChoiceStrings(choices).queue();
99+
}
100+
101+
if (lastCacheUpdate.isAfter(Instant.now().minus(CACHE_EXPIRES_AFTER))) {
102+
updateCache();
103+
}
104+
}
105+
106+
private ToIntFunction<String> suggestionScorer(String title) {
107+
// Remove the ID [#123] and then match
108+
return s -> StringDistances.editDistance(title, s.replaceFirst("\\[#\\d+] ", ""));
109+
}
110+
111+
private void updateCache() {
112+
autocompleteGHIssueCache = reference.getRepositories().stream().map(repo -> {
113+
try {
114+
return repo.getIssues(GHIssueState.ALL);
115+
} catch (IOException ex) {
116+
throw new UncheckedIOException(ex);
117+
}
118+
})
119+
.flatMap(List::stream)
120+
.sorted(GITHUB_ISSUE_TIME_COMPARATOR)
121+
.map(issue -> "[#%d] %s".formatted(issue.getNumber(), issue.getTitle()))
122+
.toList();
123+
124+
lastCacheUpdate = Instant.now();
125+
}
126+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package org.togetherjava.tjbot.commands.github;
2+
3+
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.entities.Message;
5+
import net.dv8tion.jda.api.entities.MessageEmbed;
6+
import net.dv8tion.jda.api.entities.channel.ChannelType;
7+
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
8+
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
9+
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
10+
import org.apache.commons.collections4.ListUtils;
11+
import org.kohsuke.github.*;
12+
13+
import org.togetherjava.tjbot.config.Config;
14+
import org.togetherjava.tjbot.features.MessageReceiverAdapter;
15+
16+
import java.awt.*;
17+
import java.io.FileNotFoundException;
18+
import java.io.IOException;
19+
import java.io.UncheckedIOException;
20+
import java.time.Instant;
21+
import java.time.ZoneOffset;
22+
import java.time.format.DateTimeFormatter;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
import java.util.Optional;
26+
import java.util.function.Predicate;
27+
import java.util.regex.Matcher;
28+
import java.util.regex.Pattern;
29+
import java.util.stream.Collectors;
30+
31+
/**
32+
* GitHub Referencing feature. If someone sends #id of an issue (e.g. #207) in specified channel,
33+
* the bot replies with an embed that contains info on the issue/PR.
34+
*/
35+
public final class GitHubReference extends MessageReceiverAdapter {
36+
static final String ID_GROUP = "id";
37+
38+
/**
39+
* The pattern(#123) used to determine whether a message is referencing an issue.
40+
*/
41+
static final Pattern ISSUE_REFERENCE_PATTERN =
42+
Pattern.compile("#(?<%s>\\d+)".formatted(ID_GROUP));
43+
private static final int ISSUE_OPEN = Color.green.getRGB();
44+
private static final int ISSUE_CLOSE = Color.red.getRGB();
45+
46+
/**
47+
* A constant representing the date and time formatter used for formatting the creation date of
48+
* an issue. The pattern "dd MMM, yyyy" represents the format "09 Oct, 2023".
49+
*/
50+
static final DateTimeFormatter FORMATTER =
51+
DateTimeFormatter.ofPattern("dd MMM, yyyy").withZone(ZoneOffset.UTC);
52+
private final Predicate<String> hasGithubIssueReferenceEnabled;
53+
private final Config config;
54+
55+
/**
56+
* The repositories that are searched when looking for an issue.
57+
*/
58+
private List<GHRepository> repositories;
59+
60+
public GitHubReference(Config config) {
61+
this.config = config;
62+
this.hasGithubIssueReferenceEnabled =
63+
Pattern.compile(config.getGitHubReferencingEnabledChannelPattern())
64+
.asMatchPredicate();
65+
acquireRepositories();
66+
}
67+
68+
/**
69+
* Acquires the list of repositories to use as a source for lookup.
70+
*/
71+
private void acquireRepositories() {
72+
try {
73+
repositories = new ArrayList<>();
74+
75+
GitHub githubApi = GitHub.connectUsingOAuth(config.getGitHubApiKey());
76+
77+
for (long repoId : config.getGitHubRepositories()) {
78+
repositories.add(githubApi.getRepositoryById(repoId));
79+
}
80+
} catch (IOException ex) {
81+
throw new UncheckedIOException(ex);
82+
}
83+
}
84+
85+
@Override
86+
public void onMessageReceived(MessageReceivedEvent event) {
87+
if (event.getAuthor().isBot() || !isAllowedChannelOrChildThread(event)) {
88+
return;
89+
}
90+
91+
Message message = event.getMessage();
92+
String content = message.getContentRaw();
93+
Matcher matcher = ISSUE_REFERENCE_PATTERN.matcher(content);
94+
List<MessageEmbed> embeds = new ArrayList<>();
95+
96+
while (matcher.find()) {
97+
long defaultRepoId = config.getGitHubRepositories().get(0);
98+
findIssue(Integer.parseInt(matcher.group(ID_GROUP)), defaultRepoId)
99+
.ifPresent(issue -> embeds.add(generateReply(issue)));
100+
}
101+
102+
replyBatchEmbeds(embeds, message, false);
103+
}
104+
105+
/**
106+
* Replies to the given message with the given embeds in "batches", sending
107+
* {@value Message#MAX_EMBED_COUNT} embeds at a time (the discord limit)
108+
*/
109+
private void replyBatchEmbeds(List<MessageEmbed> embeds, Message message,
110+
boolean mentionRepliedUser) {
111+
List<List<MessageEmbed>> partition = ListUtils.partition(embeds, Message.MAX_EMBED_COUNT);
112+
boolean isFirstBatch = true;
113+
114+
MessageChannel sourceChannel = message.getChannelType() == ChannelType.GUILD_PUBLIC_THREAD
115+
? message.getChannel().asThreadChannel()
116+
: message.getChannel().asTextChannel();
117+
118+
for (List<MessageEmbed> messageEmbeds : partition) {
119+
if (isFirstBatch) {
120+
message.replyEmbeds(messageEmbeds).mentionRepliedUser(mentionRepliedUser).queue();
121+
122+
isFirstBatch = false;
123+
} else {
124+
sourceChannel.sendMessageEmbeds(messageEmbeds).queue();
125+
}
126+
}
127+
}
128+
129+
/**
130+
* Generates the embed to reply with when someone references an issue.
131+
*/
132+
MessageEmbed generateReply(GHIssue issue) throws UncheckedIOException {
133+
try {
134+
String title = "[#%d] %s".formatted(issue.getNumber(), issue.getTitle());
135+
String titleUrl = issue.getHtmlUrl().toString();
136+
String description = issue.getBody();
137+
138+
String labels = issue.getLabels()
139+
.stream()
140+
.map(GHLabel::getName)
141+
.collect(Collectors.joining(", "));
142+
143+
String assignees = issue.getAssignees()
144+
.stream()
145+
.map(this::getUserNameOrThrow)
146+
.collect(Collectors.joining(", "));
147+
148+
Instant createdAt = issue.getCreatedAt().toInstant();
149+
String dateOfCreation = FORMATTER.format(createdAt);
150+
151+
String footer = "%s • %s • %s".formatted(labels, assignees, dateOfCreation);
152+
153+
return new EmbedBuilder()
154+
.setColor(issue.getState() == GHIssueState.OPEN ? ISSUE_OPEN : ISSUE_CLOSE)
155+
.setTitle(title, titleUrl)
156+
.setDescription(description)
157+
.setAuthor(issue.getUser().getName(), null, issue.getUser().getAvatarUrl())
158+
.setFooter(footer)
159+
.build();
160+
161+
} catch (IOException ex) {
162+
throw new UncheckedIOException(ex);
163+
}
164+
}
165+
166+
/**
167+
* Either properly gathers the name of a user or throws a UncheckedIOException.
168+
*/
169+
private String getUserNameOrThrow(GHUser user) throws UncheckedIOException {
170+
try {
171+
return user.getName();
172+
} catch (IOException ex) {
173+
throw new UncheckedIOException(ex);
174+
}
175+
}
176+
177+
/**
178+
* Looks through all of the given repositories for an issue/pr with the given id.
179+
*/
180+
Optional<GHIssue> findIssue(int id, String targetIssueTitle) {
181+
return repositories.stream().map(repository -> {
182+
try {
183+
GHIssue issue = repository.getIssue(id);
184+
if (issue.getTitle().equals(targetIssueTitle)) {
185+
return Optional.of(issue);
186+
}
187+
} catch (FileNotFoundException ignored) {
188+
return Optional.<GHIssue>empty();
189+
} catch (IOException ex) {
190+
throw new UncheckedIOException(ex);
191+
}
192+
return Optional.<GHIssue>empty();
193+
}).filter(Optional::isPresent).findFirst().orElse(Optional.empty());
194+
}
195+
196+
Optional<GHIssue> findIssue(int id, long defaultRepoId) {
197+
return repositories.stream()
198+
.filter(repository -> repository.getId() == defaultRepoId)
199+
.map(repository -> {
200+
try {
201+
return Optional.of(repository.getIssue(id));
202+
} catch (FileNotFoundException ignored) {
203+
return Optional.<GHIssue>empty();
204+
} catch (IOException ex) {
205+
throw new UncheckedIOException(ex);
206+
}
207+
})
208+
.filter(Optional::isPresent)
209+
.map(Optional::orElseThrow)
210+
.findAny();
211+
}
212+
213+
/**
214+
* All repositories monitored by this instance.
215+
*/
216+
List<GHRepository> getRepositories() {
217+
return repositories;
218+
}
219+
220+
private boolean isAllowedChannelOrChildThread(MessageReceivedEvent event) {
221+
if (event.getChannelType().isThread()) {
222+
ThreadChannel threadChannel = event.getChannel().asThreadChannel();
223+
String rootChannel = threadChannel.getParentChannel().getName();
224+
return this.hasGithubIssueReferenceEnabled.test(rootChannel);
225+
}
226+
227+
String textChannel = event.getChannel().asTextChannel().getName();
228+
return this.hasGithubIssueReferenceEnabled.test(textChannel);
229+
}
230+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* This package offers in-discord features regarding GitHub.
3+
*/
4+
@MethodsReturnNonnullByDefault
5+
@ParametersAreNonnullByDefault
6+
package org.togetherjava.tjbot.commands.github;
7+
8+
import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;
9+
10+
import javax.annotation.ParametersAreNonnullByDefault;

0 commit comments

Comments
 (0)