diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index 073af91de2..3af6070d10 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -139,7 +139,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", "workflow_run", "workflow_job", + "package", "status", "workflow_run", "workflow_job", "workflow_step", }, (&Webhook{ HookEvent: &webhook_module.HookEvent{SendEverything: true}, diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 99c1535155..ff30d48197 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -648,3 +648,26 @@ type WorkflowJobPayload struct { func (p *WorkflowJobPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } + +// WorkflowStepPayload represents a payload information of workflow step event. +type WorkflowStepPayload struct { + // The action performed on the workflow step + Action string `json:"action"` + // The workflow run that contains the step + WorkflowRun *ActionWorkflowRun `json:"workflow_run"` + // The workflow job that contains the step + WorkflowJob *ActionWorkflowJob `json:"workflow_job"` + // The workflow step that was acted upon + Step *ActionWorkflowStep `json:"step"` + // The organization that owns the repository (if applicable) + Organization *Organization `json:"organization,omitempty"` + // The repository containing the workflow + Repo *Repository `json:"repository"` + // The user who triggered the workflow + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowStepPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 18a4086710..a04887afa6 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -38,8 +38,9 @@ const ( HookEventPullRequestReview HookEventType = "pull_request_review" // Actions event only HookEventSchedule HookEventType = "schedule" - HookEventWorkflowRun HookEventType = "workflow_run" - HookEventWorkflowJob HookEventType = "workflow_job" + HookEventWorkflowRun HookEventType = "workflow_run" + HookEventWorkflowJob HookEventType = "workflow_job" + HookEventWorkflowStep HookEventType = "workflow_step" ) func AllEvents() []HookEventType { @@ -70,6 +71,7 @@ func AllEvents() []HookEventType { HookEventStatus, HookEventWorkflowRun, HookEventWorkflowJob, + HookEventWorkflowStep, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..78bbd40273 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2329,6 +2329,8 @@ "repo.settings.event_workflow_run_desc": "Gitea Actions Workflow run queued, waiting, in progress, or completed.", "repo.settings.event_workflow_job": "Workflow Jobs", "repo.settings.event_workflow_job_desc": "Gitea Actions Workflow job queued, waiting, in progress, or completed.", + "repo.settings.event_workflow_step": "Workflow Steps", + "repo.settings.event_workflow_step_desc": "Gitea Actions Workflow step started or completed.", "repo.settings.event_package": "Package", "repo.settings.event_package_desc": "Package created or deleted in a repository.", "repo.settings.branch_filter": "Branch filter", diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index eee39760ed..c39e09b104 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -183,6 +183,16 @@ func (s *Service) UpdateTask( ) (*connect.Response[runnerv1.UpdateTaskResponse], error) { runner := GetRunner(ctx) + // Snapshot step states before the update so we can detect which steps changed. + oldStepStates := make(map[int64]actions_model.Status) + if existingTask, err := actions_model.GetTaskByID(ctx, req.Msg.State.Id); err == nil { + if err := existingTask.LoadAttributes(ctx); err == nil { + for _, step := range existingTask.Steps { + oldStepStates[step.Index] = step.Status + } + } + } + task, err := actions_model.UpdateTaskByState(ctx, runner.ID, req.Msg.State) if err != nil { return nil, status.Errorf(codes.Internal, "update task: %v", err) @@ -222,6 +232,14 @@ func (s *Service) UpdateTask( actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job) + // Emit step events for any step whose status changed during this update. + for _, step := range task.Steps { + oldStatus, seen := oldStepStates[step.Index] + if !seen || oldStatus != step.Status { + actions_service.NotifyWorkflowStepStatusUpdate(ctx, task.Job, task, step) + } + } + if task.Status.IsDone() { actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task) } diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 8dc19b63a8..ba3cda58b1 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -176,6 +176,7 @@ func updateHookEvents(events []string) webhook_module.HookEvents { hookEvents[webhook_module.HookEventStatus] = util.SliceContainsString(events, string(webhook_module.HookEventStatus), true) hookEvents[webhook_module.HookEventWorkflowRun] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowRun), true) hookEvents[webhook_module.HookEventWorkflowJob] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowJob), true) + hookEvents[webhook_module.HookEventWorkflowStep] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowStep), true) // Issues hookEvents[webhook_module.HookEventIssues] = issuesHook(events, "issues_only") diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 8c57a68b25..5fb38f7d5e 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -187,6 +187,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { webhook_module.HookEventStatus: form.Status, webhook_module.HookEventWorkflowRun: form.WorkflowRun, webhook_module.HookEventWorkflowJob: form.WorkflowJob, + webhook_module.HookEventWorkflowStep: form.WorkflowStep, }, BranchFilter: form.BranchFilter, } diff --git a/services/actions/notify.go b/services/actions/notify.go index e8b05c9fec..035ced3785 100644 --- a/services/actions/notify.go +++ b/services/actions/notify.go @@ -119,6 +119,30 @@ func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model. } } +// NotifyWorkflowStepStatusUpdate notifies a single step status update when a concrete task is available. +// Use it when a step transitions between queued, in_progress, and completed states. +func NotifyWorkflowStepStatusUpdate(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + if job.RunAttemptID == 0 { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + notify_service.WorkflowStepStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task, step) + return + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + notify_service.WorkflowStepStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task, step) +} + // NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available. // Use it for runner/task lifecycle callbacks so the notification includes the originating task context. func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { diff --git a/services/convert/convert.go b/services/convert/convert.go index dc1692b6ca..0854fb4478 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -357,6 +357,20 @@ func ToActionsStatus(status actions_model.Status) (string, string) { return action, conclusion } +// ToActionWorkflowStep converts an actions_model.ActionTaskStep to an api.ActionWorkflowStep. +// The step index (0-based position) must be passed by the caller because it is not stored on the model. +func ToActionWorkflowStep(step *actions_model.ActionTaskStep, stepIndex int) *api.ActionWorkflowStep { + stepStatus, stepConclusion := ToActionsStatus(step.Status) + return &api.ActionWorkflowStep{ + Name: step.Name, + Number: int64(stepIndex), + Status: stepStatus, + Conclusion: stepConclusion, + StartedAt: step.Started.AsTime().UTC(), + CompletedAt: step.Stopped.AsTime().UTC(), + } +} + // ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob // task is optional and can be nil func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..a77cceae89 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -230,6 +230,7 @@ type WebhookForm struct { Status bool WorkflowRun bool WorkflowJob bool + WorkflowStep bool Active bool BranchFilter string `binding:"GlobPattern"` AuthorizationHeader string diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 875a70e564..511f80244e 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -82,4 +82,6 @@ type Notifier interface { WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) + + WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) } diff --git a/services/notify/notify.go b/services/notify/notify.go index 152d53b01c..c2f229b372 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -416,3 +416,10 @@ func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, s notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task) } } + +// WorkflowStepStatusUpdate dispatches a workflow step status change to every registered notifier. +func WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + for _, notifier := range notifiers { + notifier.WorkflowStepStatusUpdate(ctx, repo, sender, job, task, step) + } +} diff --git a/services/notify/null.go b/services/notify/null.go index c3085d7c9e..b4bf4fc622 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -219,3 +219,6 @@ func (*NullNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_mod func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { } + +func (*NullNotifier) WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { +} diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 955826544f..b5496d217d 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -188,6 +188,12 @@ func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload return createDingtalkPayload(text, text, "Workflow Job", p.WorkflowJob.HTMLURL), nil } +func (dingtalkConvertor) WorkflowStep(p *api.WorkflowStepPayload) (DingtalkPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, noneLinkFormatter, true) + + return createDingtalkPayload(text, text, "Workflow Step", 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 c0af7c0243..f988f3e03a 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -290,6 +290,12 @@ func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload return d.createPayload(p.Sender, text, "", p.WorkflowJob.HTMLURL, color), nil } +func (d discordConvertor) WorkflowStep(p *api.WorkflowStepPayload) (DiscordPayload, error) { + text, color := getWorkflowStepPayloadInfo(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/discord_test.go b/services/webhook/discord_test.go index 7f503e3374..370a06b629 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -280,6 +280,21 @@ func TestDiscordPayload(t *testing.T) { assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) + + t.Run("WorkflowStep", func(t *testing.T) { + p := workflowStepTestPayload() + + pl, err := dc.WorkflowStep(p) + require.NoError(t, err) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "Workflow Step completed: build / Run tests:success", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, p.WorkflowJob.HTMLURL, pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) + }) } func TestDiscordJSONPayload(t *testing.T) { diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index ac581df85a..1267235431 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -191,6 +191,12 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er return newFeishuTextPayload(text), nil } +func (feishuConvertor) WorkflowStep(p *api.WorkflowStepPayload) (FeishuPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, noneLinkFormatter, true) + + return newFeishuTextPayload(text), nil +} + // feishuGenSign generates a signature for Feishu webhook // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot func feishuGenSign(secret string, timestamp int64) string { diff --git a/services/webhook/general.go b/services/webhook/general.go index 9572c926df..61455bbd16 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -389,6 +389,37 @@ func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkForm return text, color } +func getWorkflowStepPayloadInfo(p *api.WorkflowStepPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { + description := p.Step.Conclusion + if description == "" { + description = p.Step.Status + } + stepLink := linkFormatter(p.WorkflowJob.HTMLURL, fmt.Sprintf("%s / %s", p.WorkflowJob.Name, p.Step.Name)+":"+description) + + text = fmt.Sprintf("Workflow Step %s: %s", p.Action, stepLink) + 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 += " by " + 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/general_test.go b/services/webhook/general_test.go index ec735d785a..2689f82381 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -610,6 +610,125 @@ func TestGetReleasePayloadInfo(t *testing.T) { } } +func workflowJobTestPayload() *api.WorkflowJobPayload { + return &api.WorkflowJobPayload{ + Action: "completed", + WorkflowJob: &api.ActionWorkflowJob{ + ID: 1, + Name: "build", + Status: "completed", + RunID: 42, + HeadSha: "abc123", + HTMLURL: "http://localhost:3000/test/repo/actions/runs/42/jobs/1", + }, + Repo: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + Sender: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + } +} + +func workflowStepTestPayload() *api.WorkflowStepPayload { + return &api.WorkflowStepPayload{ + Action: "completed", + WorkflowRun: &api.ActionWorkflowRun{ + ID: 42, + DisplayTitle: "Push: main", + }, + WorkflowJob: &api.ActionWorkflowJob{ + ID: 1, + Name: "build", + Status: "completed", + RunID: 42, + HeadSha: "abc123", + HTMLURL: "http://localhost:3000/test/repo/actions/runs/42/jobs/1", + }, + Step: &api.ActionWorkflowStep{ + Name: "Run tests", + Number: 0, + Status: "completed", + Conclusion: "success", + }, + Repo: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + Sender: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + } +} + +func TestGetWorkflowStepPayloadInfo(t *testing.T) { + cases := []struct { + action string + status string + conclusion string + expectText string + expectColor int + }{ + { + action: "queued", + status: "queued", + conclusion: "", + expectText: "Workflow Step queued: build / Run tests:queued", + expectColor: orangeColorLight, + }, + { + action: "in_progress", + status: "in_progress", + conclusion: "", + expectText: "Workflow Step in_progress: build / Run tests:in_progress", + expectColor: greyColor, + }, + { + action: "completed", + status: "completed", + conclusion: "success", + expectText: "Workflow Step completed: build / Run tests:success", + expectColor: greenColor, + }, + { + action: "completed", + status: "completed", + conclusion: "failure", + expectText: "Workflow Step completed: build / Run tests:failure", + expectColor: redColor, + }, + { + action: "completed", + status: "completed", + conclusion: "cancelled", + expectText: "Workflow Step completed: build / Run tests:cancelled", + expectColor: yellowColor, + }, + { + action: "completed", + status: "completed", + conclusion: "skipped", + expectText: "Workflow Step completed: build / Run tests:skipped", + expectColor: purpleColor, + }, + } + + for i, c := range cases { + p := workflowStepTestPayload() + p.Action = c.action + p.Step.Status = c.status + p.Step.Conclusion = c.conclusion + text, color := getWorkflowStepPayloadInfo(p, noneLinkFormatter, false) + assert.Equal(t, c.expectText, text, "case %d", i) + assert.Equal(t, c.expectColor, color, "case %d", i) + } +} + func TestGetIssueCommentPayloadInfo(t *testing.T) { p := pullRequestCommentTestPayload() diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index fa01ecd0b1..7b4abe5a50 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -265,6 +265,12 @@ func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, return m.newPayload(text) } +func (m matrixConvertor) WorkflowStep(p *api.WorkflowStepPayload) (MatrixPayload, error) { + text, _ := getWorkflowStepPayloadInfo(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 34db903712..73b193ae0c 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -346,6 +346,20 @@ func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, ), nil } +func (msteamsConvertor) WorkflowStep(p *api.WorkflowStepPayload) (MSTeamsPayload, error) { + title, color := getWorkflowStepPayloadInfo(p, noneLinkFormatter, false) + + return createMSTeamsPayload( + p.Repo, + p.Sender, + title, + "", + p.WorkflowJob.HTMLURL, + color, + &MSTeamsFact{"WorkflowStep:", p.Step.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 7627935a32..bc12d6b5a2 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -984,6 +984,55 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo } } +func (*webhookNotifier) WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + source := EventSource{ + Repository: repo, + Owner: repo.Owner, + } + + var org *api.Organization + if repo.Owner.IsOrganization() { + org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) + } + + stepStatus, _ := convert.ToActionsStatus(step.Status) + + convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job) + if err != nil { + log.Error("ToActionWorkflowJob: %v", err) + return + } + + // Find the step index within the task steps for the Number field. + stepIndex := int(step.Index) + + convertedStep := convert.ToActionWorkflowStep(step, stepIndex) + + // Build a minimal WorkflowRun for the payload. + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, job.Run, nil) + if err != nil { + log.Error("ToActionWorkflowRun: %v", err) + return + } + + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowStep, &api.WorkflowStepPayload{ + Action: stepStatus, + WorkflowRun: convertedRun, + WorkflowJob: convertedJob, + Step: convertedStep, + 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 (*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, diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index e6a00b0293..cdb8a6ffd9 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -122,6 +122,10 @@ func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPa return PackagistPayload{}, nil } +func (pc packagistConvertor) WorkflowStep(_ *api.WorkflowStepPayload) (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 b607bf3250..b1ec74284c 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -31,6 +31,7 @@ type payloadConvertor[T any] interface { Status(*api.CommitStatusPayload) (T, error) WorkflowRun(*api.WorkflowRunPayload) (T, error) WorkflowJob(*api.WorkflowJobPayload) (T, error) + WorkflowStep(*api.WorkflowStepPayload) (T, error) } func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (t T, err error) { @@ -86,6 +87,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module return convertUnmarshalledJSON(rc.WorkflowRun, data) case webhook_module.HookEventWorkflowJob: return convertUnmarshalledJSON(rc.WorkflowJob, data) + case webhook_module.HookEventWorkflowStep: + return convertUnmarshalledJSON(rc.WorkflowStep, data) } return t, fmt.Errorf("newPayload unsupported event: %s", event) } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 94d41d2179..d237b19b24 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -185,6 +185,12 @@ func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, er return s.createPayload(text, nil), nil } +func (s slackConvertor) WorkflowStep(p *api.WorkflowStepPayload) (SlackPayload, error) { + text, _ := getWorkflowStepPayloadInfo(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/slack_test.go b/services/webhook/slack_test.go index 839ed6f770..1e31ef0368 100644 --- a/services/webhook/slack_test.go +++ b/services/webhook/slack_test.go @@ -155,6 +155,15 @@ func TestSlackPayload(t *testing.T) { assert.Equal(t, "[] Release created: by ", pl.Text) }) + + t.Run("WorkflowStep", func(t *testing.T) { + p := workflowStepTestPayload() + + pl, err := sc.WorkflowStep(p) + require.NoError(t, err) + + assert.Equal(t, "Workflow Step completed: by ", pl.Text) + }) } func TestSlackJSONPayload(t *testing.T) { diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index 8e9a53a5de..d040da289b 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -192,6 +192,12 @@ func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload return createTelegramPayloadHTML(text), nil } +func (telegramConvertor) WorkflowStep(p *api.WorkflowStepPayload) (TelegramPayload, error) { + text, _ := getWorkflowStepPayloadInfo(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 cac6a700c0..010adf1c49 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -193,6 +193,12 @@ func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (Wechatwork return newWechatworkMarkdownPayload(text), nil } +func (wc wechatworkConvertor) WorkflowStep(p *api.WorkflowStepPayload) (WechatworkPayload, error) { + text, _ := getWorkflowStepPayloadInfo(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 5f8ba7bbf1..b8e02b45d4 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -346,6 +346,16 @@ + + + + + + {{ctx.Locale.Tr "repo.settings.event_workflow_step"}} + {{ctx.Locale.Tr "repo.settings.event_workflow_step_desc"}} + + + diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 12a11d45bd..dfade358dd 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -35,9 +35,11 @@ import ( "code.gitea.io/gitea/tests" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "connectrpc.com/connect" "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestNewWebHookLink(t *testing.T) { @@ -1112,6 +1114,111 @@ jobs: }) } +func Test_WebhookWorkflowStep(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + var payloads []api.WorkflowStepPayload + var triggeredEvent string + provider := newMockWebhookProvider(func(r *http.Request) { + assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_step", "X-GitHub-Event-Type should contain workflow_step") + assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_step", "X-Gitea-Event-Type should contain workflow_step") + assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_step", "X-Gogs-Event-Type should contain workflow_step") + content, _ := io.ReadAll(r.Body) + var payload api.WorkflowStepPayload + err := json.Unmarshal(content, &payload) + assert.NoError(t, err) + payloads = append(payloads, payload) + triggeredEvent = "workflow_step" + }, http.StatusOK) + defer provider.Close() + + // 1. create a webhook subscribed to workflow_step 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_step") + + 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"}, false) + + // 2. push a workflow with a single step + wfTreePath := ".gitea/workflows/push.yml" + wfFileContent := `name: Push +on: push +jobs: + wf1-job: + runs-on: ubuntu-latest + steps: + - run: echo 'step one' +` + opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) + createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) + + commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + + // 3. no step webhooks yet — only job-level events have fired at this point + assert.Empty(t, payloads) + + // 4. fetch the task so the runner picks it up (step rows created in DB) + task := runner.fetchTask(t) + assert.NotNil(t, task) + + // 5. send an intermediate UpdateTask marking step 0 as in_progress + _, err = runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Steps: []*runnerv1.StepState{ + {Id: 0, StartedAt: timestamppb.Now()}, + }, + }, + })) + assert.NoError(t, err) + + assert.Equal(t, "workflow_step", triggeredEvent) + require.Len(t, payloads, 1) + assert.Equal(t, "in_progress", payloads[0].Action) + assert.Equal(t, "in_progress", payloads[0].Step.Status) + assert.Equal(t, commitID, payloads[0].WorkflowJob.HeadSha) + assert.Equal(t, "repo1", payloads[0].Repo.Name) + assert.Equal(t, "user2/repo1", payloads[0].Repo.FullName) + + // 6. send final UpdateTask marking the task as success and step 0 as completed + _, err = runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Result: runnerv1.Result_RESULT_SUCCESS, + StoppedAt: timestamppb.Now(), + Steps: []*runnerv1.StepState{ + { + Id: 0, + Result: runnerv1.Result_RESULT_SUCCESS, + StartedAt: timestamppb.Now(), + StoppedAt: timestamppb.Now(), + }, + }, + }, + })) + assert.NoError(t, err) + + assert.Equal(t, "workflow_step", triggeredEvent) + require.Len(t, payloads, 2) + assert.Equal(t, "completed", payloads[1].Action) + assert.Equal(t, "completed", payloads[1].Step.Status) + assert.Equal(t, "success", payloads[1].Step.Conclusion) + assert.Equal(t, commitID, payloads[1].WorkflowJob.HeadSha) + assert.Equal(t, "repo1", payloads[1].Repo.Name) + assert.Equal(t, "user2/repo1", payloads[1].Repo.FullName) + assert.NotNil(t, payloads[1].WorkflowRun) + assert.NotNil(t, payloads[1].WorkflowJob) + }) +} + type workflowRunWebhook struct { URL string payloads []api.WorkflowRunPayload