Actions Failure, Succes, Recover Webhooks (#7508)

Implement Actions Success, Failure and Recover webhooks for Forgejo, Gitea, Gogs, Slack, Discord, DingTalk, Telegram, Microsoft Teams, Feishu / Lark Suite, Matrix, WeCom (Wechat Work), Packagist. Some of these webhooks have not been manually tested.

Implement settings for these new webhooks.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/7508): <!--number 7508 --><!--line 0 --><!--description QWN0aW9ucyBGYWlsdXJlLCBTdWNjZXMsIFJlY292ZXIgV2ViaG9va3M=-->Actions Failure, Succes, Recover Webhooks<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7508
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: christopher-besch <mail@chris-besch.com>
Co-committed-by: christopher-besch <mail@chris-besch.com>
This commit is contained in:
christopher-besch 2025-06-03 14:29:19 +02:00 committed by Earl Warren
parent 6ce9d764bc
commit d17aa98262
27 changed files with 683 additions and 12 deletions

View file

@ -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},
}
}

View file

@ -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) {

View file

@ -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"`
}

View file

@ -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, "", " ")
}

View file

@ -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"`

View file

@ -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 ""
}

View file

@ -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

View file

@ -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,
}

View file

@ -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(),
}
}

View file

@ -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

View file

@ -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",

View file

@ -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) {

View file

@ -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{}

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
func getMessageBody(htmlText string) string {

View file

@ -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 {

View file

@ -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,

View file

@ -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)
})
}

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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)),

View file

@ -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()

View file

@ -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{}

View file

@ -153,6 +153,28 @@
<span class="help">{{ctx.Locale.Tr "repo.settings.event_pull_request_review_request_desc"}}</span>
</label>
</fieldset>
<!-- Action Run Events -->
<fieldset class="simple-grid grid-2">
<legend>{{ctx.Locale.Tr "repo.settings.event_header_action"}}</legend>
<!-- Action Run Failure -->
<label>
<input name="action_failure" type="checkbox" {{if .Webhook.ActionRunFailure}}checked{{end}}>
{{ctx.Locale.Tr "repo.settings.event_action_failure"}}
<span class="help">{{ctx.Locale.Tr "repo.settings.event_action_failure_desc"}}</span>
</label>
<!-- Action Run Recover -->
<label>
<input name="action_recover" type="checkbox" {{if .Webhook.ActionRunRecover}}checked{{end}}>
{{ctx.Locale.Tr "repo.settings.event_action_recover"}}
<span class="help">{{ctx.Locale.Tr "repo.settings.event_action_recover_desc"}}</span>
</label>
<!-- Action Run Success -->
<label>
<input name="action_success" type="checkbox" {{if .Webhook.ActionRunSuccess}}checked{{end}}>
{{ctx.Locale.Tr "repo.settings.event_action_success"}}
<span class="help">{{ctx.Locale.Tr "repo.settings.event_action_success_desc"}}</span>
</label>
</fieldset>
</fieldset>
</fieldset>
</div>

View file

@ -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)
}
}