From 29962aa2c9c7ad82df8d6f5761cdee13661d7533 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Tue, 3 Mar 2026 17:39:28 +0200 Subject: [PATCH 1/8] WIP: Adding support for workflow run events --- server/plugin/command.go | 22 ++++--- server/plugin/subscriptions.go | 12 ++++ server/plugin/template.go | 7 +++ server/plugin/template_test.go | 102 +++++++++++++++++++++++++++++++++ server/plugin/webhook.go | 68 ++++++++++++++++++++++ 5 files changed, 202 insertions(+), 9 deletions(-) diff --git a/server/plugin/command.go b/server/plugin/command.go index 9c4d273a0..a9c2076b3 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -33,10 +33,12 @@ const ( featurePullReviews = "pull_reviews" featureStars = "stars" featureReleases = "releases" - featureWorkflowFailure = "workflow_failure" - featureWorkflowSuccess = "workflow_success" - featureDiscussions = "discussions" - featureDiscussionComments = "discussion_comments" + featureWorkflowFailure = "workflow_failure" + featureWorkflowSuccess = "workflow_success" + featureWorkflowRunFailure = "workflow_run_failure" + featureWorkflowRunSuccess = "workflow_run_success" + featureDiscussions = "discussions" + featureDiscussionComments = "discussion_comments" ) const ( @@ -60,10 +62,12 @@ var validFeatures = map[string]bool{ featurePullReviews: true, featureStars: true, featureReleases: true, - featureWorkflowFailure: true, - featureWorkflowSuccess: true, - featureDiscussions: true, - featureDiscussionComments: true, + featureWorkflowFailure: true, + featureWorkflowSuccess: true, + featureWorkflowRunFailure: true, + featureWorkflowRunSuccess: true, + featureDiscussions: true, + featureDiscussionComments: true, } type Features string @@ -1137,7 +1141,7 @@ func getAutocompleteData(config *Configuration) *model.AutocompleteData { subscriptionsAdd := model.NewAutocompleteData("add", "[owner/repo] [features] [flags]", "Subscribe the current channel to receive notifications about opened pull requests and issues for an organization or repository. [features] and [flags] are optional arguments") subscriptionsAdd.AddTextArgument("Owner/repo to subscribe to", "[owner/repo]", "") - subscriptionsAdd.AddNamedTextArgument("features", "Comma-delimited list of one or more of: issues, pulls, pulls_merged, pulls_created, pushes, creates, deletes, issue_creations, issue_comments, pull_reviews, releases, workflow_success, workflow_failure, discussions, discussion_comments, label:\"\". Defaults to pulls,issues,creates,deletes", "", `/[^,-\s]+(,[^,-\s]+)*/`, false) + subscriptionsAdd.AddNamedTextArgument("features", "Comma-delimited list of one or more of: issues, pulls, pulls_merged, pulls_created, pushes, creates, deletes, issue_creations, issue_comments, pull_reviews, releases, workflow_success, workflow_failure, workflow_run_failure, workflow_run_success, discussions, discussion_comments, label:\"\". Defaults to pulls,issues,creates,deletes", "", `/[^,-\s]+(,[^,-\s]+)*/`, false) if config.GitHubOrg != "" { subscriptionsAdd.AddNamedStaticListArgument("exclude-org-member", "Events triggered by organization members will not be delivered (the organization config should be set, otherwise this flag has not effect)", false, []model.AutocompleteListItem{ diff --git a/server/plugin/subscriptions.go b/server/plugin/subscriptions.go index 2f9f10a9d..6b13ac131 100644 --- a/server/plugin/subscriptions.go +++ b/server/plugin/subscriptions.go @@ -147,6 +147,18 @@ func (s *Subscription) Workflows() bool { return strings.Contains(s.Features.String(), featureWorkflowFailure) || strings.Contains(s.Features.String(), featureWorkflowSuccess) } +func (s *Subscription) WorkflowRuns() bool { + return strings.Contains(s.Features.String(), featureWorkflowRunFailure) || strings.Contains(s.Features.String(), featureWorkflowRunSuccess) +} + +func (s *Subscription) WorkflowRunFailures() bool { + return strings.Contains(s.Features.String(), featureWorkflowRunFailure) +} + +func (s *Subscription) WorkflowRunSuccesses() bool { + return strings.Contains(s.Features.String(), featureWorkflowRunSuccess) +} + func (s *Subscription) Release() bool { return strings.Contains(s.Features.String(), featureReleases) } diff --git a/server/plugin/template.go b/server/plugin/template.go index 33e43840a..1e7e0423e 100644 --- a/server/plugin/template.go +++ b/server/plugin/template.go @@ -453,6 +453,8 @@ Reviewers: {{range $i, $el := .RequestedReviewers -}} {{- if $i}}, {{end}}{{temp " * `pull_reviews` - includes pull request reviews\n" + " * `workflow_failure` - includes workflow job failure\n" + " * `workflow_success` - includes workflow job success\n" + + " * `workflow_run_failure` - includes workflow run failures (failures, cancellations, timeouts)\n" + + " * `workflow_run_success` - includes workflow run successes\n" + " * `releases` - includes release created and deleted\n" + " * `label:` - limit pull request and issue events to only this label. Must include `pulls` or `issues` in feature list when using a label.\n" + " * `discussions` - includes new discussions\n" + @@ -489,6 +491,11 @@ It now has **{{.GetRepo.GetStargazersCount}}** stars.`)) {{if eq .GetWorkflowJob.GetConclusion "failure"}}Job failed: {{template "workflowJob" .GetWorkflowJob}} Step failed: {{.GetWorkflowJob.Steps | workflowJobFailedStep}} {{end}}Commit: {{.GetRepo.GetHTMLURL}}/commit/{{.GetWorkflowJob.GetHeadSHA}}`)) + + template.Must(masterTemplate.New("workflowRunCompleted").Funcs(funcMap).Parse(` +{{template "repo" .GetRepo}} Workflow [{{.GetWorkflow.GetName}}]({{.GetWorkflowRun.GetHTMLURL}}) {{if eq .GetWorkflowRun.GetConclusion "success"}}succeeded :white_check_mark:{{else if eq .GetWorkflowRun.GetConclusion "failure"}}failed :x:{{else if eq .GetWorkflowRun.GetConclusion "cancelled"}}was cancelled :no_entry_sign:{{else if eq .GetWorkflowRun.GetConclusion "timed_out"}}timed out :warning:{{else}}completed with conclusion: {{.GetWorkflowRun.GetConclusion}}{{end}} +Branch: ` + "`" + `{{.GetWorkflowRun.GetHeadBranch}}` + "`" + ` | Run [#{{.GetWorkflowRun.GetRunNumber}}]({{.GetWorkflowRun.GetHTMLURL}}) | Triggered by {{template "user" .GetSender}} +Commit: {{.GetRepo.GetHTMLURL}}/commit/{{.GetWorkflowRun.GetHeadSHA}}`)) template.Must(masterTemplate.New("newReleaseEvent").Funcs(funcMap).Parse(` {{template "repo" .GetRepo}} {{template "user" .GetSender}} {{- if eq .GetAction "created" }} created a release {{template "release" .GetRelease}} diff --git a/server/plugin/template_test.go b/server/plugin/template_test.go index ec6118590..8972a0e0b 100644 --- a/server/plugin/template_test.go +++ b/server/plugin/template_test.go @@ -1578,6 +1578,108 @@ Commit: https://github.com/mattermost/mattermost-plugin-github/commit/1234567890 }) } +func TestWorkflowRunNotification(t *testing.T) { + t.Run("failed", func(t *testing.T) { + expected := ` +[\[mattermost-plugin-github\]](https://github.com/mattermost/mattermost-plugin-github) Workflow [CI Pipeline](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) failed :x: +Branch: ` + "`main`" + ` | Run [#42](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) | Triggered by [panda](https://github.com/panda) +Commit: https://github.com/mattermost/mattermost-plugin-github/commit/abc1234567` + + actual, err := renderTemplate("workflowRunCompleted", &github.WorkflowRunEvent{ + Repo: &repo, + Sender: &user, + Action: sToP(actionCompleted), + Workflow: &github.Workflow{ + Name: sToP("CI Pipeline"), + }, + WorkflowRun: &github.WorkflowRun{ + Conclusion: sToP("failure"), + HeadBranch: sToP("main"), + HeadSHA: sToP("abc1234567"), + HTMLURL: sToP("https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999"), + RunNumber: iToP(42), + }, + }) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("success", func(t *testing.T) { + expected := ` +[\[mattermost-plugin-github\]](https://github.com/mattermost/mattermost-plugin-github) Workflow [CI Pipeline](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) succeeded :white_check_mark: +Branch: ` + "`main`" + ` | Run [#42](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) | Triggered by [panda](https://github.com/panda) +Commit: https://github.com/mattermost/mattermost-plugin-github/commit/abc1234567` + + actual, err := renderTemplate("workflowRunCompleted", &github.WorkflowRunEvent{ + Repo: &repo, + Sender: &user, + Action: sToP(actionCompleted), + Workflow: &github.Workflow{ + Name: sToP("CI Pipeline"), + }, + WorkflowRun: &github.WorkflowRun{ + Conclusion: sToP("success"), + HeadBranch: sToP("main"), + HeadSHA: sToP("abc1234567"), + HTMLURL: sToP("https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999"), + RunNumber: iToP(42), + }, + }) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("cancelled", func(t *testing.T) { + expected := ` +[\[mattermost-plugin-github\]](https://github.com/mattermost/mattermost-plugin-github) Workflow [CI Pipeline](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) was cancelled :no_entry_sign: +Branch: ` + "`main`" + ` | Run [#42](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) | Triggered by [panda](https://github.com/panda) +Commit: https://github.com/mattermost/mattermost-plugin-github/commit/abc1234567` + + actual, err := renderTemplate("workflowRunCompleted", &github.WorkflowRunEvent{ + Repo: &repo, + Sender: &user, + Action: sToP(actionCompleted), + Workflow: &github.Workflow{ + Name: sToP("CI Pipeline"), + }, + WorkflowRun: &github.WorkflowRun{ + Conclusion: sToP("cancelled"), + HeadBranch: sToP("main"), + HeadSHA: sToP("abc1234567"), + HTMLURL: sToP("https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999"), + RunNumber: iToP(42), + }, + }) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("timed_out", func(t *testing.T) { + expected := ` +[\[mattermost-plugin-github\]](https://github.com/mattermost/mattermost-plugin-github) Workflow [CI Pipeline](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) timed out :warning: +Branch: ` + "`main`" + ` | Run [#42](https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999) | Triggered by [panda](https://github.com/panda) +Commit: https://github.com/mattermost/mattermost-plugin-github/commit/abc1234567` + + actual, err := renderTemplate("workflowRunCompleted", &github.WorkflowRunEvent{ + Repo: &repo, + Sender: &user, + Action: sToP(actionCompleted), + Workflow: &github.Workflow{ + Name: sToP("CI Pipeline"), + }, + WorkflowRun: &github.WorkflowRun{ + Conclusion: sToP("timed_out"), + HeadBranch: sToP("main"), + HeadSHA: sToP("abc1234567"), + HTMLURL: sToP("https://github.com/mattermost/mattermost-plugin-github/actions/runs/99999"), + RunNumber: iToP(42), + }, + }) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) +} + func sToP(s string) *string { return &s } diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index 90d62ac4d..1320d688e 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -38,9 +38,17 @@ const ( actionEdited = "edited" actionCompleted = "completed" + actionRequested = "requested" + actionInProgress = "in_progress" + workflowJobFail = "failure" workflowJobSuccess = "success" + workflowRunConclusionFailure = "failure" + workflowRunConclusionSuccess = "success" + workflowRunConclusionCancelled = "cancelled" + workflowRunConclusionTimedOut = "timed_out" + postPropGithubRepo = "gh_repo" postPropGithubObjectID = "gh_object_id" postPropGithubObjectType = "gh_object_type" @@ -301,6 +309,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { handler = func() { p.postWorkflowJobEvent(event) } + case *github.WorkflowRunEvent: + repo = event.GetRepo() + handler = func() { + p.postWorkflowRunEvent(event) + } case *github.ReleaseEvent: repo = event.GetRepo() handler = func() { @@ -1474,6 +1487,61 @@ func (p *Plugin) postWorkflowJobEvent(event *github.WorkflowJobEvent) { } } +func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { + if event.GetAction() != actionCompleted { + return + } + + conclusion := event.GetWorkflowRun().GetConclusion() + if conclusion != workflowRunConclusionFailure && + conclusion != workflowRunConclusionSuccess && + conclusion != workflowRunConclusionCancelled && + conclusion != workflowRunConclusionTimedOut { + return + } + + repo := event.GetRepo() + subs := p.GetSubscribedChannelsForRepository(repo) + + if len(subs) == 0 { + return + } + + workflowRunMessage, err := renderTemplate("workflowRunCompleted", event) + if err != nil { + p.client.Log.Warn("Failed to render template", "Error", err.Error()) + return + } + + isFailure := conclusion == workflowRunConclusionFailure || + conclusion == workflowRunConclusionCancelled || + conclusion == workflowRunConclusionTimedOut + + for _, sub := range subs { + if !sub.WorkflowRuns() { + continue + } + + if isFailure && !sub.WorkflowRunFailures() { + continue + } + if conclusion == workflowRunConclusionSuccess && !sub.WorkflowRunSuccesses() { + continue + } + + post := &model.Post{ + UserId: p.BotUserID, + Type: "custom_git_workflow_run", + Message: workflowRunMessage, + ChannelId: sub.ChannelID, + } + + if err = p.client.Post.CreatePost(post); err != nil { + p.client.Log.Warn("Error webhook post", "Post", post, "Error", err.Error()) + } + } +} + func (p *Plugin) makeBotPost(message, postType string) *model.Post { return &model.Post{ UserId: p.BotUserID, From 369cb9fb33e61f2df2682d5d82a49e6de201ce41 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Tue, 3 Mar 2026 20:24:37 +0200 Subject: [PATCH 2/8] Removing unused values --- server/plugin/webhook.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index 1320d688e..c8d9f5fd3 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -38,9 +38,6 @@ const ( actionEdited = "edited" actionCompleted = "completed" - actionRequested = "requested" - actionInProgress = "in_progress" - workflowJobFail = "failure" workflowJobSuccess = "success" From c004e2dcc7572e1983182517c01487c0775fbe80 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Tue, 3 Mar 2026 20:36:53 +0200 Subject: [PATCH 3/8] Refactoring for simplicity and removing redundant functions --- server/plugin/subscriptions.go | 4 --- server/plugin/template.go | 2 +- server/plugin/webhook.go | 53 ++++++++++++++-------------------- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/server/plugin/subscriptions.go b/server/plugin/subscriptions.go index 6b13ac131..5ae6dc592 100644 --- a/server/plugin/subscriptions.go +++ b/server/plugin/subscriptions.go @@ -147,10 +147,6 @@ func (s *Subscription) Workflows() bool { return strings.Contains(s.Features.String(), featureWorkflowFailure) || strings.Contains(s.Features.String(), featureWorkflowSuccess) } -func (s *Subscription) WorkflowRuns() bool { - return strings.Contains(s.Features.String(), featureWorkflowRunFailure) || strings.Contains(s.Features.String(), featureWorkflowRunSuccess) -} - func (s *Subscription) WorkflowRunFailures() bool { return strings.Contains(s.Features.String(), featureWorkflowRunFailure) } diff --git a/server/plugin/template.go b/server/plugin/template.go index 1e7e0423e..b62e7b264 100644 --- a/server/plugin/template.go +++ b/server/plugin/template.go @@ -120,7 +120,7 @@ func init() { funcMap["workflowJobFailedStep"] = func(steps []*github.TaskStep) string { for _, step := range steps { - if step.GetConclusion() == workflowJobFail { + if step.GetConclusion() == workflowConclusionFailure { return step.GetName() } } diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index c8d9f5fd3..e5e27a669 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -38,13 +38,10 @@ const ( actionEdited = "edited" actionCompleted = "completed" - workflowJobFail = "failure" - workflowJobSuccess = "success" - - workflowRunConclusionFailure = "failure" - workflowRunConclusionSuccess = "success" - workflowRunConclusionCancelled = "cancelled" - workflowRunConclusionTimedOut = "timed_out" + workflowConclusionFailure = "failure" + workflowConclusionSuccess = "success" + workflowConclusionCancelled = "cancelled" + workflowConclusionTimedOut = "timed_out" postPropGithubRepo = "gh_repo" postPropGithubObjectID = "gh_object_id" @@ -1448,8 +1445,7 @@ func (p *Plugin) postWorkflowJobEvent(event *github.WorkflowJobEvent) { return } - // Create a post only when the workflow job is completed and has either failed or succeeded - if event.GetWorkflowJob().GetConclusion() != workflowJobFail && event.GetWorkflowJob().GetConclusion() != workflowJobSuccess { + if event.GetWorkflowJob().GetConclusion() != workflowConclusionFailure && event.GetWorkflowJob().GetConclusion() != workflowConclusionSuccess { return } @@ -1490,40 +1486,35 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { } conclusion := event.GetWorkflowRun().GetConclusion() - if conclusion != workflowRunConclusionFailure && - conclusion != workflowRunConclusionSuccess && - conclusion != workflowRunConclusionCancelled && - conclusion != workflowRunConclusionTimedOut { + isSuccess := conclusion == workflowConclusionSuccess + isFailure := conclusion == workflowConclusionFailure || + conclusion == workflowConclusionCancelled || + conclusion == workflowConclusionTimedOut + + if !isSuccess && !isFailure { return } repo := event.GetRepo() subs := p.GetSubscribedChannelsForRepository(repo) - if len(subs) == 0 { return } - workflowRunMessage, err := renderTemplate("workflowRunCompleted", event) - if err != nil { - p.client.Log.Warn("Failed to render template", "Error", err.Error()) - return - } - - isFailure := conclusion == workflowRunConclusionFailure || - conclusion == workflowRunConclusionCancelled || - conclusion == workflowRunConclusionTimedOut - + var workflowRunMessage string for _, sub := range subs { - if !sub.WorkflowRuns() { + // Only send notifications if we have a subscription for the workflow run conclusion + if (isFailure && !sub.WorkflowRunFailures()) || (isSuccess && !sub.WorkflowRunSuccesses()) { continue } - if isFailure && !sub.WorkflowRunFailures() { - continue - } - if conclusion == workflowRunConclusionSuccess && !sub.WorkflowRunSuccesses() { - continue + if workflowRunMessage == "" { + var err error + workflowRunMessage, err = renderTemplate("workflowRunCompleted", event) + if err != nil { + p.client.Log.Warn("Failed to render template", "Error", err.Error()) + return + } } post := &model.Post{ @@ -1533,7 +1524,7 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { ChannelId: sub.ChannelID, } - if err = p.client.Post.CreatePost(post); err != nil { + if err := p.client.Post.CreatePost(post); err != nil { p.client.Log.Warn("Error webhook post", "Post", post, "Error", err.Error()) } } From 963e5ceb83fee97279c000dc7066fc63371ee87e Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Tue, 3 Mar 2026 20:52:54 +0200 Subject: [PATCH 4/8] Fixing linter error --- server/plugin/command.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/plugin/command.go b/server/plugin/command.go index a9c2076b3..9c0de4294 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -33,12 +33,12 @@ const ( featurePullReviews = "pull_reviews" featureStars = "stars" featureReleases = "releases" - featureWorkflowFailure = "workflow_failure" - featureWorkflowSuccess = "workflow_success" - featureWorkflowRunFailure = "workflow_run_failure" - featureWorkflowRunSuccess = "workflow_run_success" - featureDiscussions = "discussions" - featureDiscussionComments = "discussion_comments" + featureWorkflowFailure = "workflow_failure" + featureWorkflowSuccess = "workflow_success" + featureWorkflowRunFailure = "workflow_run_failure" + featureWorkflowRunSuccess = "workflow_run_success" + featureDiscussions = "discussions" + featureDiscussionComments = "discussion_comments" ) const ( @@ -62,12 +62,12 @@ var validFeatures = map[string]bool{ featurePullReviews: true, featureStars: true, featureReleases: true, - featureWorkflowFailure: true, - featureWorkflowSuccess: true, - featureWorkflowRunFailure: true, - featureWorkflowRunSuccess: true, - featureDiscussions: true, - featureDiscussionComments: true, + featureWorkflowFailure: true, + featureWorkflowSuccess: true, + featureWorkflowRunFailure: true, + featureWorkflowRunSuccess: true, + featureDiscussions: true, + featureDiscussionComments: true, } type Features string From c77ec437813c73d248347d8adc047026b3c738fe Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Tue, 3 Mar 2026 22:41:18 +0200 Subject: [PATCH 5/8] Added missing subscription filter checks --- server/plugin/webhook.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index e5e27a669..9c34d23c7 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -1503,11 +1503,18 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { var workflowRunMessage string for _, sub := range subs { - // Only send notifications if we have a subscription for the workflow run conclusion if (isFailure && !sub.WorkflowRunFailures()) || (isSuccess && !sub.WorkflowRunSuccesses()) { continue } + if p.excludeConfigOrgMember(event.GetSender(), sub) { + continue + } + + if p.shouldDenyEventDueToNotOrgMember(event.GetSender(), sub) { + continue + } + if workflowRunMessage == "" { var err error workflowRunMessage, err = renderTemplate("workflowRunCompleted", event) From 4cb4262838f32af15a5f0ca412c3de7fbc623cde Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Thu, 5 Mar 2026 13:38:34 +0200 Subject: [PATCH 6/8] Added tests, improving workflow handler flow --- server/plugin/template.go | 5 +- server/plugin/test_utils.go | 23 +++++ server/plugin/webhook.go | 18 ++-- server/plugin/webhook_test.go | 168 ++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 13 deletions(-) diff --git a/server/plugin/template.go b/server/plugin/template.go index b62e7b264..e9dc3f8e1 100644 --- a/server/plugin/template.go +++ b/server/plugin/template.go @@ -496,6 +496,7 @@ Step failed: {{.GetWorkflowJob.Steps | workflowJobFailedStep}} {{template "repo" .GetRepo}} Workflow [{{.GetWorkflow.GetName}}]({{.GetWorkflowRun.GetHTMLURL}}) {{if eq .GetWorkflowRun.GetConclusion "success"}}succeeded :white_check_mark:{{else if eq .GetWorkflowRun.GetConclusion "failure"}}failed :x:{{else if eq .GetWorkflowRun.GetConclusion "cancelled"}}was cancelled :no_entry_sign:{{else if eq .GetWorkflowRun.GetConclusion "timed_out"}}timed out :warning:{{else}}completed with conclusion: {{.GetWorkflowRun.GetConclusion}}{{end}} Branch: ` + "`" + `{{.GetWorkflowRun.GetHeadBranch}}` + "`" + ` | Run [#{{.GetWorkflowRun.GetRunNumber}}]({{.GetWorkflowRun.GetHTMLURL}}) | Triggered by {{template "user" .GetSender}} Commit: {{.GetRepo.GetHTMLURL}}/commit/{{.GetWorkflowRun.GetHeadSHA}}`)) + template.Must(masterTemplate.New("newReleaseEvent").Funcs(funcMap).Parse(` {{template "repo" .GetRepo}} {{template "user" .GetSender}} {{- if eq .GetAction "created" }} created a release {{template "release" .GetRelease}} @@ -512,8 +513,8 @@ Commit: {{.GetRepo.GetHTMLURL}}/commit/{{.GetWorkflowRun.GetHeadSHA}}`)) `)) template.Must(masterTemplate.New("newDiscussionComment").Funcs(funcMap).Parse(` -{{template "repo" .GetRepo}} -{{- if eq .GetAction "created" }} New comment +{{template "repo" .GetRepo}} +{{- if eq .GetAction "created" }} New comment {{- else if eq .GetAction "edited" }} Comment edited {{- else if eq .GetAction "deleted" }} Comment deleted {{- end }} by {{template "user" .GetSender}} on discussion [#{{.GetDiscussion.GetNumber}} {{.GetDiscussion.GetTitle}}]({{.GetDiscussion.GetHTMLURL}}): diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index cc9978e4a..2c35d5067 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -427,6 +427,29 @@ func GetMockDiscussionEvent(repo, org, sender string) *github.DiscussionEvent { } } +func GetMockWorkflowRunEvent(action, conclusion, repo, org, sender string) *github.WorkflowRunEvent { + return &github.WorkflowRunEvent{ + Action: github.String(action), + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + HTMLURL: github.String(fmt.Sprintf("%s%s/%s", GithubBaseURL, repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + Workflow: &github.Workflow{ + Name: github.String("CI Pipeline"), + }, + WorkflowRun: &github.WorkflowRun{ + Conclusion: github.String(conclusion), + HeadBranch: github.String("main"), + HeadSHA: github.String("abc1234567"), + HTMLURL: github.String(fmt.Sprintf("%s%s/%s/actions/runs/99999", GithubBaseURL, repo, org)), + RunNumber: github.Int(42), + }, + } +} + func GetMockDiscussionCommentEvent(repo, org, action, sender string) *github.DiscussionCommentEvent { return &github.DiscussionCommentEvent{ Action: &action, diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index 9c34d23c7..5b4fed0ac 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -1501,7 +1501,12 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { return } - var workflowRunMessage string + workflowRunMessage, err := renderTemplate("workflowRunCompleted", event) + if err != nil { + p.client.Log.Warn("Failed to render template", "Error", err.Error()) + return + } + for _, sub := range subs { if (isFailure && !sub.WorkflowRunFailures()) || (isSuccess && !sub.WorkflowRunSuccesses()) { continue @@ -1515,15 +1520,6 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { continue } - if workflowRunMessage == "" { - var err error - workflowRunMessage, err = renderTemplate("workflowRunCompleted", event) - if err != nil { - p.client.Log.Warn("Failed to render template", "Error", err.Error()) - return - } - } - post := &model.Post{ UserId: p.BotUserID, Type: "custom_git_workflow_run", @@ -1531,7 +1527,7 @@ func (p *Plugin) postWorkflowRunEvent(event *github.WorkflowRunEvent) { ChannelId: sub.ChannelID, } - if err := p.client.Post.CreatePost(post); err != nil { + if err = p.client.Post.CreatePost(post); err != nil { p.client.Log.Warn("Error webhook post", "Post", post, "Error", err.Error()) } } diff --git a/server/plugin/webhook_test.go b/server/plugin/webhook_test.go index 1f3ef60a2..11561b7c2 100644 --- a/server/plugin/webhook_test.go +++ b/server/plugin/webhook_test.go @@ -1197,6 +1197,174 @@ func TestPostDiscussionEvent(t *testing.T) { } } +func TestPostWorkflowRunEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.WorkflowRunEvent + setup func() + }{ + { + name: "action is not completed, event ignored", + event: GetMockWorkflowRunEvent("in_progress", "success", MockRepo, MockOrg, MockSender), + setup: func() {}, + }, + { + name: "unsupported conclusion, event ignored", + event: GetMockWorkflowRunEvent(actionCompleted, "skipped", MockRepo, MockOrg, MockSender), + setup: func() {}, + }, + { + name: "no subscribed channels for repository", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).Return(nil).Times(1) + }, + }, + { + name: "subscription does not include workflow_run_failure feature", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: featureStars, Repository: MockRepo}, + }, + })).Times(1) + }, + }, + { + name: "subscription does not include workflow_run_success feature", + event: GetMockWorkflowRunEvent(actionCompleted, "success", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, + }, + })).Times(1) + }, + }, + { + name: "excluded org member skips subscription", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + { + ChannelID: MockChannelID, + CreatorID: MockCreatorID, + Features: Features(featureWorkflowRunFailure), + Repository: MockRepo, + Flags: SubscriptionFlags{ExcludeOrgMembers: true}, + }, + }, + })).Times(1) + mockKvStore.EXPECT().Get(MockCreatorID+"_githubtoken", mock.MatchedBy(func(val any) bool { + _, ok := val.(**GitHubUserInfo) + return ok + })).Return(nil).Times(1) + mockAPI.On("LogWarn", "Failed to exclude org member", "error", mock.AnythingOfType("string")) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "include only org members checks membership", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + { + ChannelID: MockChannelID, + CreatorID: MockCreatorID, + Features: Features(featureWorkflowRunFailure), + Repository: MockRepo, + Flags: SubscriptionFlags{IncludeOnlyOrgMembers: true}, + }, + }, + })).Times(1) + mockKvStore.EXPECT().Get(MockCreatorID+"_githubtoken", mock.MatchedBy(func(val any) bool { + _, ok := val.(**GitHubUserInfo) + return ok + })).Return(nil).Times(1) + mockAPI.On("LogWarn", "Failed to get user info", "error", mock.AnythingOfType("string")) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "error creating post", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, + }, + })).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "Post", mock.Anything, "Error", "error creating post") + }, + }, + { + name: "successful workflow run failure notification", + event: GetMockWorkflowRunEvent(actionCompleted, "failure", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, + }, + })).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "successful workflow run success notification", + event: GetMockWorkflowRunEvent(actionCompleted, "success", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockrepo/mockorg": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunSuccess), Repository: MockRepo}, + }, + })).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockAPI.ExpectedCalls = nil + tc.setup() + + p.postWorkflowRunEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + func TestPostDiscussionCommentEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) From a34b178249b070f1a0d98a53e8b9929f62068cbd Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Thu, 5 Mar 2026 13:49:40 +0200 Subject: [PATCH 7/8] Fixing org repo order in text fixture --- server/plugin/test_utils.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index 2c35d5067..49c1f6242 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -433,8 +433,8 @@ func GetMockWorkflowRunEvent(action, conclusion, repo, org, sender string) *gith Repo: &github.Repository{ Name: github.String(repo), Owner: &github.User{Login: github.String(org)}, - FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), - HTMLURL: github.String(fmt.Sprintf("%s%s/%s", GithubBaseURL, repo, org)), + FullName: github.String(fmt.Sprintf("%s/%s", org, repo)), + HTMLURL: github.String(fmt.Sprintf("%s%s/%s", GithubBaseURL, org, repo)), }, Sender: &github.User{Login: github.String(sender)}, Workflow: &github.Workflow{ @@ -444,7 +444,7 @@ func GetMockWorkflowRunEvent(action, conclusion, repo, org, sender string) *gith Conclusion: github.String(conclusion), HeadBranch: github.String("main"), HeadSHA: github.String("abc1234567"), - HTMLURL: github.String(fmt.Sprintf("%s%s/%s/actions/runs/99999", GithubBaseURL, repo, org)), + HTMLURL: github.String(fmt.Sprintf("%s%s/%s/actions/runs/99999", GithubBaseURL, org, repo)), RunNumber: github.Int(42), }, } From 6b2ce7d6d5b955bbe1b6f03d70b9fc0f34616fe3 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Thu, 5 Mar 2026 14:04:10 +0200 Subject: [PATCH 8/8] Updated test assertions and added cancelled and timed_out cases --- server/plugin/webhook_test.go | 44 +++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/server/plugin/webhook_test.go b/server/plugin/webhook_test.go index 11561b7c2..03ada648d 100644 --- a/server/plugin/webhook_test.go +++ b/server/plugin/webhook_test.go @@ -1234,7 +1234,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: featureStars, Repository: MockRepo}, }, })).Times(1) @@ -1248,7 +1248,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, }, })).Times(1) @@ -1262,7 +1262,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { { ChannelID: MockChannelID, CreatorID: MockCreatorID, @@ -1288,7 +1288,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { { ChannelID: MockChannelID, CreatorID: MockCreatorID, @@ -1314,7 +1314,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, }, })).Times(1) @@ -1330,7 +1330,7 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, }, })).Times(1) @@ -1345,13 +1345,43 @@ func TestPostWorkflowRunEvent(t *testing.T) { _, ok := val.(**Subscriptions) return ok })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ - "mockrepo/mockorg": { + "mockorg/mockrepo": { {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunSuccess), Repository: MockRepo}, }, })).Times(1) mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) }, }, + { + name: "successful workflow run cancelled notification", + event: GetMockWorkflowRunEvent(actionCompleted, "cancelled", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockorg/mockrepo": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, + }, + })).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "successful workflow run timed_out notification", + event: GetMockWorkflowRunEvent(actionCompleted, "timed_out", MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", mock.MatchedBy(func(val any) bool { + _, ok := val.(**Subscriptions) + return ok + })).DoAndReturn(setupMockSubscriptions(map[string][]*Subscription{ + "mockorg/mockrepo": { + {ChannelID: MockChannelID, CreatorID: MockCreatorID, Features: Features(featureWorkflowRunFailure), Repository: MockRepo}, + }, + })).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + }, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) {