Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Azure DevOps #136

Merged
merged 1 commit into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions azuredevops/azuredevops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package azuredevops

// this package receives Azure DevOps Server webhooks
// https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops-2020

import (
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
)

// parse errors
var (
ErrInvalidHTTPMethod = errors.New("invalid HTTP Method")
ErrParsingPayload = errors.New("error parsing payload")
)

// Event defines an Azure DevOps server hook event type
type Event string

// Azure DevOps Server hook types
const (
BuildCompleteEventType Event = "build.complete"
GitPullRequestCreatedEventType Event = "git.pullrequest.created"
GitPullRequestUpdatedEventType Event = "git.pullrequest.updated"
GitPullRequestMergedEventType Event = "git.pullrequest.merged"
)

// Webhook instance contains all methods needed to process events
type Webhook struct {
}

// New creates and returns a WebHook instance
func New() (*Webhook, error) {
hook := new(Webhook)
return hook, nil
}

// Parse verifies and parses the events specified and returns the payload object or an error
func (hook Webhook) Parse(r *http.Request, events ...Event) (interface{}, error) {
defer func() {
_, _ = io.Copy(ioutil.Discard, r.Body)
_ = r.Body.Close()
}()

if r.Method != http.MethodPost {
return nil, ErrInvalidHTTPMethod
}

payload, err := ioutil.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
return nil, ErrParsingPayload
}

var pl BasicEvent
err = json.Unmarshal([]byte(payload), &pl)
if err != nil {
return nil, ErrParsingPayload
}

switch pl.EventType {
case GitPullRequestCreatedEventType, GitPullRequestMergedEventType, GitPullRequestUpdatedEventType:
var fpl GitPullRequestEvent
err = json.Unmarshal([]byte(payload), &fpl)
return fpl, err
case BuildCompleteEventType:
var fpl BuildCompleteEvent
err = json.Unmarshal([]byte(payload), &fpl)
return fpl, err
default:
return nil, fmt.Errorf("unknown event %s", pl.EventType)
}
}
113 changes: 113 additions & 0 deletions azuredevops/azuredevops_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package azuredevops

import (
"log"
"net/http"
"net/http/httptest"
"os"
"testing"

"reflect"

"github.com/stretchr/testify/require"
)

// NOTES:
// - Run "go test" to run tests
// - Run "gocov test | gocov report" to report on test converage by file
// - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called
//
// or
//
// -- may be a good idea to change to output path to somewherelike /tmp
// go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html
//

const (
virtualDir = "/webhooks"
)

var hook *Webhook

func TestMain(m *testing.M) {

// setup
var err error
hook, err = New()
if err != nil {
log.Fatal(err)
}
os.Exit(m.Run())
// teardown
}

func newServer(handler http.HandlerFunc) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc(virtualDir, handler)
return httptest.NewServer(mux)
}

func TestWebhooks(t *testing.T) {
assert := require.New(t)
tests := []struct {
name string
event Event
typ interface{}
filename string
headers http.Header
}{
{
name: "build.complete",
event: BuildCompleteEventType,
typ: BuildCompleteEvent{},
filename: "../testdata/azuredevops/build.complete.json",
},
{
name: "git.pullrequest.created",
event: GitPullRequestCreatedEventType,
typ: GitPullRequestEvent{},
filename: "../testdata/azuredevops/git.pullrequest.created.json",
},
{
name: "git.pullrequest.merged",
event: GitPullRequestMergedEventType,
typ: GitPullRequestEvent{},
filename: "../testdata/azuredevops/git.pullrequest.merged.json",
},
{
name: "git.pullrequest.updated",
event: GitPullRequestUpdatedEventType,
typ: GitPullRequestEvent{},
filename: "../testdata/azuredevops/git.pullrequest.updated.json",
},
}

for _, tt := range tests {
tc := tt
client := &http.Client{}
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
payload, err := os.Open(tc.filename)
assert.NoError(err)
defer func() {
_ = payload.Close()
}()

var parseError error
var results interface{}
server := newServer(func(w http.ResponseWriter, r *http.Request) {
results, parseError = hook.Parse(r, tc.event)
})
defer server.Close()
req, err := http.NewRequest(http.MethodPost, server.URL+virtualDir, payload)
assert.NoError(err)
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
assert.NoError(err)
assert.Equal(http.StatusOK, resp.StatusCode)
assert.NoError(parseError)
assert.Equal(reflect.TypeOf(tc.typ), reflect.TypeOf(results))
})
}
}
193 changes: 193 additions & 0 deletions azuredevops/payload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package azuredevops

import (
"fmt"
"strings"
"time"
)

// https://docs.microsoft.com/en-us/azure/devops/service-hooks/events

// azure devops does not send an event header, this BasicEvent is provided to get the EventType

type BasicEvent struct {
ID string `json:"id"`
EventType Event `json:"eventType"`
PublisherID string `json:"publisherId"`
Scope string `json:"scope"`
CreatedDate Date `json:"createdDate"`
}

