Comment-Based Commands

A popular requirement for GitHub Apps is to react to comments to perform commands e.g. @bot do something.

While you can rely on the traditional listeners presented previously and implement the parsing of the comments by yourself, Quarkus GitHub App comes with an additional extension called quarkus-github-app-command-airline which makes it extremely easy to develop comment-based commands.

This extension is based on the popular Airline library and you can use all the features of the library to develop your commands.

You can mix traditional event listeners and comment-based commands in the same GitHub App. They use the exact same infrastructure.

Adding the dependency

First things first, add the quarkus-github-app-command-airline dependency to your GitHub App pom.xml:

<dependency>
    <groupId>io.quarkiverse.githubapp</groupId>
    <artifactId>quarkus-github-app-command-airline</artifactId>
    <version>2.8.2</version>
</dependency>

If you requested the dependency when you generated the Quarkus project, Quarkus will generate a small example for you: MyGitHubBot.

Your first command

Let’s say we want to implement a command to re-run a CI workflow. The idea is to react to users submitting a comment containing @mybot retest in a pull request.

This is as simple as:

@Cli(name = "@bot", commands = { RetestCommand.class }) (1)
public class MyFirstCli {

    @Command(name = "retest") (2)
    static class RetestCommand implements Runnable { (3)

        @Override
        public void run() { (4)
            // do something
        }
    }
}
1 First thing is to declare a @Cli class. The name of the @Cli is what will trigger the parsing of the command.
2 We create a command called retest.
3 All the commands of a same @Cli have to implement the same interface, in this case Runnable.
4 Your interface must have a run(…​) method. You can inject parameters as we will see later but it has to be called run.

That is all that is necessary. Every time a comment containing @bot retest is posted in your project, the run() method above is called.

By default, the command is run whether the comment is added to issues or pull requests (as far as comments are concerned a pull request is just another type of issue). We will see a bit later how we can configure it.

Be careful about permissions when exposing commands, typically, in this example, we probably don’t want all the users to be able to trigger a new CI run.

Make sure to implement the proper permission checks.

Injecting parameters

Injecting the IssueComment payload

To turn the command above into something actually useful, we miss a few things:

  • We don’t have any context about the comment (which issue or pull request was it posted in?).

  • We don’t have a GitHub client around.

This is a very common requirement and something you can’t do without, really.

Luckily for us, we can get all this injected into the run() method of our commands.

Let’s take a first example:

@Cli(name = "@bot", commands = { Command1.class, Command2.class })
public class PayloadInjectionCli {

    interface Commands { (1)

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException; (2)
    }

    @Command(name = "command1")
    static class Command1 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException { (3)
            issueCommentPayload.getIssue().comment("Ack");
        }
    }

    @Command(name = "command2")
    static class Command2 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            if (issueCommentPayload.getIssue().isPullRequest()) {
                GHPullRequest pullRequest = issueCommentPayload.getRepository()
                        .getPullRequest(issueCommentPayload.getIssue().getNumber()); (4)

                // do something with the pull request
            }
        }
    }
}
1 As already mentioned, we have to define a common interface for all commands with a run(…​) method.
2 It is possible to inject the IssueComment payload, from which you can get the issue (or the comment). Keep in mind a pull request is also an issue.
3 Unfortunately, most of GitHub API calls throw IOExceptions in case an error occurs. It is not pretty but just throw them from your methods, the framework will handle them for you.
4 This is how you can get to a GHPullRequest instance that represents a pull request from the pull request associated issue.

From the GHEventPayload.IssueComment instance, you can also get to the GHRepository via issueCommentPayload.getRepository() and so do everything you need on the repository in which the comment was posted.

Injecting the GitHub client

You can inject a GitHub client as a parameter. It is authenticated with your GitHub App installation’s token.

This is especially useful if you want to execute REST API calls that are outside of the bounds of your current GHRepository.

@Cli(name = "@bot", commands = { Command1.class, Command2.class })
public class GitHubInjectionCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException; (1)
    }

    @Command(name = "command1")
    static class Command1 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException {
            // do something with the gitHub client
        }
    }

    @Command(name = "command2")
    static class Command2 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException {
            // do something with the gitHub client
        }
    }
}
1 You can inject a GitHub instance in your run() method.

Injecting a GraphQL client

