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