// git.pullrequest.*
// git.pullrequest.created
// git.pullrequest.merged
// git.pullrequest.updated

type GitPullRequestEvent struct {
ID string `json:"id"`
EventType Event `json:"eventType"`
PublisherID string `json:"publisherId"`
Scope string `json:"scope"`
Message Message `json:"message"`
DetailedMessage Message `json:"detailedMessage"`
Resource PullRequest `json:"resource"`
ResourceVersion string `json:"resourceVersion"`
ResourceContainers interface{} `json:"resourceContainers"`
CreatedDate Date `json:"createdDate"`
}

// build.complete

type BuildCompleteEvent struct {
ID string `json:"id"`
EventType Event `json:"eventType"`
PublisherID string `json:"publisherId"`
Scope string `json:"scope"`
Message Message `json:"message"`
DetailedMessage Message `json:"detailedMessage"`
Resource Build `json:"resource"`
ResourceVersion string `json:"resourceVersion"`
ResourceContainers interface{} `json:"resourceContainers"`
CreatedDate Date `json:"createdDate"`
}

// -----------------------

type Message struct {
Text string `json:"text"`
HTML string `json:"html"`
Markdown string `json:"markdown"`
}

type Commit struct {
CommitID string `json:"commitId"`
URL string `json:"url"`
}

type PullRequest struct {
Repository Repository `json:"repository"`
PullRequestID int `json:"pullRequestId"`
Status string `json:"status"`
CreatedBy User `json:"createdBy"`
CreationDate Date `json:"creationDate"`
ClosedDate Date `json:"closedDate"`
Title string `json:"title"`
Description string `json:"description"`
SourceRefName string `json:"sourceRefName"`
TargetRefName string `json:"targetRefName"`
MergeStatus string `json:"mergeStatus"`
MergeID string `json:"mergeId"`
LastMergeSourceCommit Commit `json:"lastMergeSourceCommit"`
LastMergeTargetCommit Commit `json:"lastMergeTargetCommit"`
LastMergeCommit Commit `json:"lastMergeCommit"`
Reviewers []Reviewer `json:"reviewers"`
Commits []Commit `json:"commits"`
URL string `json:"url"`
}

type Repository struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Project Project `json:"project"`
DefaultBranch string `json:"defaultBranch"`
RemoteURL string `json:"remoteUrl"`
}

type Project struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
State string `json:"state"`
}

type User struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
UniqueName string `json:"uniqueName"`
URL string `json:"url"`
ImageURL string `json:"imageUrl"`
}

type Reviewer struct {
ReviewerURL string `json:"reviewerUrl"`
Vote int `json:"vote"`
ID string `json:"id"`
DisplayName string `json:"displayName"`
UniqueName string `json:"uniqueName"`
URL string `json:"url"`
ImageURL string `json:"imageUrl"`
IsContainer bool `json:"isContainer"`
}

type Build struct {
URI string `json:"uri"`
ID int `json:"id"`
BuildNumber string `json:"buildNumber"`
URL string `json:"url"`
StartTime Date `json:"startTime"`
FinishTime Date `json:"finishTime"`
Reason string `json:"reason"`
Status string `json:"status"`
DropLocation string `json:"dropLocation"`
Drop Drop `json:"drop"`
Log Log `json:"log"`
SourceGetVersion string `json:"sourceGetVersion"`
LastChangedBy User `json:"lastChangedBy"`
RetainIndefinitely bool `json:"retainIndefinitely"`
HasDiagnostics bool `json:"hasDiagnostics"`
Definition BuildDefinition `json:"definition"`
Queue Queue `json:"queue"`
Requests []Request `json:"requests"`
}

type Drop struct {
Location string `json:"location"`
Type string `json:"type"`
URL string `json:"url"`
DownloadURL string `json:"downloadUrl"`
}

type Log struct {
Type string `json:"type"`
URL string `json:"url"`
DownloadURL string `json:"downloadUrl"`
}

type BuildDefinition struct {
BatchSize int `json:"batchSize"`
TriggerType string `json:"triggerType"`
DefinitionType string `json:"definitionType"`
ID int `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
}

type Queue struct {
QueueType string `json:"queueType"`
ID int `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
}

type Request struct {
ID int `json:"id"`
URL string `json:"url"`
RequestedFor User `json:"requestedFor"`
}

type Date time.Time

func (b *Date) UnmarshalJSON(p []byte) error {
t, err := time.Parse(time.RFC3339Nano, strings.Replace(string(p), "\"", "", -1))
if err != nil {
return err
}
*b = Date(t)
return nil
}

func (b Date) MarshalJSON() ([]byte, error) {
stamp := fmt.Sprintf("\"%s\"", time.Time(b).Format(time.RFC3339Nano))
return []byte(stamp), nil
}
Loading