diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index f2a26efbb9..6ff77a380d 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -73,7 +73,7 @@ func TestWebhook_EventsArray(t *testing.T) { "pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone", "pull_request_comment", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_review_comment", "pull_request_sync", "pull_request_review_request", "wiki", "repository", "release", - "package", "status", + "package", "status", "workflow_job", }, (&Webhook{ HookEvent: &webhook_module.HookEvent{SendEverything: true}, diff --git a/modules/structs/hook.go b/modules/structs/hook.go index cef2dbd712..aaa9fbc9d3 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -469,3 +469,18 @@ type CommitStatusPayload struct { func (p *CommitStatusPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } + +// WorkflowJobPayload represents a payload information of workflow job event. +type WorkflowJobPayload struct { + Action string `json:"action"` + WorkflowJob *ActionWorkflowJob `json:"workflow_job"` + PullRequest *PullRequest `json:"pull_request,omitempty"` + Organization *Organization `json:"organization,omitempty"` + Repo *Repository `json:"repository"` + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowJobPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 203491ac02..22409b4aff 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -96,3 +96,40 @@ type ActionArtifactsResponse struct { Entries []*ActionArtifact `json:"artifacts"` TotalCount int64 `json:"total_count"` } + +// ActionWorkflowStep represents a step of a WorkflowJob +type ActionWorkflowStep struct { + Name string `json:"name"` + Number int64 `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + // swagger:strfmt date-time + StartedAt time.Time `json:"started_at,omitempty"` + // swagger:strfmt date-time + CompletedAt time.Time `json:"completed_at,omitempty"` +} + +// ActionWorkflowJob represents a WorkflowJob +type ActionWorkflowJob struct { + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + RunID int64 `json:"run_id"` + RunURL string `json:"run_url"` + Name string `json:"name"` + Labels []string `json:"labels"` + RunAttempt int64 `json:"run_attempt"` + HeadSha string `json:"head_sha"` + HeadBranch string `json:"head_branch,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + RunnerID int64 `json:"runner_id,omitempty"` + RunnerName string `json:"runner_name,omitempty"` + Steps []*ActionWorkflowStep `json:"steps"` + // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + StartedAt time.Time `json:"started_at,omitempty"` + // swagger:strfmt date-time + CompletedAt time.Time `json:"completed_at,omitempty"` +} diff --git a/modules/webhook/type.go b/modules/webhook/type.go index b244bb0cff..72ffde26a1 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -37,7 +37,8 @@ const ( // FIXME: This event should be a group of pull_request_review_xxx events HookEventPullRequestReview HookEventType = "pull_request_review" // Actions event only - HookEventSchedule HookEventType = "schedule" + HookEventSchedule HookEventType = "schedule" + HookEventWorkflowJob HookEventType = "workflow_job" ) func AllEvents() []HookEventType { @@ -66,6 +67,7 @@ func AllEvents() []HookEventType { HookEventRelease, HookEventPackage, HookEventStatus, + HookEventWorkflowJob, } } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4f1db5da6a..2f13c1a19c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2380,6 +2380,9 @@ settings.event_pull_request_review_request = Pull Request Review Requested settings.event_pull_request_review_request_desc = Pull request review requested or review request removed. settings.event_pull_request_approvals = Pull Request Approvals settings.event_pull_request_merge = Pull Request Merge +settings.event_header_workflow = Workflow Events +settings.event_workflow_job = Workflow Jobs +settings.event_workflow_job_desc = Gitea Actions Workflow job queued, waiting, in progress, or completed. settings.event_package = Package settings.event_package_desc = Package created or deleted in a repository. settings.branch_filter = Branch filter diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index f34dfb443b..27a0317942 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" actions_service "code.gitea.io/gitea/services/actions" + notify_service "code.gitea.io/gitea/services/notify" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" @@ -210,7 +211,7 @@ func (s *Service) UpdateTask( if err := task.LoadJob(ctx); err != nil { return nil, status.Errorf(codes.Internal, "load job: %v", err) } - if err := task.Job.LoadRun(ctx); err != nil { + if err := task.Job.LoadAttributes(ctx); err != nil { return nil, status.Errorf(codes.Internal, "load run: %v", err) } @@ -219,6 +220,10 @@ func (s *Service) UpdateTask( actions_service.CreateCommitStatus(ctx, task.Job) } + if task.Status.IsDone() { + notify_service.WorkflowJobStatusUpdate(ctx, task.Job.Run.Repo, task.Job.Run.TriggerUser, task.Job, task) + } + if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { if err := actions_service.EmitJobsIfReady(task.Job.RunID); err != nil { log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err) diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 9c49819970..ce0c1b5097 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -207,6 +207,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI webhook_module.HookEventRelease: util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true), webhook_module.HookEventPackage: util.SliceContainsString(form.Events, string(webhook_module.HookEventPackage), true), webhook_module.HookEventStatus: util.SliceContainsString(form.Events, string(webhook_module.HookEventStatus), true), + webhook_module.HookEventWorkflowJob: util.SliceContainsString(form.Events, string(webhook_module.HookEventWorkflowJob), true), }, BranchFilter: form.BranchFilter, }, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 4c39cb284f..41f0d2d0ec 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -33,6 +33,7 @@ import ( "code.gitea.io/gitea/modules/web" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" + notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" "xorm.io/builder" @@ -458,6 +459,9 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } actions_service.CreateCommitStatus(ctx, job) + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + return nil } @@ -518,6 +522,8 @@ func Cancel(ctx *context_module.Context) { return } + var updatedjobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { for _, job := range jobs { status := job.Status @@ -534,6 +540,9 @@ func Cancel(ctx *context_module.Context) { if n == 0 { return fmt.Errorf("job has changed, try again") } + if n > 0 { + updatedjobs = append(updatedjobs, job) + } continue } if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { @@ -548,6 +557,11 @@ func Cancel(ctx *context_module.Context) { actions_service.CreateCommitStatus(ctx, jobs...) + for _, job := range updatedjobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + ctx.JSON(http.StatusOK, struct{}{}) } @@ -561,6 +575,8 @@ func Approve(ctx *context_module.Context) { run := current.Run doer := ctx.Doer + var updatedjobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { run.NeedApproval = false run.ApprovedBy = doer.ID @@ -570,10 +586,13 @@ func Approve(ctx *context_module.Context) { for _, job := range jobs { if len(job.Needs) == 0 && job.Status.IsBlocked() { job.Status = actions_model.StatusWaiting - _, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") if err != nil { return err } + if n > 0 { + updatedjobs = append(updatedjobs, job) + } } } return nil @@ -584,6 +603,11 @@ func Approve(ctx *context_module.Context) { actions_service.CreateCommitStatus(ctx, jobs...) + for _, job := range updatedjobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + ctx.JSON(http.StatusOK, struct{}{}) } diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 6875584d0b..d3151a86a2 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -185,6 +185,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { webhook_module.HookEventRepository: form.Repository, webhook_module.HookEventPackage: form.Package, webhook_module.HookEventStatus: form.Status, + webhook_module.HookEventWorkflowJob: form.WorkflowJob, }, BranchFilter: form.BranchFilter, } diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index 9d613b68a5..2aeb0e8c96 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" webhook_module "code.gitea.io/gitea/modules/webhook" + notify_service "code.gitea.io/gitea/services/notify" ) // StopZombieTasks stops the task which have running status, but haven't been updated for a long time @@ -37,6 +38,10 @@ func StopEndlessTasks(ctx context.Context) error { func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) { if len(jobs) > 0 { CreateCommitStatus(ctx, jobs...) + for _, job := range jobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } } } @@ -107,14 +112,20 @@ func CancelAbandonedJobs(ctx context.Context) error { for _, job := range jobs { job.Status = actions_model.StatusCancelled job.Stopped = now + updated := false if err := db.WithTx(ctx, func(ctx context.Context) error { - _, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped") + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped") + updated = err == nil && n > 0 return err }); err != nil { log.Warn("cancel abandoned job %v: %v", job.ID, err) // go on } CreateCommitStatus(ctx, job) + if updated { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } } return nil diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 1f859fcf70..c11bb5875f 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/queue" + notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" @@ -49,6 +50,7 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { if err != nil { return err } + var updatedjobs []*actions_model.ActionRunJob if err := db.WithTx(ctx, func(ctx context.Context) error { idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) for _, job := range jobs { @@ -64,6 +66,7 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { } else if n != 1 { return fmt.Errorf("no affected for updating blocked job %v", job.ID) } + updatedjobs = append(updatedjobs, job) } } return nil @@ -71,6 +74,10 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { return err } CreateCommitStatus(ctx, jobs...) + for _, job := range updatedjobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } return nil } diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 87ea1a37f5..d179134798 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -27,6 +27,7 @@ import ( api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" + notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" @@ -363,6 +364,9 @@ func handleWorkflows( continue } CreateCommitStatus(ctx, alljobs...) + for _, job := range alljobs { + notify_service.WorkflowJobStatusUpdate(ctx, input.Repo, input.Doer, job, nil) + } } return nil } diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index ad1158313b..a30b166063 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" webhook_module "code.gitea.io/gitea/modules/webhook" + notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/jobparser" ) @@ -148,6 +149,17 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) if err := actions_model.InsertRun(ctx, run, workflows); err != nil { return err } + allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + if err != nil { + log.Error("FindRunJobs: %v", err) + } + err = run.LoadAttributes(ctx) + if err != nil { + log.Error("LoadAttributes: %v", err) + } + for _, job := range allJobs { + notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil) + } // Return nil if no errors occurred return nil diff --git a/services/actions/task.go b/services/actions/task.go index bc54ade347..1feeb67a80 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -10,6 +10,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" + notify_service "code.gitea.io/gitea/services/notify" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "google.golang.org/protobuf/types/known/structpb" @@ -17,8 +18,9 @@ import ( func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { var ( - task *runnerv1.Task - job *actions_model.ActionRunJob + task *runnerv1.Task + job *actions_model.ActionRunJob + actionTask *actions_model.ActionTask ) if err := db.WithTx(ctx, func(ctx context.Context) error { @@ -34,6 +36,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return fmt.Errorf("task LoadAttributes: %w", err) } job = t.Job + actionTask = t secrets, err := secret_model.GetSecretsOfTask(ctx, t) if err != nil { @@ -74,6 +77,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv } CreateCommitStatus(ctx, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask) return task, true, nil } diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 5225f4dcad..dc8a1dd349 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" @@ -276,6 +277,9 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re log.Error("FindRunJobs: %v", err) } CreateCommitStatus(ctx, allJobs...) + for _, job := range allJobs { + notify_service.WorkflowJobStatusUpdate(ctx, repo, doer, job, nil) + } return nil } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index f07186117e..1366d30b1f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -237,6 +237,7 @@ type WebhookForm struct { Release bool Package bool Status bool + WorkflowJob bool Active bool BranchFilter string `binding:"GlobPattern"` AuthorizationHeader string diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 29bbb5702b..40428454be 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -6,6 +6,7 @@ package notify import ( "context" + actions_model "code.gitea.io/gitea/models/actions" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" packages_model "code.gitea.io/gitea/models/packages" @@ -77,4 +78,6 @@ type Notifier interface { ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) + + WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) } diff --git a/services/notify/notify.go b/services/notify/notify.go index c97d0fcbaf..9f8be4b577 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -6,6 +6,7 @@ package notify import ( "context" + actions_model "code.gitea.io/gitea/models/actions" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" packages_model "code.gitea.io/gitea/models/packages" @@ -374,3 +375,9 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit notifier.CreateCommitStatus(ctx, repo, commit, sender, status) } } + +func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { + for _, notifier := range notifiers { + notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task) + } +} diff --git a/services/notify/null.go b/services/notify/null.go index 7354efd701..9c794a2342 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -6,6 +6,7 @@ package notify import ( "context" + actions_model "code.gitea.io/gitea/models/actions" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" packages_model "code.gitea.io/gitea/models/packages" @@ -212,3 +213,6 @@ func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.R func (*NullNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) { } + +func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { +} diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 3ea8f50764..5afca8d65a 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -176,6 +176,12 @@ func (dc dingtalkConvertor) Status(p *api.CommitStatusPayload) (DingtalkPayload, return createDingtalkPayload(text, text, "Status Changed", p.TargetURL), nil } +func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) + + return createDingtalkPayload(text, text, "Workflow Job", p.WorkflowJob.HTMLURL), nil +} + func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload { return DingtalkPayload{ MsgType: "actionCard", diff --git a/services/webhook/discord.go b/services/webhook/discord.go index 43e5e533bf..0a7eb0b166 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -271,6 +271,12 @@ func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, er return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil } +func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload, error) { + text, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) + + return d.createPayload(p.Sender, text, "", p.WorkflowJob.HTMLURL, color), nil +} + func newDiscordRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &DiscordMeta{} if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 639118d2a5..274aaf90b3 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -172,6 +172,12 @@ func (fc feishuConvertor) Status(p *api.CommitStatusPayload) (FeishuPayload, err return newFeishuTextPayload(text), nil } +func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) + + return newFeishuTextPayload(text), nil +} + func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { var pc payloadConvertor[FeishuPayload] = feishuConvertor{} return newJSONRequest(pc, w, t, true) diff --git a/services/webhook/general.go b/services/webhook/general.go index c3e2ded204..ea75038faf 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -325,6 +325,37 @@ func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatte return text, color } +func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { + description := p.WorkflowJob.Conclusion + if description == "" { + description = p.WorkflowJob.Status + } + refLink := linkFormatter(p.WorkflowJob.HTMLURL, fmt.Sprintf("%s(#%d)", p.WorkflowJob.Name, p.WorkflowJob.RunID)+"["+base.ShortSha(p.WorkflowJob.HeadSha)+"]:"+description) + + text = fmt.Sprintf("Workflow Job %s: %s", p.Action, refLink) + switch description { + case "waiting": + color = orangeColor + case "queued": + color = orangeColorLight + case "success": + color = greenColor + case "failure": + color = redColor + case "cancelled": + color = yellowColor + case "skipped": + color = purpleColor + default: + color = greyColor + } + if withSender { + text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)) + } + + return text, color +} + // ToHook convert models.Webhook to api.Hook // This function is not part of the convert package to prevent an import cycle func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index 034c0caf96..5bc7ba097e 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -252,6 +252,12 @@ func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, erro return m.newPayload(text) } +func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true) + + return m.newPayload(text) +} + var urlRegex = regexp.MustCompile(`]*?href="([^">]*?)">(.*?)`) func getMessageBody(htmlText string) string { diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 485f695be2..f70e235f20 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -317,6 +317,20 @@ func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, er ), nil } +func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) { + title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) + + return createMSTeamsPayload( + p.Repo, + p.Sender, + title, + "", + p.WorkflowJob.HTMLURL, + color, + &MSTeamsFact{"WorkflowJob:", p.WorkflowJob.Name}, + ), nil +} + func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload { facts := make([]MSTeamsFact, 0, 2) if r != nil { diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 76d6fd3472..7d779cd527 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -5,7 +5,10 @@ package webhook import ( "context" + "fmt" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -941,3 +944,114 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo log.Error("PrepareWebhooks: %v", err) } } + +func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { + source := EventSource{ + Repository: repo, + Owner: repo.Owner, + } + + var org *api.Organization + if repo.Owner.IsOrganization() { + org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) + } + + err := job.LoadAttributes(ctx) + if err != nil { + log.Error("Error loading job attributes: %v", err) + return + } + + jobIndex := 0 + jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) + if err != nil { + log.Error("Error loading getting run jobs: %v", err) + return + } + for i, j := range jobs { + if j.ID == job.ID { + jobIndex = i + break + } + } + + status, conclusion := toActionStatus(job.Status) + var runnerID int64 + var runnerName string + var steps []*api.ActionWorkflowStep + + if task != nil { + runnerID = task.RunnerID + if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok { + runnerName = runner.Name + } + for i, step := range task.Steps { + stepStatus, stepConclusion := toActionStatus(job.Status) + steps = append(steps, &api.ActionWorkflowStep{ + Name: step.Name, + Number: int64(i), + Status: stepStatus, + Conclusion: stepConclusion, + StartedAt: step.Started.AsTime().UTC(), + CompletedAt: step.Stopped.AsTime().UTC(), + }) + } + } + + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowJobPayload{ + Action: status, + WorkflowJob: &api.ActionWorkflowJob{ + ID: job.ID, + // missing api endpoint for this location + URL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.APIURL(), job.RunID, job.ID), + HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex), + RunID: job.RunID, + // Missing api endpoint for this location, artifacts are available under a nested url + RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID), + Name: job.Name, + Labels: job.RunsOn, + RunAttempt: job.Attempt, + HeadSha: job.Run.CommitSHA, + HeadBranch: git.RefName(job.Run.Ref).BranchName(), + Status: status, + Conclusion: conclusion, + RunnerID: runnerID, + RunnerName: runnerName, + Steps: steps, + CreatedAt: job.Created.AsTime().UTC(), + StartedAt: job.Started.AsTime().UTC(), + CompletedAt: job.Stopped.AsTime().UTC(), + }, + Organization: org, + Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), + Sender: convert.ToUser(ctx, sender, nil), + }); err != nil { + log.Error("PrepareWebhooks: %v", err) + } +} + +func toActionStatus(status actions_model.Status) (string, string) { + var action string + var conclusion string + switch status { + // This is a naming conflict of the webhook between Gitea and GitHub Actions + case actions_model.StatusWaiting: + action = "queued" + case actions_model.StatusBlocked: + action = "waiting" + case actions_model.StatusRunning: + action = "in_progress" + } + if status.IsDone() { + action = "completed" + switch status { + case actions_model.StatusSuccess: + conclusion = "success" + case actions_model.StatusCancelled: + conclusion = "cancelled" + case actions_model.StatusFailure: + conclusion = "failure" + } + } + return action, conclusion +} diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index 6864fc822a..8829d95da6 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -114,6 +114,10 @@ func (pc packagistConvertor) Status(_ *api.CommitStatusPayload) (PackagistPayloa return PackagistPayload{}, nil } +func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil +} + func newPackagistRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &PackagistMeta{} if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index d98c20f479..adb7243fb1 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -29,6 +29,7 @@ type payloadConvertor[T any] interface { Wiki(*api.WikiPayload) (T, error) Package(*api.PackagePayload) (T, error) Status(*api.CommitStatusPayload) (T, error) + WorkflowJob(*api.WorkflowJobPayload) (T, error) } func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (t T, err error) { @@ -80,6 +81,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module return convertUnmarshalledJSON(rc.Package, data) case webhook_module.HookEventStatus: return convertUnmarshalledJSON(rc.Status, data) + case webhook_module.HookEventWorkflowJob: + return convertUnmarshalledJSON(rc.WorkflowJob, data) } return t, fmt.Errorf("newPayload unsupported event: %s", event) } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 80ed747fd1..589ef3fe9b 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -173,6 +173,12 @@ func (s slackConvertor) Status(p *api.CommitStatusPayload) (SlackPayload, error) return s.createPayload(text, nil), nil } +func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, SlackLinkFormatter, true) + + return s.createPayload(text, nil), nil +} + // Push implements payloadConvertor Push method func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) { // n new commits diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index 485e2d990b..ca74eabe1c 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -180,6 +180,12 @@ func (t telegramConvertor) Status(p *api.CommitStatusPayload) (TelegramPayload, return createTelegramPayloadHTML(text), nil } +func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true) + + return createTelegramPayloadHTML(text), nil +} + func createTelegramPayloadHTML(msgHTML string) TelegramPayload { // https://core.telegram.org/bots/api#formatting-options return TelegramPayload{ diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index 1c834b4020..2b19822caf 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -181,6 +181,12 @@ func (wc wechatworkConvertor) Status(p *api.CommitStatusPayload) (WechatworkPayl return newWechatworkMarkdownPayload(text), nil } +func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (WechatworkPayload, error) { + text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) + + return newWechatworkMarkdownPayload(text), nil +} + func newWechatworkRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { var pc payloadConvertor[WechatworkPayload] = wechatworkConvertor{} return newJSONRequest(pc, w, t, true) diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl index 3b28a4c6c0..16ad263e42 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -259,6 +259,20 @@ + +
+ +
+ +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.event_workflow_job_desc"}} +
+
+
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 596ccce266..effeff111d 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -11,10 +11,12 @@ import ( "net/url" "strings" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" @@ -22,6 +24,7 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/tests" + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -605,3 +608,146 @@ func Test_WebhookStatus_NoWrongTrigger(t *testing.T) { assert.EqualValues(t, "push", trigger) }) } + +func Test_WebhookWorkflowJob(t *testing.T) { + var payloads []api.WorkflowJobPayload + var triggeredEvent string + provider := newMockWebhookProvider(func(r *http.Request) { + assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_job", "X-GitHub-Event-Type should contain workflow_job") + assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_job", "X-Gitea-Event-Type should contain workflow_job") + assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_job", "X-Gogs-Event-Type should contain workflow_job") + content, _ := io.ReadAll(r.Body) + var payload api.WorkflowJobPayload + err := json.Unmarshal(content, &payload) + assert.NoError(t, err) + payloads = append(payloads, payload) + triggeredEvent = "workflow_job" + }, http.StatusOK) + defer provider.Close() + + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // 1. create a new webhook with special webhook for repo1 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + testAPICreateWebhookForRepo(t, session, "user2", "repo1", provider.URL(), "workflow_job") + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) + assert.NoError(t, err) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, "user2", "repo1", "mock-runner", []string{"ubuntu-latest"}) + + // 2. trigger the webhooks + + // add workflow file to the repo + // init the workflow + wfTreePath := ".gitea/workflows/push.yml" + wfFileContent := `name: Push +on: push +jobs: + wf1-job: + runs-on: ubuntu-latest + steps: + - run: echo 'test the webhook' + wf2-job: + runs-on: ubuntu-latest + needs: wf1-job + steps: + - run: echo 'cmd 1' + - run: echo 'cmd 2' +` + opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, fmt.Sprintf("create %s", wfTreePath), wfFileContent) + createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) + + commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + + // 3. validate the webhook is triggered + assert.EqualValues(t, "workflow_job", triggeredEvent) + assert.Len(t, payloads, 2) + assert.EqualValues(t, "queued", payloads[0].Action) + assert.EqualValues(t, "queued", payloads[0].WorkflowJob.Status) + assert.EqualValues(t, []string{"ubuntu-latest"}, payloads[0].WorkflowJob.Labels) + assert.EqualValues(t, commitID, payloads[0].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[0].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[0].Repo.FullName) + + assert.EqualValues(t, "waiting", payloads[1].Action) + assert.EqualValues(t, "waiting", payloads[1].WorkflowJob.Status) + assert.EqualValues(t, commitID, payloads[1].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[1].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[1].Repo.FullName) + + // 4. Execute a single Job + task := runner.fetchTask(t) + outcome := &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + execTime: time.Millisecond, + } + runner.execTask(t, task, outcome) + + // 5. validate the webhook is triggered + assert.EqualValues(t, "workflow_job", triggeredEvent) + assert.Len(t, payloads, 5) + assert.EqualValues(t, "in_progress", payloads[2].Action) + assert.EqualValues(t, "in_progress", payloads[2].WorkflowJob.Status) + assert.EqualValues(t, "mock-runner", payloads[2].WorkflowJob.RunnerName) + assert.EqualValues(t, commitID, payloads[2].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[2].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[2].Repo.FullName) + + assert.EqualValues(t, "completed", payloads[3].Action) + assert.EqualValues(t, "completed", payloads[3].WorkflowJob.Status) + assert.EqualValues(t, "mock-runner", payloads[3].WorkflowJob.RunnerName) + assert.EqualValues(t, "success", payloads[3].WorkflowJob.Conclusion) + assert.EqualValues(t, commitID, payloads[3].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[3].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[3].Repo.FullName) + assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[3].WorkflowJob.RunID, payloads[3].WorkflowJob.ID)) + assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL) + assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0)) + assert.Len(t, payloads[3].WorkflowJob.Steps, 1) + + assert.EqualValues(t, "queued", payloads[4].Action) + assert.EqualValues(t, "queued", payloads[4].WorkflowJob.Status) + assert.EqualValues(t, []string{"ubuntu-latest"}, payloads[4].WorkflowJob.Labels) + assert.EqualValues(t, commitID, payloads[4].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[4].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[4].Repo.FullName) + + // 6. Execute a single Job + task = runner.fetchTask(t) + outcome = &mockTaskOutcome{ + result: runnerv1.Result_RESULT_FAILURE, + execTime: time.Millisecond, + } + runner.execTask(t, task, outcome) + + // 7. validate the webhook is triggered + assert.EqualValues(t, "workflow_job", triggeredEvent) + assert.Len(t, payloads, 7) + assert.EqualValues(t, "in_progress", payloads[5].Action) + assert.EqualValues(t, "in_progress", payloads[5].WorkflowJob.Status) + assert.EqualValues(t, "mock-runner", payloads[5].WorkflowJob.RunnerName) + + assert.EqualValues(t, commitID, payloads[5].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[5].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[5].Repo.FullName) + + assert.EqualValues(t, "completed", payloads[6].Action) + assert.EqualValues(t, "completed", payloads[6].WorkflowJob.Status) + assert.EqualValues(t, "failure", payloads[6].WorkflowJob.Conclusion) + assert.EqualValues(t, "mock-runner", payloads[6].WorkflowJob.RunnerName) + assert.EqualValues(t, commitID, payloads[6].WorkflowJob.HeadSha) + assert.EqualValues(t, "repo1", payloads[6].Repo.Name) + assert.EqualValues(t, "user2/repo1", payloads[6].Repo.FullName) + assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[6].WorkflowJob.RunID, payloads[6].WorkflowJob.ID)) + assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL) + assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1)) + assert.Len(t, payloads[6].WorkflowJob.Steps, 2) + }) +}