246 lines
6 KiB
Go
246 lines
6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/google/go-github/v43/github"
|
|
)
|
|
|
|
type approvalEnvironment struct {
|
|
client *github.Client
|
|
repoFullName string
|
|
repo string
|
|
repoOwner string
|
|
runID int
|
|
approvalIssue *github.Issue
|
|
approvalIssueNumber int
|
|
issueTitle string
|
|
issueBody string
|
|
issueApprovers []string
|
|
minimumApprovals int
|
|
targetRepoOwner string
|
|
targetRepoName string
|
|
failOnDenial bool
|
|
}
|
|
|
|
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) {
|
|
repoOwnerAndName := strings.Split(repoFullName, "/")
|
|
if len(repoOwnerAndName) != 2 {
|
|
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
|
|
}
|
|
repo := repoOwnerAndName[1]
|
|
|
|
return &approvalEnvironment{
|
|
client: client,
|
|
repoFullName: repoFullName,
|
|
repo: repo,
|
|
repoOwner: repoOwner,
|
|
runID: runID,
|
|
issueApprovers: approvers,
|
|
minimumApprovals: minimumApprovals,
|
|
issueTitle: issueTitle,
|
|
issueBody: issueBody,
|
|
targetRepoOwner: targetRepoOwner,
|
|
targetRepoName: targetRepoName,
|
|
failOnDenial: failOnDenial,
|
|
}, nil
|
|
}
|
|
|
|
func (a approvalEnvironment) runURL() string {
|
|
baseUrl := a.client.BaseURL.String()
|
|
if strings.Contains(baseUrl, "github.com") {
|
|
baseUrl = "https://github.com/"
|
|
}
|
|
return fmt.Sprintf("%s%s/actions/runs/%d", baseUrl, a.repoFullName, a.runID)
|
|
}
|
|
|
|
func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
|
|
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
|
|
|
|
if a.issueTitle != "" {
|
|
issueTitle = a.issueTitle
|
|
}
|
|
|
|
approversBody := ""
|
|
for _, approver := range a.issueApprovers {
|
|
approversBody = fmt.Sprintf("%s> * @%s\n", approversBody, approver)
|
|
}
|
|
|
|
issueBody := fmt.Sprintf(`> Workflow is pending manual review.
|
|
> URL: %s
|
|
|
|
> [!IMPORTANT]
|
|
> Required approvers:
|
|
%s
|
|
|
|
> [!TIP]
|
|
> Respond %s to continue workflow or %s to cancel.`,
|
|
a.runURL(),
|
|
approversBody,
|
|
formatAcceptedWords(approvedWords),
|
|
formatAcceptedWords(deniedWords),
|
|
)
|
|
|
|
if a.issueBody != "" {
|
|
issueBody = fmt.Sprintf(">%s\n>\n%s", a.issueBody, issueBody)
|
|
}
|
|
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
|
|
|
|
var err error
|
|
fmt.Printf(
|
|
"Creating issue in repo %s/%s with the following content:\nTitle: %s\nApprovers: %s\nBody:\n%s\n",
|
|
a.targetRepoOwner,
|
|
a.targetRepoName,
|
|
issueTitle,
|
|
a.issueApprovers,
|
|
issueBody,
|
|
)
|
|
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.targetRepoOwner, a.targetRepoName, &github.IssueRequest{
|
|
Title: &issueTitle,
|
|
Body: &issueBody,
|
|
Assignees: &a.issueApprovers,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.approvalIssueNumber = a.approvalIssue.GetNumber()
|
|
|
|
fmt.Printf("Issue created: %s\n", a.approvalIssue.GetHTMLURL())
|
|
return nil
|
|
}
|
|
|
|
func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool, error) {
|
|
outputFile := os.Getenv("GITHUB_OUTPUT")
|
|
if outputFile == "" {
|
|
return false, nil
|
|
}
|
|
|
|
f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var pairs []string
|
|
|
|
for key, value := range outputs {
|
|
pairs = append(pairs, fmt.Sprintf("%s=%s", key, value))
|
|
}
|
|
|
|
// Add a newline before writing the new outputs if the file is not empty. This prevents
|
|
// two outputs from being written on the same line.
|
|
fileInfo, err := f.Stat()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if fileInfo.Size() > 0 {
|
|
if _, err := f.WriteString("\n"); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if _, err := f.WriteString(strings.Join(pairs, "\n")); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
|
|
remainingApprovers := make([]string, len(approvers))
|
|
copy(remainingApprovers, approvers)
|
|
|
|
if minimumApprovals == 0 {
|
|
minimumApprovals = len(approvers)
|
|
}
|
|
|
|
for _, comment := range comments {
|
|
commentUser := comment.User.GetLogin()
|
|
approverIdx := approversIndex(remainingApprovers, commentUser)
|
|
if approverIdx < 0 {
|
|
continue
|
|
}
|
|
|
|
commentBody := comment.GetBody()
|
|
isApprovalComment, err := isApproved(commentBody)
|
|
if err != nil {
|
|
return approvalStatusPending, err
|
|
}
|
|
if isApprovalComment {
|
|
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
|
|
return approvalStatusApproved, nil
|
|
}
|
|
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
|
|
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
|
|
continue
|
|
}
|
|
|
|
isDenialComment, err := isDenied(commentBody)
|
|
if err != nil {
|
|
return approvalStatusPending, err
|
|
}
|
|
if isDenialComment {
|
|
return approvalStatusDenied, nil
|
|
}
|
|
}
|
|
|
|
return approvalStatusPending, nil
|
|
}
|
|
|
|
func approversIndex(approvers []string, name string) int {
|
|
for idx, approver := range approvers {
|
|
if approver == name {
|
|
return idx
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func isApproved(commentBody string) (bool, error) {
|
|
for _, approvedWord := range approvedWords {
|
|
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", approvedWord))
|
|
if err != nil {
|
|
fmt.Printf("Error parsing. %v", err)
|
|
return false, err
|
|
}
|
|
|
|
matched := re.MatchString(commentBody)
|
|
|
|
if matched {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func isDenied(commentBody string) (bool, error) {
|
|
for _, deniedWord := range deniedWords {
|
|
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", deniedWord))
|
|
if err != nil {
|
|
fmt.Printf("Error parsing. %v", err)
|
|
return false, err
|
|
}
|
|
matched := re.MatchString(commentBody)
|
|
if matched {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func formatAcceptedWords(words []string) string {
|
|
var quotedWords []string
|
|
|
|
for _, word := range words {
|
|
quotedWords = append(quotedWords, fmt.Sprintf("\"%s\"", word))
|
|
}
|
|
|
|
return strings.Join(quotedWords, ", ")
|
|
}
|