diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go index 356f5fdcc9..1f81caf424 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -299,6 +299,24 @@ func (w *Webhook) HasPackageEvent() bool { (w.ChooseEvents && w.Package) } +// HasActionRunFailureEvent returns if hook enabled action failure event. +func (w *Webhook) HasActionRunFailureEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.ActionRunFailure) +} + +// HasActionRunRecoverEvent returns if hook enabled action recover event. +func (w *Webhook) HasActionRunRecoverEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.ActionRunRecover) +} + +// HasActionRunSuccessEvent returns if hook enabled action success event. +func (w *Webhook) HasActionRunSuccessEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.ActionRunSuccess) +} + // HasPullRequestReviewRequestEvent returns true if hook enabled pull request review request event. func (w *Webhook) HasPullRequestReviewRequestEvent() bool { return w.SendEverything || @@ -337,6 +355,9 @@ func (w *Webhook) EventCheckers() []struct { {w.HasReleaseEvent, webhook_module.HookEventRelease}, {w.HasPackageEvent, webhook_module.HookEventPackage}, {w.HasPullRequestReviewRequestEvent, webhook_module.HookEventPullRequestReviewRequest}, + {w.HasActionRunFailureEvent, webhook_module.HookEventActionRunFailure}, + {w.HasActionRunRecoverEvent, webhook_module.HookEventActionRunRecover}, + {w.HasActionRunSuccessEvent, webhook_module.HookEventActionRunSuccess}, } } diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index e70c3b2557..60cd2b333b 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -74,7 +74,8 @@ 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", "wiki", "repository", "release", - "package", "pull_request_review_request", + "package", "pull_request_review_request", "action_run_failure", + "action_run_recover", "action_run_success", }, (&Webhook{ HookEvent: &webhook_module.HookEvent{SendEverything: true}, @@ -89,15 +90,78 @@ func TestWebhook_EventsArray(t *testing.T) { } func TestCreateWebhook(t *testing.T) { - hook := &Webhook{ - RepoID: 3, - URL: "https://www.example.com/unit_test", - ContentType: ContentTypeJSON, - Events: `{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}`, - } - unittest.AssertNotExistsBean(t, hook) - require.NoError(t, CreateWebhook(db.DefaultContext, hook)) - unittest.AssertExistsAndLoadBean(t, hook) + t.Run("Some chosen events 1", func(t *testing.T) { + hook := &Webhook{ + RepoID: 3, + URL: "https://www.example.com/unit_test", + ContentType: ContentTypeJSON, + Events: `{"push_only":false,"send_everything":false,"choose_events":true,"events":{"create":false,"push":true,"pull_request":true}}`, + } + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, CreateWebhook(db.DefaultContext, hook)) + hookFromDb := unittest.AssertExistsAndLoadBean(t, hook) + assert.Equal(t, []string{ + string(webhook_module.HookEventPush), + string(webhook_module.HookEventPullRequest), + }, hookFromDb.EventsArray()) + }) + + t.Run("Some chosen events 2", func(t *testing.T) { + hook := &Webhook{ + RepoID: 3, + URL: "https://www.example.com/unit_test", + ContentType: ContentTypeJSON, + Events: `{"push_only":false,"send_everything":false,"choose_events":true,"events":{"action_run_recover":false,"action_run_success":true}}`, + } + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, CreateWebhook(db.DefaultContext, hook)) + hookFromDb := unittest.AssertExistsAndLoadBean(t, hook) + assert.Equal(t, []string{string(webhook_module.HookEventActionRunSuccess)}, hookFromDb.EventsArray()) + }) + + t.Run("All events", func(t *testing.T) { + hook := &Webhook{ + RepoID: 3, + URL: "https://www.example.com/unit_test", + ContentType: ContentTypeJSON, + Events: `{"push_only":false,"send_everything":false,"choose_events":true,"events":{"create":true,"delete":true,"fork":true,"issues":true,"issue_assign":true,"issue_label":true,"issue_milestone":true,"issue_comment":true,"push":true,"pull_request":true,"pull_request_assign":true,"pull_request_label":true,"pull_request_milestone":true,"pull_request_comment":true,"pull_request_review":true,"pull_request_sync":true,"pull_request_review_request":true,"wiki":true,"repository":true,"release":true,"package":true,"action_run_failure":true,"action_run_recover":true,"action_run_success":true}}`, + } + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, CreateWebhook(db.DefaultContext, hook)) + hookFromDb := unittest.AssertExistsAndLoadBean(t, hook) + assert.Equal(t, []string{ + string(webhook_module.HookEventCreate), + string(webhook_module.HookEventDelete), + string(webhook_module.HookEventFork), + string(webhook_module.HookEventPush), + string(webhook_module.HookEventIssues), + string(webhook_module.HookEventIssueAssign), + string(webhook_module.HookEventIssueLabel), + string(webhook_module.HookEventIssueMilestone), + string(webhook_module.HookEventIssueComment), + string(webhook_module.HookEventPullRequest), + string(webhook_module.HookEventPullRequestAssign), + string(webhook_module.HookEventPullRequestLabel), + string(webhook_module.HookEventPullRequestMilestone), + string(webhook_module.HookEventPullRequestComment), + string(webhook_module.HookEventPullRequestReviewApproved), + string(webhook_module.HookEventPullRequestReviewRejected), + string(webhook_module.HookEventPullRequestReviewComment), + string(webhook_module.HookEventPullRequestSync), + string(webhook_module.HookEventWiki), + string(webhook_module.HookEventRepository), + string(webhook_module.HookEventRelease), + string(webhook_module.HookEventPackage), + string(webhook_module.HookEventPullRequestReviewRequest), + // these aren't webhook event types + // string(webhook_module.HookEventSchedule), + // string(webhook_module.HookEventWorkflowDispatch), + string(webhook_module.HookEventActionRunFailure), + string(webhook_module.HookEventActionRunRecover), + string(webhook_module.HookEventActionRunSuccess), + }, + hookFromDb.EventsArray()) + }) } func TestGetWebhookByRepoID(t *testing.T) { diff --git a/modules/structs/action.go b/modules/structs/action.go index df9f845adc..2c42365c19 100644 --- a/modules/structs/action.go +++ b/modules/structs/action.go @@ -3,6 +3,10 @@ package structs +import ( + "time" +) + // ActionRunJob represents a job of a run // swagger:model type ActionRunJob struct { @@ -23,3 +27,54 @@ type ActionRunJob struct { // the action run job status Status string `json:"status"` } + +// ActionRun represents an action run +// swagger:model +type ActionRun struct { + // the action run id + ID int64 `json:"id"` + // the action run's title + Title string `json:"title"` + // the repo this action is part of + Repo *Repository `json:"repository"` + // the name of workflow file + WorkflowID string `json:"workflow_id"` + // a unique number for each run of a repository + Index int64 `json:"index_in_repo"` + // the user that triggered this action run + TriggerUser *User `json:"trigger_user"` + // the cron id for the schedule trigger + ScheduleID int64 + // the commit/tag/… the action run ran on + PrettyRef string `json:"prettyref"` + // has the commit/tag/… the action run ran on been deleted + IsRefDeleted bool `json:"is_ref_deleted"` + // the commit sha the action run ran on + CommitSHA string `json:"commit_sha"` + // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow. + IsForkPullRequest bool `json:"is_fork_pull_request"` + // may need approval if it's a fork pull request + NeedApproval bool `json:"need_approval"` + // who approved this action run + ApprovedBy int64 `json:"approved_by"` + // the webhook event that causes the workflow to run + Event string `json:"event"` + // the payload of the webhook event that causes the workflow to run + EventPayload string `json:"event_payload"` + // the trigger event defined in the `on` configuration of the triggered workflow + TriggerEvent string `json:"trigger_event"` + // the current status of this run + Status string `json:"status"` + // when the action run was started + Started time.Time `json:"started,omitempty"` + // when the action run was stopped + Stopped time.Time `json:"stopped,omitempty"` + // when the action run was created + Created time.Time `json:"created,omitempty"` + // when the action run was last updated + Updated time.Time `json:"updated,omitempty"` + // how long the action run ran for + Duration time.Duration `json:"duration,omitempty"` + // the url of this action run + HTMLURL string `json:"html_url"` +} diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 28c2e00588..5adcad0881 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -119,6 +119,7 @@ var ( _ Payloader = &RepositoryPayload{} _ Payloader = &ReleasePayload{} _ Payloader = &PackagePayload{} + _ Payloader = &ActionPayload{} ) // _________ __ @@ -484,3 +485,36 @@ type PackagePayload struct { func (p *PackagePayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } + +// _ _ _ +// / \ ___| |_(_) ___ _ __ +// / _ \ / __| __| |/ _ \| '_ \ +// / ___ \ (__| |_| | (_) | | | | +// /_/ \_\___|\__|_|\___/|_| |_| + +// this name is ridiculous, yes +// it's the sub-type of hook that has something to do with Forgejo Actions +type HookActionAction string + +const ( + HookActionFailure HookActionAction = "failure" + HookActionRecover HookActionAction = "recover" + HookActionSuccess HookActionAction = "success" +) + +// ActionPayload payload for action webhooks +type ActionPayload struct { + Action HookActionAction `json:"action"` + Run *ActionRun `json:"run"` + // the status of this run before it completed + // this must be a not done status + PriorStatus string `json:"prior_status"` + // the last run for the same workflow + // could be nil when Run is the first for it's workflow + LastRun *ActionRun `json:"last_run,omitempty"` +} + +// JSONPayload return payload information +func (p *ActionPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} diff --git a/modules/webhook/structs.go b/modules/webhook/structs.go index 927a91a74c..6c0161bfc2 100644 --- a/modules/webhook/structs.go +++ b/modules/webhook/structs.go @@ -4,6 +4,7 @@ package webhook // HookEvents is a set of web hook events +// update TestCreateWebhook in models/webhook/webhook_test.go when adding or changing values here type HookEvents struct { Create bool `json:"create"` Delete bool `json:"delete"` @@ -26,9 +27,12 @@ type HookEvents struct { Repository bool `json:"repository"` Release bool `json:"release"` Package bool `json:"package"` + ActionRunFailure bool `json:"action_run_failure"` + ActionRunRecover bool `json:"action_run_recover"` + ActionRunSuccess bool `json:"action_run_success"` } -// HookEvent represents events that will delivery hook. +// HookEvent represents events that will deliver a hook. type HookEvent struct { PushOnly bool `json:"push_only"` SendEverything bool `json:"send_everything"` diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 244dc423c1..e833f90f58 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -7,6 +7,7 @@ package webhook type HookEventType string // Types of hook events +// update TestCreateWebhook in models/webhook/webhook_test.go when adding or changing values here const ( HookEventCreate HookEventType = "create" HookEventDelete HookEventType = "delete" @@ -33,6 +34,9 @@ const ( HookEventPackage HookEventType = "package" HookEventSchedule HookEventType = "schedule" HookEventWorkflowDispatch HookEventType = "workflow_dispatch" + HookEventActionRunFailure HookEventType = "action_run_failure" + HookEventActionRunRecover HookEventType = "action_run_recover" + HookEventActionRunSuccess HookEventType = "action_run_success" ) // Event returns the HookEventType as an event string @@ -65,6 +69,12 @@ func (h HookEventType) Event() string { return "repository" case HookEventRelease: return "release" + case HookEventActionRunFailure: + return "action_run_failure" + case HookEventActionRunRecover: + return "action_run_recover" + case HookEventActionRunSuccess: + return "action_run_success" } return "" } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1cd824b45d..e43276a122 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2486,6 +2486,13 @@ settings.event_pull_request_review_request_desc = Pull request review requested settings.event_pull_request_approvals = Pull request approvals settings.event_pull_request_merge = Pull request merge settings.event_pull_request_enforcement = Enforcement +settings.event_header_action = Action Run events +settings.event_action_failure = Failure +settings.event_action_failure_desc = Action Run ended as failure. +settings.event_action_recover = Recover +settings.event_action_recover_desc = Action Run succeeded after last Action Run in the same workflow failed. +settings.event_action_success = Success +settings.event_action_success_desc = Action Run succeeded. settings.event_package = Package settings.event_package_desc = Package created or deleted in a repository. settings.branch_filter = Branch filter diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 6d4d9e47e2..0caa196e25 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -175,6 +175,9 @@ func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent { Wiki: form.Wiki, Repository: form.Repository, Package: form.Package, + ActionRunFailure: form.ActionFailure, + ActionRunRecover: form.ActionRecover, + ActionRunSuccess: form.ActionSuccess, }, BranchFilter: form.BranchFilter, } diff --git a/services/convert/action.go b/services/convert/action.go new file mode 100644 index 0000000000..5e17172b45 --- /dev/null +++ b/services/convert/action.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package convert + +import ( + "context" + + actions_model "forgejo.org/models/actions" + access_model "forgejo.org/models/perm/access" + api "forgejo.org/modules/structs" +) + +// ToActionRun convert actions_model.User to api.ActionRun +// the run needs all attributes loaded +func ToActionRun(ctx context.Context, run *actions_model.ActionRun) *api.ActionRun { + if run == nil { + return nil + } + + // The doer is the one whose perspective is used to view this ActionRun. + // In the best case we use the user that created the webhook. + // Unfortunately we don't know who that was. + // So instead we use the repo owner, who is able to create webhooks and allow others to do so by making them repo admins. + // This is pretty close to perfect. + doer := run.Repo.Owner + permissionInRepo, _ := access_model.GetUserRepoPermission(ctx, run.Repo, doer) + + return &api.ActionRun{ + ID: run.ID, + Title: run.Title, + Repo: ToRepo(ctx, run.Repo, permissionInRepo), + WorkflowID: run.WorkflowID, + Index: run.Index, + TriggerUser: ToUser(ctx, run.TriggerUser, doer), + ScheduleID: run.ScheduleID, + PrettyRef: run.PrettyRef(), + IsRefDeleted: run.IsRefDeleted, + CommitSHA: run.CommitSHA, + IsForkPullRequest: run.IsForkPullRequest, + NeedApproval: run.NeedApproval, + ApprovedBy: run.ApprovedBy, + Event: run.Event.Event(), + EventPayload: run.EventPayload, + TriggerEvent: run.TriggerEvent, + Status: run.Status.String(), + Started: run.Started.AsTime(), + Stopped: run.Stopped.AsTime(), + Created: run.Created.AsTime(), + Updated: run.Updated.AsTime(), + Duration: run.Duration(), + HTMLURL: run.HTMLURL(), + } +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 4a46c9cc5f..bb81e939b0 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -277,6 +277,9 @@ type WebhookCoreForm struct { Wiki bool Repository bool Package bool + ActionFailure bool + ActionRecover bool + ActionSuccess bool Active bool BranchFilter string `binding:"GlobPattern"` AuthorizationHeader string diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 9d5c7e573f..ec53c79c2c 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -207,6 +207,12 @@ func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, err return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil } +func (dc dingtalkConvertor) Action(p *api.ActionPayload) (DingtalkPayload, error) { + text, _ := getActionPayloadInfo(p, noneLinkFormatter) + + return createDingtalkPayload(text, text, "view action", p.Run.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 2b58bf892d..db98d40583 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -325,6 +325,12 @@ func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error) return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil } +func (d discordConvertor) Action(p *api.ActionPayload) (DiscordPayload, error) { + text, color := getActionPayloadInfo(p, noneLinkFormatter) + + return d.createPayload(p.Run.TriggerUser, text, "", p.Run.HTMLURL, color), nil +} + var _ shared.PayloadConvertor[DiscordPayload] = discordConvertor{} func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 01b3d07983..57f2362783 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -191,6 +191,12 @@ func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error) return newFeishuTextPayload(text), nil } +func (fc feishuConvertor) Action(p *api.ActionPayload) (FeishuPayload, error) { + text, _ := getActionPayloadInfo(p, noneLinkFormatter) + + return newFeishuTextPayload(text), nil +} + type feishuConvertor struct{} var _ shared.PayloadConvertor[FeishuPayload] = feishuConvertor{} diff --git a/services/webhook/general.go b/services/webhook/general.go index 176a9a27f9..c728b6ba1a 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -304,6 +304,25 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, w return text, color } +func getActionPayloadInfo(p *api.ActionPayload, linkFormatter linkFormatter) (text string, color int) { + runLink := linkFormatter(p.Run.HTMLURL, p.Run.Title) + repoLink := linkFormatter(p.Run.Repo.HTMLURL, p.Run.Repo.FullName) + + switch p.Action { + case api.HookActionFailure: + text = fmt.Sprintf("%s Action Failed in %s %s", runLink, repoLink, p.Run.PrettyRef) + color = redColor + case api.HookActionRecover: + text = fmt.Sprintf("%s Action Recovered in %s %s", runLink, repoLink, p.Run.PrettyRef) + color = greenColor + case api.HookActionSuccess: + text = fmt.Sprintf("%s Action Succeeded in %s %s", runLink, repoLink, p.Run.PrettyRef) + color = greenColor + } + + 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 b321fb3f8c..10c779742d 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -270,6 +270,22 @@ func pullReleaseTestPayload() *api.ReleasePayload { } } +func ActionTestPayload() *api.ActionPayload { + // this is not a complete action payload but enough for testing purposes + return &api.ActionPayload{ + Run: &api.ActionRun{ + Repo: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + PrettyRef: "main", + HTMLURL: "http://localhost:3000/test/repo/actions/runs/69", + Title: "Build release", + }, + } +} + func pullRequestTestPayload() *api.PullRequestPayload { return &api.PullRequestPayload{ Action: api.HookIssueOpened, @@ -675,3 +691,36 @@ func TestGetIssueCommentPayloadInfo(t *testing.T) { assert.Equal(t, c.color, color, "case %d", i) } } + +func TestGetActionPayloadInfo(t *testing.T) { + p := ActionTestPayload() + + cases := []struct { + action api.HookActionAction + text string + color int + }{ + { + api.HookActionFailure, + "Build release Action Failed in test/repo main", + redColor, + }, + { + api.HookActionSuccess, + "Build release Action Succeeded in test/repo main", + greenColor, + }, + { + api.HookActionRecover, + "Build release Action Recovered in test/repo main", + greenColor, + }, + } + + for i, c := range cases { + p.Action = c.action + text, color := getActionPayloadInfo(p, noneLinkFormatter) + assert.Equal(t, c.text, text, "case %d", i) + assert.Equal(t, c.color, color, "case %d", i) + } +} diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index f1cc9384d3..bdb0c292ab 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -273,6 +273,12 @@ func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) { return m.newPayload(text) } +func (m matrixConvertor) Action(p *api.ActionPayload) (MatrixPayload, error) { + text, _ := getActionPayloadInfo(p, htmlLinkFormatter) + + 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 1ed03afd26..3b35c407e1 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -326,6 +326,23 @@ func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) ), nil } +func (m msteamsConvertor) Action(p *api.ActionPayload) (MSTeamsPayload, error) { + title, color := getActionPayloadInfo(p, noneLinkFormatter) + + // TODO: is TriggerUser correct here? + // if you'd like to test these proprietary services, see the discussion on: https://codeberg.org/forgejo/forgejo/pulls/7508 + return createMSTeamsPayload( + p.Run.Repo, + p.Run.TriggerUser, + title, + "", + p.Run.HTMLURL, + color, + // TODO: does this make any sense? + &MSTeamsFact{"Action:", p.Run.Title}, + ), 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 e9fd52c940..b3201e5d10 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -6,6 +6,7 @@ package webhook import ( "context" + actions_model "forgejo.org/models/actions" issues_model "forgejo.org/models/issues" packages_model "forgejo.org/models/packages" "forgejo.org/models/perm" @@ -887,6 +888,38 @@ func (m *webhookNotifier) PackageDelete(ctx context.Context, doer *user_model.Us notifyPackage(ctx, doer, pd, api.HookPackageDeleted) } +func (m *webhookNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) { + source := EventSource{ + Repository: run.Repo, + Owner: run.TriggerUser, + } + + payload := &api.ActionPayload{ + Run: convert.ToActionRun(ctx, run), + LastRun: convert.ToActionRun(ctx, lastRun), + PriorStatus: priorStatus.String(), + } + + if run.Status.IsSuccess() { + payload.Action = api.HookActionSuccess + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunSuccess, payload); err != nil { + log.Error("PrepareWebhooks: %v", err) + } + // send another event when this is a recover + if lastRun != nil && !lastRun.Status.IsSuccess() { + payload.Action = api.HookActionRecover + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunRecover, payload); err != nil { + log.Error("PrepareWebhooks: %v", err) + } + } + } else { + payload.Action = api.HookActionFailure + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunFailure, payload); err != nil { + log.Error("PrepareWebhooks: %v", err) + } + } +} + func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) { source := EventSource{ Repository: pd.Repository, diff --git a/services/webhook/notifier_test.go b/services/webhook/notifier_test.go index b57990a9d8..a810de91c1 100644 --- a/services/webhook/notifier_test.go +++ b/services/webhook/notifier_test.go @@ -6,6 +6,7 @@ package webhook import ( "testing" + actions_model "forgejo.org/models/actions" "forgejo.org/models/db" repo_model "forgejo.org/models/repo" "forgejo.org/models/unittest" @@ -13,10 +14,12 @@ import ( webhook_model "forgejo.org/models/webhook" "forgejo.org/modules/git" "forgejo.org/modules/json" + "forgejo.org/modules/log" "forgejo.org/modules/repository" "forgejo.org/modules/setting" "forgejo.org/modules/structs" "forgejo.org/modules/test" + webhook_module "forgejo.org/modules/webhook" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -119,3 +122,190 @@ func TestPushCommits(t *testing.T) { assert.Equal(t, "2c54faec6c45d31c1abfaecdab471eac6633738a", payloadContent.Commits[0].ID) }) } + +func assertActionEqual(t *testing.T, expectedRun *actions_model.ActionRun, actualRun *structs.ActionRun) { + assert.NotNil(t, expectedRun) + assert.NotNil(t, actualRun) + // only test a few things + assert.Equal(t, expectedRun.ID, actualRun.ID) + assert.Equal(t, expectedRun.Status.String(), actualRun.Status) + assert.Equal(t, expectedRun.Index, actualRun.Index) + assert.Equal(t, expectedRun.RepoID, actualRun.Repo.ID) + // convert to unix because of time zones + assert.Equal(t, expectedRun.Stopped.AsTime().Unix(), actualRun.Stopped.Unix()) + assert.Equal(t, expectedRun.Title, actualRun.Title) + assert.Equal(t, expectedRun.WorkflowID, actualRun.WorkflowID) +} + +func TestAction(t *testing.T) { + defer unittest.OverrideFixtures("services/webhook/TestPushCommits")() + require.NoError(t, unittest.PrepareTestDatabase()) + + triggerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: triggerUser.ID}) + + oldSuccessRun := &actions_model.ActionRun{ + ID: 1, + Status: actions_model.StatusSuccess, + Index: 1, + RepoID: repo.ID, + Stopped: 1693648027, + WorkflowID: "some_workflow", + Title: "oldSuccessRun", + TriggerUser: triggerUser, + TriggerUserID: triggerUser.ID, + TriggerEvent: "push", + } + oldSuccessRun.LoadAttributes(db.DefaultContext) + oldFailureRun := &actions_model.ActionRun{ + ID: 1, + Status: actions_model.StatusFailure, + Index: 1, + RepoID: repo.ID, + Stopped: 1693648027, + WorkflowID: "some_workflow", + Title: "oldFailureRun", + TriggerUser: triggerUser, + TriggerUserID: triggerUser.ID, + TriggerEvent: "push", + } + oldFailureRun.LoadAttributes(db.DefaultContext) + newSuccessRun := &actions_model.ActionRun{ + ID: 1, + Status: actions_model.StatusSuccess, + Index: 1, + RepoID: repo.ID, + Stopped: 1693648327, + WorkflowID: "some_workflow", + Title: "newSuccessRun", + TriggerUser: triggerUser, + TriggerUserID: triggerUser.ID, + TriggerEvent: "push", + } + newSuccessRun.LoadAttributes(db.DefaultContext) + newFailureRun := &actions_model.ActionRun{ + ID: 1, + Status: actions_model.StatusFailure, + Index: 1, + RepoID: repo.ID, + Stopped: 1693648327, + WorkflowID: "some_workflow", + Title: "newFailureRun", + TriggerUser: triggerUser, + TriggerUserID: triggerUser.ID, + TriggerEvent: "push", + } + newFailureRun.LoadAttributes(db.DefaultContext) + + t.Run("Successful Run after Nothing", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, nil) + + // there's only one of these at the time + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionSuccess, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newSuccessRun, payloadContent.Run) + assert.Nil(t, payloadContent.LastRun) + }) + + t.Run("Successful Run after Failure", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, oldFailureRun) + + { + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%oldFailureRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionSuccess, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newSuccessRun, payloadContent.Run) + assertActionEqual(t, oldFailureRun, payloadContent.LastRun) + } + { + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_recover' AND payload_content LIKE '%recover%newSuccessRun%oldFailureRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunRecover, hookTask.EventType) + + log.Error("something: %s", hookTask.PayloadContent) + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionRecover, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newSuccessRun, payloadContent.Run) + assertActionEqual(t, oldFailureRun, payloadContent.LastRun) + } + }) + + t.Run("Successful Run after Success", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, oldSuccessRun) + + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%oldSuccessRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionSuccess, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newSuccessRun, payloadContent.Run) + assertActionEqual(t, oldSuccessRun, payloadContent.LastRun) + }) + + t.Run("Failed Run after Nothing", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, nil) + + // there should only be this one at the time + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionFailure, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newFailureRun, payloadContent.Run) + assert.Nil(t, payloadContent.LastRun) + }) + + t.Run("Failed Run after Failure", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, oldFailureRun) + + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%oldFailureRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionFailure, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newFailureRun, payloadContent.Run) + assertActionEqual(t, oldFailureRun, payloadContent.LastRun) + }) + + t.Run("Failed Run after Success", func(t *testing.T) { + defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)() + + NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, oldSuccessRun) + + hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%oldSuccessRun%'")) + assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType) + + var payloadContent structs.ActionPayload + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent)) + assert.Equal(t, structs.HookActionFailure, payloadContent.Action) + assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus) + assertActionEqual(t, newFailureRun, payloadContent.Run) + assertActionEqual(t, oldSuccessRun, payloadContent.LastRun) + }) +} diff --git a/services/webhook/shared/payloader.go b/services/webhook/shared/payloader.go index 0a6535eddb..e3be4c4b4c 100644 --- a/services/webhook/shared/payloader.go +++ b/services/webhook/shared/payloader.go @@ -36,6 +36,7 @@ type PayloadConvertor[T any] interface { Release(*api.ReleasePayload) (T, error) Wiki(*api.WikiPayload) (T, error) Package(*api.PackagePayload) (T, error) + Action(*api.ActionPayload) (T, error) } func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (T, error) { @@ -86,6 +87,8 @@ func NewPayload[T any](rc PayloadConvertor[T], data []byte, event webhook_module return convertUnmarshalledJSON(rc.Wiki, data) case webhook_module.HookEventPackage: return convertUnmarshalledJSON(rc.Package, data) + case webhook_module.HookEventActionRunFailure, webhook_module.HookEventActionRunRecover, webhook_module.HookEventActionRunSuccess: + return convertUnmarshalledJSON(rc.Action, data) } var t T return t, fmt.Errorf("newPayload unsupported event: %s", event) diff --git a/services/webhook/slack.go b/services/webhook/slack.go index e854f89c6c..8c61e7ba25 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -142,6 +142,7 @@ func SlackLinkToRef(repoURL, ref string) string { return SlackLinkFormatter(url, refName) } +// TODO: fix spelling to Converter // Create implements payloadConvertor Create method func (s slackConvertor) Create(p *api.CreatePayload) (SlackPayload, error) { refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref) @@ -311,6 +312,12 @@ func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, erro return s.createPayload(text, nil), nil } +func (s slackConvertor) Action(p *api.ActionPayload) (SlackPayload, error) { + text, _ := getActionPayloadInfo(p, SlackLinkFormatter) + + return s.createPayload(text, nil), nil +} + func (s slackConvertor) createPayload(text string, attachments []SlackAttachment) SlackPayload { return SlackPayload{ Channel: s.Channel, diff --git a/services/webhook/sourcehut/builds.go b/services/webhook/sourcehut/builds.go index bd3eeebc6c..2593afb0b2 100644 --- a/services/webhook/sourcehut/builds.go +++ b/services/webhook/sourcehut/builds.go @@ -190,6 +190,10 @@ func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buil return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported } +func (pc sourcehutConvertor) Action(_ *api.ActionPayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + // newPayload opens and adjusts the manifest to submit to the builds service // // in case of an error the Error field will be set, to be visible by the end-user under recent deliveries diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index d0abd667f4..e6897f68bc 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -205,6 +205,12 @@ func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, erro return createTelegramPayload(text), nil } +func (telegramConvertor) Action(p *api.ActionPayload) (TelegramPayload, error) { + text, _ := getActionPayloadInfo(p, htmlLinkFormatter) + + return createTelegramPayload(text), nil +} + func createTelegramPayload(message string) TelegramPayload { return TelegramPayload{ Message: markup.Sanitize(strings.TrimSpace(message)), diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index 989b535564..ecbbfcfbd6 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -103,7 +103,7 @@ type EventSource struct { Owner *user_model.User } -// handle delivers hook tasks +// handler delivers hook tasks func handler(items ...int64) []int64 { ctx := graceful.GetManager().HammerContext() diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index 323d23aba7..5c765b0754 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -201,6 +201,12 @@ func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload, return newWechatworkMarkdownPayload(text), nil } +func (wc wechatworkConvertor) Action(p *api.ActionPayload) (WechatworkPayload, error) { + text, _ := getActionPayloadInfo(p, noneLinkFormatter) + + return newWechatworkMarkdownPayload(text), nil +} + type wechatworkConvertor struct{} var _ shared.PayloadConvertor[WechatworkPayload] = wechatworkConvertor{} diff --git a/templates/webhook/shared-settings.tmpl b/templates/webhook/shared-settings.tmpl index b6c05b78ed..60b09ab172 100644 --- a/templates/webhook/shared-settings.tmpl +++ b/templates/webhook/shared-settings.tmpl @@ -153,6 +153,28 @@ {{ctx.Locale.Tr "repo.settings.event_pull_request_review_request_desc"}} + +
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index be1b9cbcec..ffddd47faa 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -337,6 +337,8 @@ func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSe resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK) htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + testWebhookFormsSharedChooseEvents(t, htmlForm) + // fill the form payload := map[string]string{ "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), @@ -472,3 +474,37 @@ func assertHasFlashMessages(t *testing.T, resp *httptest.ResponseRecorder, expec t.Errorf("unexpected flash message %q: %q", k, v) } } + +func testWebhookFormsSharedChooseEvents(t *testing.T, htmlForm *goquery.Selection) { + webhookTypes := []string{ + "create", + "delete", + "fork", + "push", + "repository", + "release", + "package", + "wiki", + "issues", + "issue_assign", + "issue_label", + "issue_milestone", + "issue_comment", + "pull_request", + "pull_request_assign", + "pull_request_label", + "pull_request_milestone", + "pull_request_comment", + "pull_request_review", + "pull_request_sync", + "pull_request_review_request", + "action_failure", + "action_recover", + "action_success", + } + + // check all types of webhooks are present in the form + for _, webhookType := range webhookTypes { + assertInput(t, htmlForm, webhookType) + } +}