In a similar way, you can inject a DynamicGraphQLClient as a parameter if you want to execute GraphQL queries. It is authenticated with your GitHub App installation’s token.

Injecting CDI beans

You can inject CDI beans as parameters but it is not a recommended practice as it makes your common command interface more cluttered. See CDI injection for more details.

CDI injection

You can inject any CDI bean in your commands, either via parameters of the run() method or via field injection.

It is recommended to use field injection to avoid cluttering the run() method:

@Cli(name = "@bot", commands = { Command1.class })
public class CdiInjectionCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException;
    }

    @Command(name = "command1")
    static class Command1 implements Commands {

        @Inject
        CdiBean cdiBean; (1)

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            cdiBean.doSomething();
        }
    }
}
1 You can inject the @ApplicationScoped CdiBean via the CDI jakarta.inject.Inject annotation.

Additional options

@Command options

With the @CommandOptions annotation, you can fine tune the behavior of a @Command.

Scope

By default, commands are executed for both issues and pull requests. Some commands might only make sense for issues or pull requests.

Luckily, you can limit the scope of a command using @CommandOptions(scope = …​):

    @Command(name = "only-for-issues")
    @CommandOptions(scope = CommandScope.ISSUES) (1)
    static class CommandOnlyForIssues implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }
1 This command will only be executed in the context of an issue.
    @Command(name = "only-for-pull-requests")
    @CommandOptions(scope = CommandScope.PULL_REQUESTS) (1)
    static class CommandOnlyForPullRequests implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }
1 This command will only be executed in the context of a pull request.

Execution error strategy

By default, when an execution error occurs, a MINUS_ONE reaction is added to the comment but that’s it. You might want to customize this behavior and have your GitHub App post a comment:

    @Command(name = "execution-error-strategy")
    @CommandOptions(executionErrorStrategy = ExecutionErrorStrategy.COMMENT_MESSAGE) (1)
    static class CommandWithCustomExecutionErrorStrategy implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }
1 When an error occurs executing the command, a comment containing > `%s`\n\n:rotating_light: An error occurred while executing the command. will be posted.

If you want to go further, you can customize this message:

    @Command(name = "execution-error-message")
    @CommandOptions(executionErrorStrategy = ExecutionErrorStrategy.COMMENT_MESSAGE, executionErrorMessage = "Your custom error message") (1)
    static class CommandWithCustomExecutionErrorMessage implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }
1 When an error occurs executing the command, a comment containing Your custom error message will be posted.

Use %s in your message to include the executed command.

Reaction strategy

By default, the Command Airline extension provides feedback about command execution with reactions.

It is possible to configure this behavior. For instance to disable them entirely:

    @Command(name = "reaction-strategy")
    @CommandOptions(reactionStrategy = ReactionStrategy.NONE)
    static class CommandWithCustomReactionStrategy implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }

@Cli options

You can fine tune the behavior of your @Cli via the @CliOptions annotation.

Defining aliases

The name attribute of the @Cli annotation defines how your commands will be invoked. For instance, @Cli(name = "@bot") means that your commands will be executed if a user starts their comments with @bot.

You might want to define several aliases for this invocation (e.g. @bot, @quarkus-bot, @quarkusbot), which you can do as follows:

@Cli(name = "@quarkus-bot", commands = { AliasesCliCommand.class })
@CliOptions(aliases = { "@quarkusbot", "@bot" }) (1)
public class CliOptionsAliasesCli {
1 Commands will be executed for comments starting with @quarkus-bot, @bot or @quarkusbot.

Default command options

As seen above, you can fine tune command options with the @CommandOptions annotation.

If you need to define common command options, you can do it at the @Cli level:

@Cli(name = "@bot", commands = { DefaultCommandOptionsCliCommand.class })
@CliOptions(defaultCommandOptions = @CommandOptions(scope = CommandScope.ISSUES)) (1)
public class CliOptionsDefaultCommandOptionsCli {
1 Commands will be executed for comments starting with @quarkus-bot, @bot or @quarkusbot.

You can override the default command options by adding a @CommandOptions annotation to a command.

Parse error strategy

By default, when an error occurs parsing the command, a comment is posted containing:

  • > `%s`\n\n:rotating_light: Unable to parse the command. (%s being the parsed command)

  • the errors

