feat(approval-flow): treat issue closure as denial

This commit is contained in:
sunny 2026-04-05 20:28:35 +00:00
parent d8289abf87
commit f3ed733103
6 changed files with 117 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

67
main.go
View file

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