Skip to content

Commit

Permalink
Continue/feature/reference gh (#981)
Browse files Browse the repository at this point in the history
* 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
6 people authored Jan 23, 2024
1 parent 65007f2 commit c63add7
Show file tree
Hide file tree
Showing 7 changed files with 411 additions and 14 deletions.
4 changes: 3 additions & 1 deletion application/config.json.template
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"token": "<put_your_token_here>",
"gistApiKey": "<your_gist_personal_access_token>",
"githubApiKey": "<your_github_personal_access_token>",
"databasePath": "local-database.db",
"projectWebsite": "https://github.com/Together-Java/TJ-Bot",
"discordGuildInvite": "https://discord.com/invite/XXFUXzK",
Expand Down Expand Up @@ -86,6 +86,8 @@
"wsf",
"wsh"
],
"githubReferencingEnabledChannelPattern": "server-suggestions|tjbot-discussion|modernjava-discussion",
"githubRepositories": [403389278,587644974,601602394],
"logInfoChannelWebhook": "<put_your_webhook_here>",
"logErrorChannelWebhook": "<put_your_webhook_here>",
"openaiApiKey": "<check pins in #tjbot_discussion for the key>",
Expand Down
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();
}
}
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);
}
}
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;
Loading

0 comments on commit c63add7

Please sign in to comment.