manual-approval/approval.go
jaime merino 9e098fbb6e - Fixed Approver Expansion: Repaired the broken expandGroupFromUser function in approvers.go. It now correctly utilizes the Forgejo SDK to search for teams within an organization and expand them into individual users, while respecting the option to exclude the workflow initiator.
- Removed GitHub Dependencies: Excised the unused newGithubClient and its associated dependencies (google/go-github and oauth2) from main.go and go.mod.

   - Project Infrastructure Updates:
       - Updated Dockerfile to use golang:1.25 for consistency with go.mod.
       - Modified action.yaml to run from the local Dockerfile, ensuring that the Forgejo-specific logic is utilized.
   - Validation: Verified all changes through a successful build and by running the full test suite, all of which passed.
2026-04-22 15:28:23 +02:00

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