Repository: jenkins-x/lighthouse
Pull request #1055 (2020-09-14 14:15:35)

refactor: refactor the plugin command system
This PR refactors the plugin command system.

It makes the command system non regex dependent. A command has the format `/[PREFIX]COMMAND [ARG]`, this is declared at the command level and is also used to generate the command usage and examples help.
The command description is also added to the command and is used to generate the command help.

I added unit tests for the `Command` struct.

I also clean up some obsolete code that was created at the start of refactoring and not used anymore.


I'll have to write a doc on how to write a plugin command.

jenkins-x-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **eddycharly**
You can assign the PR to them by writing `/assign @eddycharly` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=jenkins-x%2Flighthouse).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/jenkins-x/lighthouse/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["eddycharly"]} -->
jenkins-x-bot: @eddycharly: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/10) | `/test bbs`
ghe | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/7) | `/test ghe`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @eddycharly: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/10) | `/test bbs`
ghe | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/7) | `/test ghe`
github | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/9) | `/test github`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @eddycharly: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/10) | `/test bbs`
ghe | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/7) | `/test ghe`
github | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/9) | `/test github`
gitlab | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/11) | `/test gitlab`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @eddycharly: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/10) | `/test bbs`
ghe | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/7) | `/test ghe`
github | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/9) | `/test github`
gitlab | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/11) | `/test gitlab`
tekton | 5d708d72103de5329c55830304c18bb895a87dc4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055/12) | `/test tekton`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1055) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
abayer: /lgtm

Very nice!
jenkins-x-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *[abayer](https://github.com/jenkins-x/lighthouse/pull/1055#issuecomment-692131123 "LGTM")*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=jenkins-x%2Flighthouse).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com#issuecomment-692131123/jenkins-x/lighthouse/blob/master/OWNERS)~~ [abayer]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/pkg/plugins/approve/approve.go b/pkg/plugins/approve/approve.go
index 6be8370c..cdf5103e 100644
--- a/pkg/plugins/approve/approve.go
+++ b/pkg/plugins/approve/approve.go
@@ -32,7 +32,6 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/plugins/approve/approvers"
 	"github.com/jenkins-x/lighthouse/pkg/repoowners"
@@ -103,18 +102,18 @@ var (
 		ReviewEventHandler: handleReviewEvent,
 		PullRequestHandler: handlePullRequestEvent,
 		Commands: []plugins.Command{{
+			Name: "lgtm|approve",
+			Arg: &plugins.CommandArg{
+				Pattern:  "no-issue|cancel",
+				Optional: true,
+			},
+			Description: "Approves a pull request",
+			Featured:    true,
+			WhoCanUse:   "Users listed as 'approvers' in appropriate OWNERS files.",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(e.Action != scm.ActionCreate || !e.IsPR || e.IssueState == "closed")
 			},
-			Regex:                 commandRegex,
-			GenericCommentHandler: handleGenericCommentEvent,
-			Help: []pluginhelp.Command{{
-				Usage:       "/approve [no-issue|cancel]",
-				Description: "Approves a pull request",
-				Featured:    true,
-				WhoCanUse:   "Users listed as 'approvers' in appropriate OWNERS files.",
-				Examples:    []string{"/approve", "/approve no-issue", "/lh-approve"},
-			}},
+			Handler: handleGenericCommentEvent,
 		}},
 	}
 )
