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

Continue/feature/reference gh #981

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
490ec6f
Bugfix
Tais993 Sep 30, 2022
aa58c07
Update application/src/main/java/org/togetherjava/tjbot/commands/syst…
Tais993 Oct 1, 2022
5f574d3
fixed debug message
Taz03 Oct 1, 2022
a9fef62
add github referencing + github command
illuminator3 Oct 2, 2022
9de6564
make codeql and sonarcloud happy
illuminator3 Oct 2, 2022
1af1b54
spotless, *sigh*
illuminator3 Oct 2, 2022
4325805
forgot these two
illuminator3 Oct 2, 2022
d45db55
remove mention when reference
illuminator3 Oct 2, 2022
99ce678
aaaaaaaaaa
illuminator3 Oct 2, 2022
cad10d2
Merge branch 'develop' into feature/reference-gh
illuminator3 Oct 4, 2022
a85abc2
fix compilation
illuminator3 Oct 4, 2022
ef9989e
fix compilation x2
illuminator3 Oct 4, 2022
1ba1418
apply spotless
illuminator3 Oct 4, 2022
3db6cfa
fix doc
illuminator3 Oct 4, 2022
62b728d
requested changes
illuminator3 Oct 4, 2022
3d2e48c
requested changes
illuminator3 Oct 4, 2022
4c40202
requested changes
illuminator3 Oct 5, 2022
d98b829
Update application/config.json.template
illuminator3 Oct 6, 2022
099f250
Update application/src/main/java/org/togetherjava/tjbot/commands/gith…
illuminator3 Oct 6, 2022
b93dd7e
Update application/src/main/java/org/togetherjava/tjbot/commands/gith…
illuminator3 Oct 6, 2022
c7a8773
resolve conflicts
ankitsmt211 Nov 24, 2023
d780f41
Merge branch 'develop' into continue/feature/reference-gh
ankitsmt211 Nov 24, 2023
39bc9b2
sonar fix
ankitsmt211 Nov 24, 2023
eb930b8
adding back suspicousKeywords
ankitsmt211 Nov 24, 2023
10414c6
requested changes in old PR
ankitsmt211 Nov 24, 2023
92fdef4
java doc fixes
ankitsmt211 Nov 24, 2023
4fafdb0
avatar of author in embed
ankitsmt211 Nov 24, 2023
c8afcb3
refactor embed reply for clarity, add date of creation
ankitsmt211 Nov 24, 2023
5cbdfc9
sonar fix
ankitsmt211 Nov 24, 2023
2d9b54a
refactor date to a better format
ankitsmt211 Nov 24, 2023
c3102af
upgrade from 1.313->1.315
ankitsmt211 Nov 24, 2023
fbd249d
remove duplicate
ankitsmt211 Nov 24, 2023
d5f3a0c
requested changes
ankitsmt211 Nov 25, 2023
53fc48b
refactor date using calendar api to java time api & remove months array
ankitsmt211 Nov 26, 2023
2dfaa0c
get rid of redundant modifier and an extra line of space
ankitsmt211 Nov 26, 2023
0695af9
making formatter a constant field instead of local var
ankitsmt211 Nov 26, 2023
109abf8
update config template and verify allowed channels
ankitsmt211 Nov 27, 2023
82703f2
replacing parallelstream with sequential
ankitsmt211 Jan 18, 2024
74bfc9b
adding repository Ids for all TJ repos
ankitsmt211 Jan 20, 2024
d9f75c8
changes to find issue method
ankitsmt211 Jan 21, 2024
4d01ccd
sonar and better var name
ankitsmt211 Jan 21, 2024
f2d4c43
sonar fix
ankitsmt211 Jan 21, 2024
be7fa14
remove unnecessary use of strip
ankitsmt211 Jan 21, 2024
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
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();
SquidXTV marked this conversation as resolved.
Show resolved Hide resolved

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;
}
ankitsmt211 marked this conversation as resolved.
Show resolved Hide resolved

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
Loading