manual-approval/approval.go

281 lines
6.6 KiB
Go

package main
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
)
type approvalEnvironment struct {
forgejoClient *forgejo.Client
repoFullName string
repo string
repoOwner string
runID int
approvalIssue *forgejo.Issue
approvalIssueNumber int
issueTitle string
issueBody string
issueApprovers []string
minimumApprovals int
targetRepoOwner string
targetRepoName string
failOnDenial bool
}
func newApprovalEnvironment(forgejoClient *forgejo.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{
forgejoClient: forgejoClient,
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 {
serverUrl := os.Getenv("GITHUB_SERVER_URL")
if serverUrl == "" {
serverUrl = "https://github.com"
}
return fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimRight(serverUrl, "/"), a.repoFullName, a.runID)
}
func (a *approvalEnvironment) createApprovalIssue(_ 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)
} else {
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,
)
created, _, err := a.forgejoClient.CreateIssue(a.targetRepoOwner, a.targetRepoName, forgejo.CreateIssueOption{
Title: issueTitle,
Body: issueBody,
Assignees: a.issueApprovers,
})
if err != nil {
return err
}
a.approvalIssue = created
a.approvalIssueNumber = int(created.Index)
fmt.Printf("Issue created: %s\n", a.approvalIssue.HTMLURL)
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 func() {
_ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails.
}()
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 []*forgejo.Comment, approvers []string, minimumApprovals int) (approvalStatus, error) {
approvedUsers := []string{}
deniedUsers := []string{}
// If minimum approvals is not set, default to all approvers
if minimumApprovals == 0 {
minimumApprovals = len(approvers)
}
for _, comment := range comments {
commenter := comment.Poster.UserName
if approversIndex(approvers, commenter) == -1 {
continue
}
isApproval, err := isApproved(comment.Body)
if err != nil {
return "", err
}
isDenial, err := isDenied(comment.Body)
if err != nil {
return "", err
}
if isApproval {
approvedUsers = append(approvedUsers, commenter)
} else if isDenial {
deniedUsers = append(deniedUsers, commenter)
}
}
approvedUsers = deduplicateUsers(approvedUsers)
deniedUsers = deduplicateUsers(deniedUsers)
if len(deniedUsers) > 0 {
return approvalStatusDenied, nil
}
if len(approvedUsers) >= minimumApprovals {
return approvalStatusApproved, nil
}
return approvalStatusPending, nil
}
func approversIndex(approvers []string, name string) int {
for idx, approver := range approvers {
if strings.EqualFold(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, ", ")
}
func splitLongLine(line string, maxL int) ([]string, bool) {
if len(line) <= maxL {
return []string{line}, false
}
words := strings.Fields(line)
var result []string
var currentLine string
for _, word := range words {
if len(currentLine)+len(word)+1 > maxL {
result = append(result, currentLine)
currentLine = word
} else {
if currentLine != "" {
currentLine += " "
}
currentLine += word
}
}
if currentLine != "" {
result = append(result, currentLine)
}
return result, true
}