diff --git a/action.yaml b/action.yaml index fdd28ae..25cf2e0 100644 --- a/action.yaml +++ b/action.yaml @@ -48,6 +48,12 @@ inputs: description: Number of seconds to wait between polling GitHub API for approval status required: false default: '10' + close-issue-means-denial: + description: > + If true, closing the approval issue without an explicit approval + comment will be treated as a denial. Disabled by default. + required: false + default: "false" outputs: issue-number: description: The number of the issue created diff --git a/approval.go b/approval.go index c53aeca..2d320f4 100644 --- a/approval.go +++ b/approval.go @@ -11,23 +11,24 @@ import ( ) type approvalEnvironment struct { - client *github.Client - repoFullName string - repo string - repoOwner string - runID int - approvalIssue *github.Issue - approvalIssueNumber int - issueTitle string - issueBody string - issueApprovers []string - minimumApprovals int - targetRepoOwner string - targetRepoName string - failOnDenial bool + client *github.Client + repoFullName string + repo string + repoOwner string + runID int + approvalIssue *github.Issue + approvalIssueNumber int + issueTitle string + issueBody string + issueApprovers []string + minimumApprovals int + targetRepoOwner string + targetRepoName string + failOnDenial bool + closeIssueMeansDenial bool } -func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) { +func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool, closeIssueMeansDenial bool) (*approvalEnvironment, error) { repoOwnerAndName := strings.Split(repoFullName, "/") if len(repoOwnerAndName) != 2 { return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName) @@ -35,18 +36,19 @@ func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner strin repo := repoOwnerAndName[1] return &approvalEnvironment{ - client: client, - repoFullName: repoFullName, - repo: repo, - repoOwner: repoOwner, - runID: runID, - issueApprovers: approvers, - minimumApprovals: minimumApprovals, - issueTitle: issueTitle, - issueBody: issueBody, - targetRepoOwner: targetRepoOwner, - targetRepoName: targetRepoName, - failOnDenial: failOnDenial, + client: client, + repoFullName: repoFullName, + repo: repo, + repoOwner: repoOwner, + runID: runID, + issueApprovers: approvers, + minimumApprovals: minimumApprovals, + issueTitle: issueTitle, + issueBody: issueBody, + targetRepoOwner: targetRepoOwner, + targetRepoName: targetRepoName, + failOnDenial: failOnDenial, + closeIssueMeansDenial: closeIssueMeansDenial, }, nil } @@ -150,9 +152,9 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool, return false, err } - defer func() { - _ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails. - }() + defer func() { + _ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails. + }() var pairs []string @@ -308,10 +310,10 @@ func splitLongString(input string) []string { currentLength := 0 for i, line := range lines { - lineLength := len(line) + lineLength := len(line) if i < len(lines)-1 { lineLength++ - } + } if currentLength+lineLength > maxLength { if currentChunk.Len() > 0 { @@ -344,4 +346,3 @@ func splitLongString(input string) []string { } return result } - diff --git a/approval_test.go b/approval_test.go index 086a6fa..421cd22 100644 --- a/approval_test.go +++ b/approval_test.go @@ -481,9 +481,9 @@ func TestSaveOutput(t *testing.T) { minimumApprovals: 0, } - if err := os.Remove(testCase.env_github_output); err != nil && !os.IsNotExist(err) { - t.Fatalf("failed to remove file: %v", err) - } + if err := os.Remove(testCase.env_github_output); err != nil && !os.IsNotExist(err) { + t.Fatalf("failed to remove file: %v", err) + } actual, err := a.SetActionOutputs(nil) diff --git a/constants.go b/constants.go index 69c509e..20eaa1c 100644 --- a/constants.go +++ b/constants.go @@ -26,6 +26,7 @@ const ( envVarTargetRepoOwner string = "INPUT_TARGET-REPOSITORY-OWNER" envVarTargetRepo string = "INPUT_TARGET-REPOSITORY" envVarPollingIntervalSeconds string = "INPUT_POLLING-INTERVAL-SECONDS" + envVarCloseIssueMeansDenial string = "INPUT_CLOSE-ISSUE-MEANS-DENIAL" ) var ( diff --git a/main.go b/main.go index 666cfb6..8d1916b 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,7 @@ func handleInterrupt(ctx context.Context, client *github.Client, apprv *approval func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client, pollingInterval time.Duration) chan int { channel := make(chan int) go func() { + loop_ctr := 0 for { comments, _, err := client.Issues.ListComments(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueListCommentsOptions{}) if err != nil { @@ -119,6 +120,58 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie channel <- 1 close(channel) return + case approvalStatusPending: + if apprv.closeIssueMeansDenial { + // Loop counter to make an API call only once per 10 interation, intention: avoid github rate limiting and reduce api cost and stress. + if loop_ctr < 10 { + loop_ctr += 1 + continue + } + loop_ctr = 0 + + issue, _, err := client.Issues.Get( + + ctx, + apprv.targetRepoOwner, + apprv.targetRepoName, + apprv.approvalIssueNumber, + ) + if err != nil { + fmt.Printf("error fetching issue state: %v\n", err) + channel <- 1 + close(channel) + + return + } + + if issue.GetState() == "closed" { + + // Issue was closed externally without any approval/denial comment. + // Treat as denial per user configuration. + denyComment := "Issue was closed without approval. Treating closure as denial" + + if !apprv.failOnDenial { + denyComment += " but continuing workflow." + } else { + denyComment += " and failing workflow." + } + fmt.Println(denyComment) + // Issue is already closed — add comment only, skip re-closing + _, _, err := client.Issues.CreateComment( + ctx, + apprv.targetRepoOwner, + apprv.targetRepoName, + apprv.approvalIssueNumber, + &github.IssueComment{Body: &denyComment}, + ) + if err != nil { + fmt.Printf("error commenting on closed issue: %v\n", err) + } + channel <- 1 + close(channel) + return + } + } } time.Sleep(pollingInterval) @@ -237,6 +290,16 @@ func main() { } } + closeIssueMeansDenial := false + closeIssueMeansDenialRaw := os.Getenv(envVarCloseIssueMeansDenial) + if closeIssueMeansDenialRaw != "" { + closeIssueMeansDenial, err = strconv.ParseBool(closeIssueMeansDenialRaw) + if err != nil { + fmt.Printf("error parsing close-issue-means-denial: %v\n", err) + os.Exit(1) + } + } + pollingInterval := defaultPollingInterval pollingIntervalSecondsRaw := os.Getenv(envVarPollingIntervalSeconds) if pollingIntervalSecondsRaw != "" { @@ -274,7 +337,7 @@ func main() { } } - apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial) + apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial, closeIssueMeansDenial) if err != nil { fmt.Printf("error creating approval environment: %v\n", err) os.Exit(1) @@ -286,9 +349,9 @@ func main() { os.Exit(1) } - outputs := map[string]string { + outputs := map[string]string{ "issue-number": fmt.Sprintf("%d", apprv.approvalIssueNumber), - "issue-url": apprv.approvalIssue.GetHTMLURL(), + "issue-url": apprv.approvalIssue.GetHTMLURL(), } _, err = apprv.SetActionOutputs(outputs) if err != nil { @@ -305,15 +368,15 @@ func main() { case exitCode := <-commentLoopChannel: approvalStatus := "" - if (!failOnDenial && exitCode == 1) { + if !failOnDenial && exitCode == 1 { approvalStatus = "denied" exitCode = 0 - } else if (exitCode == 1) { + } else if exitCode == 1 { approvalStatus = "denied" } else { approvalStatus = "approved" } - outputs := map[string]string { + outputs := map[string]string{ "approval-status": approvalStatus, } if _, err := apprv.SetActionOutputs(outputs); err != nil {