@@ -154,7 +153,7 @@ func configHelp(config *plugins.Configuration, enabledRepos []string) (map[strin
 	return approveConfig, nil
 }
 
-func handleGenericCommentEvent(_ []string, pc plugins.Agent, ce scmprovider.GenericCommentEvent) error {
+func handleGenericCommentEvent(_ plugins.CommandMatch, pc plugins.Agent, ce scmprovider.GenericCommentEvent) error {
 	baseURL, err := url.Parse(ce.IssueLink)
 	if err != nil {
 		return errors.Wrapf(err, "failed to parse URL %s", ce.Link)
diff --git a/pkg/plugins/assign/assign.go b/pkg/plugins/assign/assign.go
index f59e6e63..9eccd363 100644
--- a/pkg/plugins/assign/assign.go
+++ b/pkg/plugins/assign/assign.go
@@ -18,45 +18,34 @@ package assign
 
 import (
 	"fmt"
-	"regexp"
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 const pluginName = "assign"
 
-var (
-	match = regexp.MustCompile(`(?mi)^/(?:lh-)?(un)?(cc|assign)(( @?(?:")?[-/\w]+?)*(?:")?)\s*$`)
-)
-
 var (
 	plugin = plugins.Plugin{
 		Description: "The assign plugin assigns or requests reviews from users. Specific users can be assigned with the command '/assign @user1' or have reviews requested of them with the command '/cc @user1'. If no user is specified the commands default to targeting the user who created the command. Assignments and requested reviews can be removed in the same way that they are added by prefixing the commands with 'un'.",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  match,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handleGenericComment(match[1] != "un", match[2], match[3], pc, e)
+			Prefix: "un",
+			Name:   "cc|assign",
+			Arg: &plugins.CommandArg{
+				Pattern:  `@?"?[-/\w]+"?(?:[ \t]+@?"?[-/\w]+"?)*`,
+				Optional: true,
+			},
+			Description: "Assigns an assignee to the PR or issue or requests a review from the user(s)",
+			Featured:    true,
+			WhoCanUse:   "Anyone can use the command, but the target user must be an org member, a repo collaborator, or should have previously commented on the issue or PR.",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handleGenericComment(match.Prefix != "un", match.Name, match.Arg, pc, e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/[un]assign [[@]<username>...]",
-				Description: "Assigns an assignee to the PR",
-				Featured:    true,
-				WhoCanUse:   "Anyone can use the command, but the target user must be an org member, a repo collaborator, or should have previously commented on the issue or PR.",
-				Examples:    []string{"/assign", "/unassign", "/assign @k8s-ci-robot", "/lh-assign"},
-			}, {
-				Usage:       "/[un]cc [[@]<username>...]",
-				Description: "Requests a review from the user(s).",
-				Featured:    true,
-				WhoCanUse:   "Anyone can use the command, but the target user must be a member of the org that owns the repository.",
-				Examples:    []string{"/cc", "/uncc", "/cc @k8s-ci-robot", "/lh-cc"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/cat/cat.go b/pkg/plugins/cat/cat.go
index df121ba6..46723042 100644
--- a/pkg/plugins/cat/cat.go
+++ b/pkg/plugins/cat/cat.go
@@ -30,14 +30,12 @@ import (
 	"time"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 )
 
 var (
-	match          = regexp.MustCompile(`(?mi)^/(?:lh-)?meow(vie)?(?: (.+))?\s*$`)
 	grumpyKeywords = regexp.MustCompile(`(?mi)^(no|grumpy)\s*$`)
 	meow           = &realClowder{
 		url: "https://api.thecatapi.com/v1/images/search?format=json&results_per_page=1",
@@ -54,16 +52,14 @@ var (
 		Description:        "The cat plugin adds a cat image to an issue or PR in response to the `/meow` command.",
 		ConfigHelpProvider: configHelp,
 		Commands: []plugins.Command{{
-			GenericCommentHandler: handleGenericComment,
-			Filter:                func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:                 match,
-			Help: []pluginhelp.Command{{
-				Description: "Add a cat image to the issue or PR",
-				Usage:       "/meow(vie) [CATegory]",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/meow", "/meow caturday", "/meowvie clothes", "/lh-meow"},
-			}},
+			Name: "meow|meowvie",
+			Arg: &plugins.CommandArg{
+				Pattern:  `.+`,
+				Optional: true,
+			},
+			Description: "Add a cat image to the issue or PR",
+			Handler:     handleGenericComment,
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
 		}},
 	}
 )
@@ -183,9 +179,10 @@ func (c *realClowder) readCat(category string, movieCat bool) (string, error) {
 	return a.Format()
 }
 
-func handleGenericComment(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+func handleGenericComment(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 	return handle(
-		match,
+		match.Name == "meowvie",
+		match.Arg,
 		pc.SCMProviderClient,
 		pc.Logger,
 		&e,
@@ -194,13 +191,7 @@ func handleGenericComment(match []string, pc plugins.Agent, e scmprovider.Generi
 	)
 }
 
-func handle(match []string, spc scmProviderClient, log *logrus.Entry, e *scmprovider.GenericCommentEvent, c clowder, setKey func()) error {
-	// Parse match
-	category, movieCat, err := parseMatch(match)
-	if err != nil {
-		return err
-	}
-
+func handle(movieCat bool, category string, spc scmProviderClient, log *logrus.Entry, e *scmprovider.GenericCommentEvent, c clowder, setKey func()) error {
 	// Now that we know this is a relevant event we can set the key.
 	setKey()
 
@@ -229,13 +220,3 @@ func handle(match []string, spc scmProviderClient, log *logrus.Entry, e *scmprov
 
 	return errors.New("could not find a valid cat image")
 }
-
-func parseMatch(mat []string) (string, bool, error) {
-	if len(mat) != 3 {
-		err := fmt.Errorf("expected 3 capture groups in regexp match, but got %d", len(mat))
-		return "", false, err
-	}
-	category := strings.TrimSpace(mat[2])
-	movieCat := len(mat[1]) > 0 // "vie" suffix is present.
-	return category, movieCat, nil
-}
diff --git a/pkg/plugins/command.go b/pkg/plugins/command.go
new file mode 100644
index 00000000..3e61d6d4
--- /dev/null
+++ b/pkg/plugins/command.go
@@ -0,0 +1,179 @@
+package plugins
+
+import (
+	"errors"
+	"regexp"
+	"strings"
+
+	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
+	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
+)
+
+// CommandArg defines a plugin command argument
+type CommandArg struct {
+	Usage    string
+	Pattern  string
+	Optional bool
+}
+
+// GetRegex returns the regex string corresponding to the definiition of a CommandArg
+func (a *CommandArg) GetRegex() string {
+	pattern := a.Pattern
+	if pattern == "" {
+		pattern = `[^\r\n]+`
+	}
+	re := `(?:[ \t]+(` + pattern + "))"
+	if a.Optional {
+		re += "?"
+	}
+	return re
+}
+
+// GetUsage returns the CommandArg usage
+func (a *CommandArg) GetUsage() string {
+	usage := a.Usage
+	if usage == "" {
+		usage = a.Pattern
+	}
+	if usage == "" {
+		usage = "anything"
+	}
+	if a.Optional {
+		return "[" + usage + "]"
+	}
+	return "<" + usage + ">"
+}
+
+// CommandMatch defines a plugin command match to be passed to the command handler
+type CommandMatch struct {
+	Prefix string
+	Name   string
+	Arg    string
+}
+
+// Command defines a plugin command sent through a comment
+type Command struct {
+	Prefix      string
+	Name        string
+	Arg         *CommandArg
+	Description string
+	Featured    bool
+	WhoCanUse   string
+	MaxMatches  int
+	Filter      func(e scmprovider.GenericCommentEvent) bool
+	Handler     CommandEventHandler
+	regex       *regexp.Regexp
+}
+
+// InvokeCommandHandler performs command checks (filter, then regex if any) the calls the handler with the match (if any)
+func (cmd Command) InvokeCommandHandler(ce *scmprovider.GenericCommentEvent, handler func(CommandEventHandler, *scmprovider.GenericCommentEvent, CommandMatch) error) error {
+	if cmd.Handler == nil || (cmd.Filter != nil && !cmd.Filter(*ce)) {
+		return nil
+	}
+	regex := cmd.GetRegex()
+	if regex != nil {
+		max := cmd.MaxMatches
+		if max == 0 {
+			max = -1
+		}
+		for _, m := range regex.FindAllStringSubmatch(ce.Body, max) {
+			if err := handler(cmd.Handler, ce, cmd.createMatch(m)); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	return errors.New("command must have a regexp configured")
+}
+
+// GetRegex creates the regular expression from a command syntax
+func (cmd *Command) GetRegex() *regexp.Regexp {
+	if cmd.regex != nil {
+		return cmd.regex
+	}
+	re := "(?mi)^/(?:lh-)?"
+	if cmd.Prefix != "" {
+		re += "(" + cmd.Prefix + ")?"
+	}
+	re += "(" + cmd.Name + ")"
+	if cmd.Arg != nil {
+		re += cmd.Arg.GetRegex()
+	}
+	re += `\s*$`
+	cmd.regex = regexp.MustCompile(re)
+	return cmd.regex
+}
+
+// GetMatches returns command matches
+func (cmd Command) GetMatches(content string) ([]CommandMatch, error) {
+	regex := cmd.GetRegex()
+	if regex != nil {
+		max := cmd.MaxMatches
+		if max == 0 {
+			max = -1
+		}
+		var matches []CommandMatch
+		for _, match := range regex.FindAllStringSubmatch(content, max) {
+			matches = append(matches, cmd.createMatch(match))
+		}
+		return matches, nil
+	}
+	return nil, errors.New("regex cannot be nil")
+}
+
+// FilterAndGetMatches filters the event and returns command matches
+func (cmd Command) FilterAndGetMatches(event *scmprovider.GenericCommentEvent) ([]CommandMatch, error) {
+	if cmd.Handler == nil || (cmd.Filter != nil && !cmd.Filter(*event)) {
+		return nil, nil
+	}
+	return cmd.GetMatches(event.Body)
+}
+
+// GetHelp returns command help
+func (cmd Command) GetHelp() pluginhelp.Command {
+	var examples []string
+	for _, name := range strings.Split(cmd.Name, "|") {
+		examples = append(examples, "/"+name, "/lh-"+name)
+	}
+	usage := "/[lh-]"
+	if cmd.Prefix != "" {
+		usage += "[" + cmd.Prefix + "]"
+		for _, name := range strings.Split(cmd.Name, "|") {
+			examples = append(examples, "/"+cmd.Prefix+name, "/lh-"+cmd.Prefix+name)
+		}
+	}
+	usage += cmd.Name
+	if cmd.Arg != nil {
+		usage += " " + cmd.Arg.GetUsage()
+		// TODO examples
+	}
+	who := "Anyone"
+	if cmd.WhoCanUse != "" {
+		who = cmd.WhoCanUse
+	}
+	return pluginhelp.Command{
+		Usage:       usage,
+		Featured:    cmd.Featured,
+		Description: cmd.Description,
+		Examples:    examples,
+		WhoCanUse:   who,
+	}
+}
+
+// CreateMatch creates a match from an array of strings
+func (cmd Command) createMatch(matches []string) CommandMatch {
+	match := CommandMatch{}
+	if cmd.Prefix != "" {
+		match.Prefix = matches[1]
+		match.Name = matches[2]
+		if cmd.Arg != nil {
+			match.Arg = matches[3]
+		}
+	} else {
+		match.Name = matches[1]
+		if cmd.Arg != nil {
+			match.Arg = matches[2]
+		}
+	}
+	return match
+}
diff --git a/pkg/plugins/dog/dog.go b/pkg/plugins/dog/dog.go
index 375d9545..1a221940 100644
--- a/pkg/plugins/dog/dog.go
+++ b/pkg/plugins/dog/dog.go
@@ -29,16 +29,11 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 var (
-	match           = regexp.MustCompile(`(?mi)^/(?:lh-)?(woof|bark)\s*$`)
-	fineRegex       = regexp.MustCompile(`(?mi)^/(?:lh-)?this-is-fine\s*$`)
-	notFineRegex    = regexp.MustCompile(`(?mi)^/(?:lh-)?this-is-not-fine\s*$`)
-	unbearableRegex = regexp.MustCompile(`(?mi)^/(?:lh-)?this-is-unbearable\s*$`)
-	filetypes       = regexp.MustCompile(`(?i)\.(jpg|gif|png)$`)
+	filetypes = regexp.MustCompile(`(?i)\.(jpg|gif|png)$`)
 )
 
 const (
@@ -53,57 +48,33 @@ func createPlugin(p pack) plugins.Plugin {
 	return plugins.Plugin{
 		Description: "The dog plugin adds a dog image to an issue or PR in response to the `/woof` command.",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  match,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "woof|bark",
+			Description: "Add a dog image to the issue or PR",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return handle(pc.SCMProviderClient, pc.Logger, &e, p)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/(lh-)?(woof|bark)",
-				Description: "Add a dog image to the issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/woof", "/bark"},
-			}},
 		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  fineRegex,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "this-is-fine",
+			Description: "Add a dog image to the issue or PR",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return formatURLAndSendResponse(pc.SCMProviderClient, &e, fineURL)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/(lh-)?this-is-fine",
-				Description: "Add a dog image to the issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/this-is-fine", "/lh-this-is-fine"},
-			}},
 		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  notFineRegex,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "this-is-not-fine",
+			Description: "Add a dog image to the issue or PR",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return formatURLAndSendResponse(pc.SCMProviderClient, &e, notFineURL)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/(lh-)?this-is-not-fine",
-				Description: "Add a dog image to the issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/this-is-not-fine", "/lh-this-is-not-fine"},
-			}},
 		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  unbearableRegex,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "this-is-unbearable",
+			Description: "Add a dog image to the issue or PR",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return formatURLAndSendResponse(pc.SCMProviderClient, &e, unbearableURL)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/(lh-)?this-is-unbearable",
-				Description: "Add a dog image to the issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/this-is-unbearable", "/lh-this-is-unbearable"},
-			}},
 		}},
 	}
 }
diff --git a/pkg/plugins/help/help.go b/pkg/plugins/help/help.go
index 9772fb3d..6c179ac1 100644
--- a/pkg/plugins/help/help.go
+++ b/pkg/plugins/help/help.go
@@ -17,12 +17,10 @@ limitations under the License.
 package help
 
 import (
-	"regexp"
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
@@ -31,11 +29,10 @@ import (
 const pluginName = "help"
 
 var (
-	helpRe            = regexp.MustCompile(`(?mi)^/(?:lh-)?(help|remove-help|good-first-issue|remove-good-first-issue)\s*$`)
 	helpGuidelinesURL = "https://git.k8s.io/community/contributors/guide/help-wanted.md"
 	helpMsgPruneMatch = "This request has been marked as needing help from a contributor."
 	helpMsg           = `
-	This request has been marked as needing help from a contributor.
+This request has been marked as needing help from a contributor.
 
 Please ensure the request meets the requirements listed [here](` + helpGuidelinesURL + `).
 
@@ -44,7 +41,7 @@ by commenting with the ` + "`/remove-help`" + ` command.
 `
 	goodFirstIssueMsgPruneMatch = "This request has been marked as suitable for new contributors."
 	goodFirstIssueMsg           = `
-	This request has been marked as suitable for new contributors.
+This request has been marked as suitable for new contributors.
 
 Please ensure the request meets the requirements listed [here](` + helpGuidelinesURL + "#good-first-issue" + `).
 
@@ -57,24 +54,20 @@ var (
 	plugin = plugins.Plugin{
 		Description: "The help plugin provides commands that add or remove the '" + labels.Help + "' and the '" + labels.GoodFirstIssue + "' labels from issues.",
 		Commands: []plugins.Command{{
+			Prefix:      "remove-",
+			Name:        "help|good-first-issue",
+			Description: "Applies or removes the '" + labels.Help + "' and '" + labels.GoodFirstIssue + "' labels to an issue.",
+			WhoCanUse:   "Anyone can trigger this command on a PR.",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(e.IsPR || e.IssueState != "open" || e.Action != scm.ActionCreate)
 			},
-			Regex: helpRe,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				cp, err := pc.CommentPruner()
 				if err != nil {
 					return err
 				}
-				return handle(match[1], pc.SCMProviderClient, pc.Logger, cp, &e)
+				return handle(match.Prefix != "", match.Name, pc.SCMProviderClient, pc.Logger, cp, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/[remove-](help|good-first-issue)",
-				Description: "Applies or removes the '" + labels.Help + "' and '" + labels.GoodFirstIssue + "' labels to an issue.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command on a PR.",
-				Examples:    []string{"/help", "/remove-help", "/good-first-issue", "/remove-good-first-issue"},
-			}},
 		}},
 	}
 )
@@ -96,7 +89,7 @@ type commentPruner interface {
 	PruneComments(pr bool, shouldPrune func(*scm.Comment) bool)
 }
 
-func handle(command string, spc scmProviderClient, log *logrus.Entry, cp commentPruner, e *scmprovider.GenericCommentEvent) error {
+func handle(remove bool, command string, spc scmProviderClient, log *logrus.Entry, cp commentPruner, e *scmprovider.GenericCommentEvent) error {
 	org := e.Repo.Namespace
 	repo := e.Repo.Name
 	commentAuthor := e.Author.Login
@@ -110,7 +103,7 @@ func handle(command string, spc scmProviderClient, log *logrus.Entry, cp comment
 	hasGoodFirstIssue := scmprovider.HasLabel(labels.GoodFirstIssue, issueLabels)
 
 	// If PR has help label and we're asking for it to be removed, remove label
-	if hasHelp && command == "remove-help" {
+	if hasHelp && command == "help" && remove {
 		if err := spc.RemoveLabel(org, repo, e.Number, labels.Help, e.IsPR); err != nil {
 			log.WithError(err).Errorf("GitHub failed to remove the following label: %s", labels.Help)
 		}
@@ -134,7 +127,7 @@ func handle(command string, spc scmProviderClient, log *logrus.Entry, cp comment
 
 	// If PR does not have the good-first-issue label and we are asking for it to be added,
 	// add both the good-first-issue and help labels
-	if !hasGoodFirstIssue && command == "good-first-issue" {
+	if !hasGoodFirstIssue && command == "good-first-issue" && !remove {
 		if err := spc.CreateComment(org, repo, e.Number, e.IsPR, plugins.FormatResponseRaw(e.Body, e.IssueLink, spc.QuoteAuthorForComment(commentAuthor), goodFirstIssueMsg)); err != nil {
 			log.WithError(err).Errorf("Failed to create comment \"%s\".", goodFirstIssueMsg)
 		}
@@ -154,7 +147,7 @@ func handle(command string, spc scmProviderClient, log *logrus.Entry, cp comment
 
 	// If PR does not have the help label and we're asking it to be added,
 	// add the label
-	if !hasHelp && command == "help" {
+	if !hasHelp && command == "help" && !remove {
 		if err := spc.CreateComment(org, repo, e.Number, e.IsPR, plugins.FormatResponseRaw(e.Body, e.IssueLink, spc.QuoteAuthorForComment(commentAuthor), helpMsg)); err != nil {
 			log.WithError(err).Errorf("Failed to create comment \"%s\".", helpMsg)
 		}
@@ -167,7 +160,7 @@ func handle(command string, spc scmProviderClient, log *logrus.Entry, cp comment
 
 	// If PR has good-first-issue label and we are asking for it to be removed,
 	// remove just the good-first-issue label
-	if hasGoodFirstIssue && command == "remove-good-first-issue" {
+	if hasGoodFirstIssue && command == "good-first-issue" && remove {
 		if err := spc.RemoveLabel(org, repo, e.Number, labels.GoodFirstIssue, e.IsPR); err != nil {
 			log.WithError(err).Errorf("GitHub failed to remove the following label: %s", labels.GoodFirstIssue)
 		}
diff --git a/pkg/plugins/hold/hold.go b/pkg/plugins/hold/hold.go
index e644ffd7..bb6cb2ac 100644
--- a/pkg/plugins/hold/hold.go
+++ b/pkg/plugins/hold/hold.go
@@ -22,14 +22,12 @@ package hold
 
 import (
 	"fmt"
-	"regexp"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
@@ -37,28 +35,22 @@ const (
 	pluginName = "hold"
 )
 
-var (
-	match = regexp.MustCompile(`(?mi)^/(?:lh-)?hold(?:\s+(cancel))?\s*$`)
-)
-
 type hasLabelFunc func(label string, issueLabels []*scm.Label) bool
 
 var (
 	plugin = plugins.Plugin{
 		Description: "The hold plugin allows anyone to add or remove the '" + labels.Hold + "' Label from a pull request in order to temporarily prevent the PR from merging without withholding approval.",
 		Commands: []plugins.Command{{
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handleGenericComment(match[1] == "cancel", pc, e)
+			Name:        "hold",
+			Description: "Adds or removes the `" + labels.Hold + "` Label which is used to indicate that the PR should not be automatically merged.",
+			Arg: &plugins.CommandArg{
+				Pattern:  "cancel",
+				Optional: true,
+			},
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handleGenericComment(match.Arg == "cancel", pc, e)
 			},
 			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  match,
-			Help: []pluginhelp.Command{{
-				Usage:       "/hold [cancel]",
-				Description: "Adds or removes the `" + labels.Hold + "` Label which is used to indicate that the PR should not be automatically merged.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can use the /hold command to add or remove the '" + labels.Hold + "' Label.",
-				Examples:    []string{"/hold", "/hold cancel"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/label/label.go b/pkg/plugins/label/label.go
index ab242fdd..645dac60 100644
--- a/pkg/plugins/label/label.go
+++ b/pkg/plugins/label/label.go
@@ -18,14 +18,12 @@ package label
 
 import (
 	"fmt"
-	"regexp"
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
@@ -33,8 +31,6 @@ const pluginName = "label"
 
 var (
 	defaultLabels           = []string{"kind", "priority", "area"}
-	labelRegex              = regexp.MustCompile(`(?m)^/(?:lh-)?(area|committee|kind|language|priority|sig|triage|wg|label)\s*(.*)$`)
-	removeLabelRegex        = regexp.MustCompile(`(?m)^/(?:lh-)?remove-(area|committee|kind|language|priority|sig|triage|wg|label)\s*(.*)$`)
 	nonExistentLabelOnIssue = "Those labels are not set on the issue: `%v`"
 )
 
@@ -43,31 +39,16 @@ var (
 		Description:        "The label plugin provides commands that add or remove certain types of labels. Labels of the following types can be manipulated: 'area/*', 'committee/*', 'kind/*', 'language/*', 'priority/*', 'sig/*', 'triage/*', and 'wg/*'. More labels can be configured to be used via the /label command.",
 		ConfigHelpProvider: configHelp,
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  labelRegex,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(false, match[1], match[2], pc.SCMProviderClient, pc.Logger, pc.PluginConfig.Label.AdditionalLabels, &e)
+			Prefix: "remove-",
+			Name:   "area|committee|kind|language|priority|sig|triage|wg|label",
+			Arg: &plugins.CommandArg{
+				Pattern: ".*",
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/(area|committee|kind|language|priority|sig|triage|wg|label) <target>",
-				Description: "Applies a label from one of the recognized types of labels.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command on a PR.",
-				Examples:    []string{"/kind bug", "/remove-area prow", "/sig testing", "/language zh"},
-			}},
-		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  removeLabelRegex,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(true, match[1], match[2], pc.SCMProviderClient, pc.Logger, pc.PluginConfig.Label.AdditionalLabels, &e)
+			Description: "Applies or removes a label from one of the recognized types of labels.",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handle(match.Prefix != "", match.Name, match.Arg, pc.SCMProviderClient, pc.Logger, pc.PluginConfig.Label.AdditionalLabels, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/remove-(area|committee|kind|language|priority|sig|triage|wg|label) <target>",
-				Description: "Removes a label from one of the recognized types of labels.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command on a PR.",
-				Examples:    []string{"/kind bug", "/remove-area prow", "/sig testing", "/language zh"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/lgtm/lgtm.go b/pkg/plugins/lgtm/lgtm.go
index 2fd460b3..fc3d72fd 100644
--- a/pkg/plugins/lgtm/lgtm.go
+++ b/pkg/plugins/lgtm/lgtm.go
@@ -28,7 +28,6 @@ import (
 	"k8s.io/apimachinery/pkg/util/sets"
 
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/repoowners"
 )
@@ -38,14 +37,14 @@ const (
 )
 
 var (
+	// LGTMLabel is the name of the lgtm label applied by the lgtm plugin
+	LGTMLabel                  = labels.LGTM
+	match                      = regexp.MustCompile(`(?mi)^/(?:lh-)?lgtm(?: no-issue)?(?:\s+(cancel))?\s*$`)
+	removeLGTMLabelNoti        = "New changes are detected. LGTM label has been removed."
 	addLGTMLabelNotification   = "LGTM label has been added.  <details>Git tree hash: %s</details>"
 	addLGTMLabelNotificationRe = regexp.MustCompile(fmt.Sprintf(addLGTMLabelNotification, "(.*)"))
 	configInfoReviewActsAsLgtm = `Reviews of "approve" or "request changes" act as adding or removing LGTM.`
 	configInfoStoreTreeHash    = `Squashing commits does not remove LGTM.`
-	// LGTMLabel is the name of the lgtm label applied by the lgtm plugin
-	LGTMLabel           = labels.LGTM
-	match               = regexp.MustCompile(`(?mi)^/(?:lh-)?lgtm(?: no-issue)?(?:\s+(cancel))?\s*$`)
-	removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed."
 )
 
 func configInfoStickyLgtmTeam(team string) string {
@@ -65,24 +64,23 @@ var (
 		},
 		ReviewEventHandler: handlePullRequestReviewEvent,
 		Commands: []plugins.Command{{
+			Name: "lgtm",
+			Arg: &plugins.CommandArg{
+				Pattern:  "no-issue|cancel",
+				Optional: true,
+			},
+			Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.",
+			WhoCanUse:   "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(!e.IsPR || e.IssueState != "open" || e.Action != scm.ActionCreate)
 			},
-			Regex: match,
-			GenericCommentHandler: func(m []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Handler: func(m plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				cp, err := pc.CommentPruner()
 				if err != nil {
 					return err
 				}
-				return handleGenericComment(m[1] == "cancel", pc.SCMProviderClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
+				return handleGenericComment(m.Arg == "cancel", pc.SCMProviderClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/lgtm [cancel] or GitHub Review action",
-				Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.",
-				Featured:    true,
-				WhoCanUse:   "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.",
-				Examples:    []string{"/lgtm", "/lgtm cancel", "<a href=\"https://help.github.com/articles/about-pull-request-reviews/\">'Approve' or 'Request Changes'</a>"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/lifecycle/lifecycle.go b/pkg/plugins/lifecycle/lifecycle.go
index 91937561..6b0bb614 100644
--- a/pkg/plugins/lifecycle/lifecycle.go
+++ b/pkg/plugins/lifecycle/lifecycle.go
@@ -17,22 +17,16 @@ limitations under the License.
 package lifecycle
 
 import (
-	"regexp"
-
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 var (
 	lifecycleLabels = []string{labels.LifecycleActive, labels.LifecycleFrozen, labels.LifecycleStale, labels.LifecycleRotten}
-	lifecycleRe     = regexp.MustCompile(`(?mi)^/(?:lh-)?(remove-)?lifecycle (active|frozen|stale|rotten)\s*$`)
-	closeRe         = regexp.MustCompile(`(?mi)^/(?:lh-)?close\s*$`)
-	reopenRe        = regexp.MustCompile(`(?mi)^/(?:lh-)?reopen\s*$`)
 )
 
 const pluginName = "lifecycle"
@@ -41,48 +35,37 @@ var (
 	plugin = plugins.Plugin{
 		Description: "Close, reopen, flag and/or unflag an issue or PR as frozen/stale/rotten",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  lifecycleRe,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handleOne(match[1] != "", "lifecycle/"+match[2], pc.SCMProviderClient, pc.Logger, &e)
+			Prefix: "remove-",
+			Name:   "lifecycle",
+			Arg: &plugins.CommandArg{
+				Pattern: "frozen|stale|rotten",
+			},
+			Description: "Flags an issue or PR as frozen/stale/rotten",
+			WhoCanUse:   "Anyone can trigger this command.",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handleOne(match.Prefix != "", "lifecycle/"+match.Arg, pc.SCMProviderClient, pc.Logger, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/[remove-]lifecycle <frozen|stale|rotten>",
-				Description: "Flags an issue or PR as frozen/stale/rotten",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command.",
-				Examples:    []string{"/lifecycle frozen", "/remove-lifecycle stale", "/lh-lifecyle rotten"},
-			}},
 		}, {
+			Name:        "close",
+			Description: "Closes an issue or PR.",
+			WhoCanUse:   "Authors and collaborators on the repository can trigger this command.",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return e.Action == scm.ActionCreate && e.IssueState == "open"
 			},
-			Regex: closeRe,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return handleClose(pc.SCMProviderClient, pc.Logger, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/close",
-				Description: "Closes an issue or PR.",
-				Featured:    false,
-				WhoCanUse:   "Authors and collaborators on the repository can trigger this command.",
-				Examples:    []string{"/close", "/lh-close"},
-			}},
 		}, {
+			Name:        "reopen",
+			Description: "Reopens an issue or PR",
+			WhoCanUse:   "Authors and collaborators on the repository can trigger this command.",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return e.Action == scm.ActionCreate && e.IssueState == "closed"
 			},
-			Regex: reopenRe,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return handleReopen(pc.SCMProviderClient, pc.Logger, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/reopen",
-				Description: "Reopens an issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Authors and collaborators on the repository can trigger this command.",
-				Examples:    []string{"/reopen", "/lh-reopen"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/lifecycle/reopen.go b/pkg/plugins/lifecycle/reopen.go
index 8529e159..8d6fd5c7 100644
--- a/pkg/plugins/lifecycle/reopen.go
+++ b/pkg/plugins/lifecycle/reopen.go
@@ -99,6 +99,7 @@ func handleReopen(spc scmProviderClient, log *logrus.Entry, e *scmprovider.Gener
 		}
 		return err
 	}
+
 	// Add a comment after reopening the issue to leave an audit trail of who
 	// asked to reopen it.
 	return spc.CreateComment(
diff --git a/pkg/plugins/milestone/milestone.go b/pkg/plugins/milestone/milestone.go
index 64831a90..f05dbf49 100644
--- a/pkg/plugins/milestone/milestone.go
+++ b/pkg/plugins/milestone/milestone.go
@@ -20,7 +20,6 @@ package milestone
 
 import (
 	"fmt"
-	"regexp"
 	"sort"
 	"strings"
 
@@ -28,14 +27,12 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 const pluginName = "milestone"
 
 var (
-	milestoneRegex   = regexp.MustCompile(`(?m)^/(?:lh-)?milestone\s+(.+?)\s*$`)
 	mustBeAuthorized = "You must be a member of the [%s/%s](https://github.com/orgs/%s/teams/%s/members) GitHub team to set the milestone. If you believe you should be able to issue the /milestone command, please contact your %s and have them propose you as an additional delegate for this responsibility."
 	invalidMilestone = "The provided milestone is not valid for this repository. Milestones in this repository: [%s]\n\nUse `/milestone %s` to clear the milestone."
 	milestoneTeamMsg = "The milestone maintainers team is the GitHub team %q with ID: %d."
@@ -56,18 +53,16 @@ var (
 		Description:        "The milestone plugin allows members of a configurable GitHub team to set the milestone on an issue or pull request.",
 		ConfigHelpProvider: configHelp,
 		Commands: []plugins.Command{{
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(match[1], pc.SCMProviderClient, pc.Logger, &e, pc.PluginConfig.RepoMilestone)
+			Name: "milestone",
+			Arg: &plugins.CommandArg{
+				Pattern: ".+?",
+			},
+			Description: "Updates the milestone for an issue or PR",
+			WhoCanUse:   "Members of the milestone maintainers GitHub team can use the '/milestone' command.",
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handle(match.Arg, pc.SCMProviderClient, pc.Logger, &e, pc.PluginConfig.RepoMilestone)
 			},
 			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  milestoneRegex,
-			Help: []pluginhelp.Command{{
-				Usage:       "/milestone <version> or /milestone clear",
-				Description: "Updates the milestone for an issue or PR",
-				Featured:    false,
-				WhoCanUse:   "Members of the milestone maintainers GitHub team can use the '/milestone' command.",
-				Examples:    []string{"/milestone v1.10", "/milestone v1.9", "/milestone clear", "/lh-milestone clear"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/milestonestatus/milestonestatus.go b/pkg/plugins/milestonestatus/milestonestatus.go
index 2df77be3..bc30ef83 100644
--- a/pkg/plugins/milestonestatus/milestonestatus.go
+++ b/pkg/plugins/milestonestatus/milestonestatus.go
@@ -20,21 +20,18 @@ package milestonestatus
 
 import (
 	"fmt"
-	"regexp"
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 const pluginName = "milestonestatus"
 
 var (
-	statusRegex      = regexp.MustCompile(`(?m)^/(?:lh-)?status\s+(.+)$`)
 	mustBeAuthorized = "You must be a member of the [%s/%s](https://github.com/orgs/%s/teams/%s/members) GitHub team to add status labels. If you believe you should be able to issue the /status command, please contact your %s and have them propose you as an additional delegate for this responsibility."
 	milestoneTeamMsg = "The milestone maintainers team is the GitHub team %q with ID: %d."
 	statusMap        = map[string]string{
@@ -49,18 +46,16 @@ var (
 		Description:        "The milestonestatus plugin allows members of the milestone maintainers GitHub team to specify the 'status/*' label that should apply to a pull request.",
 		ConfigHelpProvider: configHelp,
 		Commands: []plugins.Command{{
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(match[1], pc.SCMProviderClient, pc.Logger, &e, pc.PluginConfig.RepoMilestone)
+			Name: "status",
+			Arg: &plugins.CommandArg{
+				Pattern: ".+",
+			},
+			Description: "Applies the 'status/' label to a PR.",
+			WhoCanUse:   "Members of the milestone maintainers GitHub team can use the '/status' command. This team is specified in the config by providing the GitHub team's ID.",
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handle(match.Arg, pc.SCMProviderClient, pc.Logger, &e, pc.PluginConfig.RepoMilestone)
 			},
 			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  statusRegex,
-			Help: []pluginhelp.Command{{
-				Usage:       "/status (approved-for-milestone|in-progress|in-review)",
-				Description: "Applies the 'status/' label to a PR.",
-				Featured:    false,
-				WhoCanUse:   "Members of the milestone maintainers GitHub team can use the '/status' command. This team is specified in the config by providing the GitHub team's ID.",
-				Examples:    []string{"/status approved-for-milestone", "/status in-progress", "/status in-review", "/lh-status in-review"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/override/override.go b/pkg/plugins/override/override.go
index 784d69b9..acb7ba91 100644
--- a/pkg/plugins/override/override.go
+++ b/pkg/plugins/override/override.go
@@ -28,7 +28,6 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
 	"github.com/jenkins-x/lighthouse/pkg/jobutil"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/util"
@@ -79,20 +78,18 @@ var (
 	plugin = plugins.Plugin{
 		Description: "The override plugin allows repo admins to force a github status context to pass",
 		Commands: []plugins.Command{{
+			Name: "override",
+			Arg: &plugins.CommandArg{
+				Pattern: `[^\r\n]+`,
+			},
+			Description: "Forces a github status context to green (one per line).",
+			WhoCanUse:   "Repo administrators",
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(!e.IsPR || e.IssueState != "open" || e.Action != scm.ActionCreate)
 			},
-			Regex: overrideRe,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(match, pc.SCMProviderClient, pc.LighthouseClient, pc.Config.JobConfig, pc.Logger, e)
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handle(match.Arg, pc.SCMProviderClient, pc.LighthouseClient, pc.Config.JobConfig, pc.Logger, e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/override [context]",
-				Description: "Forces a github status context to green (one per line).",
-				Featured:    false,
-				WhoCanUse:   "Repo administrators",
-				Examples:    []string{"/override pull-repo-whatever", "/override ci/circleci", "/override deleted-job", "/lh-override some-job"},
-			}},
 		}},
 	}
 )
@@ -129,19 +126,19 @@ func formatList(list []string) string {
 	return strings.Join(lines, "\n")
 }
 
-func handle(match []string, spc scmProviderClient, lhClient lighthouseclient.LighthouseJobInterface, jc config.JobConfig, log *logrus.Entry, e scmprovider.GenericCommentEvent) error {
+func handle(context string, spc scmProviderClient, lhClient lighthouseclient.LighthouseJobInterface, jc config.JobConfig, log *logrus.Entry, e scmprovider.GenericCommentEvent) error {
 	org := e.Repo.Namespace
 	repo := e.Repo.Name
 	number := e.Number
 	user := e.Author.Login
 
 	overrides := sets.NewString()
-	if match[1] == "" {
+	if context == "" {
 		resp := "/override requires a failed status context to operate on, but none was given"
 		log.Debug(resp)
 		return spc.CreateComment(org, repo, number, e.IsPR, plugins.FormatResponseRaw(e.Body, e.Link, spc.QuoteAuthorForComment(user), resp))
 	}
-	overrides.Insert(match[2])
+	overrides.Insert(context)
 
 	if !authorized(spc, log, org, repo, user) {
 		resp := fmt.Sprintf("%s unauthorized: /override is restricted to repo administrators", user)
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index 6572b075..00fbe0d4 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -22,7 +22,6 @@ import (
 	"io/ioutil"
 	"net/url"
 	"os"
-	"regexp"
 	"strings"
 	"sync"
 
@@ -44,72 +43,6 @@ var (
 	plugins = map[string]Plugin{}
 )
 
-// Command defines a plugin command sent through a comment
-type Command struct {
-	Help                  []pluginhelp.Command
-	Regex                 *regexp.Regexp
-	MaxMatches            int
-	Filter                func(e scmprovider.GenericCommentEvent) bool
-	GenericCommentHandler CommandEventHandler
-}
-
-// InvokeHandler performs command checks (filter, then regex if any) the calls the handler with the match (if any)
-func (cmd Command) InvokeHandler(ce *scmprovider.GenericCommentEvent, handler func([]string) error) error {
-	if cmd.GenericCommentHandler == nil || (cmd.Filter != nil && !cmd.Filter(*ce)) {
-		return nil
-	}
-	if cmd.Regex != nil {
-		max := cmd.MaxMatches
-		if max == 0 {
-			max = -1
-		}
-		for _, m := range cmd.Regex.FindAllStringSubmatch(ce.Body, max) {
-			if err := handler(m); err != nil {
-				return err
-			}
-		}
-	} else {
-		return handler(nil)
-	}
-	return nil
-}
-
-// InvokeCommandHandler performs command checks (filter, then regex if any) the calls the handler with the match (if any)
-func (cmd Command) InvokeCommandHandler(ce *scmprovider.GenericCommentEvent, handler func(CommandEventHandler, *scmprovider.GenericCommentEvent, []string) error) error {
-	if cmd.GenericCommentHandler == nil || (cmd.Filter != nil && !cmd.Filter(*ce)) {
-		return nil
-	}
-	if cmd.Regex != nil {
-		max := cmd.MaxMatches
-		if max == 0 {
-			max = -1
-		}
-		for _, m := range cmd.Regex.FindAllStringSubmatch(ce.Body, max) {
-			if err := handler(cmd.GenericCommentHandler, ce, m); err != nil {
-				return err
-			}
-		}
-	} else {
-		return handler(cmd.GenericCommentHandler, ce, nil)
-	}
-	return nil
-}
-
-// GetMatches returns command matches
-func (cmd Command) GetMatches(ce *scmprovider.GenericCommentEvent) ([][]string, error) {
-	if cmd.Filter != nil && !cmd.Filter(*ce) {
-		return nil, nil
-	}
-	if cmd.Regex != nil {
-		max := cmd.MaxMatches
-		if max == 0 {
-			max = -1
-		}
-		return cmd.Regex.FindAllStringSubmatch(ce.Body, max), nil
-	}
-	return nil, errors.New("regex cannot be nil")
-}
-
 // Plugin defines a plugin and its handlers
 type Plugin struct {
 	Description           string
@@ -123,18 +56,8 @@ type Plugin struct {
 	Commands              []Command
 }
 
-// InvokeCommand calls InvokeHandler on all commands
-func (plugin Plugin) InvokeCommand(ce *scmprovider.GenericCommentEvent, handler func([]string) error) error {
-	for _, cmd := range plugin.Commands {
-		if err := cmd.InvokeHandler(ce, handler); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 // InvokeCommandHandler calls InvokeHandler on all commands
-func (plugin Plugin) InvokeCommandHandler(ce *scmprovider.GenericCommentEvent, handler func(CommandEventHandler, *scmprovider.GenericCommentEvent, []string) error) error {
+func (plugin Plugin) InvokeCommandHandler(ce *scmprovider.GenericCommentEvent, handler func(CommandEventHandler, *scmprovider.GenericCommentEvent, CommandMatch) error) error {
 	for _, cmd := range plugin.Commands {
 		if err := cmd.InvokeCommandHandler(ce, handler); err != nil {
 			return err
@@ -174,7 +97,7 @@ type ReviewEventHandler func(Agent, scm.ReviewHook) error
 type GenericCommentHandler func(Agent, scmprovider.GenericCommentEvent) error
 
 // CommandEventHandler defines the function contract for a command handler.
-type CommandEventHandler func([]string, Agent, scmprovider.GenericCommentEvent) error
+type CommandEventHandler func(CommandMatch, Agent, scmprovider.GenericCommentEvent) error
 
 // HelpProviders returns the map of registered plugins with their associated HelpProvider.
 func HelpProviders() map[string]HelpProvider {
@@ -192,9 +115,7 @@ func HelpProviders() map[string]HelpProvider {
 				h.Description = v.Description
 			}
 			for _, c := range v.Commands {
-				for _, cc := range c.Help {
-					h.AddCommand(cc)
-				}
+				h.AddCommand(c.GetHelp())
 			}
 			h.Events = EventsForPlugin(v)
 			return h, err
@@ -431,7 +352,7 @@ func EventsForPlugin(plugin Plugin) []string {
 	if plugin.StatusEventHandler != nil {
 		events = append(events, "status")
 	}
-	if plugin.Commands != nil && len(plugin.Commands) > 0 {
+	if plugin.GenericCommentHandler != nil {
 		events = append(events, "GenericCommentEvent (any event for user text)")
 	}
 	return events
diff --git a/pkg/plugins/pony/pony.go b/pkg/plugins/pony/pony.go
index 01af8065..d2ed0506 100644
--- a/pkg/plugins/pony/pony.go
+++ b/pkg/plugins/pony/pony.go
@@ -23,10 +23,8 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
-	"regexp"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
@@ -51,26 +49,20 @@ const (
 	pluginName = "pony"
 )
 
-var (
-	match = regexp.MustCompile(`(?mi)^/(?:lh-)?pony(?: +(.+))?\s*$`)
-)
-
 func createPlugin(h herd) plugins.Plugin {
 	return plugins.Plugin{
 		Description: "The pony plugin adds a pony image to an issue or PR in response to the `/pony` command.",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  match,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return handle(match[1], pc.SCMProviderClient, pc.Logger, &e, h)
+			Name: "pony",
+			Arg: &plugins.CommandArg{
+				Pattern:  ".+",
+				Optional: true,
+			},
+			Description: "Add a little pony image to the issue or PR. A particular pony can optionally be named for a picture of that specific pony.",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return handle(match.Arg, pc.SCMProviderClient, pc.Logger, &e, h)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/pony [pony]",
-				Description: "Add a little pony image to the issue or PR. A particular pony can optionally be named for a picture of that specific pony.",
-				Featured:    false,
-				WhoCanUse:   "Anyone",
-				Examples:    []string{"/pony", "/pony Twilight Sparkle", "/lh-pony"},
-			}},
 		}},
 	}
 }
diff --git a/pkg/plugins/shrug/shrug.go b/pkg/plugins/shrug/shrug.go
index 6af04d10..8196b2c7 100644
--- a/pkg/plugins/shrug/shrug.go
+++ b/pkg/plugins/shrug/shrug.go
@@ -18,65 +18,34 @@ package shrug
 
 import (
 	"fmt"
-	"regexp"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
 	"github.com/jenkins-x/lighthouse/pkg/labels"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 const pluginName = "shrug"
 
-var (
-	shrugRe   = regexp.MustCompile(`(?mi)^/(?:lh-)?shrug\s*$`)
-	unshrugRe = regexp.MustCompile(`(?mi)^/(?:lh-)?unshrug\s*$`)
-)
-
-type event struct {
-	org           string
-	repo          string
-	number        int
-	prAuthor      string
-	commentAuthor string
-	body          string
-	assignees     []scm.User
-	hasLabel      func(label string) (bool, error)
-	htmlurl       string
-}
-
 var (
 	plugin = plugins.Plugin{
 		Description: labels.Shrug,
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  shrugRe,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "shrug",
+			Description: "Adds the " + labels.Shrug + " label",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return addLabel(pc.SCMProviderClient, pc.Logger, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/shrug",
-				Description: "Adds the " + labels.Shrug + " label",
-				Featured:    false,
-				WhoCanUse:   "Anyone, " + labels.Shrug,
-				Examples:    []string{"/shrug", "/lh-shrug"},
-			}},
 		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  unshrugRe,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "unshrug",
+			Description: "Removes the " + labels.Shrug + " label",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return removeLabel(pc.SCMProviderClient, pc.Logger, &e)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/unshrug",
-				Description: "Removes the " + labels.Shrug + " label",
-				Featured:    false,
-				WhoCanUse:   "Anyone, " + labels.Shrug,
-				Examples:    []string{"/unshrug", "/lh-unshrug"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/skip/skip.go b/pkg/plugins/skip/skip.go
index 35c6ba8f..910b502d 100644
--- a/pkg/plugins/skip/skip.go
+++ b/pkg/plugins/skip/skip.go
@@ -20,11 +20,9 @@ package skip
 
 import (
 	"fmt"
-	"regexp"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/plugins/trigger"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
@@ -33,10 +31,6 @@ import (
 
 const pluginName = "skip"
 
-var (
-	skipRe = regexp.MustCompile(`(?mi)^/(?:lh-)?skip\s*$`)
-)
-
 type scmProviderClient interface {
 	CreateComment(owner, repo string, number int, pr bool, comment string) error
 	CreateStatus(org, repo, ref string, s *scm.StatusInput) (*scm.Status, error)
@@ -50,18 +44,12 @@ var (
 	plugin = plugins.Plugin{
 		Description: "The skip plugin allows users to clean up GitHub stale commit statuses for non-blocking jobs on a PR.",
 		Commands: []plugins.Command{{
-			GenericCommentHandler: handleGenericComment,
+			Name:        "skip",
+			Description: "Cleans up GitHub stale commit statuses for non-blocking jobs on a PR.",
+			Handler:     handleGenericComment,
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(!e.IsPR || e.IssueState != "open" || e.Action != scm.ActionCreate)
 			},
-			Regex: skipRe,
-			Help: []pluginhelp.Command{{
-				Usage:       "/skip",
-				Description: "Cleans up GitHub stale commit statuses for non-blocking jobs on a PR.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command on a PR.",
-				Examples:    []string{"/skip", "/lh-skip"},
-			}},
 		}},
 	}
 )
@@ -70,7 +58,7 @@ func init() {
 	plugins.RegisterPlugin(pluginName, plugin)
 }
 
-func handleGenericComment(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+func handleGenericComment(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 	honorOkToTest := trigger.HonorOkToTest(pc.PluginConfig.TriggerFor(e.Repo.Namespace, e.Repo.Name))
 	return handle(pc.SCMProviderClient, pc.Logger, &e, pc.Config.GetPresubmits(e.Repo), honorOkToTest)
 }
diff --git a/pkg/plugins/stage/stage.go b/pkg/plugins/stage/stage.go
index 9ae95eb5..93f7af63 100644
--- a/pkg/plugins/stage/stage.go
+++ b/pkg/plugins/stage/stage.go
@@ -19,13 +19,10 @@ limitations under the License.
 package stage
 
 import (
-	"regexp"
-
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
@@ -34,8 +31,6 @@ var (
 	stageBeta   = "stage/beta"
 	stageStable = "stage/stable"
 	stageLabels = []string{stageAlpha, stageBeta, stageStable}
-	stageRe     = regexp.MustCompile(`(?mi)^/(?:lh-)?stage (alpha|beta|stable)\s*$`)
-	unstageRe   = regexp.MustCompile(`(?mi)^/(?:lh-)?remove-stage (alpha|beta|stable)\s*$`)
 )
 
 const pluginName = "stage"
@@ -44,31 +39,25 @@ var (
 	plugin = plugins.Plugin{
 		Description: "Label the stage of an issue as alpha/beta/stable",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  stageRe,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return stage(pc.SCMProviderClient, pc.Logger, &e, match[1])
+			Name: "stage",
+			Arg: &plugins.CommandArg{
+				Pattern: "alpha|beta|stable",
+			},
+			Description: "Labels the stage of an issue as alpha/beta/stable",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return stage(pc.SCMProviderClient, pc.Logger, &e, match.Arg)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/stage <alpha|beta|stable>",
-				Description: "Labels the stage of an issue as alpha/beta/stable",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command.",
-				Examples:    []string{"/stage alpha"},
-			}},
 		}, {
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  unstageRe,
-			GenericCommentHandler: func(match []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
-				return unstage(pc.SCMProviderClient, pc.Logger, &e, match[1])
+			Name: "remove-stage",
+			Arg: &plugins.CommandArg{
+				Pattern: "alpha|beta|stable",
+			},
+			Description: "Removes the stage label of an issue as alpha/beta/stable",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(match plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+				return unstage(pc.SCMProviderClient, pc.Logger, &e, match.Arg)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/remove-stage <alpha|beta|stable>",
-				Description: "Removes the stage label of an issue as alpha/beta/stable",
-				Featured:    false,
-				WhoCanUse:   "Anyone can trigger this command.",
-				Examples:    []string{"/remove-stage alpha"},
-			}},
 		}},
 	}
 )
diff --git a/pkg/plugins/trigger/trigger.go b/pkg/plugins/trigger/trigger.go
index deaa0a4a..567e7bf3 100644
--- a/pkg/plugins/trigger/trigger.go
+++ b/pkg/plugins/trigger/trigger.go
@@ -18,7 +18,6 @@ package trigger
 
 import (
 	"fmt"
-	"regexp"
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
@@ -27,7 +26,6 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
 	"github.com/jenkins-x/lighthouse/pkg/errorutil"
 	"github.com/jenkins-x/lighthouse/pkg/jobutil"
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
@@ -48,44 +46,36 @@ var (
 		PullRequestHandler: handlePullRequest,
 		PushEventHandler:   handlePush,
 		Commands: []plugins.Command{{
-			GenericCommentHandler: handleGenericCommentEvent,
+			Name:        "ok-to-test",
+			Description: "Marks a PR as 'trusted' and starts tests.",
+			WhoCanUse:   "Members of the trusted organization for the repo.",
+			Handler:     handleGenericCommentEvent,
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(e.Action != scm.ActionCreate || !e.IsPR || e.IssueState != "open")
 			},
-			Regex: jobutil.OkToTestRe,
-			Help: []pluginhelp.Command{{
-				Usage:       "/ok-to-test",
-				Description: "Marks a PR as 'trusted' and starts tests.",
-				Featured:    false,
-				WhoCanUse:   "Members of the trusted organization for the repo.",
-				Examples:    []string{"/ok-to-test", "/lh-ok-to-test"},
-			}},
+			// Help: pluginhelp.Command{
+			// 	// Usage:     "/ok-to-test",
+			// 	Examples: []string{"/ok-to-test", "/lh-ok-to-test"},
+			// },
 		}, {
-			GenericCommentHandler: handleGenericCommentEvent,
+			Name: "test",
+			Arg: &plugins.CommandArg{
+				Pattern: `[-\w]+(?:,[-\w]+)*`,
+			},
+			Description: "Manually starts a/all test job(s).",
+			Featured:    true,
+			Handler:     handleGenericCommentEvent,
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(e.Action != scm.ActionCreate || !e.IsPR || e.IssueState != "open")
 			},
-			Regex: regexp.MustCompile(`(?m)^/(?:lh-)?test\s+([-\w]+(?:,[-\w]+)*)\s*$`),
-			Help: []pluginhelp.Command{{
-				Usage:       "/test (<job name>|all)",
-				Description: "Manually starts a/all test job(s).",
-				Featured:    true,
-				WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
-				Examples:    []string{"/test all", "/test pull-bazel-test", "/lh-test all"},
-			}},
 		}, {
-			GenericCommentHandler: handleGenericCommentEvent,
+			Name:        "retest",
+			Description: "Rerun test jobs that have failed.",
+			Featured:    true,
+			Handler:     handleGenericCommentEvent,
 			Filter: func(e scmprovider.GenericCommentEvent) bool {
 				return !(e.Action != scm.ActionCreate || !e.IsPR || e.IssueState != "open")
 			},
-			Regex: jobutil.RetestRe,
-			Help: []pluginhelp.Command{{
-				Usage:       "/retest",
-				Description: "Rerun test jobs that have failed.",
-				Featured:    true,
-				WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
-				Examples:    []string{"/retest", "/lh-retest"},
-			}},
 		}},
 	}
 )
@@ -169,7 +159,7 @@ func handlePullRequest(pc plugins.Agent, pr scm.PullRequestHook) error {
 	return handlePR(getClient(pc), pc.PluginConfig.TriggerFor(org, repo), pr)
 }
 
-func handleGenericCommentEvent(_ []string, pc plugins.Agent, gc scmprovider.GenericCommentEvent) error {
+func handleGenericCommentEvent(_ plugins.CommandMatch, pc plugins.Agent, gc scmprovider.GenericCommentEvent) error {
 	return handleGenericComment(getClient(pc), pc.PluginConfig.TriggerFor(gc.Repo.Namespace, gc.Repo.Name), gc)
 }
 
diff --git a/pkg/plugins/yuks/yuks.go b/pkg/plugins/yuks/yuks.go
index bea63eac..05beb83a 100644
--- a/pkg/plugins/yuks/yuks.go
+++ b/pkg/plugins/yuks/yuks.go
@@ -27,12 +27,10 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 
-	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 )
 
 var (
-	match  = regexp.MustCompile(`(?mi)^/(?:lh-)?joke\s*$`)
 	simple = regexp.MustCompile(`^[\w?'!., ]+$`)
 )
 
@@ -45,18 +43,12 @@ func createPlugin(j joker) plugins.Plugin {
 	return plugins.Plugin{
 		Description: "The yuks plugin comments with jokes in response to the `/joke` command.",
 		Commands: []plugins.Command{{
-			Filter: func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
-			Regex:  match,
-			GenericCommentHandler: func(_ []string, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
+			Name:        "joke",
+			Description: "Tells a joke.",
+			Filter:      func(e scmprovider.GenericCommentEvent) bool { return e.Action == scm.ActionCreate },
+			Handler: func(_ plugins.CommandMatch, pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 				return joke(pc.SCMProviderClient, pc.Logger, &e, j)
 			},
-			Help: []pluginhelp.Command{{
-				Usage:       "/joke",
-				Description: "Tells a joke.",
-				Featured:    false,
-				WhoCanUse:   "Anyone can use the `/joke` command.",
-				Examples:    []string{"/joke", "/lh-joke"},
-			}},
 		}},
 	}
 }
diff --git a/pkg/webhook/events.go b/pkg/webhook/events.go
index 21906627..dcf23489 100644
--- a/pkg/webhook/events.go
+++ b/pkg/webhook/events.go
@@ -118,9 +118,9 @@ func (s *Server) handleGenericComment(l *logrus.Entry, ce *scmprovider.GenericCo
 			}(p, h.GenericCommentHandler)
 		}
 		for _, cmd := range h.Commands {
-			err := cmd.InvokeHandler(ce, func(match []string) error {
+			err := cmd.InvokeCommandHandler(ce, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				s.wg.Add(1)
-				go func(p string, h plugins.CommandEventHandler, m []string) {
+				go func(p string, h plugins.CommandEventHandler, m plugins.CommandMatch) {
 					defer s.wg.Done()
 					agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 					agent.InitializeCommentPruner(
@@ -131,7 +131,7 @@ func (s *Server) handleGenericComment(l *logrus.Entry, ce *scmprovider.GenericCo
 					if err := h(m, agent, *ce); err != nil {
 						agent.Logger.WithError(err).Error("Error handling GenericCommentEvent.")
 					}
-				}(p, cmd.GenericCommentHandler, match)
+				}(p, handler, match)
 				return nil
 			})
 			if err != nil {


Test patch:
diff --git a/pkg/plugins/approve/approve_test.go b/pkg/plugins/approve/approve_test.go
index 63bdabdd..53ad269c 100644
--- a/pkg/plugins/approve/approve_test.go
+++ b/pkg/plugins/approve/approve_test.go
@@ -1493,7 +1493,7 @@ func TestHandleGenericComment(t *testing.T) {
 				Repos:             []string{test.commentEvent.Repo.Namespace},
 				LgtmActsAsApprove: test.lgtmActsAsApprove,
 			})
-			err := plugin.InvokeCommand(&test.commentEvent, func(match []string) error {
+			err := plugin.InvokeCommandHandler(&test.commentEvent, func(_ plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, _ plugins.CommandMatch) error {
 				return handleGenericComment(
 					logrus.WithField("plugin", "approve"),
 					fakeClient,
diff --git a/pkg/plugins/assign/assign_test.go b/pkg/plugins/assign/assign_test.go
index 0acd6a82..2e0ae610 100644
--- a/pkg/plugins/assign/assign_test.go
+++ b/pkg/plugins/assign/assign_test.go
@@ -420,15 +420,15 @@ func TestAssignAndReview(t *testing.T) {
 				Number: 5,
 			}
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(&e)
+			matches, err := cmd.FilterAndGetMatches(&e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handle(m[1] != "un", m[2], m[3], newAssignHandler(e, fc, logrus.WithField("plugin", pluginName))); err != nil {
+				if err := handle(m.Prefix != "un", m.Name, m.Arg, newAssignHandler(e, fc, logrus.WithField("plugin", pluginName))); err != nil {
 					t.Fatalf("For case %s, didn't expect error from handle: %v", tc.name, err)
 				}
-				if err := handle(m[1] != "un", m[2], m[3], newReviewHandler(e, fc, logrus.WithField("plugin", pluginName))); err != nil {
+				if err := handle(m.Prefix != "un", m.Name, m.Arg, newReviewHandler(e, fc, logrus.WithField("plugin", pluginName))); err != nil {
 					t.Fatalf("For case %s, didn't expect error from handle: %v", tc.name, err)
 				}
 			}
diff --git a/pkg/plugins/cat/cat_test.go b/pkg/plugins/cat/cat_test.go
index 5137136b..d75918a1 100644
--- a/pkg/plugins/cat/cat_test.go
+++ b/pkg/plugins/cat/cat_test.go
@@ -29,6 +29,7 @@ import (
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/go-scm/scm/driver/fake"
+	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 )
@@ -363,8 +364,8 @@ Available variants:
 		Number:     5,
 		IssueState: "open",
 	}
-	if err := plugin.InvokeCommand(e, func(match []string) error {
-		return handle(match, fakeClient, logrus.WithField("plugin", pluginName), e, &realClowder{url: ts.URL + "/?format=json"}, func() {})
+	if err := plugin.InvokeCommandHandler(e, func(_ plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
+		return handle(match.Name == "meowvie", match.Arg, fakeClient, logrus.WithField("plugin", pluginName), e, &realClowder{url: ts.URL + "/?format=json"}, func() {})
 	}); err != nil {
 		t.Errorf("didn't expect error: %v", err)
 		return
@@ -484,8 +485,8 @@ func TestCats(t *testing.T) {
 				IssueState: tc.state,
 				IsPR:       tc.pr,
 			}
-			err := plugin.InvokeCommand(e, func(match []string) error {
-				return handle(match, fakeClient, logrus.WithField("plugin", pluginName), e, fakeClowder("tubbs"), func() {})
+			err := plugin.InvokeCommandHandler(e, func(_ plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
+				return handle(match.Name == "meowvie", match.Arg, fakeClient, logrus.WithField("plugin", pluginName), e, fakeClowder("tubbs"), func() {})
 			})
 			if !tc.shouldError && err != nil {
 				t.Fatalf("%s: didn't expect error: %v", tc.name, err)
diff --git a/pkg/plugins/command_test.go b/pkg/plugins/command_test.go
new file mode 100644
index 00000000..6d2b69ab
--- /dev/null
+++ b/pkg/plugins/command_test.go
@@ -0,0 +1,671 @@
+package plugins_test
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
+	"github.com/jenkins-x/lighthouse/pkg/plugins"
+)
+
+func TestCommandArgGetRegex(t *testing.T) {
+	cases := []struct {
+		name       string
+		commandArg plugins.CommandArg
+		expected   string
+	}{
+		{
+			name: "optional",
+			commandArg: plugins.CommandArg{
+				Pattern:  "foo|bar",
+				Optional: true,
+			},
+			expected: `(?:[ \t]+(foo|bar))?`,
+		},
+		{
+			name: "not optional",
+			commandArg: plugins.CommandArg{
+				Pattern: "foo|bar",
+			},
+			expected: `(?:[ \t]+(foo|bar))`,
+		},
+		{
+			name: "no pattern means everything",
+			commandArg: plugins.CommandArg{
+				Optional: true,
+			},
+			expected: `(?:[ \t]+([^\r\n]+))?`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			actual := tc.commandArg.GetRegex()
+			if actual != tc.expected {
+				t.Errorf("actual regex does not match expected %s != expected %s", actual, tc.expected)
+			}
+		})
+	}
+}
+
+func TestCommandArgGetUsage(t *testing.T) {
+	cases := []struct {
+		name       string
+		commandArg plugins.CommandArg
+		expected   string
+	}{
+		{
+			name: "optional with no usage",
+			commandArg: plugins.CommandArg{
+				Pattern:  "foo|bar",
+				Optional: true,
+			},
+			expected: "[foo|bar]",
+		},
+		{
+			name: "not optional with no usage",
+			commandArg: plugins.CommandArg{
+				Pattern: "foo|bar",
+			},
+			expected: "<foo|bar>",
+		},
+		{
+			name: "optional no pattern",
+			commandArg: plugins.CommandArg{
+				Optional: true,
+			},
+			expected: "[anything]",
+		},
+		{
+			name:       "not optional no pattern",
+			commandArg: plugins.CommandArg{},
+			expected:   "<anything>",
+		},
+		{
+			name: "optional with usage",
+			commandArg: plugins.CommandArg{
+				Pattern:  "foo|bar",
+				Usage:    "option_name",
+				Optional: true,
+			},
+			expected: "[option_name]",
+		},
+		{
+			name: "not optional with usage",
+			commandArg: plugins.CommandArg{
+				Pattern: "foo|bar",
+				Usage:   "option_name",
+			},
+			expected: "<option_name>",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			actual := tc.commandArg.GetUsage()
+			if actual != tc.expected {
+				t.Errorf("actual usage does not match expected %s != expected %s", actual, tc.expected)
+			}
+		})
+	}
+}
+
+func TestCommandGetRegex(t *testing.T) {
+	cases := []struct {
+		name     string
+		command  plugins.Command
+		expected string
+	}{
+		{
+			name: "no prefix and no arg",
+			command: plugins.Command{
+				Name: "test",
+			},
+			expected: `(?mi)^/(?:lh-)?(test)\s*$`,
+		},
+		{
+			name: "prefix and no arg",
+			command: plugins.Command{
+				Prefix: "prefix",
+				Name:   "test",
+			},
+			expected: `(?mi)^/(?:lh-)?(prefix)?(test)\s*$`,
+		},
+		{
+			name: "prefix and optional arg",
+			command: plugins.Command{
+				Prefix: "prefix",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern:  "foo|bar",
+					Optional: true,
+				},
+			},
+			expected: `(?mi)^/(?:lh-)?(prefix)?(test)(?:[ \t]+(foo|bar))?\s*$`,
+		},
+		{
+			name: "prefix and arg",
+			command: plugins.Command{
+				Prefix: "prefix",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+			},
+			expected: `(?mi)^/(?:lh-)?(prefix)?(test)(?:[ \t]+(foo|bar))\s*$`,
+		},
+		{
+			name: "no prefix and optional arg",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern:  "foo|bar",
+					Optional: true,
+				},
+			},
+			expected: `(?mi)^/(?:lh-)?(test)(?:[ \t]+(foo|bar))?\s*$`,
+		},
+		{
+			name: "no prefix and arg",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+			},
+			expected: `(?mi)^/(?:lh-)?(test)(?:[ \t]+(foo|bar))\s*$`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			actual := tc.command.GetRegex()
+			if actual == nil {
+				t.Errorf("actual regex is nil")
+			} else {
+				if actual.String() != tc.expected {
+					t.Errorf("actual usage does not match expected %s != expected %s", actual, tc.expected)
+				}
+			}
+		})
+	}
+}
+
+func TestCommandGetRegexCached(t *testing.T) {
+	command := plugins.Command{
+		Prefix: "prefix",
+		Name:   "test",
+		Arg: &plugins.CommandArg{
+			Pattern: "foo|bar",
+		},
+	}
+	re1 := command.GetRegex()
+	re2 := command.GetRegex()
+	if re1 != re2 {
+		t.Errorf("command regex should have been cached")
+	}
+}
+
+func TestCommandGetMatches(t *testing.T) {
+	cases := []struct {
+		name     string
+		command  plugins.Command
+		content  string
+		expected []plugins.CommandMatch
+	}{
+		{
+			name: "no prefix and no arg, content match",
+			command: plugins.Command{
+				Name: "test",
+			},
+			content: "/test",
+			expected: []plugins.CommandMatch{{
+				Name: "test",
+			}},
+		},
+		{
+			name: "no prefix and no arg, content not match arg",
+			command: plugins.Command{
+				Name: "test",
+			},
+			content: "/test test",
+		},
+		{
+			name: "no prefix and no arg, content not match prefix",
+			command: plugins.Command{
+				Name: "test",
+			},
+			content: "/re-test",
+		},
+		{
+			name: "no prefix and no arg, content not match command",
+			command: plugins.Command{
+				Name: "test",
+			},
+			content: "/build",
+		},
+		{
+			name: "prefix and no arg, content match",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+			},
+			content: "/prefix-test",
+			expected: []plugins.CommandMatch{{
+				Prefix: "prefix-",
+				Name:   "test",
+			}},
+		},
+		{
+			name: "prefix and no arg, content not match arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+			},
+			content: "/prefix-test test",
+		},
+		{
+			name: "prefix and no arg, content not match prefix",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+			},
+			content: "/wrong-test",
+		},
+		{
+			name: "prefix and no arg, content not match command",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+			},
+			content: "/build",
+		},
+		{
+			name: "no prefix and arg, content match",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+			},
+			content: "/test foo",
+			expected: []plugins.CommandMatch{{
+				Name: "test",
+				Arg:  "foo",
+			}},
+		},
+		{
+			name: "no prefix and arg, content not match arg",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/test bar",
+		},
+		{
+			name: "no prefix and arg, content not match prefix",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/wrong-test foo",
+		},
+		{
+			name: "no prefix and arg, content not match command",
+			command: plugins.Command{
+				Name: "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/build foo",
+		},
+		{
+			name: "prefix and arg, content match with prefix",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+			},
+			content: "/prefix-test foo",
+			expected: []plugins.CommandMatch{{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg:    "foo",
+			}},
+		},
+		{
+			name: "prefix and arg, content match without prefix",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+			},
+			content: "/test foo",
+			expected: []plugins.CommandMatch{{
+				Name: "test",
+				Arg:  "foo",
+			}},
+		},
+		{
+			name: "prefix and arg, content match without arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern:  "foo|bar",
+					Optional: true,
+				},
+			},
+			content: "/test",
+			expected: []plugins.CommandMatch{{
+				Name: "test",
+			}},
+		},
+		{
+			name: "prefix and arg, content match with prefix and without arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern:  "foo|bar",
+					Optional: true,
+				},
+			},
+			content: "/prefix-test",
+			expected: []plugins.CommandMatch{{
+				Prefix: "prefix-",
+				Name:   "test",
+			}},
+		},
+		{
+			name: "prefix and arg, content not match arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/test bar",
+		},
+		{
+			name: "prefix and arg, content not match prefix",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/wrong-test foo",
+		},
+		{
+			name: "prefix and arg, content not match command",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo",
+				},
+			},
+			content: "/build foo",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			matches, err := tc.command.GetMatches(tc.content)
+			if err != nil {
+				t.Errorf("an error has occured %w", err)
+			} else {
+				if !reflect.DeepEqual(tc.expected, matches) {
+					t.Errorf("expected matches %q, but got %q", tc.expected, matches)
+				}
+			}
+		})
+	}
+}
+
+func TestCommandGetHelp(t *testing.T) {
+	cases := []struct {
+		name     string
+		command  plugins.Command
+		expected pluginhelp.Command
+	}{
+		{
+			name: "no prefix and no arg",
+			command: plugins.Command{
+				Name:        "test",
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-]test",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+				},
+			},
+		},
+		{
+			name: "prefix and no arg",
+			command: plugins.Command{
+				Prefix:      "prefix-",
+				Name:        "test",
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and optional arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern:  "foo|bar",
+					Optional: true,
+				},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test [foo|bar]",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and optional arg no pattern",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Optional: true,
+				},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test [anything]",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and optional arg with usage",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Usage:    "arg description",
+					Optional: true,
+				},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test [arg description]",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and arg",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test <foo|bar>",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and arg no pattern",
+			command: plugins.Command{
+				Prefix:      "prefix-",
+				Name:        "test",
+				Arg:         &plugins.CommandArg{},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test <anything>",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "prefix and arg with usage",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Usage: "arg description",
+				},
+				Description: "Some command description",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test <arg description>",
+				Featured:    false,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "featured",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+				Description: "Some command description",
+				Featured:    true,
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test <foo|bar>",
+				Featured:    true,
+				Description: "Some command description",
+				WhoCanUse:   "Anyone",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+		{
+			name: "who can use",
+			command: plugins.Command{
+				Prefix: "prefix-",
+				Name:   "test",
+				Arg: &plugins.CommandArg{
+					Pattern: "foo|bar",
+				},
+				Description: "Some command description",
+				WhoCanUse:   "only admins",
+			},
+			expected: pluginhelp.Command{
+				Usage:       "/[lh-][prefix-]test <foo|bar>",
+				Description: "Some command description",
+				WhoCanUse:   "only admins",
+				Examples: []string{
+					"/test",
+					"/lh-test",
+					"/prefix-test",
+					"/lh-prefix-test",
+				},
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			help := tc.command.GetHelp()
+			if !reflect.DeepEqual(tc.expected, help) {
+				t.Errorf("expected help %v, but got %v", tc.expected, help)
+			}
+		})
+	}
+}
diff --git a/pkg/plugins/dog/dog_test.go b/pkg/plugins/dog/dog_test.go
index ac59a760..b3190fd0 100644
--- a/pkg/plugins/dog/dog_test.go
+++ b/pkg/plugins/dog/dog_test.go
@@ -230,7 +230,7 @@ func TestHttpResponse(t *testing.T) {
 			Logger:            logrus.WithField("plugin", pluginName),
 		}
 		plugin := createPlugin(realPack(ts.URL))
-		err = plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+		err = plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 			return handler(match, agent, *e)
 		})
 		if err != nil {
@@ -357,7 +357,7 @@ func TestDogs(t *testing.T) {
 				Logger:            logrus.WithField("plugin", pluginName),
 			}
 			plugin := createPlugin(fakePack("http://127.0.0.1"))
-			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			})
 			if err != nil {
diff --git a/pkg/plugins/help/help_test.go b/pkg/plugins/help/help_test.go
index 6bf3d8a0..ce836d54 100644
--- a/pkg/plugins/help/help_test.go
+++ b/pkg/plugins/help/help_test.go
@@ -216,12 +216,12 @@ func TestLabel(t *testing.T) {
 				Author:     scm.User{Login: "Alice"},
 			}
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handle(m[1], &fakeSCMProviderClient.Client, logrus.WithField("plugin", pluginName), &fakePruner{}, e); err != nil {
+				if err := handle(m.Prefix != "", m.Name, &fakeSCMProviderClient.Client, logrus.WithField("plugin", pluginName), &fakePruner{}, e); err != nil {
 					t.Fatalf("For case %s, didn't expect error from label test: %v", tc.name, err)
 				}
 			}
diff --git a/pkg/plugins/hold/hold_test.go b/pkg/plugins/hold/hold_test.go
index b220c60f..b1818578 100644
--- a/pkg/plugins/hold/hold_test.go
+++ b/pkg/plugins/hold/hold_test.go
@@ -101,12 +101,12 @@ func TestHandle(t *testing.T) {
 				return tc.hasLabel
 			}
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handle(m[1] == "cancel", scmprovider.ToTestClient(client), logrus.WithField("plugin", pluginName), e, hasLabel); err != nil {
+				if err := handle(m.Arg == "cancel", scmprovider.ToTestClient(client), logrus.WithField("plugin", pluginName), e, hasLabel); err != nil {
 					t.Fatalf("For case %s, didn't expect error from hold: %v", tc.name, err)
 				}
 			}
diff --git a/pkg/plugins/label/label_test.go b/pkg/plugins/label/label_test.go
index f6b00a2a..a5deb0f4 100644
--- a/pkg/plugins/label/label_test.go
+++ b/pkg/plugins/label/label_test.go
@@ -496,7 +496,7 @@ func TestLabel(t *testing.T) {
 					},
 				},
 			}
