Files
forge/services/webhook/msteams.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

412 lines
11 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
webhook_model "github.com/hanzoai/git/models/webhook"
"github.com/hanzoai/git/modules/git"
api "github.com/hanzoai/git/modules/structs"
"github.com/hanzoai/git/modules/util"
webhook_module "github.com/hanzoai/git/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"`
}
)
type msteamsConvertor struct{}
// 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{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{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 strings.Builder
// for each commit, generate attachment text
for i, commit := range p.Commits {
fmt.Fprintf(&text, "[%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.WriteString("\n\n")
}
}
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
text.String(),
titleLink,
greenColor,
&MSTeamsFact{"Commit count:", strconv.Itoa(p.TotalCommits)},
), nil
}
// Issue implements PayloadConvertor Issue method
func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) {
title, _, extraMarkdown, color := getIssuesPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
extraMarkdown,
p.Issue.HTMLURL,
color,
&MSTeamsFact{"Issue #:", strconv.FormatInt(p.Issue.ID, 10)},
), 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 #:", strconv.FormatInt(p.Issue.ID, 10)},
), nil
}
// PullRequest implements PayloadConvertor PullRequest method
func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) {
title, _, extraMarkdown, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false)
facts := []*MSTeamsFact{
{"Pull request:", fmt.Sprintf("[#%d](%s)", p.PullRequest.Index, p.PullRequest.HTMLURL)},
}
if (p.Action == api.HookIssueReviewRequested || p.Action == api.HookIssueReviewRequestRemoved) && p.RequestedReviewer != nil {
reviewerName := p.RequestedReviewer.UserName
if p.RequestedReviewer.FullName != "" {
reviewerName += " (" + p.RequestedReviewer.FullName + ")"
}
facts = append(facts, &MSTeamsFact{"Requested Reviewer:", reviewerName})
}
return createMSTeamsPayload(
p.Repository,
p.Sender,
title,
extraMarkdown,
p.PullRequest.HTMLURL,
color,
facts...,
), 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](%s)", p.PullRequest.Index, p.PullRequest.HTMLURL)},
), 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,
), 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 (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, error) {
title, color := getStatusPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.TargetURL,
color,
&MSTeamsFact{"CommitStatus:", p.Context},
), nil
}
func (msteamsConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MSTeamsPayload, error) {
title, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.WorkflowRun.HTMLURL,
color,
&MSTeamsFact{"WorkflowRun:", p.WorkflowRun.DisplayTitle},
), nil
}
func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) {
title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.WorkflowJob.HTMLURL,
color,
&MSTeamsFact{"WorkflowJob:", p.WorkflowJob.Name},
), nil
}
func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, extraFacts ...*MSTeamsFact) MSTeamsPayload {
facts := make([]MSTeamsFact, 0, len(extraFacts)+1)
if r != nil {
facts = append(facts, MSTeamsFact{
Name: "Repository:",
Value: fmt.Sprintf("[%s](%s)", r.FullName, r.HTMLURL),
})
}
for _, f := range extraFacts {
if f != nil {
facts = append(facts, *f)
}
}
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 Hanzo Git",
Targets: []MSTeamsActionTarget{
{
Os: "default",
URI: actionTarget,
},
},
},
},
}
}
func newMSTeamsRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
var pc payloadConvertor[MSTeamsPayload] = msteamsConvertor{}
return newJSONRequest(pc, w, t, true)
}
func init() {
RegisterWebhookRequester(webhook_module.MSTEAMS, newMSTeamsRequest)
}