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