manual-approval/main.go

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 StackitGit 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 StackitGit client")
}
client, err := forgejo.NewClient(serverUrl, forgejo.SetToken(token))
if err != nil {
return nil, fmt.Errorf("failed to create StackitGit 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 StackitGit 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)
}
}