manual-approval/main.go
jaime merino 9e098fbb6e - Fixed Approver Expansion: Repaired the broken expandGroupFromUser function in approvers.go. It now correctly utilizes the Forgejo SDK to search for teams within an organization and expand them into individual users, while respecting the option to exclude the workflow initiator.
- Removed GitHub Dependencies: Excised the unused newGithubClient and its associated dependencies (google/go-github and oauth2) from main.go and go.mod.

   - Project Infrastructure Updates:
       - Updated Dockerfile to use golang:1.25 for consistency with go.mod.
       - Modified action.yaml to run from the local Dockerfile, ensuring that the Forgejo-specific logic is utilized.
   - Validation: Verified all changes through a successful build and by running the full test suite, all of which passed.
2026-04-22 15:28:23 +02:00

300 lines
8.6 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"time"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
)
// patchIssueState closes or otherwise updates an issue's state without decoding
// the response body into a github.Issue. go-github's Issues.Edit decodes the
// response into github.Issue, which fails against Forgejo because its issue
// response embeds "repository.owner" as a plain string rather than a User object.
func patchIssueState(_ context.Context, client *forgejo.Client, owner, repo string, number int, state string) error {
issueState := forgejo.StateType(state)
_, _, err := client.EditIssue(owner, repo, int64(number), forgejo.EditIssueOption{
State: &issueState,
})
return err
}
func handleInterrupt(ctx context.Context, client *forgejo.Client, apprv *approvalEnvironment) {
newState := "closed"
closeComment := "Workflow cancelled, closing issue."
fmt.Println(closeComment)
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
fmt.Printf("error closing issue: %v\n", err)
return
}
}
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *forgejo.Client, pollingInterval time.Duration) chan int {
channel := make(chan int)
go func() {
for {
comments, _, err := client.ListIssueComments(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.ListIssueCommentOptions{})
if err != nil {
fmt.Printf("error getting comments: %v\n", err)
channel <- 1
close(channel)
return
}
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
if err != nil {
fmt.Printf("error getting approval from comments: %v\n", err)
channel <- 1
close(channel)
return
}
fmt.Printf("Workflow status: %s\n", approved)
switch approved {
case approvalStatusApproved:
newState := "closed"
closeComment := fmt.Sprintf("The required number of approvals (%d) has been met; continuing workflow and closing this issue.", apprv.minimumApprovals)
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
close(channel)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
close(channel)
return
}
channel <- 0
fmt.Println("Workflow manual approval completed")
close(channel)
return
case approvalStatusDenied:
newState := "closed"
closeComment := "Request denied. Closing issue "
if !apprv.failOnDenial {
closeComment += "but continuing"
} else {
closeComment += "and failing"
}
closeComment += " workflow."
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
close(channel)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
close(channel)
return
}
channel <- 1
close(channel)
return
}
time.Sleep(pollingInterval)
}
}()
return channel
}
func newForgejoClient() (*forgejo.Client, error) {
token := os.Getenv(envVarToken)
serverUrl := os.Getenv("GITHUB_SERVER_URL")
if serverUrl == "" {
return nil, fmt.Errorf("GITHUB_SERVER_URL must be set for Forgejo client")
}
client, err := forgejo.NewClient(serverUrl, forgejo.SetToken(token))
if err != nil {
return nil, fmt.Errorf("failed to create forgejo client: %w", err)
}
return client, nil
}
func validateInput() error {
missingEnvVars := []string{}
if os.Getenv(envVarRepoFullName) == "" {
missingEnvVars = append(missingEnvVars, envVarRepoFullName)
}
if os.Getenv(envVarRunID) == "" {
missingEnvVars = append(missingEnvVars, envVarRunID)
}
if os.Getenv(envVarRepoOwner) == "" {
missingEnvVars = append(missingEnvVars, envVarRepoOwner)
}
if os.Getenv(envVarToken) == "" {
missingEnvVars = append(missingEnvVars, envVarToken)
}
if os.Getenv(envVarApprovers) == "" {
missingEnvVars = append(missingEnvVars, envVarApprovers)
}
if len(missingEnvVars) > 0 {
return fmt.Errorf("missing env vars: %v", missingEnvVars)
}
return nil
}
func main() {
if err := validateInput(); err != nil {
fmt.Printf("%v\n", err)
os.Exit(1)
}
targetRepoName := os.Getenv(envVarTargetRepo)
targetRepoOwner := os.Getenv(envVarTargetRepoOwner)
repoFullName := os.Getenv(envVarRepoFullName)
runID, err := strconv.Atoi(os.Getenv(envVarRunID))
if err != nil {
fmt.Printf("error getting runID: %v\n", err)
os.Exit(1)
}
repoOwner := os.Getenv(envVarRepoOwner)
if targetRepoName == "" || targetRepoOwner == "" {
parts := strings.SplitN(repoFullName, "/", 2)
targetRepoOwner = parts[0]
targetRepoName = parts[1]
}
ctx := context.Background()
forgejoClient, err := newForgejoClient()
if err != nil {
fmt.Printf("error connecting to forgejo server: %v\n", err)
os.Exit(1)
}
approvers, err := retrieveApprovers(forgejoClient, repoOwner)
if err != nil {
fmt.Printf("error retrieving approvers: %v\n", err)
os.Exit(1)
}
failOnDenial := true
failOnDenialRaw := os.Getenv(envVarFailOnDenial)
if failOnDenialRaw != "" {
failOnDenial, err = strconv.ParseBool(failOnDenialRaw)
if err != nil {
fmt.Printf("error parsing fail on denial: %v\n", err)
os.Exit(1)
}
}
pollingInterval := defaultPollingInterval
pollingIntervalSecondsRaw := os.Getenv(envVarPollingIntervalSeconds)
if pollingIntervalSecondsRaw != "" {
pollingIntervalSeconds, err := strconv.Atoi(pollingIntervalSecondsRaw)
if err != nil {
fmt.Printf("error parsing polling interval: %v\n", err)
os.Exit(1)
}
if pollingIntervalSeconds <= 0 {
fmt.Printf("error: polling interval must be greater than 0\n")
os.Exit(1)
}
pollingInterval = time.Duration(pollingIntervalSeconds) * time.Second
}
issueTitle := os.Getenv(envVarIssueTitle)
var issueBody string
if os.Getenv(envVarIssueBodyFilePath) != "" {
fileContents, err := os.ReadFile(os.Getenv(envVarIssueBodyFilePath))
if err != nil {
fmt.Printf("error reading issue body file: %v\n", err)
os.Exit(1)
}
issueBody = string(fileContents)
} else {
issueBody = os.Getenv(envVarIssueBody)
}
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := 0
if minimumApprovalsRaw != "" {
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
if err != nil {
fmt.Printf("error parsing minimum approvals: %v\n", err)
os.Exit(1)
}
}
apprv, err := newApprovalEnvironment(forgejoClient, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
if err != nil {
fmt.Printf("error creating approval environment: %v\n", err)
os.Exit(1)
}
err = apprv.createApprovalIssue(ctx)
if err != nil {
fmt.Printf("error creating issue: %v\n", err)
os.Exit(1)
}
outputs := map[string]string{
"issue-number": fmt.Sprintf("%d", apprv.approvalIssueNumber),
"issue-url": apprv.approvalIssue.HTMLURL,
}
_, err = apprv.SetActionOutputs(outputs)
if err != nil {
fmt.Printf("error saving output: %v\n", err)
os.Exit(1)
}
killSignalChannel := make(chan os.Signal, 1)
signal.Notify(killSignalChannel, os.Interrupt)
commentLoopChannel := newCommentLoopChannel(ctx, apprv, forgejoClient, pollingInterval)
select {
case exitCode := <-commentLoopChannel:
approvalStatus := ""
if !failOnDenial && exitCode == 1 {
approvalStatus = "denied"
exitCode = 0
} else if exitCode == 1 {
approvalStatus = "denied"
} else {
approvalStatus = "approved"
}
outputs := map[string]string{
"approval-status": approvalStatus,
}
if _, err := apprv.SetActionOutputs(outputs); err != nil {
fmt.Printf("error setting action output: %v\n", err)
exitCode = 1
}
os.Exit(exitCode)
case <-killSignalChannel:
handleInterrupt(ctx, forgejoClient, apprv)
os.Exit(1)
}
}