  • the help generated by Airline for this given @Cli, when relevant

This behavior can be customized:

@Cli(name = "@bot", commands = { ParseErrorStrategyCliCommand.class })
@CliOptions(parseErrorStrategy = ParseErrorStrategy.NONE) (1)
public class CliOptionsParseErrorStrategyCli {
1 In this case, no comment will be added when a parse error occurs. A CONFUSED reaction will be added to the comment though.

The following strategies are available:

  • NONE - Nothing is done.

  • COMMENT_MESSAGE - A comment containing the parse error message is posted.

  • COMMENT_MESSAGE_HELP - A comment containing the parse error message and the generated help is posted.

  • COMMENT_MESSAGE_ERRORS - A comment containing the parse error message and the parse errors is posted.

  • COMMENT_MESSAGE_HELP_ERRORS - A comment containing the parse error message, the generated help and the parse errors is posted.

You can also customize the error message:

@Cli(name = "@bot", commands = { ParseErrorMessageCliCommand.class })
@CliOptions(parseErrorStrategy = ParseErrorStrategy.COMMENT_MESSAGE, parseErrorMessage = "Your custom message") (1)
public class CliOptionsParseErrorMessageCli {
1 A comment containing Your custom message is posted when a parse error occurs.

Use %s in your message to include the parsed command.

Permissions

Permissions

GitHub has 3 levels of permissions for a repository:

  • Read

  • Write

  • Admin

The Read permission is not very useful in our case as anyone able to add a comment to an issue or pull request has the Read permission. But restricting some commands to users with the Write or Admin permission is a common requirement.

Note that when requiring a permission, you require that the user has at least the given permission. So, if you require the Write permission, users with the Admin permission are also authorized to execute the command.

Requiring a permission for a command is as easy as adding a @Permission annotation to your command:

@Cli(name = "@bot", commands = { WriteCommand.class })
public class PermissionCli {

    interface Commands {

        void run() throws IOException;
    }

    @Command(name = "write-command")
    @Permission(GHPermissionType.WRITE) (1)
    static class WriteCommand implements Commands {

        @Override
        public void run() throws IOException {
            // do something
        }
    }
}
1 To execute the write-command, the Write permission is required.

Note that you can also define a permission at the @Cli level. In this case, the permission will be applied to all commands, except if you override it with a @Permission annotation at the command level.

@Cli(name = "@bot", commands = { WriteCommand.class, AdminCommand.class })
@Permission(GHPermissionType.WRITE) (1)
public class PermissionOverrideCli {

    interface Commands {

        void run() throws IOException;
    }

    @Command(name = "write-command") (2)
    static class WriteCommand implements Commands {

        @Override
        public void run() throws IOException {
            // do something
        }
    }

    @Command(name = "admin-command")
    @Permission(GHPermissionType.ADMIN) (3)
    static class AdminCommand implements Commands {

        @Override
        public void run() throws IOException {
            // do something
        }
    }
}
1 For all commands in this @Cli, the Write permission is required if not defined otherwise.
2 The write-command doesn’t have any @Permission annotation so the default one is used: the Write permission is required.
3 The admin-command overrides the default permission: the Admin permission is required.

Team permissions

Team permissions behaves in exactly the same way as standard permissions, except for the usage of the @Team annotation.

You can define several teams in the @Team annotation. Permission is granted if the user is part of at least one team (logical OR).

Use the slug of the team to reference a team i.e. the team identifier in the team page URL.

@Cli(name = "@bot", commands = { MyTeamCommand.class })
public class TeamPermissionCli {

    interface Commands {

        void run() throws IOException;
    }

    @Command(name = "command")
    @Team({ "my-team1", "my-team2" }) (1)
    static class MyTeamCommand implements Commands {

        @Override
        public void run() throws IOException {
            // do something
        }
    }
}
1 The command will be executed only if the user is part of my-team1 or my-team2.

When using team permissions, the GitHub App requires the read permission on the repository and the organization so that it can read the permissions.

Reaction based feedback

Feedback to commands is handled via reactions added to the comment.

The following reactions can be added:

  • ROCKET - command is executed, this reaction will be removed on command completion.

  • PLUS_ONE - command was executed successfully.

  • MINUS_ONE - the user doesn’t have the permission to execute the command or there was an error executing the command.

