Add issue approver output and enhance approval logic

- Updated action outputs to include the GitHub username of the approver.
- Modified approval logic to return the approver's username upon approval or denial.
- Enhanced tests to validate the new approver output.

Rename 'issue-approver' to 'issue-responder' for clarity in approval process
This commit is contained in:
Felipe Lambert 2026-01-26 11:19:45 -03:00
parent d8289abf87
commit bac46d08b3
7 changed files with 66 additions and 27 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.env
.idea/
go.sum
output.txt

View file

@ -1,5 +1,6 @@
IMAGE_REPO=ghcr.io/trstringer/manual-approval
TARGET_PLATFORM=linux/amd64,linux/arm64,linux/arm/v8
GO_DOCKER_IMAGE=golang:1.24
.PHONY: tidy
tidy:
@ -28,6 +29,10 @@ build_push:
test:
go test -v .
.PHONY: test_docker
test_docker:
docker run --rm -v $$(pwd):/app -w /app $(GO_DOCKER_IMAGE) sh -c "go mod tidy && go test -v ."
.PHONY: lint
lint:
docker run --rm -v $$(pwd):/app -w /app golangci/golangci-lint:v2.1.6 golangci-lint run -v

View file

@ -82,7 +82,10 @@ The file method works unless the file itself is so big that after breaking it in
### Outputs
* `issue-number` is a string that indicates the number of the issue created.
* `issue-url` is a string that indicates the URL of the issue created.
* `approval-status` is a string that indicates the final status of the approval. This will be either `approved` or `denied`.
* `issue-responder` is a string that indicates the GitHub username that approved or denied the request.
### Creating Issues in a different repository

View file

@ -55,6 +55,8 @@ outputs:
description: The URL of the issue created
approval-status:
description: The status of the approval ("approved" or "denied")
issue-responder:
description: The GitHub username that approved or denied the request
runs:
using: docker
image: docker://ghcr.io/trstringer/manual-approval:1.12.0

View file

