mirror of
https://github.com/go-gitea/gitea
synced 2025-08-05 09:08:22 +00:00
Send email on Workflow Run Success/Failure (#34982)
Closes #23725   /claim #23725 --------- Signed-off-by: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: ChristopherHX <christopher.homberger@web.de>
This commit is contained in:
@@ -174,3 +174,41 @@ func fromDisplayName(u *user_model.User) string {
|
||||
}
|
||||
return u.GetCompleteName()
|
||||
}
|
||||
|
||||
func generateMetadataHeaders(repo *repo_model.Repository) map[string]string {
|
||||
return map[string]string{
|
||||
// https://datatracker.ietf.org/doc/html/rfc2919
|
||||
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2369
|
||||
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
|
||||
|
||||
"X-Mailer": "Gitea",
|
||||
|
||||
"X-Gitea-Repository": repo.Name,
|
||||
"X-Gitea-Repository-Path": repo.FullName(),
|
||||
"X-Gitea-Repository-Link": repo.HTMLURL(),
|
||||
|
||||
"X-GitLab-Project": repo.Name,
|
||||
"X-GitLab-Project-Path": repo.FullName(),
|
||||
}
|
||||
}
|
||||
|
||||
func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string {
|
||||
return map[string]string{
|
||||
"X-Gitea-Sender": doer.Name,
|
||||
"X-Gitea-Recipient": recipient.Name,
|
||||
"X-Gitea-Recipient-Address": recipient.Email,
|
||||
"X-GitHub-Sender": doer.Name,
|
||||
"X-GitHub-Recipient": recipient.Name,
|
||||
"X-GitHub-Recipient-Address": recipient.Email,
|
||||
}
|
||||
}
|
||||
|
||||
func generateReasonHeaders(reason string) map[string]string {
|
||||
return map[string]string{
|
||||
"X-Gitea-Reason": reason,
|
||||
"X-GitHub-Reason": reason,
|
||||
"X-GitLab-NotificationReason": reason,
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,7 +30,7 @@ import (
|
||||
// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
|
||||
const maxEmailBodySize = 9_000_000
|
||||
|
||||
func fallbackMailSubject(issue *issues_model.Issue) string {
|
||||
func fallbackIssueMailSubject(issue *issues_model.Issue) string {
|
||||
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
||||
if actName != "new" {
|
||||
prefix = "Re: "
|
||||
}
|
||||
fallback = prefix + fallbackMailSubject(comment.Issue)
|
||||
fallback = prefix + fallbackIssueMailSubject(comment.Issue)
|
||||
|
||||
if comment.Comment != nil && comment.Comment.Review != nil {
|
||||
reviewComments = make([]*issues_model.Comment, 0, 10)
|
||||
@@ -202,7 +203,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
||||
msg.SetHeader("References", references...)
|
||||
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
|
||||
|
||||
for key, value := range generateAdditionalHeaders(comment, actType, recipient) {
|
||||
for key, value := range generateAdditionalHeadersForIssue(comment, actType, recipient) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
|
||||
@@ -302,35 +303,18 @@ func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.
|
||||
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
|
||||
}
|
||||
|
||||
func generateAdditionalHeaders(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
|
||||
func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
|
||||
repo := ctx.Issue.Repo
|
||||
|
||||
return map[string]string{
|
||||
// https://datatracker.ietf.org/doc/html/rfc2919
|
||||
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
|
||||
issueID := strconv.FormatInt(ctx.Issue.Index, 10)
|
||||
headers := generateMetadataHeaders(repo)
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2369
|
||||
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
|
||||
maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
|
||||
maps.Copy(headers, generateReasonHeaders(reason))
|
||||
|
||||
"X-Mailer": "Gitea",
|
||||
"X-Gitea-Reason": reason,
|
||||
"X-Gitea-Sender": ctx.Doer.Name,
|
||||
"X-Gitea-Recipient": recipient.Name,
|
||||
"X-Gitea-Recipient-Address": recipient.Email,
|
||||
"X-Gitea-Repository": repo.Name,
|
||||
"X-Gitea-Repository-Path": repo.FullName(),
|
||||
"X-Gitea-Repository-Link": repo.HTMLURL(),
|
||||
"X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
|
||||
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
|
||||
headers["X-Gitea-Issue-ID"] = issueID
|
||||
headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
|
||||
headers["X-GitLab-Issue-IID"] = issueID
|
||||
|
||||
"X-GitHub-Reason": reason,
|
||||
"X-GitHub-Sender": ctx.Doer.Name,
|
||||
"X-GitHub-Recipient": recipient.Name,
|
||||
"X-GitHub-Recipient-Address": recipient.Email,
|
||||
|
||||
"X-GitLab-NotificationReason": reason,
|
||||
"X-GitLab-Project": repo.Name,
|
||||
"X-GitLab-Project-Path": repo.FullName(),
|
||||
"X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ import (
|
||||
"testing"
|
||||
texttmpl "text/template"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
@@ -298,13 +299,13 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailComment, recipients [
|
||||
return msgs[0]
|
||||
}
|
||||
|
||||
func TestGenerateAdditionalHeaders(t *testing.T) {
|
||||
func TestGenerateAdditionalHeadersForIssue(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
comment := &mailComment{Issue: issue, Doer: doer}
|
||||
recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
|
||||
|
||||
headers := generateAdditionalHeaders(comment, "dummy-reason", recipient)
|
||||
headers := generateAdditionalHeadersForIssue(comment, "dummy-reason", recipient)
|
||||
|
||||
expected := map[string]string{
|
||||
"List-ID": "user2/repo1 <repo1.user2.localhost>",
|
||||
@@ -441,6 +442,16 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
|
||||
assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDForActionsWorkflowRunStatusEmail(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795, RepoID: repo.ID})
|
||||
assert.NoError(t, run.LoadAttributes(db.DefaultContext))
|
||||
msgID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
assert.Equal(t, "<user2/repo2/actions/runs/191@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestFromDisplayName(t *testing.T) {
|
||||
tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
|
||||
assert.NoError(t, err)
|
||||
|
165
services/mailer/mail_workflow_run.go
Normal file
165
services/mailer/mail_workflow_run.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplWorkflowRun = "notify/workflow_run"
|
||||
|
||||
type convertedWorkflowJob struct {
|
||||
HTMLURL string
|
||||
Status actions_model.Status
|
||||
Name string
|
||||
Attempt int64
|
||||
}
|
||||
|
||||
func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string {
|
||||
return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain)
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) {
|
||||
subject := "Run"
|
||||
switch run.Status {
|
||||
case actions_model.StatusFailure:
|
||||
subject += " failed"
|
||||
case actions_model.StatusCancelled:
|
||||
subject += " cancelled"
|
||||
case actions_model.StatusSuccess:
|
||||
subject += " succeeded"
|
||||
}
|
||||
subject = fmt.Sprintf("%s: %s (%s)", subject, run.WorkflowID, base.ShortSha(run.CommitSHA))
|
||||
displayName := fromDisplayName(sender)
|
||||
messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
metadataHeaders := generateMetadataHeaders(repo)
|
||||
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
if err != nil {
|
||||
log.Error("GetRunJobsByRunID: %v", err)
|
||||
return
|
||||
}
|
||||
sort.SliceStable(jobs, func(i, j int) bool {
|
||||
si, sj := jobs[i].Status, jobs[j].Status
|
||||
/*
|
||||
If both i and j are/are not success, leave it to si < sj.
|
||||
If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false.
|
||||
If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true.
|
||||
*/
|
||||
if si.IsSuccess() != sj.IsSuccess() {
|
||||
return !si.IsSuccess()
|
||||
}
|
||||
return si < sj
|
||||
})
|
||||
|
||||
convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
|
||||
if err != nil {
|
||||
log.Error("convert.ToActionWorkflowJob: %v", err)
|
||||
continue
|
||||
}
|
||||
convertedJobs = append(convertedJobs, convertedWorkflowJob{
|
||||
HTMLURL: converted0.HTMLURL,
|
||||
Name: converted0.Name,
|
||||
Status: job.Status,
|
||||
Attempt: converted0.RunAttempt,
|
||||
})
|
||||
}
|
||||
|
||||
langMap := make(map[string][]*user_model.User)
|
||||
for _, user := range recipients {
|
||||
langMap[user.Language] = append(langMap[user.Language], user)
|
||||
}
|
||||
for lang, tos := range langMap {
|
||||
locale := translation.NewLocale(lang)
|
||||
var runStatusText string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusText = "All jobs have succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusText = "All jobs have failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusText = "Some jobs were not successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusText = "All jobs have been cancelled"
|
||||
}
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplWorkflowRun, map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": runStatusText,
|
||||
"Jobs": convertedJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err)
|
||||
return
|
||||
}
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
for _, rec := range tos {
|
||||
msg := sender_service.NewMessageFrom(
|
||||
rec.Email,
|
||||
displayName,
|
||||
setting.MailService.FromEmail,
|
||||
subject,
|
||||
mailBody.String(),
|
||||
)
|
||||
msg.Info = subject
|
||||
for k, v := range generateSenderRecipientHeaders(sender, rec) {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
for k, v := range metadataHeaders {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
msg.SetHeader("Message-ID", messageID)
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
SendAsync(msgs...)
|
||||
}
|
||||
}
|
||||
|
||||
func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) {
|
||||
if setting.MailService == nil {
|
||||
return
|
||||
}
|
||||
if run.Status.IsSkipped() {
|
||||
return
|
||||
}
|
||||
|
||||
recipients := make([]*user_model.User, 0)
|
||||
|
||||
if !sender.IsGiteaActions() && !sender.IsGhost() && sender.IsMailable() {
|
||||
notifyPref, err := user_model.GetUserSetting(ctx, sender.ID,
|
||||
user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
|
||||
if err != nil {
|
||||
log.Error("GetUserSetting: %v", err)
|
||||
return
|
||||
}
|
||||
if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled {
|
||||
recipients = append(recipients, sender)
|
||||
}
|
||||
}
|
||||
|
||||
if len(recipients) > 0 {
|
||||
composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients)
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
@@ -205,3 +206,10 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *
|
||||
log.Error("SendRepoTransferNotifyMail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
|
||||
if !run.Status.IsDone() {
|
||||
return
|
||||
}
|
||||
MailActionsTrigger(ctx, sender, repo, run)
|
||||
}
|
||||
|
Reference in New Issue
Block a user