  • CONFUSED - there was an error parsing the command.

Providing help

If you propose a lot of features, it might be useful for the users to be able to get a description of the available commands and what they are doing.

Luckily, Airline can generate a comprehensive help description from your commands. Obviously, the help will be more comprehensive if you describe your commands with the appropriate annotation attributes.

As we already mentioned, the Quarkus extension requires all your commands to implement the same command interface. This is why we provide an abstract class that you can subclass to implement your help command.

Providing a help command to the users would look like:

@Cli(name = "@bot", commands = { Command1.class, Command2.class,
        HelpCommand.class }, description = "Your helpful bot doing all sorts of things") (1)
public class HelpCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException;
    }

    @Command(name = "command1", description = "Do command1 with style") (2)
    static class Command1 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException {
            // do something
        }
    }

    @Command(name = "command2", description = "Do command2 with style")
    static class Command2 implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException {
            // do something
        }
    }

    @Command(name = "help", description = "Print help")
    static class HelpCommand extends AbstractHelpCommand implements Commands { (3)

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload, GitHub gitHub) throws IOException {
            super.run(issueCommentPayload); (4)
        }
    }
}
1 Add a description to your @Cli annotation. Also add the help command to your commands.
2 Add descriptions to your commands so that they are included in the help.
3 Have your help command extend AbstractHelpCommand. AbstractHelpCommand adds a comment with the help when someone execute the commands.
4 Call the run() method of AbstractHelpCommand. AbstractHelpCommand requires a GHEventPayload.IssueComment payload to be injected.

Some common examples of Airline usage

In this section, you will find a couple of common Airline examples.

For more information about what you can do with Airline, please refer to the Airline documentation.

Arguments

You can have Airline injecting command arguments to your command:

@Cli(name = "@bot", commands = { CommandWithArguments.class })
public class ArgumentsCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException;
    }

    @Command(name = "add-users")
    static class CommandWithArguments implements Commands {

        @Arguments(description = "List of GitHub usernames") (1)
        List<String> usernames;

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            issueCommentPayload.getIssue().comment("Hello " + String.join(", ", usernames)); (2)
        }
    }
}
1 Use the @Arguments annotation to inject the arguments.
2 You can then consume them in your run() method.

Group commands

Airline support command groups. A popular example is how the git command line is architectured e.g. git remote …​.

@Cli(name = "@bot", groups = { @Group(name = "remote", commands = { ListCommand.class, ShowCommand.class }) }) (1)
public class GroupCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException;
    }

    @Command(name = "list") (2)
    static class ListCommand implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }

    @Command(name = "show") (3)
    static class ShowCommand implements Commands {

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // do something
        }
    }
}
1 Define groups in the @Cli annotation. Each group has a name and is composed of a list of commands.
2 To execute this command, add a comment with @bot remote list.
3 To execute this command, add a comment with @bot remote show.

Injecting metadata

You can inject Airline metadata instances (e.g. GlobalMetadata, CommandMetadata) into the commands:

@Cli(name = "@bot", commands = { Command1.class })
public class InjectMetadataCli {

    interface Commands {

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException;
    }

    @Command(name = "command1")
    static class Command1 implements Commands {

        @AirlineInject (1)
        GlobalMetadata<InjectMetadataCli> globalMetadata;

        @AirlineInject (1)
        CommandMetadata commandMetadata;

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            // ...
        }
    }
}
1 Use @AirlineInject to inject Airline metadata.

Composition

You can use composition to create reusable Airline components:

@Cli(name = "@composition", commands = { TestCompositionCommand.class })
public class CompositionCli {

    @Command(name = "test")
    static class TestCompositionCommand implements DefaultCommand {

        @AirlineModule (1)
        VerboseModule verboseModule = new VerboseModule();

        @Override
        public void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
            if (verboseModule.verbose) {
                issueCommentPayload.getIssue().comment("hello from @composition test - verbose");
            } else {
                issueCommentPayload.getIssue().comment("hello from @composition test");
            }
        }
    }

    public static class VerboseModule {

        @Option(name = { "-v", "--verbose" }, description = "Enables verbose mode")
        protected boolean verbose = false;
    }

    public interface DefaultCommand {

        void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException;
    }
}
1 Use @AirlineModule to inject the reusable options into the command.