gitea/services/webhook/msteams.go
oliverpool 26653b196b
Store webhook event in database (#29145)
Refactor the webhook logic, to have the type-dependent processing happen
only in one place.

---

## Current webhook flow

1. An event happens
2. It is pre-processed (depending on the webhook type) and its body is
added to a task queue
3. When the task is processed, some more logic (depending on the webhook
type as well) is applied to make an HTTP request

This means that webhook-type dependant logic is needed in step 2 and 3.
This is cumbersome and brittle to maintain.

Updated webhook flow with this PR:
1. An event happens
2. It is stored as-is and added to a task queue
3. When the task is processed, the event is processed (depending on the
webhook type) to make an HTTP request

So the only webhook-type dependent logic happens in one place (step 3)
which should be much more robust.

## Consequences of the refactor

- the raw event must be stored in the hooktask (until now, the
pre-processed body was stored)
- to ensure that previous hooktasks are correctly sent, a
`payload_version` is added (version 1: the body has already been
pre-process / version 2: the body is the raw event)

So future webhook additions will only have to deal with creating an
http.Request based on the raw event (no need to adjust the code in
multiple places, like currently).

Moreover since this processing happens when fetching from the task
queue, it ensures that the queuing of new events (upon a `git push` for
instance) does not get slowed down by a slow webhook.

As a concrete example, the PR #19307 for custom webhooks, should be
substantially smaller:
- no need to change `services/webhook/deliver.go` 
- minimal change in `services/webhook/webhook.go` (add the new webhook
to the map)
- no need to change all the individual webhook files (since with this
refactor the `*webhook_model.Webhook` is provided as argument)
2024-03-07 23:18:38 +01:00

353 lines
9.1 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type (
// MSTeamsFact for Fact Structure
MSTeamsFact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// MSTeamsSection is a MessageCard section
MSTeamsSection struct {
ActivityTitle string `json:"activityTitle"`
ActivitySubtitle string `json:"activitySubtitle"`
ActivityImage string `json:"activityImage"`
Facts []MSTeamsFact `json:"facts"`
Text string `json:"text"`
}
// MSTeamsAction is an action (creates buttons, links etc)
MSTeamsAction struct {
Type string `json:"@type"`
Name string `json:"name"`
Targets []MSTeamsActionTarget `json:"targets,omitempty"`
}
// MSTeamsActionTarget is the actual link to follow, etc
MSTeamsActionTarget struct {
Os string `json:"os"`
URI string `json:"uri"`
}
// MSTeamsPayload is the parent object
MSTeamsPayload struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Summary string `json:"summary"`
Sections []MSTeamsSection `json:"sections"`
PotentialAction []MSTeamsAction `json:"potentialAction"`
}
)
// Create implements PayloadConvertor Create method
func (m msteamsConvertor) Create(p *api.CreatePayload) (MSTeamsPayload, error) {
// created tag/branch
refName := git.RefName(p.Ref).ShortName()
title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName),
greenColor,
&MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName},
), nil
}
// Delete implements PayloadConvertor Delete method
func (m msteamsConvertor) Delete(p *api.DeletePayload) (MSTeamsPayload, error) {
// deleted tag/branch
refName := git.RefName(p.Ref).ShortName()
title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName),
yellowColor,
&MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName},
), nil
}
// Fork implements PayloadConvertor Fork method
func (m msteamsConvertor) Fork(p *api.ForkPayload) (MSTeamsPayload, error) {
title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.Repo.HTMLURL,
greenColor,
&MSTeamsFact{"Forkee:", p.Forkee.FullName},
), nil
}
// Push implements PayloadConvertor Push method
func (m msteamsConvertor) Push(p *api.PushPayload) (MSTeamsPayload, error) {
var (
branchName = git.RefName(p.Ref).ShortName()
commitDesc string
)
var titleLink string
if p.TotalCommits == 1 {
commitDesc = "1 new commit"
titleLink = p.Commits[0].URL
} else {
commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
titleLink = p.CompareURL
}
if titleLink == "" {
titleLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName)
}
title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc)
var text string
// for each commit, generate attachment text
for i, commit := range p.Commits {
text += fmt.Sprintf("[%s](%s) %s - %s", commit.ID[:7], commit.URL,
strings.TrimRight(commit.Message, "\r\n"), commit.Author.Name)
// add linebreak to each commit but the last
if i < len(p.Commits)-1 {
text += "\n\n"
}
}
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
text,
titleLink,
greenColor,
&MSTeamsFact{"Commit count:", fmt.Sprintf("%d", p.TotalCommits)},
), nil
}
// Issue implements PayloadConvertor Issue method
func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) {
title, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
attachmentText,
p.Issue.HTMLURL,
color,
&MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)},
), nil
}
// IssueComment implements PayloadConvertor IssueComment method
func (m msteamsConvertor) IssueComment(p *api.IssueCommentPayload) (MSTeamsPayload, error) {
title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
p.Comment.Body,
p.Comment.HTMLURL,
color,
&MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)},
), nil
}
// PullRequest implements PayloadConvertor PullRequest method
func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) {
title, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
attachmentText,
p.PullRequest.HTMLURL,
color,
&MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)},
), nil
}
// Review implements PayloadConvertor Review method
func (m msteamsConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MSTeamsPayload, error) {
var text, title string
var color int
switch p.Action {
case api.HookIssueReviewed:
action, err := parseHookPullRequestEventType(event)
if err != nil {
return MSTeamsPayload{}, err
}
title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
text = p.Review.Content
switch event {
case webhook_module.HookEventPullRequestReviewApproved:
color = greenColor
case webhook_module.HookEventPullRequestReviewRejected:
color = redColor
case webhook_module.HookEventPullRequestReviewComment:
color = greyColor
default:
color = yellowColor
}
}
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
text,
p.PullRequest.HTMLURL,
color,
&MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)},
), nil
}
// Repository implements PayloadConvertor Repository method
func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload, error) {
var title, url string
var color int
switch p.Action {
case api.HookRepoCreated:
title = fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
url = p.Repository.HTMLURL
color = greenColor
case api.HookRepoDeleted:
title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
color = yellowColor
}
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
"",
url,
color,
nil,
), nil
}
// Wiki implements PayloadConvertor Wiki method
func (m msteamsConvertor) Wiki(p *api.WikiPayload) (MSTeamsPayload, error) {
title, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
"",
p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page),
color,
&MSTeamsFact{"Repository:", p.Repository.FullName},
), nil
}
// Release implements PayloadConvertor Release method
func (m msteamsConvertor) Release(p *api.ReleasePayload) (MSTeamsPayload, error) {
title, color := getReleasePayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
"",
p.Release.HTMLURL,
color,
&MSTeamsFact{"Tag:", p.Release.TagName},
), nil
}
func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) {
title, color := getPackagePayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
"",
p.Package.HTMLURL,
color,
&MSTeamsFact{"Package:", p.Package.Name},
), nil
}
func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload {
facts := make([]MSTeamsFact, 0, 2)
if r != nil {
facts = append(facts, MSTeamsFact{
Name: "Repository:",
Value: r.FullName,
})
}
if fact != nil {
facts = append(facts, *fact)
}
return MSTeamsPayload{
Type: "MessageCard",
Context: "https://schema.org/extensions",
ThemeColor: fmt.Sprintf("%x", color),
Title: title,
Summary: title,
Sections: []MSTeamsSection{
{
ActivityTitle: s.FullName,
ActivitySubtitle: s.UserName,
ActivityImage: s.AvatarURL,
Text: text,
Facts: facts,
},
},
PotentialAction: []MSTeamsAction{
{
Type: "OpenUri",
Name: "View in Gitea",
Targets: []MSTeamsActionTarget{
{
Os: "default",
URI: actionTarget,
},
},
},
},
}
}
type msteamsConvertor struct{}
var _ payloadConvertor[MSTeamsPayload] = msteamsConvertor{}
func newMSTeamsRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(msteamsConvertor{}, w, t, true)
}