391 lines
11 KiB
Go
391 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-github/v43/github"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// 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(ctx context.Context, client *github.Client, owner, repo string, number int, state string) error {
|
|
req, err := client.NewRequest("PATCH",
|
|
fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number),
|
|
&github.IssueRequest{State: &state},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = client.Do(ctx, req, nil)
|
|
return err
|
|
}
|
|
|
|
func handleInterrupt(ctx context.Context, client *github.Client, apprv *approvalEnvironment) {
|
|
newState := "closed"
|
|
closeComment := "Workflow cancelled, closing issue."
|
|
|
|
fmt.Println(closeComment)
|
|
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
|
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 *github.Client, pollingInterval time.Duration) chan int {
|
|
channel := make(chan int)
|
|
go func() {
|
|
loop_ctr := 0
|
|
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
|
|
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.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
|
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.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
|
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
|
|
case approvalStatusPending:
|
|
if apprv.closeIssueMeansDenial {
|
|
// Loop counter to make an API call only once per 10 interation, intention: avoid github rate limiting and reduce api cost and stress.
|
|
if loop_ctr < 10 {
|
|
loop_ctr += 1
|
|
continue
|
|
}
|
|
loop_ctr = 0
|
|
|
|
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)
|
|
}
|
|
}()
|
|
return channel
|
|
}
|
|
|
|
func newGithubClient(ctx context.Context) (*github.Client, error) {
|
|
token := os.Getenv(envVarToken)
|
|
ts := oauth2.StaticTokenSource(
|
|
&oauth2.Token{AccessToken: token},
|
|
)
|
|
tc := oauth2.NewClient(ctx, ts)
|
|
|
|
serverUrl, serverUrlPresent := os.LookupEnv("GITHUB_SERVER_URL")
|
|
apiUrl, apiUrlPresent := os.LookupEnv("GITHUB_API_URL")
|
|
|
|
if !serverUrlPresent && !apiUrlPresent {
|
|
return github.NewClient(tc), nil
|
|
}
|
|
|
|
if !apiUrlPresent {
|
|
// Only GITHUB_SERVER_URL is set; assume GitHub Enterprise with the
|
|
// default /api/v3 path and let NewEnterpriseClient append it.
|
|
return github.NewEnterpriseClient(serverUrl, serverUrl, tc)
|
|
}
|
|
|
|
// GITHUB_API_URL is set. github.NewEnterpriseClient appends "/api/v3/" to
|
|
// any URL whose path doesn't already end with it. This breaks Forgejo/Gitea
|
|
// instances whose API lives at "/api/v1/". Instead, set BaseURL directly so
|
|
// the URL is used as-is, which works for GitHub.com, GHES, and Forgejo alike.
|
|
if !strings.HasSuffix(apiUrl, "/") {
|
|
apiUrl += "/"
|
|
}
|
|
baseURL, err := url.Parse(apiUrl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid GITHUB_API_URL %q: %w", apiUrl, err)
|
|
}
|
|
client := github.NewClient(tc)
|
|
client.BaseURL = baseURL
|
|
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()
|
|
client, err := newGithubClient(ctx)
|
|
if err != nil {
|
|
fmt.Printf("error connecting to server: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
approvers, err := retrieveApprovers(client, 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)
|
|
}
|
|
}
|
|
|
|
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 != "" {
|
|
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(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)
|
|
}
|
|
|
|
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.GetHTMLURL(),
|
|
}
|
|
_, 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, client, 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, client, apprv)
|
|
os.Exit(1)
|
|
}
|
|
}
|