diff --git a/Makefile b/Makefile index 420c847..6db7571 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -IMAGE_REPO=ghcr.io/trstringer/manual-approval +IMAGE_REPO=ghcr.io/snskarora/manual-approval +VERSION=latest TARGET_PLATFORM=linux/amd64,linux/arm64,linux/arm/v8 .PHONY: tidy diff --git a/action.yaml b/action.yaml index fdd28ae..a037b5f 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 @@ -57,4 +63,4 @@ outputs: description: The status of the approval ("approved" or "denied") runs: using: docker - image: docker://ghcr.io/trstringer/manual-approval:1.12.0 + image: docker://ghcr.io/snskarora/manual-approval:latest diff --git a/approval.go b/approval.go index 6391ad2..d364896 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 } @@ -106,15 +108,15 @@ func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error { } a.approvalIssueNumber = a.approvalIssue.GetNumber() - bodyChunks := splitLongString(a.issueBody) - for _, chunk := range bodyChunks { - _, _, err = a.client.Issues.CreateComment(ctx, a.targetRepoOwner, a.targetRepoName, *a.approvalIssue.Number, &github.IssueComment{ - Body: &chunk, - }) - if err != nil { - return fmt.Errorf("failed to add comment chunk to issue: %w", err) - } - } + bodyChunks := splitLongString(a.issueBody) + for _, chunk := range bodyChunks { + _, _, err = a.client.Issues.CreateComment(ctx, a.targetRepoOwner, a.targetRepoName, *a.approvalIssue.Number, &github.IssueComment{ + Body: &chunk, + }) + if err != nil { + return fmt.Errorf("failed to add comment chunk to issue: %w", err) + } + } fmt.Printf("Issue created: %s\n", a.approvalIssue.GetHTMLURL()) return nil @@ -131,9 +133,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 @@ -289,10 +291,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 { @@ -325,4 +327,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 89305fd..d419669 100644 --- a/main.go +++ b/main.go @@ -97,6 +97,51 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie } channel <- 1 close(channel) + case approvalStatusPending: + if apprv.closeIssueMeansDenial { + 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) @@ -198,6 +243,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 != "" { @@ -235,7 +290,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) @@ -247,9 +302,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 { @@ -266,15 +321,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 {