-
-
Notifications
You must be signed in to change notification settings - Fork 90
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * 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 <[email protected]> * Update application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubCommand.java Co-authored-by: Tanish Azad <[email protected]> * Update application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubReference.java Co-authored-by: Tanish Azad <[email protected]> * 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 <[email protected]> Co-authored-by: Tais993 <[email protected]> Co-authored-by: Tanish Azad <[email protected]> Co-authored-by: Taz03 <[email protected]> Co-authored-by: illuminator3 <[email protected]>
- Loading branch information
1 parent
65007f2
commit c63add7
Showing
7 changed files
with
411 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubCommand.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
package org.togetherjava.tjbot.commands.github; | ||
|
||
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; | ||
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; | ||
import net.dv8tion.jda.api.interactions.commands.OptionType; | ||
import org.kohsuke.github.GHIssue; | ||
import org.kohsuke.github.GHIssueState; | ||
|
||
import org.togetherjava.tjbot.features.CommandVisibility; | ||
import org.togetherjava.tjbot.features.SlashCommandAdapter; | ||
import org.togetherjava.tjbot.features.utils.StringDistances; | ||
|
||
import java.io.IOException; | ||
import java.io.UncheckedIOException; | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.util.*; | ||
import java.util.function.ToIntFunction; | ||
import java.util.regex.Matcher; | ||
import java.util.stream.Stream; | ||
|
||
/** | ||
* Slash command (/github-search) used to search for an issue in one of the repositories listed in | ||
* the config. It also auto suggests issues/PRs on trigger. | ||
*/ | ||
public final class GitHubCommand extends SlashCommandAdapter { | ||
private static final Duration CACHE_EXPIRES_AFTER = Duration.ofMinutes(1); | ||
|
||
/** | ||
* Compares two GitHub Issues ascending by the time they have been updated at. | ||
*/ | ||
private static final Comparator<GHIssue> GITHUB_ISSUE_TIME_COMPARATOR = (i1, i2) -> { | ||
try { | ||
return i2.getUpdatedAt().compareTo(i1.getUpdatedAt()); | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
}; | ||
|
||
private static final String TITLE_OPTION = "title"; | ||
|
||
private final GitHubReference reference; | ||
|
||
private Instant lastCacheUpdate; | ||
private List<String> autocompleteGHIssueCache; | ||
|
||
public GitHubCommand(GitHubReference reference) { | ||
super("github-search", "Search configured GitHub repositories for an issue/pull request", | ||
CommandVisibility.GUILD); | ||
|
||
this.reference = reference; | ||
|
||
getData().addOption(OptionType.STRING, TITLE_OPTION, | ||
"Title of the issue you're looking for", true, true); | ||
|
||
updateCache(); | ||
} | ||
|
||
@Override | ||
public void onSlashCommand(SlashCommandInteractionEvent event) { | ||
String titleOption = event.getOption(TITLE_OPTION).getAsString(); | ||
Matcher matcher = GitHubReference.ISSUE_REFERENCE_PATTERN.matcher(titleOption); | ||
|
||
if (!matcher.find()) { | ||
event.reply( | ||
"Could not parse your query. Was not able to find an issue number in it (e.g. #207).") | ||
.setEphemeral(true) | ||
.queue(); | ||
|
||
return; | ||
} | ||
|
||
int issueId = Integer.parseInt(matcher.group(GitHubReference.ID_GROUP)); | ||
// extracting issue title from "[#10] add more stuff" | ||
String[] issueData = titleOption.split(" ", 2); | ||
String targetIssueTitle = issueData[1]; | ||
|
||
reference.findIssue(issueId, targetIssueTitle) | ||
.ifPresentOrElse(issue -> event.replyEmbeds(reference.generateReply(issue)).queue(), | ||
() -> event.reply("Could not find the issue you are looking for.") | ||
.setEphemeral(true) | ||
.queue()); | ||
} | ||
|
||
@Override | ||
public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { | ||
String title = event.getOption(TITLE_OPTION).getAsString(); | ||
|
||
if (title.isEmpty()) { | ||
event.replyChoiceStrings(autocompleteGHIssueCache.stream().limit(25).toList()).queue(); | ||
} else { | ||
Queue<String> closestSuggestions = | ||
new PriorityQueue<>(Comparator.comparingInt(suggestionScorer(title))); | ||
|
||
closestSuggestions.addAll(autocompleteGHIssueCache); | ||
|
||
List<String> choices = Stream.generate(closestSuggestions::poll).limit(25).toList(); | ||
event.replyChoiceStrings(choices).queue(); | ||
} | ||
|
||
if (lastCacheUpdate.isAfter(Instant.now().minus(CACHE_EXPIRES_AFTER))) { | ||
updateCache(); | ||
} | ||
} | ||
|
||
private ToIntFunction<String> suggestionScorer(String title) { | ||
// Remove the ID [#123] and then match | ||
return s -> StringDistances.editDistance(title, s.replaceFirst("\\[#\\d+] ", "")); | ||
} | ||
|
||
private void updateCache() { | ||
autocompleteGHIssueCache = reference.getRepositories().stream().map(repo -> { | ||
try { | ||
return repo.getIssues(GHIssueState.ALL); | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
}) | ||
.flatMap(List::stream) | ||
.sorted(GITHUB_ISSUE_TIME_COMPARATOR) | ||
.map(issue -> "[#%d] %s".formatted(issue.getNumber(), issue.getTitle())) | ||
.toList(); | ||
|
||
lastCacheUpdate = Instant.now(); | ||
} | ||
} |
230 changes: 230 additions & 0 deletions
230
application/src/main/java/org/togetherjava/tjbot/commands/github/GitHubReference.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,230 @@ | ||
package org.togetherjava.tjbot.commands.github; | ||
|
||
import net.dv8tion.jda.api.EmbedBuilder; | ||
import net.dv8tion.jda.api.entities.Message; | ||
import net.dv8tion.jda.api.entities.MessageEmbed; | ||
import net.dv8tion.jda.api.entities.channel.ChannelType; | ||
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; | ||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; | ||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent; | ||
import org.apache.commons.collections4.ListUtils; | ||
import org.kohsuke.github.*; | ||
|
||
import org.togetherjava.tjbot.config.Config; | ||
import org.togetherjava.tjbot.features.MessageReceiverAdapter; | ||
|
||
import java.awt.*; | ||
import java.io.FileNotFoundException; | ||
import java.io.IOException; | ||
import java.io.UncheckedIOException; | ||
import java.time.Instant; | ||
import java.time.ZoneOffset; | ||
import java.time.format.DateTimeFormatter; | ||
import java.util.ArrayList; | ||
import java.util.List; | ||
import java.util.Optional; | ||
import java.util.function.Predicate; | ||
import java.util.regex.Matcher; | ||
import java.util.regex.Pattern; | ||
import java.util.stream.Collectors; | ||
|
||
/** | ||
* GitHub Referencing feature. If someone sends #id of an issue (e.g. #207) in specified channel, | ||
* the bot replies with an embed that contains info on the issue/PR. | ||
*/ | ||
public final class GitHubReference extends MessageReceiverAdapter { | ||
static final String ID_GROUP = "id"; | ||
|
||
/** | ||
* The pattern(#123) used to determine whether a message is referencing an issue. | ||
*/ | ||
static final Pattern ISSUE_REFERENCE_PATTERN = | ||
Pattern.compile("#(?<%s>\\d+)".formatted(ID_GROUP)); | ||
private static final int ISSUE_OPEN = Color.green.getRGB(); | ||
private static final int ISSUE_CLOSE = Color.red.getRGB(); | ||
|
||
/** | ||
* A constant representing the date and time formatter used for formatting the creation date of | ||
* an issue. The pattern "dd MMM, yyyy" represents the format "09 Oct, 2023". | ||
*/ | ||
static final DateTimeFormatter FORMATTER = | ||
DateTimeFormatter.ofPattern("dd MMM, yyyy").withZone(ZoneOffset.UTC); | ||
private final Predicate<String> hasGithubIssueReferenceEnabled; | ||
private final Config config; | ||
|
||
/** | ||
* The repositories that are searched when looking for an issue. | ||
*/ | ||
private List<GHRepository> repositories; | ||
|
||
public GitHubReference(Config config) { | ||
this.config = config; | ||
this.hasGithubIssueReferenceEnabled = | ||
Pattern.compile(config.getGitHubReferencingEnabledChannelPattern()) | ||
.asMatchPredicate(); | ||
acquireRepositories(); | ||
} | ||
|
||
/** | ||
* Acquires the list of repositories to use as a source for lookup. | ||
*/ | ||
private void acquireRepositories() { | ||
try { | ||
repositories = new ArrayList<>(); | ||
|
||
GitHub githubApi = GitHub.connectUsingOAuth(config.getGitHubApiKey()); | ||
|
||
for (long repoId : config.getGitHubRepositories()) { | ||
repositories.add(githubApi.getRepositoryById(repoId)); | ||
} | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
} | ||
|
||
@Override | ||
public void onMessageReceived(MessageReceivedEvent event) { | ||
if (event.getAuthor().isBot() || !isAllowedChannelOrChildThread(event)) { | ||
return; | ||
} | ||
|
||
Message message = event.getMessage(); | ||
String content = message.getContentRaw(); | ||
Matcher matcher = ISSUE_REFERENCE_PATTERN.matcher(content); | ||
List<MessageEmbed> embeds = new ArrayList<>(); | ||
|
||
while (matcher.find()) { | ||
long defaultRepoId = config.getGitHubRepositories().get(0); | ||
findIssue(Integer.parseInt(matcher.group(ID_GROUP)), defaultRepoId) | ||
.ifPresent(issue -> embeds.add(generateReply(issue))); | ||
} | ||
|
||
replyBatchEmbeds(embeds, message, false); | ||
} | ||
|
||
/** | ||
* Replies to the given message with the given embeds in "batches", sending | ||
* {@value Message#MAX_EMBED_COUNT} embeds at a time (the discord limit) | ||
*/ | ||
private void replyBatchEmbeds(List<MessageEmbed> embeds, Message message, | ||
boolean mentionRepliedUser) { | ||
List<List<MessageEmbed>> partition = ListUtils.partition(embeds, Message.MAX_EMBED_COUNT); | ||
boolean isFirstBatch = true; | ||
|
||
MessageChannel sourceChannel = message.getChannelType() == ChannelType.GUILD_PUBLIC_THREAD | ||
? message.getChannel().asThreadChannel() | ||
: message.getChannel().asTextChannel(); | ||
|
||
for (List<MessageEmbed> messageEmbeds : partition) { | ||
if (isFirstBatch) { | ||
message.replyEmbeds(messageEmbeds).mentionRepliedUser(mentionRepliedUser).queue(); | ||
|
||
isFirstBatch = false; | ||
} else { | ||
sourceChannel.sendMessageEmbeds(messageEmbeds).queue(); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Generates the embed to reply with when someone references an issue. | ||
*/ | ||
MessageEmbed generateReply(GHIssue issue) throws UncheckedIOException { | ||
try { | ||
String title = "[#%d] %s".formatted(issue.getNumber(), issue.getTitle()); | ||
String titleUrl = issue.getHtmlUrl().toString(); | ||
String description = issue.getBody(); | ||
|
||
String labels = issue.getLabels() | ||
.stream() | ||
.map(GHLabel::getName) | ||
.collect(Collectors.joining(", ")); | ||
|
||
String assignees = issue.getAssignees() | ||
.stream() | ||
.map(this::getUserNameOrThrow) | ||
.collect(Collectors.joining(", ")); | ||
|
||
Instant createdAt = issue.getCreatedAt().toInstant(); | ||
String dateOfCreation = FORMATTER.format(createdAt); | ||
|
||
String footer = "%s • %s • %s".formatted(labels, assignees, dateOfCreation); | ||
|
||
return new EmbedBuilder() | ||
.setColor(issue.getState() == GHIssueState.OPEN ? ISSUE_OPEN : ISSUE_CLOSE) | ||
.setTitle(title, titleUrl) | ||
.setDescription(description) | ||
.setAuthor(issue.getUser().getName(), null, issue.getUser().getAvatarUrl()) | ||
.setFooter(footer) | ||
.build(); | ||
|
||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
} | ||
|
||
/** | ||
* Either properly gathers the name of a user or throws a UncheckedIOException. | ||
*/ | ||
private String getUserNameOrThrow(GHUser user) throws UncheckedIOException { | ||
try { | ||
return user.getName(); | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
} | ||
|
||
/** | ||
* Looks through all of the given repositories for an issue/pr with the given id. | ||
*/ | ||
Optional<GHIssue> findIssue(int id, String targetIssueTitle) { | ||
return repositories.stream().map(repository -> { | ||
try { | ||
GHIssue issue = repository.getIssue(id); | ||
if (issue.getTitle().equals(targetIssueTitle)) { | ||
return Optional.of(issue); | ||
} | ||
} catch (FileNotFoundException ignored) { | ||
return Optional.<GHIssue>empty(); | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
return Optional.<GHIssue>empty(); | ||
}).filter(Optional::isPresent).findFirst().orElse(Optional.empty()); | ||
} | ||
|
||
Optional<GHIssue> findIssue(int id, long defaultRepoId) { | ||
return repositories.stream() | ||
.filter(repository -> repository.getId() == defaultRepoId) | ||
.map(repository -> { | ||
try { | ||
return Optional.of(repository.getIssue(id)); | ||
} catch (FileNotFoundException ignored) { | ||
return Optional.<GHIssue>empty(); | ||
} catch (IOException ex) { | ||
throw new UncheckedIOException(ex); | ||
} | ||
}) | ||
.filter(Optional::isPresent) | ||
.map(Optional::orElseThrow) | ||
.findAny(); | ||
} | ||
|
||
/** | ||
* All repositories monitored by this instance. | ||
*/ | ||
List<GHRepository> getRepositories() { | ||
return repositories; | ||
} | ||
|
||
private boolean isAllowedChannelOrChildThread(MessageReceivedEvent event) { | ||
if (event.getChannelType().isThread()) { | ||
ThreadChannel threadChannel = event.getChannel().asThreadChannel(); | ||
String rootChannel = threadChannel.getParentChannel().getName(); | ||
return this.hasGithubIssueReferenceEnabled.test(rootChannel); | ||
} | ||
|
||
String textChannel = event.getChannel().asTextChannel().getName(); | ||
return this.hasGithubIssueReferenceEnabled.test(textChannel); | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
application/src/main/java/org/togetherjava/tjbot/commands/github/package-info.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
/** | ||
* This package offers in-discord features regarding GitHub. | ||
*/ | ||
@MethodsReturnNonnullByDefault | ||
@ParametersAreNonnullByDefault | ||
package org.togetherjava.tjbot.commands.github; | ||
|
||
import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; | ||
|
||
import javax.annotation.ParametersAreNonnullByDefault; |
Oops, something went wrong.