@ -160,7 +160,7 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool,
return true, nil
}
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, string, error) {
remainingApprovers := make([]string, len(approvers))
copy(remainingApprovers, approvers)
@ -178,11 +178,11 @@ func approvalFromComments(comments []*github.IssueComment, approvers []string, m
commentBody := comment.GetBody()
isApprovalComment, err := isApproved(commentBody)
if err != nil {
return approvalStatusPending, err
return approvalStatusPending, "", err
}
if isApprovalComment {
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
return approvalStatusApproved, nil
return approvalStatusApproved, commentUser, nil
}
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
@ -191,14 +191,14 @@ func approvalFromComments(comments []*github.IssueComment, approvers []string, m
isDenialComment, err := isDenied(commentBody)
if err != nil {
return approvalStatusPending, err
return approvalStatusPending, "", err
}
if isDenialComment {
return approvalStatusDenied, nil
return approvalStatusDenied, commentUser, nil
}
}
return approvalStatusPending, nil
return approvalStatusPending, "", nil
}
func approversIndex(approvers []string, name string) int {
@ -325,4 +325,3 @@ func splitLongString(input string) []string {
}
return result
}

View file

@ -25,6 +25,7 @@ func TestApprovalFromComments(t *testing.T) {
approvers []string
minimumApprovals int
expectedStatus approvalStatus
expectedResponder string
}{
{
name: "single_approver_single_comment_approved",
@ -36,6 +37,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1},
expectedStatus: approvalStatusApproved,
expectedResponder: login1,
},
{
name: "single_approver_single_comment_denied",
@ -47,6 +49,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1},
expectedStatus: approvalStatusDenied,
expectedResponder: login1,
},
{
name: "single_approver_single_comment_pending",
@ -58,6 +61,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1},
expectedStatus: approvalStatusPending,
expectedResponder: "",
},
{
name: "single_approver_multi_comment_approved",
@ -73,6 +77,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1},
expectedStatus: approvalStatusApproved,
expectedResponder: login1,
},
{
name: "multi_approver_approved",
@ -88,6 +93,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1, login2},
expectedStatus: approvalStatusApproved,
expectedResponder: login2,
},
{
name: "multi_approver_mixed",
@ -103,6 +109,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1, login2},
expectedStatus: approvalStatusPending,
expectedResponder: "",
},
{
name: "multi_approver_denied",
@ -118,6 +125,7 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1, login2},
expectedStatus: approvalStatusDenied,
expectedResponder: login1,
},
{
name: "multi_approver_minimum_one_approval",
@ -134,6 +142,7 @@ func TestApprovalFromComments(t *testing.T) {
approvers: []string{login1, login2},
expectedStatus: approvalStatusApproved,
minimumApprovals: 1,
expectedResponder: login2,
},
{
name: "multi_approver_minimum_two_approvals",
@ -150,6 +159,7 @@ func TestApprovalFromComments(t *testing.T) {
approvers: []string{login1, login2, login3},
expectedStatus: approvalStatusApproved,
minimumApprovals: 2,
expectedResponder: login2,
},
{
name: "multi_approver_approvals_less_than_minimum",
@ -162,6 +172,7 @@ func TestApprovalFromComments(t *testing.T) {
approvers: []string{login1, login2, login3},
expectedStatus: approvalStatusPending,
minimumApprovals: 2,
expectedResponder: "",
},
{
name: "single_approver_single_comment_approved_case_insensitive",
@ -173,18 +184,23 @@ func TestApprovalFromComments(t *testing.T) {
},
approvers: []string{login1},
expectedStatus: approvalStatusApproved,
expectedResponder: login1u,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actual, err := approvalFromComments(testCase.comments, testCase.approvers, testCase.minimumApprovals)
actualStatus, actualResponder, err := approvalFromComments(testCase.comments, testCase.approvers, testCase.minimumApprovals)
if err != nil {
t.Fatalf("error getting approval from comments: %v", err)
}
if actual != testCase.expectedStatus {
t.Fatalf("actual %s, expected %s", actual, testCase.expectedStatus)
if actualStatus != testCase.expectedStatus {
t.Fatalf("actual %s, expected %s", actualStatus, testCase.expectedStatus)
}
if actualResponder != testCase.expectedResponder {
t.Fatalf("actual responder %s, expected %s", actualResponder, testCase.expectedResponder)
}
})
}

47
main.go
View file

@ -32,21 +32,27 @@ 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)
type commentLoopResult struct {
exitCode int
responder string
status approvalStatus
}
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client, pollingInterval time.Duration) chan commentLoopResult {
channel := make(chan commentLoopResult)
go func() {
for {
comments, _, err := client.Issues.ListComments(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueListCommentsOptions{})
if err != nil {
fmt.Printf("error getting comments: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
approved, responder, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
if err != nil {
fmt.Printf("error getting approval from comments: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
fmt.Printf("Workflow status: %s\n", approved)
@ -59,16 +65,16 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
_, _, err = client.Issues.Edit(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
if err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
channel <- 0
channel <- commentLoopResult{exitCode: 0, responder: responder, status: approvalStatusApproved}
fmt.Println("Workflow manual approval completed")
close(channel)
case approvalStatusDenied:
@ -86,16 +92,16 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
_, _, err = client.Issues.Edit(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
if err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
channel <- commentLoopResult{exitCode: 1}
close(channel)
}
channel <- 1
channel <- commentLoopResult{exitCode: 1, responder: responder, status: approvalStatusDenied}
close(channel)
}
@ -263,25 +269,32 @@ func main() {
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client, pollingInterval)
select {
case exitCode := <-commentLoopChannel:
case result := <-commentLoopChannel:
approvalStatus := ""
if (!failOnDenial && exitCode == 1) {
if result.status == approvalStatusApproved {
approvalStatus = "approved"
} else if result.status == approvalStatusDenied {
approvalStatus = "denied"
exitCode = 0
} else if (exitCode == 1) {
} else if (!failOnDenial && result.exitCode == 1) {
approvalStatus = "denied"
} else if (result.exitCode == 1) {
approvalStatus = "denied"
} else {
approvalStatus = "approved"
}
outputs := map[string]string {
"approval-status": approvalStatus,
"issue-responder": result.responder,
}
if _, err := apprv.SetActionOutputs(outputs); err != nil {
fmt.Printf("error setting action output: %v\n", err)
exitCode = 1
result.exitCode = 1
}
os.Exit(exitCode)
if !failOnDenial && result.exitCode == 1 {
result.exitCode = 0
}
os.Exit(result.exitCode)
case <-killSignalChannel:
handleInterrupt(ctx, client, apprv)
os.Exit(1)