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 }