diff --git a/build.gradle b/build.gradle index 905a784..88ffa85 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,9 @@ plugins { repositories { mavenCentral() + maven { + url = 'https://libraries.minecraft.net' + } } java.toolchain { @@ -30,12 +33,17 @@ dependencies { implementation 'org.bouncycastle:bcpkix-jdk15on:1.58' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.13.3' - implementation 'io.jsonwebtoken:jjwt-api:0.10.5' - implementation 'io.jsonwebtoken:jjwt-impl:0.10.5' - implementation 'io.jsonwebtoken:jjwt-jackson:0.10.5' + final jjwt = '0.10.5' + implementation "io.jsonwebtoken:jjwt-api:$jjwt" + implementation "io.jsonwebtoken:jjwt-impl:$jjwt" + implementation "io.jsonwebtoken:jjwt-jackson:$jjwt" + + implementation 'com.mojang:brigadier:1.0.18' implementation 'com.apollographql.apollo:apollo-runtime:2.5.14' // Apollo (GraphQL) implementation 'com.apollographql.apollo:apollo-rx3-support:2.5.14' // Apollo support for RxJava3 + + implementation 'org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r' } apollo { diff --git a/src/main/graphql/com/github/api/Comments.graphql b/src/main/graphql/com/github/api/Comments.graphql new file mode 100644 index 0000000..6d04077 --- /dev/null +++ b/src/main/graphql/com/github/api/Comments.graphql @@ -0,0 +1,7 @@ +mutation MinimizeComment($reason: ReportedContentClassifiers!, $comment: ID!) { + minimizeComment(input: {classifier: $reason, subjectId: $comment}) { + minimizedComment { + isMinimized + } + } +} diff --git a/src/main/java/net/neoforged/automation/Configuration.java b/src/main/java/net/neoforged/automation/Configuration.java index 9fec067..0e1d9d0 100644 --- a/src/main/java/net/neoforged/automation/Configuration.java +++ b/src/main/java/net/neoforged/automation/Configuration.java @@ -8,20 +8,26 @@ import org.kohsuke.github.GitHub; import java.io.IOException; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; public record Configuration( + Commands commands, + PRActions prActions, Map repositories ) { - public record RepoConfiguration(Map labelLocks) { - + public record RepoConfiguration(Boolean enabled, Map labelLocks, List formattingTasks) { + public RepoConfiguration { + enabled = enabled == null || enabled; + } + public static final RepoConfiguration DEFAULT = new RepoConfiguration(true, Map.of(), List.of()); } private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER).enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE)); - private static volatile Configuration configuration = new Configuration(Map.of()); + private static volatile Configuration configuration = new Configuration(new Commands(List.of(), false, false), new PRActions(null, null), Map.of()); public static void load(GitHub gitHub, RepoLocation location) throws IOException { configuration = getOrCommit(gitHub.getRepository(location.repo()), location.path(), location.branch()); @@ -41,12 +47,17 @@ private static Configuration getOrCommit(GHRepository repository, String path, S } private Configuration sanitize() { - return new Configuration(repositories.entrySet() + return new Configuration( + commands, prActions, repositories.entrySet() .stream().collect(Collectors.toMap(f -> f.getKey().toLowerCase(Locale.ROOT), Map.Entry::getValue))); } public static RepoConfiguration get(GHRepository repository) { - return configuration.repositories().get(repository.getFullName().toLowerCase(Locale.ROOT)); + return configuration.repositories().getOrDefault(repository.getFullName().toLowerCase(Locale.ROOT), RepoConfiguration.DEFAULT); + } + + public static Configuration get() { + return configuration; } public record RepoLocation(String repo, String path, String branch) { @@ -63,4 +74,8 @@ public record LabelLock( boolean close, @Nullable String message ) {} + + public record Commands(List prefixes, boolean reactToComment, boolean minimizeComment) {} + + public record PRActions(String repository, String workflow) {} } diff --git a/src/main/java/net/neoforged/automation/Main.java b/src/main/java/net/neoforged/automation/Main.java index f21122e..dab5fa5 100644 --- a/src/main/java/net/neoforged/automation/Main.java +++ b/src/main/java/net/neoforged/automation/Main.java @@ -1,11 +1,15 @@ package net.neoforged.automation; +import com.mojang.brigadier.CommandDispatcher; import io.javalin.Javalin; +import net.neoforged.automation.command.Commands; import net.neoforged.automation.util.AuthUtil; import net.neoforged.automation.util.GHAction; +import net.neoforged.automation.webhook.handler.CommandHandler; import net.neoforged.automation.webhook.handler.ConfigurationUpdateHandler; import net.neoforged.automation.webhook.handler.LabelLockHandler; import net.neoforged.automation.webhook.handler.MergeConflictCheckHandler; +import net.neoforged.automation.webhook.handler.PRActionRunnerHandler; import net.neoforged.automation.webhook.handler.ReleaseMessageHandler; import net.neoforged.automation.webhook.impl.GitHubEvent; import net.neoforged.automation.webhook.impl.WebhookHandler; @@ -50,6 +54,10 @@ public static WebhookHandler setupWebhookHandlers(StartupConfiguration startupCo ghApp -> ghApp.getInstallationByOrganization(startupConfig.get("releasesGitHubAppOrganization", "")) )) .build())) - .registerFilteredHandler(GitHubEvent.ISSUES, new LabelLockHandler(), GHAction.LABELED, GHAction.UNLABELED); + .registerFilteredHandler(GitHubEvent.ISSUES, new LabelLockHandler(), GHAction.LABELED, GHAction.UNLABELED) + .registerFilteredHandler(GitHubEvent.WORKFLOW_RUN, new PRActionRunnerHandler(), GHAction.COMPLETED) + .registerFilteredHandler(GitHubEvent.ISSUE_COMMENT, new CommandHandler( + Commands.register(new CommandDispatcher<>()) + ), GHAction.CREATED); } } diff --git a/src/main/java/net/neoforged/automation/command/Commands.java b/src/main/java/net/neoforged/automation/command/Commands.java new file mode 100644 index 0000000..29faabb --- /dev/null +++ b/src/main/java/net/neoforged/automation/command/Commands.java @@ -0,0 +1,62 @@ +package net.neoforged.automation.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import net.neoforged.automation.Configuration; +import net.neoforged.automation.command.api.GHCommandContext; +import net.neoforged.automation.util.FunctionalInterfaces; + +public class Commands { + public static CommandDispatcher register(CommandDispatcher dispatcher) { + dispatcher.register(literal("applyFormatting") + .requires(Requirement.IS_PR.and(Requirement.IS_MAINTAINER.or(Requirement.IS_PR))) + .executes(FunctionalInterfaces.throwingCommand(context -> { + var pr = context.getSource().pullRequest(); + var config = Configuration.get(); + var repoConfig = Configuration.get(context.getSource().repository()); + if (!repoConfig.formattingTasks().isEmpty()) { + var comment = pr.comment("Applying formatting..."); + FormattingCommand.run( + context.getSource().gitHub(), pr, + config.prActions(), repoConfig, + err -> { + context.getSource().onError().run(); + try { + context.getSource().issue() + .comment("Workflow failed: " + err.getHtmlUrl()); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }, () -> { + context.getSource().onSuccess().run(); + FunctionalInterfaces.ignoreExceptions(comment::delete); + } + ); + } + return GHCommandContext.DEFERRED_RESPONSE; + }))); + + return dispatcher; + } + + private static ExtendedLiteralArgumentBuilder literal(String name) { + return new ExtendedLiteralArgumentBuilder<>(name); + } + + private static RequiredArgumentBuilder argument(String name, ArgumentType type) { + return RequiredArgumentBuilder.argument(name, type); + } + + private static class ExtendedLiteralArgumentBuilder extends LiteralArgumentBuilder { + + protected ExtendedLiteralArgumentBuilder(String literal) { + super(literal); + } + + public LiteralArgumentBuilder requires(FunctionalInterfaces.PredException requirement) { + return this.requires(FunctionalInterfaces.wrapPred(requirement)); + } + } +} diff --git a/src/main/java/net/neoforged/automation/command/FormattingCommand.java b/src/main/java/net/neoforged/automation/command/FormattingCommand.java new file mode 100644 index 0000000..871cf63 --- /dev/null +++ b/src/main/java/net/neoforged/automation/command/FormattingCommand.java @@ -0,0 +1,58 @@ +package net.neoforged.automation.command; + +import net.neoforged.automation.Configuration; +import net.neoforged.automation.runner.PRActionRunner; +import net.neoforged.automation.runner.PRRunUtils; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHWorkflowRun; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubAccessor; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.function.Consumer; +import java.util.zip.ZipFile; + +public class FormattingCommand { + + public static void run(GitHub gh, GHPullRequest pr, Configuration.PRActions actions, Configuration.RepoConfiguration repoConfiguration, Consumer onFailure, Runnable onSuccess) throws IOException { + PRActionRunner.builder(pr) + .upload("src*/main/java/") + .command(repoConfiguration.formattingTasks()) + .onFailed((gitHub, run) -> onFailure.accept(run)) + .onFinished((gitHub, run, artifact) -> { + PRRunUtils.setupPR(pr, (dir, git) -> { + try (var file = new ZipFile(artifact.toFile())) { + var enm = file.entries(); + while (enm.hasMoreElements()) { + var entry = enm.nextElement(); + Files.write(dir.resolve(entry.getName()), file.getInputStream(entry).readAllBytes()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + git.add().addFilepattern(".").call(); + + var botName = gitHub.getApp().getSlug() + "[bot]"; + var user = gitHub.getUser(botName); + var creds = new UsernamePasswordCredentialsProvider( + botName, + GitHubAccessor.getToken(gitHub) + ); + + git.commit().setCredentialsProvider(creds) + .setCommitter(botName, user.getId() + "+" + botName + "@users.noreply.github.com") + .setMessage("Update formatting") + .setSign(false) + .setNoVerify(true) + .call(); + git.push().setRemote("origin").setRefSpecs(new RefSpec("HEAD:refs/heads/" + pr.getHead().getRef())).setCredentialsProvider(creds).call(); + onSuccess.run(); + }); + }) + .build() + .queue(gh, actions); + } +} diff --git a/src/main/java/net/neoforged/automation/command/Requirement.java b/src/main/java/net/neoforged/automation/command/Requirement.java new file mode 100644 index 0000000..aced1c1 --- /dev/null +++ b/src/main/java/net/neoforged/automation/command/Requirement.java @@ -0,0 +1,22 @@ +package net.neoforged.automation.command; + +import net.neoforged.automation.command.api.GHCommandContext; +import net.neoforged.automation.util.FunctionalInterfaces; +import org.kohsuke.github.GHPermissionType; + +public enum Requirement implements FunctionalInterfaces.PredException { + IS_PR(ctx -> ctx.issue().isPullRequest()), + IS_MAINTAINER(ctx -> ctx.repository().hasPermission(ctx.user(), GHPermissionType.WRITE)), + IS_AUTHOR(ctx -> ctx.user().equals(ctx.issue().getUser())); + + private final FunctionalInterfaces.PredException test; + + Requirement(FunctionalInterfaces.PredException test) { + this.test = test; + } + + @Override + public boolean test(GHCommandContext ghCommandContext) throws Exception { + return test.test(ghCommandContext); + } +} diff --git a/src/main/java/net/neoforged/automation/command/api/GHCommandContext.java b/src/main/java/net/neoforged/automation/command/api/GHCommandContext.java new file mode 100644 index 0000000..f1fd38b --- /dev/null +++ b/src/main/java/net/neoforged/automation/command/api/GHCommandContext.java @@ -0,0 +1,32 @@ +package net.neoforged.automation.command.api; + +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHUser; +import org.kohsuke.github.GitHub; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public record GHCommandContext(GitHub gitHub, GHEventPayload.IssueComment payload, Runnable onError, Runnable onSuccess) { + public static final int DEFERRED_RESPONSE = 2; + + public GHUser user() { + return payload.getSender(); + } + + public GHRepository repository() { + return issue().getRepository(); + } + + public GHIssue issue() { + return payload.getIssue(); + } + + public GHPullRequest pullRequest() throws IOException { + return repository().getPullRequest(issue().getNumber()); + } +} diff --git a/src/main/java/net/neoforged/automation/runner/ActionFailedCallback.java b/src/main/java/net/neoforged/automation/runner/ActionFailedCallback.java new file mode 100644 index 0000000..ea13192 --- /dev/null +++ b/src/main/java/net/neoforged/automation/runner/ActionFailedCallback.java @@ -0,0 +1,9 @@ +package net.neoforged.automation.runner; + +import org.kohsuke.github.GHWorkflowRun; +import org.kohsuke.github.GitHub; + +@FunctionalInterface +public interface ActionFailedCallback { + void onFailed(GitHub gitHub, GHWorkflowRun run) throws Exception; +} diff --git a/src/main/java/net/neoforged/automation/runner/ActionFinishedCallback.java b/src/main/java/net/neoforged/automation/runner/ActionFinishedCallback.java new file mode 100644 index 0000000..eb1fe85 --- /dev/null +++ b/src/main/java/net/neoforged/automation/runner/ActionFinishedCallback.java @@ -0,0 +1,11 @@ +package net.neoforged.automation.runner; + +import org.kohsuke.github.GHWorkflowRun; +import org.kohsuke.github.GitHub; + +import java.nio.file.Path; + +@FunctionalInterface +public interface ActionFinishedCallback { + void onFinished(GitHub gitHub, GHWorkflowRun run, Path artifact) throws Exception; +} diff --git a/src/main/java/net/neoforged/automation/runner/PRActionRunner.java b/src/main/java/net/neoforged/automation/runner/PRActionRunner.java new file mode 100644 index 0000000..2a77b58 --- /dev/null +++ b/src/main/java/net/neoforged/automation/runner/PRActionRunner.java @@ -0,0 +1,91 @@ +package net.neoforged.automation.runner; + +import net.neoforged.automation.Configuration; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GitHub; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class PRActionRunner { + public static final Map QUEUED = new ConcurrentHashMap<>(); + + public final ActionFinishedCallback finishedCallback; + public final ActionFailedCallback failedCallback; + private final String uploadPattern; + private final GHPullRequest pr; + private final String command1, command2; + + private PRActionRunner(ActionFinishedCallback finishedCallback, ActionFailedCallback failedCallback, String uploadPattern, GHPullRequest pr, String command1, String command2) { + this.finishedCallback = finishedCallback; + this.failedCallback = failedCallback; + this.uploadPattern = uploadPattern; + this.pr = pr; + this.command1 = command1; + this.command2 = command2; + } + + public void queue(GitHub gitHub, Configuration.PRActions config) throws IOException { + var id = UUID.randomUUID(); + var repo = gitHub.getRepository(config.repository()); + var spl = config.workflow().split("@"); + repo.getWorkflow(spl[0]) + .dispatch(spl[1], Map.of( + "repository", pr.getRepository().getFullName(), + "pr", String.valueOf(pr.getNumber()), + "command1", command1, + "command2", command2, + "upload", uploadPattern, + "id", id.toString() + )); + QUEUED.put(id, this); + } + + public static PRActionRunner.Builder builder(GHPullRequest pr) { + return new Builder(pr); + } + + public static class Builder { + private final GHPullRequest pr; + private String uploadPattern; + private String cmd1, cmd2; + private ActionFinishedCallback finished; + private ActionFailedCallback failed; + + private Builder(GHPullRequest pr) { + this.pr = pr; + } + + public Builder command(List command) { + return command(command.getFirst(), command.size() > 1 ? command.get(1) : ""); + } + + public Builder command(String command1, String command2) { + cmd1 = command1; + cmd2 = command2; + return this; + } + + public Builder onFinished(ActionFinishedCallback callback) { + this.finished = callback; + return this; + } + + public Builder onFailed(ActionFailedCallback failed) { + this.failed = failed; + return this; + } + + public Builder upload(String pattern) { + this.uploadPattern = pattern; + return this; + } + + public PRActionRunner build() { + return new PRActionRunner(finished, failed, uploadPattern, pr, cmd1, cmd2); + } + } +} diff --git a/src/main/java/net/neoforged/automation/runner/PRRunUtils.java b/src/main/java/net/neoforged/automation/runner/PRRunUtils.java new file mode 100644 index 0000000..bac32f7 --- /dev/null +++ b/src/main/java/net/neoforged/automation/runner/PRRunUtils.java @@ -0,0 +1,35 @@ +package net.neoforged.automation.runner; + +import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.transport.URIish; +import org.kohsuke.github.GHPullRequest; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +public class PRRunUtils { + public static void setupPR(GHPullRequest pr, GitConsumer consumer) throws IOException, GitAPIException { + var repo = Path.of("checkedoutprs/" + pr.getRepository().getFullName() + "/pr" + pr.getNumber() + "/" + new Random().nextInt(10000)); + Files.createDirectories(repo); + try (var git = Git.init().setDirectory(repo.toFile()).call()) { + git.remoteAdd().setName("origin").setUri(new URIish("https://github.com/" + pr.getHead().getRepository().getFullName() + ".git")).call(); + git.fetch().setRemote("origin").setRefSpecs("refs/heads/" + pr.getHead().getRef() + ":" + pr.getHead().getRef()).call(); + git.checkout().setName(pr.getHead().getRef()).call(); + + consumer.run(repo, git); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + FileUtils.deleteDirectory(repo.toFile()); + } + + @FunctionalInterface + public interface GitConsumer { + void run(Path dir, Git git) throws IOException, GitAPIException; + } +} diff --git a/src/main/java/net/neoforged/automation/util/AuthUtil.java b/src/main/java/net/neoforged/automation/util/AuthUtil.java index 8f05d49..b0f283c 100644 --- a/src/main/java/net/neoforged/automation/util/AuthUtil.java +++ b/src/main/java/net/neoforged/automation/util/AuthUtil.java @@ -25,7 +25,6 @@ import java.time.Instant; import java.util.Base64; import java.util.Date; -import java.util.function.Function; /** * Class containing a few helper methods for creating {@link AuthorizationProvider} for GitHub connections. diff --git a/src/main/java/net/neoforged/automation/util/FunctionalInterfaces.java b/src/main/java/net/neoforged/automation/util/FunctionalInterfaces.java new file mode 100644 index 0000000..77a47af --- /dev/null +++ b/src/main/java/net/neoforged/automation/util/FunctionalInterfaces.java @@ -0,0 +1,99 @@ +package net.neoforged.automation.util; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.context.CommandContext; + +import java.io.IOException; +import java.util.function.Predicate; +import java.util.function.Supplier; + +public class FunctionalInterfaces { + public static Command wrap(ConsException> consumer) { + return context -> { + try { + consumer.accept(context); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return Command.SINGLE_SUCCESS; + }; + } + + public static Command throwingCommand(CommandException command) { + return context -> { + try { + return command.run(context); + } catch (Exception e) { + throw new RuntimeException(e); + } + }; + } + + public static Predicate wrapPred(PredException pred) { + return context -> { + try { + return pred.test(context); + } catch (Exception e) { + throw new RuntimeException(e); + } + }; + } + + public static void ignoreExceptions(RunnableException runnable) { + try { + runnable.run(); + } catch (IOException ignored) {} + } + + public static SupplierException memoize(SupplierException supplier) { + return new SupplierException<>() { + T value; + @Override + public T get() throws IOException { + if (value == null) value = supplier.get(); + return value; + } + }; + } + + public static Supplier memoizeSup(Supplier supplier) { + return new Supplier<>() { + T value; + @Override + public T get() { + if (value == null) value = supplier.get(); + return value; + } + }; + } + + public interface ConsException { + void accept(T t) throws Exception; + } + + public interface PredException { + boolean test(T t) throws Exception; + + default PredException and(PredException other) { + return t -> this.test(t) && other.test(t); + } + + default PredException or(PredException other) { + return t -> this.test(t) || other.test(t); + } + } + + public interface RunnableException { + void run() throws IOException; + } + + public interface SupplierException { + T get() throws IOException; + } + + @FunctionalInterface + public interface CommandException { + int run(CommandContext context) throws Exception; + } +} diff --git a/src/main/java/net/neoforged/automation/util/GHAction.java b/src/main/java/net/neoforged/automation/util/GHAction.java index f105ed3..dede3d6 100644 --- a/src/main/java/net/neoforged/automation/util/GHAction.java +++ b/src/main/java/net/neoforged/automation/util/GHAction.java @@ -6,5 +6,7 @@ public enum GHAction { LABELED, UNLABELED, - SYNCHRONIZE + SYNCHRONIZE, + + COMPLETED } diff --git a/src/main/java/net/neoforged/automation/webhook/handler/CommandHandler.java b/src/main/java/net/neoforged/automation/webhook/handler/CommandHandler.java new file mode 100644 index 0000000..32d9456 --- /dev/null +++ b/src/main/java/net/neoforged/automation/webhook/handler/CommandHandler.java @@ -0,0 +1,97 @@ +package net.neoforged.automation.webhook.handler; + +import com.github.api.MinimizeCommentMutation; +import com.github.api.type.ReportedContentClassifiers; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.neoforged.automation.Configuration; +import net.neoforged.automation.command.api.GHCommandContext; +import net.neoforged.automation.util.FunctionalInterfaces; +import net.neoforged.automation.util.GHAction; +import net.neoforged.automation.webhook.impl.ActionBasedHandler; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubAccessor; +import org.kohsuke.github.ReactionContent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public record CommandHandler(CommandDispatcher dispatcher) implements ActionBasedHandler { + public static final Logger LOGGER = LoggerFactory.getLogger(CommandHandler.class); + + @Override + public void handle(GitHub gitHub, GHEventPayload.IssueComment payload, GHAction action) throws Exception { + var config = Configuration.get().commands(); + var command = findCommand(payload.getComment().getBody()); + if (command == null) return; + + var context = new GHCommandContext(gitHub, payload, () -> { + if (config.reactToComment()) { + FunctionalInterfaces.ignoreExceptions(() -> payload.getComment().createReaction(ReactionContent.CONFUSED)); + } + }, () -> { + if (config.reactToComment()) { + FunctionalInterfaces.ignoreExceptions(() -> payload.getComment().createReaction(ReactionContent.ROCKET)); + } + if (command.commentOnlyCommand() && config.minimizeComment()) { + FunctionalInterfaces.ignoreExceptions(() -> GitHubAccessor.graphQl(gitHub, MinimizeCommentMutation.builder() + .comment(payload.getComment().getNodeId()) + .reason(ReportedContentClassifiers.RESOLVED) + .build())); + } + }); + var results = dispatcher.parse(command.command(), context); + + // If the command does not fully parse, then return + if (results.getReader().getRemainingLength() > 0) { + return; + } + + try { + final int result = dispatcher.execute(results); + if (result == Command.SINGLE_SUCCESS) { + context.onSuccess().run(); + } + } catch (Exception e) { + LOGGER.error("Error while executing command '{}': ", command, e); + + if (e instanceof CommandSyntaxException exception) { + FunctionalInterfaces.ignoreExceptions(() -> payload.getIssue().comment("@%s, I encountered an exception executing that command: %s".formatted( + payload.getSender().getLogin(), exception.getMessage() + ))); + } + + context.onError().run(); + } + } + + public CommandData findCommand(String comment) { + boolean commentOnlyCommand = false; + String command = null; + for (final String prefix : Configuration.get().commands().prefixes()) { + if (comment.startsWith(prefix)) { + // If at the start, consider the entire comment a command + command = comment.substring(prefix.length()); + commentOnlyCommand = true; + } else if (comment.contains(prefix)) { + final var index = comment.indexOf(prefix); + // If anywhere else, consider the line a command + final var newLineIndex = comment.indexOf('\n', index); + if (newLineIndex >= 0) { + command = comment.substring(index + prefix.length(), newLineIndex); + } else { + command = comment.substring(index + prefix.length()); + } + } + + if (command != null) { + return new CommandData(commentOnlyCommand, command); + } + } + + return null; + } + + public record CommandData(boolean commentOnlyCommand, String command) {} +} diff --git a/src/main/java/net/neoforged/automation/webhook/handler/PRActionRunnerHandler.java b/src/main/java/net/neoforged/automation/webhook/handler/PRActionRunnerHandler.java new file mode 100644 index 0000000..44b6fd8 --- /dev/null +++ b/src/main/java/net/neoforged/automation/webhook/handler/PRActionRunnerHandler.java @@ -0,0 +1,57 @@ +package net.neoforged.automation.webhook.handler; + +import net.neoforged.automation.Configuration; +import net.neoforged.automation.runner.PRActionRunner; +import net.neoforged.automation.util.GHAction; +import net.neoforged.automation.webhook.impl.ActionBasedHandler; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHWorkflowRun; +import org.kohsuke.github.GitHub; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.util.UUID; + +public record PRActionRunnerHandler() implements ActionBasedHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(PRActionRunnerHandler.class); + + @Override + public void handle(GitHub gitHub, GHEventPayload.WorkflowRun payload, GHAction action) throws Exception { + var config = Configuration.get().prActions(); + if (payload.getRepository().getFullName().equalsIgnoreCase(config.repository()) && payload.getWorkflow().getPath().equals(".github/workflows/" + config.workflow().split("@")[0])) { + var run = payload.getWorkflowRun(); + var id = UUID.fromString(run.getDisplayTitle()); + var runner = PRActionRunner.QUEUED.remove(id); + if (runner == null) { + LOGGER.error("Unknown action run with ID {}", id); + return; + } + + if (run.getConclusion() == GHWorkflowRun.Conclusion.SUCCESS) { + var artifact = run.listArtifacts().toList() + .stream().filter(a -> a.getName().equals("artifact")) + .findFirst() + .orElse(null); + if (artifact == null) { + LOGGER.error("Action run {} didn't upload an artifact!", run.getHtmlUrl()); + runner.failedCallback.onFailed(gitHub, run); + return; + } + + var path = Files.createTempFile("artifact", ".zip"); + artifact.download(input -> { + try (var out = Files.newOutputStream(path)) { + input.transferTo(out); + } + return path; + }); + + runner.finishedCallback.onFinished(gitHub, run, path); + Files.delete(path); + } else { + runner.failedCallback.onFailed(gitHub, run); + } + } + } +} diff --git a/src/main/java/net/neoforged/automation/webhook/impl/GitHubEvent.java b/src/main/java/net/neoforged/automation/webhook/impl/GitHubEvent.java index fdbd251..5a383ad 100644 --- a/src/main/java/net/neoforged/automation/webhook/impl/GitHubEvent.java +++ b/src/main/java/net/neoforged/automation/webhook/impl/GitHubEvent.java @@ -16,6 +16,7 @@ public final class GitHubEvent { public static final GitHubEvent ISSUE_COMMENT = create("issue_comment", GHEventPayload.IssueComment.class); public static final GitHubEvent PUSH = create("push", GHEventPayload.Push.class); public static final GitHubEvent STATUS = create("status", GHEventPayload.Status.class); + public static final GitHubEvent WORKFLOW_RUN = create("workflow_run", GHEventPayload.WorkflowRun.class); private final Class type; private GitHubEvent(Class type) { diff --git a/src/main/java/net/neoforged/automation/webhook/impl/WebhookHandler.java b/src/main/java/net/neoforged/automation/webhook/impl/WebhookHandler.java index 22a6cd7..5f676ab 100644 --- a/src/main/java/net/neoforged/automation/webhook/impl/WebhookHandler.java +++ b/src/main/java/net/neoforged/automation/webhook/impl/WebhookHandler.java @@ -4,6 +4,7 @@ import io.javalin.http.ForbiddenResponse; import io.javalin.http.Handler; import io.javalin.http.HttpStatus; +import net.neoforged.automation.Configuration; import net.neoforged.automation.StartupConfiguration; import net.neoforged.automation.util.GHAction; import org.bouncycastle.crypto.digests.SHA256Digest; @@ -81,7 +82,10 @@ public void handle(@NotNull Context ctx) throws Exception { var bodyBytes = validateSignatures(ctx); try { - handler.handle(gitHub, ev.parse(gitHub, bodyBytes)); + var payload = ev.parse(gitHub, bodyBytes); + if (Configuration.get(payload.getRepository()).enabled()) { + handler.handle(gitHub, payload); + } } catch (Exception exception) { ctx.status(HttpStatus.INTERNAL_SERVER_ERROR).result("Failed to handle request: " + exception.getMessage()); LOGGER.error("Failed to handle request {}: ", ctx.header(GITHUB_DELIVERY), exception); diff --git a/src/main/java/org/kohsuke/github/GitHubAccessor.java b/src/main/java/org/kohsuke/github/GitHubAccessor.java index ddc862f..8e3451e 100644 --- a/src/main/java/org/kohsuke/github/GitHubAccessor.java +++ b/src/main/java/org/kohsuke/github/GitHubAccessor.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectReader; import net.neoforged.automation.util.ApolloReader; +import net.neoforged.automation.util.AuthUtil; import org.apache.commons.io.input.ReaderInputStream; import org.jetbrains.annotations.Nullable; import org.kohsuke.github.function.InputStreamFunction; @@ -87,6 +88,10 @@ public static void lock(GHIssue issue, @Nullable LockReason reason) throws IOExc } } + public static String getToken(GitHub gitHub) throws IOException { + return gitHub.getClient().getEncodedAuthorization().replace("Bearer ", ""); + } + public static IssueEdit edit(GHIssue issue) { final Requester request = issue.root().createRequest().method("PATCH") .inBody().withUrlPath(issue.getApiRoute()); @@ -116,7 +121,6 @@ public static T parseEventPayload(GitHub gitHub, byte return t; } - private static final Map> EXISTING_LABELS = new ConcurrentHashMap<>(); public static Set getExistingLabels(GHRepository repository) throws IOException { Set ex = EXISTING_LABELS.get(repository);