281 lines
6.6 KiB
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
|
|
}
|