diff --git a/README.md b/README.md index 0623e775..994d67b0 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,33 @@ a [Bitbucket](https://bitbucket.org) *repository*. Bitbucket Webhooks. 7. Click `Save` -That's all, the next time you push code or create a pull request (if you enabled the related event(s)) -a build will be triggered. +That's all, the next time you push code (into your repository) a build will be triggered. + + +### Visual Studio Online / Visual Studio Team Services - setup & usage: + +All you have to do is register your `bitrise-webhooks` URL for +a [visualstudio.com](https://visualstudio.com) *project* as a `Service Hooks` integration. + +You can find an official guide +on [visualstudio.com 's documentations site](https://www.visualstudio.com/en-us/get-started/integrate/service-hooks/webhooks-and-vso-vs). + +A short step-by-step guide: + +1. Open your *project* on [visualstudio.com](https://visualstudio.com) +2. Go to the *Admin/Control panel* of the *project* +3. Select `Service Hooks` +4. Create a service integration + * In the Service list select the `Web Hooks` option + * Select the `Code pushed` event as the *Trigger* + * In the `Filters` section select the `Repository` you want to integrate + * You can leave the other filters on default + * Click `Next` + * On the `Action` setup form specify the `bitrise-webhooks` URL (`.../h/visualstudio/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN`) in the `URL` field + * You can leave every other option on default +7. Click `Finish` + +That's all, the next time you push code (into your repository) a build will be triggered. ### Slack - setup & usage: diff --git a/bitrise.yml b/bitrise.yml index 9d161de9..cd394827 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -33,6 +33,7 @@ workflows: steps: - script: title: "TMP: install Go 1.6.0" + run_if: .IsCI inputs: - content: |- set -x diff --git a/service/hook/endpoint.go b/service/hook/endpoint.go index c7ea534b..f158189d 100644 --- a/service/hook/endpoint.go +++ b/service/hook/endpoint.go @@ -14,6 +14,7 @@ import ( hookCommon "github.com/bitrise-io/bitrise-webhooks/service/hook/common" "github.com/bitrise-io/bitrise-webhooks/service/hook/github" "github.com/bitrise-io/bitrise-webhooks/service/hook/slack" + "github.com/bitrise-io/bitrise-webhooks/service/hook/visualstudioteamservices" "github.com/gorilla/mux" ) @@ -22,6 +23,7 @@ func supportedProviders() map[string]hookCommon.Provider { "github": github.HookProvider{}, "bitbucket-v2": bitbucketv2.HookProvider{}, "slack": slack.HookProvider{}, + "visualstudio": visualstudioteamservices.HookProvider{}, } } diff --git a/service/hook/visualstudioteamservices/visualstudioteamservices.go b/service/hook/visualstudioteamservices/visualstudioteamservices.go new file mode 100644 index 00000000..4f9784f0 --- /dev/null +++ b/service/hook/visualstudioteamservices/visualstudioteamservices.go @@ -0,0 +1,149 @@ +package visualstudioteamservices + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/bitrise-io/bitrise-webhooks/bitriseapi" + hookCommon "github.com/bitrise-io/bitrise-webhooks/service/hook/common" + "github.com/bitrise-io/go-utils/httputil" +) + +// -------------------------- +// --- Webhook Data Model --- + +// CommitsModel ... +type CommitsModel struct { + CommitID string `json:"commitId"` + Comment string `json:"comment"` +} + +// RefUpdatesModel ... +type RefUpdatesModel struct { + Name string `json:"name"` +} + +// ResourceModel ... +type ResourceModel struct { + Commits []CommitsModel `json:"commits"` + RefUpdates []RefUpdatesModel `json:"refUpdates"` +} + +// CodePushEventModel ... +type CodePushEventModel struct { + SubscriptionID string `json:"subscriptionId"` + EventType string `json:"eventType"` + PublisherID string `json:"publisherId"` + Resource ResourceModel `json:"resource"` +} + +// --------------------------------------- +// --- Webhook Provider Implementation --- + +// HookProvider ... +type HookProvider struct{} + +func detectContentType(header http.Header) (string, error) { + contentType, err := httputil.GetSingleValueFromHeader("Content-Type", header) + if err != nil { + return "", fmt.Errorf("Issue with Content-Type Header: %s", err) + } + + return contentType, nil +} + +// transformCodePushEvent ... +func transformCodePushEvent(codePushEvent CodePushEventModel) hookCommon.TransformResultModel { + if codePushEvent.PublisherID != "tfs" { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Not a Team Foundation Server notification, can't start a build."), + } + } + + if codePushEvent.EventType != "git.push" { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Not a code push event, can't start a build."), + } + } + + if codePushEvent.SubscriptionID == "00000000-0000-0000-0000-000000000000" { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Initial (test) event detected, skipping."), + ShouldSkip: true, + } + } + + if len(codePushEvent.Resource.RefUpdates) != 1 { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Can't detect branch information (resource.refUpdates is empty), can't start a build."), + } + } + + if !strings.HasPrefix(codePushEvent.Resource.RefUpdates[0].Name, "refs/heads/") { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Badly formatted branch detected, can't start a build."), + } + } + branch := strings.TrimPrefix(codePushEvent.Resource.RefUpdates[0].Name, "refs/heads/") + + if len(codePushEvent.Resource.Commits) < 1 { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("No 'commits' included in the webhook, can't start a build."), + } + } + // VSO sends separate events for separate branches, + // and commits are in ascending order, by commit date-time + aCommit := codePushEvent.Resource.Commits[0] + + return hookCommon.TransformResultModel{ + TriggerAPIParams: []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: aCommit.CommitID, + CommitMessage: aCommit.Comment, + Branch: branch, + }, + }, + }, + } +} + +// TransformRequest ... +func (hp HookProvider) TransformRequest(r *http.Request) hookCommon.TransformResultModel { + contentType, err := detectContentType(r.Header) + if err != nil { + return hookCommon.TransformResultModel{ + Error: err, + } + } + matched, err := regexp.MatchString("application/json", contentType) + if err != nil { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Issue with Header checking: %s", err), + } + } + + if matched != true { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Content-Type is not supported: %s", contentType), + } + } + + if r.Body == nil { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Failed to read content of request body: no or empty request body"), + } + } + + var codePushEvent CodePushEventModel + if err := json.NewDecoder(r.Body).Decode(&codePushEvent); err != nil { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Failed to parse request body as JSON: %s", err), + } + } + + return transformCodePushEvent(codePushEvent) +} diff --git a/service/hook/visualstudioteamservices/visualstudioteamservices_test.go b/service/hook/visualstudioteamservices/visualstudioteamservices_test.go new file mode 100644 index 00000000..70c67d65 --- /dev/null +++ b/service/hook/visualstudioteamservices/visualstudioteamservices_test.go @@ -0,0 +1,415 @@ +package visualstudioteamservices + +import ( + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/bitrise-io/bitrise-webhooks/bitriseapi" + "github.com/stretchr/testify/require" +) + +const ( + sampleCodeEmptySubscriptionID = `{ + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "notificationId": 4, + "id": "daae438c-296b-4512-b08e-571910874e9b", + "eventType": "git.push", + "publisherId": "tfs" + }` + + sampleCodeGitPushBadEventType = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 4, + "id": "daae438c-296b-4512-b08e-571910874e9b", + "eventType": "message.posted", + "publisherId": "tfs" + }` + + sampleCodeGitPushBadPublisherID = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 4, + "id": "daae438c-296b-4512-b08e-571910874e9b", + "eventType": "git.push", + "publisherId": "-" + }` + + sampleCodeGitPushWithNoChanges = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 10, + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "resource": { + "commits": [], + "refUpdates": [ + { + "name": "refs/heads/master", + "oldObjectId": "aad331d8d3b131fa9ae03cf5e53965b51942618a", + "newObjectId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ] + } + }` + + sampleCodeGitPushWithNoBranchInformation = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 10, + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "resource": { + "commits": [ + { + "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "comment": "Fixed bug", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "refUpdates": [] + } + }` + + sampleCodeGitPushWithBadlyFormattedBranchInformation = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 10, + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "resource": { + "commits": [ + { + "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "comment": "Fixed bug", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "refUpdates": [ + { + "name": "master", + "oldObjectId": "aad331d8d3b131fa9ae03cf5e53965b51942618a", + "newObjectId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ] + } + }` + + sampleCodeGitPush = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 10, + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "message": { + "text": "Jamal Hartnett pushed updates to branch master of repository Fabrikam-Fiber-Git.", + "html": "Jamal Hartnett pushed updates to branch master of repository Fabrikam-Fiber-Git.", + "markdown": "Jamal Hartnett pushed updates to branch master of repository Fabrikam-Fiber-Git." + }, + "detailedMessage": { + "text": "Jamal Hartnett pushed 1 commit to branch master of repository Fabrikam-Fiber-Git.\n - Fixed bug 33b55f7c", + "html": "Jamal Hartnett pushed 1 commit to branch master of repository Fabrikam-Fiber-Git.\n", + "markdown": "Jamal Hartnett pushed 1 commit to branch [master](https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/#version=GBmaster) of repository [Fabrikam-Fiber-Git](https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/).\n* Fixed bug [33b55f7c](https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74)" + }, + "resource": { + "commits": [ + { + "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "comment": "Fixed bug", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "refUpdates": [ + { + "name": "refs/heads/master", + "oldObjectId": "aad331d8d3b131fa9ae03cf5e53965b51942618a", + "newObjectId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "repository": { + "id": "278d5cd2-584d-4b63-824a-2ba458937249", + "name": "Fabrikam-Fiber-Git", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249", + "project": { + "id": "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "name": "Fabrikam-Fiber-Git", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/projects/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "state": "wellFormed" + }, + "defaultBranch": "refs/heads/master", + "remoteUrl": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git" + }, + "pushedBy": { + "id": "00067FFED5C7AF52@Live.com", + "displayName": "Jamal Hartnett", + "uniqueName": "Windows Live ID\\fabrikamfiber4@hotmail.com" + }, + "pushId": 14, + "date": "2014-05-02T19:17:13.3309587Z", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/pushes/14" + }, + "createdDate": "2016-02-17T21:29:54.5474864Z" + }` + + sampleCodeGitPushWithMultipleCommits = `{ + "subscriptionId": "f0c23515-bcd1-4e30-9613-56a0a129c732", + "notificationId": 10, + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "resource": { + "commits": [ + { + "commitId": "0c23515bcd14e30961356a0a129c732asd9d0wds", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:02:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:02:00Z" + }, + "comment": "More changes", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + }, + { + "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "comment": "Fixed bug", + "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "refUpdates": [ + { + "name": "refs/heads/master", + "oldObjectId": "aad331d8d3b131fa9ae03cf5e53965b51942618a", + "newObjectId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ] + } + }` +) + +func Test_detectContentType(t *testing.T) { + t.Log("Proper Content-Type") + { + header := http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + } + contentType, err := detectContentType(header) + require.NoError(t, err) + require.Equal(t, "application/json; charset=utf-8", contentType) + } + t.Log("Missing Content-Type") + { + header := http.Header{} + contentType, err := detectContentType(header) + require.EqualError(t, err, "Issue with Content-Type Header: No value found in HEADER for the key: Content-Type") + require.Equal(t, "", contentType) + } +} + +func Test_HookProvider_TransformRequest(t *testing.T) { + provider := HookProvider{} + + t.Log("Unsupported Content-Type") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/x-www-form-urlencoded"}, + }, + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Content-Type is not supported: application/x-www-form-urlencoded") + } + + t.Log("Missing Content-Type") + { + request := http.Request{ + Header: http.Header{}, + } + hookTransformResult := provider.TransformRequest(&request) + require.False(t, hookTransformResult.ShouldSkip) + require.EqualError(t, hookTransformResult.Error, "Issue with Content-Type Header: No value found in HEADER for the key: Content-Type") + } + + t.Log("No Request Body") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Failed to read content of request body: no or empty request body") + } + + t.Log("Initial Subscription ID") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeEmptySubscriptionID)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Initial (test) event detected, skipping.") + require.True(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Bad publisher id") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushBadPublisherID)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Not a Team Foundation Server notification, can't start a build.") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Bad event type") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushBadEventType)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Not a code push event, can't start a build.") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Empty commit list") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushWithNoChanges)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "No 'commits' included in the webhook, can't start a build.") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Empty branch information") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushWithNoBranchInformation)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Can't detect branch information (resource.refUpdates is empty), can't start a build.") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Badly formatted branch information") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushWithBadlyFormattedBranchInformation)), + } + hookTransformResult := provider.TransformRequest(&request) + require.EqualError(t, hookTransformResult.Error, "Badly formatted branch detected, can't start a build.") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Git.push with one commit") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPush)), + } + hookTransformResult := provider.TransformRequest(&request) + require.NoError(t, hookTransformResult.Error) + require.False(t, hookTransformResult.ShouldSkip) + require.Equal(t, []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + CommitMessage: "Fixed bug", + Branch: "master", + }, + }, + }, hookTransformResult.TriggerAPIParams) + } + + t.Log("Git.push with multiple commits - only the first one (latest commit) should be picked") + { + request := http.Request{ + Header: http.Header{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodeGitPushWithMultipleCommits)), + } + hookTransformResult := provider.TransformRequest(&request) + require.NoError(t, hookTransformResult.Error) + require.False(t, hookTransformResult.ShouldSkip) + require.Equal(t, []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: "0c23515bcd14e30961356a0a129c732asd9d0wds", + CommitMessage: "More changes", + Branch: "master", + }, + }, + }, hookTransformResult.TriggerAPIParams) + } +} diff --git a/version/version.go b/version/version.go index 0b0d6cce..ec9d20c5 100644 --- a/version/version.go +++ b/version/version.go @@ -1,4 +1,4 @@ package version // VERSION ... -const VERSION = "1.0" +const VERSION = "1.1"