diff --git a/.gitignore b/.gitignore index 4c49bd7..a9ad188 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +.idea/ diff --git a/README.md b/README.md index 704993f..25218e9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yaml b/action.yaml index e4666fd..48f13cf 100644 --- a/action.yaml +++ b/action.yaml @@ -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 diff --git a/approval.go b/approval.go index 101445d..a720aef 100644 --- a/approval.go +++ b/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 } diff --git a/approval_test.go b/approval_test.go index 4ff0c83..afc215f 100644 --- a/approval_test.go +++ b/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] + } }) } } diff --git a/constants.go b/constants.go index a57d5ad..03e4b4e 100644 --- a/constants.go +++ b/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 +}