-			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			})
 			if err != nil {
diff --git a/pkg/plugins/lgtm/lgtm_test.go b/pkg/plugins/lgtm/lgtm_test.go
index 296ef8ce..c5b51dc8 100644
--- a/pkg/plugins/lgtm/lgtm_test.go
+++ b/pkg/plugins/lgtm/lgtm_test.go
@@ -349,12 +349,12 @@ func TestLGTMComment(t *testing.T) {
 				PullRequestComments: fc.PullRequestComments[5],
 			}
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handleGenericComment(m[1] == "cancel", fakeClient, pc, oc, logrus.WithField("plugin", pluginName), fp, *e); err != nil {
+				if err := handleGenericComment(m.Arg == "cancel", fakeClient, pc, oc, logrus.WithField("plugin", pluginName), fp, *e); err != nil {
 					t.Fatalf("For case %s, didn't expect error from label test: %v", tc.name, err)
 				}
 			}
@@ -517,12 +517,12 @@ func TestLGTMCommentWithLGTMNoti(t *testing.T) {
 			PullRequestComments: fc.PullRequestComments[5],
 		}
 		cmd := plugin.Commands[0]
-		matches, err := cmd.GetMatches(e)
+		matches, err := cmd.FilterAndGetMatches(e)
 		if err != nil {
 			t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 		}
 		for _, m := range matches {
-			if err := handleGenericComment(m[1] == "cancel", fakeClient, pc, oc, logrus.WithField("plugin", pluginName), fp, *e); err != nil {
+			if err := handleGenericComment(m.Arg == "cancel", fakeClient, pc, oc, logrus.WithField("plugin", pluginName), fp, *e); err != nil {
 				t.Errorf("For case %s, didn't expect error from lgtmComment: %v", tc.name, err)
 				continue
 			}
diff --git a/pkg/plugins/lifecycle/close_test.go b/pkg/plugins/lifecycle/close_test.go
index 55ed2dd0..2525ebdd 100644
--- a/pkg/plugins/lifecycle/close_test.go
+++ b/pkg/plugins/lifecycle/close_test.go
@@ -195,7 +195,7 @@ func TestCloseComment(t *testing.T) {
 				IssueAuthor: scm.User{Login: "author"},
 			}
 			cmd := plugin.Commands[1]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
diff --git a/pkg/plugins/lifecycle/lifecycle_test.go b/pkg/plugins/lifecycle/lifecycle_test.go
index 2426f01e..ba74be89 100644
--- a/pkg/plugins/lifecycle/lifecycle_test.go
+++ b/pkg/plugins/lifecycle/lifecycle_test.go
@@ -232,12 +232,12 @@ func TestAddLifecycleLabels(t *testing.T) {
 				Action: scm.ActionCreate,
 			}
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handleOne(m[1] != "", "lifecycle/"+m[2], fc, logrus.WithField("plugin", pluginName), e); err != nil {
+				if err := handleOne(m.Prefix != "", "lifecycle/"+m.Arg, fc, logrus.WithField("plugin", pluginName), e); err != nil {
 					t.Fatalf("For case %s, didn't expect error from label test: %v", tc.name, err)
 				}
 			}
diff --git a/pkg/plugins/lifecycle/reopen_test.go b/pkg/plugins/lifecycle/reopen_test.go
index 2131d92e..8d0216c8 100644
--- a/pkg/plugins/lifecycle/reopen_test.go
+++ b/pkg/plugins/lifecycle/reopen_test.go
@@ -17,6 +17,7 @@ limitations under the License.
 package lifecycle
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/jenkins-x/go-scm/scm"
@@ -150,10 +151,11 @@ func TestReopenComment(t *testing.T) {
 				IssueAuthor: scm.User{Login: "author"},
 			}
 			cmd := plugin.Commands[2]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
+			fmt.Println(matches)
 			for range matches {
 				if err := handleReopen(fc, logrus.WithField("plugin", pluginName), e); err != nil {
 					t.Fatalf("For case %s, didn't expect error from label test: %v", tc.name, err)
diff --git a/pkg/plugins/milestone/milestone_test.go b/pkg/plugins/milestone/milestone_test.go
index 9b1045af..1a72b990 100644
--- a/pkg/plugins/milestone/milestone_test.go
+++ b/pkg/plugins/milestone/milestone_test.go
@@ -146,12 +146,12 @@ func TestMilestoneStatus(t *testing.T) {
 			}
 
 			cmd := plugin.Commands[0]
-			matches, err := cmd.GetMatches(e)
+			matches, err := cmd.FilterAndGetMatches(e)
 			if err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 			}
 			for _, m := range matches {
-				if err := handle(m[1], fakeClient, logrus.WithField("plugin", pluginName), e, repoMilestone); err != nil {
+				if err := handle(m.Arg, fakeClient, logrus.WithField("plugin", pluginName), e, repoMilestone); err != nil {
 					t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
 				}
 			}
diff --git a/pkg/plugins/milestonestatus/milestonestatus_test.go b/pkg/plugins/milestonestatus/milestonestatus_test.go
index 6718c3bb..0940d7ba 100644
--- a/pkg/plugins/milestonestatus/milestonestatus_test.go
+++ b/pkg/plugins/milestonestatus/milestonestatus_test.go
@@ -152,7 +152,7 @@ func TestMilestoneStatus(t *testing.T) {
 					RepoMilestone: repoMilestone,
 				},
 			}
-			if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			}); err != nil {
 				t.Fatalf("(%s): Unexpected error from handle: %v.", tc.name, err)
diff --git a/pkg/plugins/override/override_test.go b/pkg/plugins/override/override_test.go
index 2661a54d..b50f8ae9 100644
--- a/pkg/plugins/override/override_test.go
+++ b/pkg/plugins/override/override_test.go
@@ -429,7 +429,7 @@ func TestHandle(t *testing.T) {
 					},
 				},
 			}
-			err := plugin.InvokeCommandHandler(&event, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err := plugin.InvokeCommandHandler(&event, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, event)
 			})
 			switch {
diff --git a/pkg/plugins/pony/pony_test.go b/pkg/plugins/pony/pony_test.go
index a5fecc62..abd9b96f 100644
--- a/pkg/plugins/pony/pony_test.go
+++ b/pkg/plugins/pony/pony_test.go
@@ -230,7 +230,7 @@ func TestHttpResponse(t *testing.T) {
 				Logger:            logrus.WithField("plugin", pluginName),
 			}
 			plugin := createPlugin(realHerd(ts.URL + testcase.path))
-			err = plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err = plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			})
 			if err != nil {
@@ -331,7 +331,7 @@ func TestPonies(t *testing.T) {
 			Logger:            logrus.WithField("plugin", pluginName),
 		}
 		plugin := createPlugin(fakeHerd("pone"))
-		err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+		err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 			return handler(match, agent, *e)
 		})
 		if err != nil {
diff --git a/pkg/plugins/shrug/shurg_test.go b/pkg/plugins/shrug/shrug_test.go
similarity index 97%
rename from pkg/plugins/shrug/shurg_test.go
rename to pkg/plugins/shrug/shrug_test.go
index 6dac50f9..e7b0e052 100644
--- a/pkg/plugins/shrug/shurg_test.go
+++ b/pkg/plugins/shrug/shrug_test.go
@@ -89,7 +89,7 @@ func TestShrugComment(t *testing.T) {
 		if tc.hasShrug {
 			fc.IssueLabelsAdded = []string{"org/repo#5:" + labels.Shrug}
 		}
-		if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+		if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 			return handler(match, agent, *e)
 		}); err != nil {
 			t.Errorf("For case %s, didn't expect error: %v", tc.name, err)
diff --git a/pkg/plugins/stage/stage_test.go b/pkg/plugins/stage/stage_test.go
index 71097744..b7373a2e 100644
--- a/pkg/plugins/stage/stage_test.go
+++ b/pkg/plugins/stage/stage_test.go
@@ -203,7 +203,7 @@ func TestStageLabels(t *testing.T) {
 				SCMProviderClient: &fakeClient.Client,
 				Logger:            logrus.WithField("plugin", pluginName),
 			}
-			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			})
 			switch {
diff --git a/pkg/plugins/trigger/generic-comment_test.go b/pkg/plugins/trigger/generic-comment_test.go
index cf11ed5e..3a654ae2 100644
--- a/pkg/plugins/trigger/generic-comment_test.go
+++ b/pkg/plugins/trigger/generic-comment_test.go
@@ -937,14 +937,14 @@ func TestHandleGenericComment(t *testing.T) {
 			// In some cases handleGenericComment can be called twice for the same event.
 			// For instance on Issue/PR creation and modification.
 			// Let's call it twice to ensure idempotency.
-			if err := plugin.InvokeCommand(&event, func(match []string) error {
-				return handleGenericComment(c, trigger, event)
+			if err := plugin.InvokeCommandHandler(&event, func(_ plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, _ plugins.CommandMatch) error {
+				return handleGenericComment(c, trigger, *e)
 			}); err != nil {
 				t.Fatalf("%s: didn't expect error: %s", tc.name, err)
 			}
 			validate(tc.name, fakeLauncher, g, tc, t)
-			if err := plugin.InvokeCommand(&event, func(match []string) error {
-				return handleGenericComment(c, trigger, event)
+			if err := plugin.InvokeCommandHandler(&event, func(_ plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, _ plugins.CommandMatch) error {
+				return handleGenericComment(c, trigger, *e)
 			}); err != nil {
 				t.Fatalf("%s: didn't expect error: %s", tc.name, err)
 			}
diff --git a/pkg/plugins/yuks/yuks_test.go b/pkg/plugins/yuks/yuks_test.go
index 1d85afa2..3de09dd7 100644
--- a/pkg/plugins/yuks/yuks_test.go
+++ b/pkg/plugins/yuks/yuks_test.go
@@ -73,7 +73,7 @@ func TestJokesMedium(t *testing.T) {
 		Logger:            logrus.WithField("plugin", pluginName),
 	}
 	plugin := createPlugin(realJoke(ts.URL))
-	if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+	if err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 		return handler(match, agent, *e)
 	}); err != nil {
 		t.Errorf("didn't expect error: %v", err)
@@ -174,7 +174,7 @@ func TestJokes(t *testing.T) {
 				Logger:            logrus.WithField("plugin", pluginName),
 			}
 			plugin := createPlugin(tc.joke)
-			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match []string) error {
+			err := plugin.InvokeCommandHandler(e, func(handler plugins.CommandEventHandler, e *scmprovider.GenericCommentEvent, match plugins.CommandMatch) error {
 				return handler(match, agent, *e)
 			})
 			if !tc.shouldError && err != nil {