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:
Brandon Ward 2023-02-16 18:20:43 -07:00 committed by GitHub
parent 948dfe9537
commit 639442a9fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 220 additions and 83 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.env
.idea/

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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]
}
})
}
}

View file

@ -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
}