Feature: Allow Custom Approval or Denial Words (#66)
* Add support for custom approval and denial words * Work out some regex nuances with GitHub
This commit is contained in:
parent
948dfe9537
commit
639442a9fa
6 changed files with 220 additions and 83 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.env
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ steps:
|
|||
issue-title: "Deploying v1.3.5 to prod from staging"
|
||||
issue-body: "Please approve or deny the deployment of version v1.3.5."
|
||||
exclude-workflow-initiator-as-approver: false
|
||||
additional-approved-words: ''
|
||||
additional-denied-words: ''
|
||||
```
|
||||
|
||||
- `approvers` is a comma-delimited list of all required approvers. An approver can either be a user or an org team. (*Note: Required approvers must have the ability to be set as approvers in the repository. If you add an approver that doesn't have this permission then you would receive an HTTP/402 Validation Failed error when running this action*)
|
||||
|
|
@ -41,6 +43,13 @@ steps:
|
|||
- `issue-title` is a string that will be appended to the title of the issue.
|
||||
- `issue-body` is a string that will be prepended to the body of the issue.
|
||||
- `exclude-workflow-initiator-as-approver` is a boolean that indicates if the workflow initiator (determined by the `GITHUB_ACTOR` environment variable) should be filtered from the final list of approvers. This is optional and defaults to `false`. Set this to `true` to prevent users in the `approvers` list from being able to self-approve workflows.
|
||||
- `additional-approved-words` is a comma separated list of strings to expand the dictionary of words that indicate approval. This is optional and defaults to an empty string.
|
||||
- `additional-denied-words` is a comma separated list of strings to expand the dictionary of words that indicate denial. This is optional and defaults to an empty string.
|
||||
|
||||
### Using Custom Words
|
||||
|
||||
GitHub has a rich library of emojis, and these all work in additional approved words or denied words. Some values GitHub will store in their text version - i.e. `:shipit:`. Other emojis, GitHub will store in their unicode emoji form, like ✅.
|
||||
For a seamless experience, it is recommended that you add the custom words to a GitHub comment, and then copy it back out of the comment into your actions configuration yaml.
|
||||
|
||||
## Org team approver
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ inputs:
|
|||
exclude-workflow-initiator-as-approver:
|
||||
description: Whether or not to filter out the user who initiated the workflow as an approver if they are in the approvers list
|
||||
default: false
|
||||
additional-approved-words:
|
||||
description: Comma separated list of words that can be used to approve beyond the defaults.
|
||||
default: ''
|
||||
additional-denied-words:
|
||||
description: Comma separated list of words that can be used to deny beyond the defaults.
|
||||
default: ''
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://ghcr.io/trstringer/manual-approval:1.8.0
|
||||
|
|
|
|||
10
approval.go
10
approval.go
|
|
@ -145,10 +145,14 @@ func approversIndex(approvers []string, name string) int {
|
|||
|
||||
func isApproved(commentBody string) (bool, error) {
|
||||
for _, approvedWord := range approvedWords {
|
||||
matched, err := regexp.MatchString(fmt.Sprintf("(?i)^%s[.!]*\n*$", approvedWord), commentBody)
|
||||
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", approvedWord))
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing. %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
matched := re.MatchString(commentBody)
|
||||
|
||||
if matched {
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -159,10 +163,12 @@ func isApproved(commentBody string) (bool, error) {
|
|||
|
||||
func isDenied(commentBody string) (bool, error) {
|
||||
for _, deniedWord := range deniedWords {
|
||||
matched, err := regexp.MatchString(fmt.Sprintf("(?i)^%s[.!]*\n*$", deniedWord), commentBody)
|
||||
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", deniedWord))
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing. %v", err)
|
||||
return false, err
|
||||
}
|
||||
matched := re.MatchString(commentBody)
|
||||
if matched {
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
248
approval_test.go
248
approval_test.go
|
|
@ -176,84 +176,130 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
|
||||
func TestApprovedCommentBody(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
commentBody string
|
||||
isSuccess bool
|
||||
name string
|
||||
commentBody string
|
||||
isSuccess bool
|
||||
customApprovalWord string
|
||||
}{
|
||||
{
|
||||
name: "approved_lowercase_no_punctuation",
|
||||
commentBody: "approved",
|
||||
isSuccess: true,
|
||||
name: "approved_lowercase_no_punctuation",
|
||||
commentBody: "approved",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approve_lowercase_no_punctuation",
|
||||
commentBody: "approve",
|
||||
isSuccess: true,
|
||||
name: "approve_lowercase_no_punctuation",
|
||||
commentBody: "approve",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "lgtm_lowercase_no_punctuation",
|
||||
commentBody: "lgtm",
|
||||
isSuccess: true,
|
||||
name: "lgtm_lowercase_no_punctuation",
|
||||
commentBody: "lgtm",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "yes_lowercase_no_punctuation",
|
||||
commentBody: "yes",
|
||||
isSuccess: true,
|
||||
name: "yes_lowercase_no_punctuation",
|
||||
commentBody: "yes",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approve_uppercase_no_punctuation",
|
||||
commentBody: "APPROVE",
|
||||
isSuccess: true,
|
||||
name: "approve_uppercase_no_punctuation",
|
||||
commentBody: "APPROVE",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_titlecase_period",
|
||||
commentBody: "Approved.",
|
||||
isSuccess: true,
|
||||
name: "approved_titlecase_period",
|
||||
commentBody: "Approved.",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_titlecase_exclamation",
|
||||
commentBody: "Approved!",
|
||||
isSuccess: true,
|
||||
name: "approved_titlecase_exclamation",
|
||||
commentBody: "Approved!",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_titlecase_multi_exclamation",
|
||||
commentBody: "Approved!!",
|
||||
isSuccess: true,
|
||||
name: "approved_titlecase_multi_exclamation",
|
||||
commentBody: "Approved!!",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_titlecase_question",
|
||||
commentBody: "Approved?",
|
||||
isSuccess: false,
|
||||
name: "approved_titlecase_question",
|
||||
commentBody: "Approved?",
|
||||
isSuccess: false,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "sentence_with_keyword",
|
||||
commentBody: "should i approve this",
|
||||
isSuccess: false,
|
||||
name: "sentence_with_keyword",
|
||||
commentBody: "should i approve this",
|
||||
isSuccess: false,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "sentence_without_keyword",
|
||||
commentBody: "this is just some random comment",
|
||||
isSuccess: false,
|
||||
name: "sentence_without_keyword",
|
||||
commentBody: "this is just some random comment",
|
||||
isSuccess: false,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_with_newline",
|
||||
commentBody: "approved\n",
|
||||
isSuccess: true,
|
||||
name: "approved_with_newline",
|
||||
commentBody: "approved\n",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_with_exclamation_newline",
|
||||
commentBody: "approved!\n",
|
||||
isSuccess: true,
|
||||
name: "approved_with_exclamation_newline",
|
||||
commentBody: "approved!\n",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_with_multi_exclamation_multi_newline",
|
||||
commentBody: "approved!!!\n\n\n",
|
||||
isSuccess: true,
|
||||
name: "approved_with_multi_exclamation_multi_newline",
|
||||
commentBody: "approved!!!\n\n\n",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "",
|
||||
},
|
||||
{
|
||||
name: "approved_with_custom_approval_word",
|
||||
commentBody: "shipit",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "shipit",
|
||||
},
|
||||
{
|
||||
name: "approved_with_github_emoji_syntax",
|
||||
commentBody: ":shipit:",
|
||||
isSuccess: true,
|
||||
customApprovalWord: ":shipit:",
|
||||
},
|
||||
{
|
||||
name: "approved_with_custom_hashtag",
|
||||
commentBody: "#shipit",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "#shipit",
|
||||
},
|
||||
{
|
||||
name: "approved_with_actual_emoji_✅",
|
||||
commentBody: "✅ ",
|
||||
isSuccess: true,
|
||||
customApprovalWord: "✅",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
// before each
|
||||
word := testCase.customApprovalWord
|
||||
if len(word) > 0 {
|
||||
approvedWords = append(approvedWords, word)
|
||||
}
|
||||
|
||||
// test
|
||||
actual, err := isApproved(testCase.commentBody)
|
||||
if err != nil {
|
||||
t.Fatalf("error getting approval: %v", err)
|
||||
|
|
@ -261,70 +307,111 @@ func TestApprovedCommentBody(t *testing.T) {
|
|||
if actual != testCase.isSuccess {
|
||||
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
||||
}
|
||||
|
||||
// after each
|
||||
if len(word) > 0 {
|
||||
approvedWords = approvedWords[:len(approvedWords)-1]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedCommentBody(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
commentBody string
|
||||
isSuccess bool
|
||||
name string
|
||||
commentBody string
|
||||
isSuccess bool
|
||||
customDenialWord string
|
||||
}{
|
||||
{
|
||||
name: "denied_lowercase_no_punctuation",
|
||||
commentBody: "denied",
|
||||
isSuccess: true,
|
||||
name: "denied_lowercase_no_punctuation",
|
||||
commentBody: "denied",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "deny_lowercase_no_punctuation",
|
||||
commentBody: "deny",
|
||||
isSuccess: true,
|
||||
name: "deny_lowercase_no_punctuation",
|
||||
commentBody: "deny",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "no_lowercase_no_punctuation",
|
||||
commentBody: "no",
|
||||
isSuccess: true,
|
||||
name: "no_lowercase_no_punctuation",
|
||||
commentBody: "no",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "deny_uppercase_no_punctuation",
|
||||
commentBody: "DENY",
|
||||
isSuccess: true,
|
||||
name: "deny_uppercase_no_punctuation",
|
||||
commentBody: "DENY",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "denied_titlecase_period",
|
||||
commentBody: "Denied.",
|
||||
isSuccess: true,
|
||||
name: "denied_titlecase_period",
|
||||
commentBody: "Denied.",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "denied_titlecase_exclamation",
|
||||
commentBody: "Denied!",
|
||||
isSuccess: true,
|
||||
name: "denied_titlecase_exclamation",
|
||||
commentBody: "Denied!",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "deny_titlecase_question",
|
||||
commentBody: "Deny?",
|
||||
isSuccess: false,
|
||||
name: "deny_titlecase_question",
|
||||
commentBody: "Deny?",
|
||||
isSuccess: false,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "sentence_with_keyword",
|
||||
commentBody: "should i deny this",
|
||||
isSuccess: false,
|
||||
name: "sentence_with_keyword",
|
||||
commentBody: "should i deny this",
|
||||
isSuccess: false,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "sentence_without_keyword",
|
||||
commentBody: "this is just some random comment",
|
||||
isSuccess: false,
|
||||
name: "sentence_without_keyword",
|
||||
commentBody: "this is just some random comment",
|
||||
isSuccess: false,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "denied_with_newline",
|
||||
commentBody: "denied\n",
|
||||
isSuccess: true,
|
||||
name: "denied_with_newline",
|
||||
commentBody: "denied\n",
|
||||
isSuccess: true,
|
||||
customDenialWord: "",
|
||||
},
|
||||
{
|
||||
name: "denied_with_custom_word",
|
||||
commentBody: "naw",
|
||||
isSuccess: true,
|
||||
customDenialWord: "naw",
|
||||
},
|
||||
{
|
||||
name: "denied_with_github_emoji",
|
||||
commentBody: ":no_entry_sign: ",
|
||||
isSuccess: true,
|
||||
customDenialWord: ":no_entry_sign:",
|
||||
},
|
||||
{
|
||||
name: "denied_with_hashtag",
|
||||
commentBody: "#noway",
|
||||
isSuccess: true,
|
||||
customDenialWord: "#noway",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
// before each
|
||||
word := testCase.customDenialWord
|
||||
if len(word) > 0 {
|
||||
deniedWords = append(deniedWords, word)
|
||||
}
|
||||
|
||||
// test
|
||||
actual, err := isDenied(testCase.commentBody)
|
||||
if err != nil {
|
||||
t.Fatalf("error getting approval: %v", err)
|
||||
|
|
@ -332,6 +419,11 @@ func TestDeniedCommentBody(t *testing.T) {
|
|||
if actual != testCase.isSuccess {
|
||||
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
||||
}
|
||||
|
||||
// after each
|
||||
if len(word) > 0 {
|
||||
deniedWords = deniedWords[:len(deniedWords)-1]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
constants.go
29
constants.go
|
|
@ -1,6 +1,10 @@
|
|||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pollingInterval time.Duration = 10 * time.Second
|
||||
|
|
@ -15,9 +19,28 @@ const (
|
|||
envVarIssueTitle string = "INPUT_ISSUE-TITLE"
|
||||
envVarIssueBody string = "INPUT_ISSUE-BODY"
|
||||
envVarExcludeWorkflowInitiatorAsApprover string = "INPUT_EXCLUDE-WORKFLOW-INITIATOR-AS-APPROVER"
|
||||
envVarAdditionalApprovedWords string = "INPUT_ADDITIONAL-APPROVED-WORDS"
|
||||
envVarAdditionalDeniedWords string = "INPUT_ADDITIONAL-DENIED-WORDS"
|
||||
)
|
||||
|
||||
var (
|
||||
approvedWords = []string{"approved", "approve", "lgtm", "yes"}
|
||||
deniedWords = []string{"denied", "deny", "no"}
|
||||
additionalApprovedWords = readAdditionalWords(envVarAdditionalApprovedWords)
|
||||
additionalDeniedWords = readAdditionalWords(envVarAdditionalDeniedWords)
|
||||
|
||||
approvedWords = append([]string{"approved", "approve", "lgtm", "yes"}, additionalApprovedWords...)
|
||||
deniedWords = append([]string{"denied", "deny", "no"}, additionalDeniedWords...)
|
||||
)
|
||||
|
||||
func readAdditionalWords(envVar string) []string {
|
||||
rawValue := strings.TrimSpace(os.Getenv(envVar))
|
||||
if len(rawValue) == 0 {
|
||||
// Nothing else to do here.
|
||||
return []string{}
|
||||
}
|
||||
slicedWords := strings.Split(rawValue, ",")
|
||||
for i := range slicedWords {
|
||||
// no leading or trailing spaces in user provided words.
|
||||
slicedWords[i] = strings.TrimSpace(slicedWords[i])
|
||||
}
|
||||
return slicedWords
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue