Skip to content

Commit 9e12301

Browse files
wbagdonWilliam Bagdon
and
William Bagdon
authored
Support for Azure DevOps (#136)
This PR adds initial support for Azure DevOps Build and Pull Request events Co-authored-by: William Bagdon <[email protected]>
1 parent 69430a8 commit 9e12301

7 files changed

+733
-0
lines changed

azuredevops/azuredevops.go

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package azuredevops
2+
3+
// this package receives Azure DevOps Server webhooks
4+
// https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops-2020
5+
6+
import (
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"io/ioutil"
12+
"net/http"
13+
)
14+
15+
// parse errors
16+
var (
17+
ErrInvalidHTTPMethod = errors.New("invalid HTTP Method")
18+
ErrParsingPayload = errors.New("error parsing payload")
19+
)
20+
21+
// Event defines an Azure DevOps server hook event type
22+
type Event string
23+
24+
// Azure DevOps Server hook types
25+
const (
26+
BuildCompleteEventType Event = "build.complete"
27+
GitPullRequestCreatedEventType Event = "git.pullrequest.created"
28+
GitPullRequestUpdatedEventType Event = "git.pullrequest.updated"
29+
GitPullRequestMergedEventType Event = "git.pullrequest.merged"
30+
)
31+
32+
// Webhook instance contains all methods needed to process events
33+
type Webhook struct {
34+
}
35+
36+
// New creates and returns a WebHook instance
37+
func New() (*Webhook, error) {
38+
hook := new(Webhook)
39+
return hook, nil
40+
}
41+
42+
// Parse verifies and parses the events specified and returns the payload object or an error
43+
func (hook Webhook) Parse(r *http.Request, events ...Event) (interface{}, error) {
44+
defer func() {
45+
_, _ = io.Copy(ioutil.Discard, r.Body)
46+
_ = r.Body.Close()
47+
}()
48+
49+
if r.Method != http.MethodPost {
50+
return nil, ErrInvalidHTTPMethod
51+
}
52+
53+
payload, err := ioutil.ReadAll(r.Body)
54+
if err != nil || len(payload) == 0 {
55+
return nil, ErrParsingPayload
56+
}
57+
58+
var pl BasicEvent
59+
err = json.Unmarshal([]byte(payload), &pl)
60+
if err != nil {
61+
return nil, ErrParsingPayload
62+
}
63+
64+
switch pl.EventType {
65+
case GitPullRequestCreatedEventType, GitPullRequestMergedEventType, GitPullRequestUpdatedEventType:
66+
var fpl GitPullRequestEvent
67+
err = json.Unmarshal([]byte(payload), &fpl)
68+
return fpl, err
69+
case BuildCompleteEventType:
70+
var fpl BuildCompleteEvent
71+
err = json.Unmarshal([]byte(payload), &fpl)
72+
return fpl, err
73+
default:
74+
return nil, fmt.Errorf("unknown event %s", pl.EventType)
75+
}
76+
}

azuredevops/azuredevops_test.go

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package azuredevops
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"testing"
9+
10+
"reflect"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// NOTES:
16+
// - Run "go test" to run tests
17+
// - Run "gocov test | gocov report" to report on test converage by file
18+
// - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called
19+
//
20+
// or
21+
//
22+
// -- may be a good idea to change to output path to somewherelike /tmp
23+
// go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html
24+
//
25+
26+
const (
27+
virtualDir = "/webhooks"
28+
)
29+
30+
var hook *Webhook
31+
32+
func TestMain(m *testing.M) {
33+
34+
// setup
35+
var err error
36+
hook, err = New()
37+
if err != nil {
38+
log.Fatal(err)
39+
}
40+
os.Exit(m.Run())
41+
// teardown
42+
}
43+
44+
func newServer(handler http.HandlerFunc) *httptest.Server {
45+
mux := http.NewServeMux()
46+
mux.HandleFunc(virtualDir, handler)
47+
return httptest.NewServer(mux)
48+
}
49+
50+
func TestWebhooks(t *testing.T) {
51+
assert := require.New(t)
52+
tests := []struct {
53+
name string
54+
event Event
55+
typ interface{}
56+
filename string
57+
headers http.Header
58+
}{
59+
{
60+
name: "build.complete",
61+
event: BuildCompleteEventType,
62+
typ: BuildCompleteEvent{},
63+
filename: "../testdata/azuredevops/build.complete.json",
64+
},
65+
{
66+
name: "git.pullrequest.created",
67+
event: GitPullRequestCreatedEventType,
68+
typ: GitPullRequestEvent{},
69+
filename: "../testdata/azuredevops/git.pullrequest.created.json",
70+
},
71+
{
72+
name: "git.pullrequest.merged",
73+
event: GitPullRequestMergedEventType,
74+
typ: GitPullRequestEvent{},
75+
filename: "../testdata/azuredevops/git.pullrequest.merged.json",
76+
},
77+
{
78+
name: "git.pullrequest.updated",
79+
event: GitPullRequestUpdatedEventType,
80+
typ: GitPullRequestEvent{},
81+
filename: "../testdata/azuredevops/git.pullrequest.updated.json",
82+
},
83+
}
84+
85+
for _, tt := range tests {
86+
tc := tt
87+
client := &http.Client{}
88+
t.Run(tt.name, func(t *testing.T) {
89+
t.Parallel()
90+
payload, err := os.Open(tc.filename)
91+
assert.NoError(err)
92+
defer func() {
93+
_ = payload.Close()
94+
}()
95+
96+
var parseError error
97+
var results interface{}
98+
server := newServer(func(w http.ResponseWriter, r *http.Request) {
99+
results, parseError = hook.Parse(r, tc.event)
100+
})
101+
defer server.Close()
102+
req, err := http.NewRequest(http.MethodPost, server.URL+virtualDir, payload)
103+
assert.NoError(err)
104+
req.Header.Set("Content-Type", "application/json")
105+
106+
resp, err := client.Do(req)
107+
assert.NoError(err)
108+
assert.Equal(http.StatusOK, resp.StatusCode)
109+
assert.NoError(parseError)
110+
assert.Equal(reflect.TypeOf(tc.typ), reflect.TypeOf(results))
111+
})
112+
}
113+
}

azuredevops/payload.go

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package azuredevops
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
)
8+
9+
// https://docs.microsoft.com/en-us/azure/devops/service-hooks/events
10+
11+
// azure devops does not send an event header, this BasicEvent is provided to get the EventType
12+
13+
type BasicEvent struct {
14+
ID string `json:"id"`
15+
EventType Event `json:"eventType"`
16+
PublisherID string `json:"publisherId"`
17+
Scope string `json:"scope"`
18+
CreatedDate Date `json:"createdDate"`
19+
}
20+
21+
// git.pullrequest.*
22+
// git.pullrequest.created
23+
// git.pullrequest.merged
24+
// git.pullrequest.updated
25+
26+
type GitPullRequestEvent struct {
27+
ID string `json:"id"`
28+
EventType Event `json:"eventType"`
29+
PublisherID string `json:"publisherId"`
30+
Scope string `json:"scope"`
31+
Message Message `json:"message"`
32+
DetailedMessage Message `json:"detailedMessage"`
33+
Resource PullRequest `json:"resource"`
34+
ResourceVersion string `json:"resourceVersion"`
35+
ResourceContainers interface{} `json:"resourceContainers"`
36+
CreatedDate Date `json:"createdDate"`
37+
}
38+
39+
// build.complete
40+
41+
type BuildCompleteEvent struct {
42+
ID string `json:"id"`
43+
EventType Event `json:"eventType"`
44+
PublisherID string `json:"publisherId"`
45+
Scope string `json:"scope"`
46+
Message Message `json:"message"`
47+
DetailedMessage Message `json:"detailedMessage"`
48+
Resource Build `json:"resource"`
49+
ResourceVersion string `json:"resourceVersion"`
50+
ResourceContainers interface{} `json:"resourceContainers"`
51+
CreatedDate Date `json:"createdDate"`
52+
}
53+
54+
// -----------------------
55+
56+
type Message struct {
57+
Text string `json:"text"`
58+
HTML string `json:"html"`
59+
Markdown string `json:"markdown"`
60+
}
61+
62+
type Commit struct {
63+
CommitID string `json:"commitId"`
64+
URL string `json:"url"`
65+
}
66+
67+
type PullRequest struct {
68+
Repository Repository `json:"repository"`
69+
PullRequestID int `json:"pullRequestId"`
70+
Status string `json:"status"`
71+
CreatedBy User `json:"createdBy"`
72+
CreationDate Date `json:"creationDate"`
73+
ClosedDate Date `json:"closedDate"`
74+
Title string `json:"title"`
75+
Description string `json:"description"`
76+
SourceRefName string `json:"sourceRefName"`
77+
TargetRefName string `json:"targetRefName"`
78+
MergeStatus string `json:"mergeStatus"`
79+
MergeID string `json:"mergeId"`
80+
LastMergeSourceCommit Commit `json:"lastMergeSourceCommit"`
81+
LastMergeTargetCommit Commit `json:"lastMergeTargetCommit"`
82+
LastMergeCommit Commit `json:"lastMergeCommit"`
83+
Reviewers []Reviewer `json:"reviewers"`
84+
Commits []Commit `json:"commits"`
85+
URL string `json:"url"`
86+
}
87+
88+
type Repository struct {
89+
ID string `json:"id"`
90+
Name string `json:"name"`
91+
URL string `json:"url"`
92+
Project Project `json:"project"`
93+
DefaultBranch string `json:"defaultBranch"`
94+
RemoteURL string `json:"remoteUrl"`
95+
}
96+
97+
type Project struct {
98+
ID string `json:"id"`
99+
Name string `json:"name"`
100+
URL string `json:"url"`
101+
State string `json:"state"`
102+
}
103+
104+
type User struct {
105+
ID string `json:"id"`
106+
DisplayName string `json:"displayName"`
107+
UniqueName string `json:"uniqueName"`
108+
URL string `json:"url"`
109+
ImageURL string `json:"imageUrl"`
110+
}
111+
112+
type Reviewer struct {
113+
ReviewerURL string `json:"reviewerUrl"`
114+
Vote int `json:"vote"`
115+
ID string `json:"id"`
116+
DisplayName string `json:"displayName"`
117+
UniqueName string `json:"uniqueName"`
118+
URL string `json:"url"`
119+
ImageURL string `json:"imageUrl"`
120+
IsContainer bool `json:"isContainer"`
121+
}
122+
123+
type Build struct {
124+
URI string `json:"uri"`
125+
ID int `json:"id"`
126+
BuildNumber string `json:"buildNumber"`
127+
URL string `json:"url"`
128+
StartTime Date `json:"startTime"`
129+
FinishTime Date `json:"finishTime"`
130+
Reason string `json:"reason"`
131+
Status string `json:"status"`
132+
DropLocation string `json:"dropLocation"`
133+
Drop Drop `json:"drop"`
134+
Log Log `json:"log"`
135+
SourceGetVersion string `json:"sourceGetVersion"`
136+
LastChangedBy User `json:"lastChangedBy"`
137+
RetainIndefinitely bool `json:"retainIndefinitely"`
138+
HasDiagnostics bool `json:"hasDiagnostics"`
139+
Definition BuildDefinition `json:"definition"`
140+
Queue Queue `json:"queue"`
141+
Requests []Request `json:"requests"`
142+
}
143+
144+
type Drop struct {
145+
Location string `json:"location"`
146+
Type string `json:"type"`
147+
URL string `json:"url"`
148+
DownloadURL string `json:"downloadUrl"`
149+
}
150+
151+
type Log struct {
152+
Type string `json:"type"`
153+
URL string `json:"url"`
154+
DownloadURL string `json:"downloadUrl"`
155+
}
156+
157+
type BuildDefinition struct {
158+
BatchSize int `json:"batchSize"`
159+
TriggerType string `json:"triggerType"`
160+
DefinitionType string `json:"definitionType"`
161+
ID int `json:"id"`
162+
Name string `json:"name"`
163+
URL string `json:"url"`
164+
}
165+
166+
type Queue struct {
167+
QueueType string `json:"queueType"`
168+
ID int `json:"id"`
169+
Name string `json:"name"`
170+
URL string `json:"url"`
171+
}
172+
173+
type Request struct {
174+
ID int `json:"id"`
175+
URL string `json:"url"`
176+
RequestedFor User `json:"requestedFor"`
177+
}
178+
179+
type Date time.Time
180+
181+
func (b *Date) UnmarshalJSON(p []byte) error {
182+
t, err := time.Parse(time.RFC3339Nano, strings.Replace(string(p), "\"", "", -1))
183+
if err != nil {
184+
return err
185+
}
186+
*b = Date(t)
187+
return nil
188+
}
189+
190+
func (b Date) MarshalJSON() ([]byte, error) {
191+
stamp := fmt.Sprintf("\"%s\"", time.Time(b).Format(time.RFC3339Nano))
192+
return []byte(stamp), nil
193+
}

0 commit comments

Comments
 (0)