diff --git a/README.md b/README.md index 994d67b0..d8e02d90 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,10 @@ For more information check the *How to add support for a new Provider* section. * handled on the path: `/h/bitbucket-v2/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN` * [Slack](https://slack.com) (both outgoing webhooks & slash commands) * handled on the path: `/h/slack/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN` - -Work in progress: - * [Visual Studio Team Services](https://www.visualstudio.com/products/visual-studio-team-services-vs) + * handled on the path: `/h/visualstudio/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN` * [GitLab](https://gitlab.com) + * handled on the path: `/h/gitlab/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN` ### GitHub - setup & usage: @@ -62,6 +61,23 @@ a [Bitbucket](https://bitbucket.org) *repository*. That's all, the next time you push code (into your repository) a build will be triggered. +### GitLab - setup & usage: + +All you have to do is register your `bitrise-webhooks` URL for +a [GitLab](https://gitlab.com) *project*. + +1. Open your *project* on [GitLab.com](https://gitlab.com) +2. Go to `Settings` of the *project* +3. Select `Web Hooks` +4. Specify the `bitrise-webhooks` URL (`.../h/gitlab/BITRISE-APP-SLUG/BITRISE-APP-API-TOKEN`) in the `URL` field +5. In the *Triggers* section select `Push events` + * Right now `bitrise-webhooks` only supports the *Push events* trigger for + GitLab Webhooks. +6. Click `Add Web Hook` + +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 @@ -316,4 +332,4 @@ response provider will be used. * Re-try handling * Bitbucket V1 (aka "Services" on the Bitbucket web UI) - not sure whether we should support this, - it'll be deprecated in the future, and we already support the newer, V2 webhooks. + it's already kind of deprecated, and we already support the newer, V2 webhooks. diff --git a/service/hook/endpoint.go b/service/hook/endpoint.go index f158189d..1842c480 100644 --- a/service/hook/endpoint.go +++ b/service/hook/endpoint.go @@ -13,6 +13,7 @@ import ( "github.com/bitrise-io/bitrise-webhooks/service/hook/bitbucketv2" 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/gitlab" "github.com/bitrise-io/bitrise-webhooks/service/hook/slack" "github.com/bitrise-io/bitrise-webhooks/service/hook/visualstudioteamservices" "github.com/gorilla/mux" @@ -24,6 +25,7 @@ func supportedProviders() map[string]hookCommon.Provider { "bitbucket-v2": bitbucketv2.HookProvider{}, "slack": slack.HookProvider{}, "visualstudio": visualstudioteamservices.HookProvider{}, + "gitlab": gitlab.HookProvider{}, } } diff --git a/service/hook/gitlab/gitlab.go b/service/hook/gitlab/gitlab.go new file mode 100644 index 00000000..819df0f4 --- /dev/null +++ b/service/hook/gitlab/gitlab.go @@ -0,0 +1,155 @@ +package gitlab + +// # Infos / notes: +// +// ## Webhook calls +// +// Official API docs: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md +// +// ### Code Push +// +// A code push webhook is sent with the header: `X-Gitlab-Event: Push Hook`. +// Official docs: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md#push-events +// +// GitLab sends push webhooks for every branch separately. Even if you +// push to two different branches at the same time (git push --all) it'll +// trigger two webhook calls, one for each branch. +// +// Commits are grouped in the webhook - if you push more than one commit +// to a single branch it'll be included in a single webhook call, including +// all of the commits. +// +// The latest commit's hash is included as the "checkout_sha" parameter +// in the webhook. As we don't want to trigger build for every commit +// which is related to a single branch we will only handle the commit +// with the hash / id specified as the "checkout_sha". +// + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "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 --- + +const ( + pushEventID = "Push Hook" +) + +// CommitModel ... +type CommitModel struct { + CommitHash string `json:"id"` + CommitMessage string `json:"message"` +} + +// CodePushEventModel ... +type CodePushEventModel struct { + ObjectKind string `json:"object_kind"` + Ref string `json:"ref"` + CheckoutSHA string `json:"checkout_sha"` + Commits []CommitModel `json:"commits"` +} + +// --------------------------------------- +// --- Webhook Provider Implementation --- + +// HookProvider ... +type HookProvider struct{} + +func detectContentTypeAndEventID(header http.Header) (string, string, error) { + contentType, err := httputil.GetSingleValueFromHeader("Content-Type", header) + if err != nil { + return "", "", fmt.Errorf("Issue with Content-Type Header: %s", err) + } + + eventID, err := httputil.GetSingleValueFromHeader("X-Gitlab-Event", header) + if err != nil { + return "", "", fmt.Errorf("Issue with X-Gitlab-Event Header: %s", err) + } + + return contentType, eventID, nil +} + +func transformCodePushEvent(codePushEvent CodePushEventModel) hookCommon.TransformResultModel { + if !strings.HasPrefix(codePushEvent.Ref, "refs/heads/") { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Ref (%s) is not a head ref", codePushEvent.Ref), + ShouldSkip: true, + } + } + branch := strings.TrimPrefix(codePushEvent.Ref, "refs/heads/") + + lastCommit := CommitModel{} + isLastCommitFound := false + for _, aCommit := range codePushEvent.Commits { + if aCommit.CommitHash == codePushEvent.CheckoutSHA { + isLastCommitFound = true + lastCommit = aCommit + break + } + } + + if !isLastCommitFound { + return hookCommon.TransformResultModel{ + Error: errors.New("The commit specified by 'checkout_sha' was not included in the 'commits' array - no match found"), + } + } + + return hookCommon.TransformResultModel{ + TriggerAPIParams: []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: lastCommit.CommitHash, + CommitMessage: lastCommit.CommitMessage, + Branch: branch, + }, + }, + }, + } +} + +// TransformRequest ... +func (hp HookProvider) TransformRequest(r *http.Request) hookCommon.TransformResultModel { + contentType, eventID, err := detectContentTypeAndEventID(r.Header) + if err != nil { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Issue with Headers: %s", err), + } + } + + if contentType != "application/json" { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Content-Type is not supported: %s", contentType), + } + } + + if eventID != "Push Hook" { + // Unsupported Event + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Unsupported Webhook event: %s", eventID), + } + } + + if r.Body == nil { + return hookCommon.TransformResultModel{ + Error: fmt.Errorf("Failed to read content of request body: no or empty request body"), + } + } + + // code push + var codePushEvent CodePushEventModel + if contentType == "application/json" { + if err := json.NewDecoder(r.Body).Decode(&codePushEvent); err != nil { + return hookCommon.TransformResultModel{Error: fmt.Errorf("Failed to parse request body: %s", err)} + } + } + return transformCodePushEvent(codePushEvent) +} diff --git a/service/hook/gitlab/gitlab_test.go b/service/hook/gitlab/gitlab_test.go new file mode 100644 index 00000000..480896e0 --- /dev/null +++ b/service/hook/gitlab/gitlab_test.go @@ -0,0 +1,245 @@ +package gitlab + +import ( + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/bitrise-io/bitrise-webhooks/bitriseapi" + "github.com/stretchr/testify/require" +) + +func Test_detectContentTypeAndEventID(t *testing.T) { + t.Log("Code Push event") + { + header := http.Header{ + "X-Gitlab-Event": {"Push Hook"}, + "Content-Type": {"application/json"}, + } + contentType, eventID, err := detectContentTypeAndEventID(header) + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.Equal(t, "Push Hook", eventID) + } + + t.Log("Unsupported event - will be handled in Transform") + { + header := http.Header{ + "X-Gitlab-Event": {"Tag Push Hook"}, + "Content-Type": {"application/json"}, + } + contentType, eventID, err := detectContentTypeAndEventID(header) + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.Equal(t, "Tag Push Hook", eventID) + } + + t.Log("Missing X-Gitlab-Event header") + { + header := http.Header{ + "Content-Type": {"application/json"}, + } + contentType, eventID, err := detectContentTypeAndEventID(header) + require.EqualError(t, err, "Issue with X-Gitlab-Event Header: No value found in HEADER for the key: X-Gitlab-Event") + require.Equal(t, "", contentType) + require.Equal(t, "", eventID) + } + + t.Log("Missing Content-Type") + { + header := http.Header{ + "X-Gitlab-Event": {"Push Hook"}, + } + contentType, eventID, err := detectContentTypeAndEventID(header) + require.EqualError(t, err, "Issue with Content-Type Header: No value found in HEADER for the key: Content-Type") + require.Equal(t, "", contentType) + require.Equal(t, "", eventID) + } +} + +func Test_transformCodePushEvent(t *testing.T) { + t.Log("Do Transform - single commit") + { + codePush := CodePushEventModel{ + ObjectKind: "push", + Ref: "refs/heads/master", + CheckoutSHA: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + Commits: []CommitModel{ + { + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + }, + }, + } + hookTransformResult := transformCodePushEvent(codePush) + require.NoError(t, hookTransformResult.Error) + require.False(t, hookTransformResult.ShouldSkip) + require.Equal(t, []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + Branch: "master", + }, + }, + }, hookTransformResult.TriggerAPIParams) + } + + t.Log("Do Transform - multiple commits - CheckoutSHA match should trigger the build") + { + codePush := CodePushEventModel{ + ObjectKind: "push", + Ref: "refs/heads/master", + CheckoutSHA: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + Commits: []CommitModel{ + { + CommitHash: "7782203aaf0daabbd245ec0370c751eac6a4eb55", + CommitMessage: `switch to three component versions`, + }, + { + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + }, + { + CommitHash: "ef77f9dba498f335e2e7078bdb52f9e11c214c58", + CommitMessage: `get version : three component version`, + }, + }, + } + hookTransformResult := transformCodePushEvent(codePush) + require.NoError(t, hookTransformResult.Error) + require.False(t, hookTransformResult.ShouldSkip) + require.Equal(t, []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + Branch: "master", + }, + }, + }, hookTransformResult.TriggerAPIParams) + } + + t.Log("No commit matches CheckoutSHA") + { + codePush := CodePushEventModel{ + ObjectKind: "push", + Ref: "refs/heads/master", + CheckoutSHA: "checkout-sha", + Commits: []CommitModel{ + { + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + }, + }, + } + hookTransformResult := transformCodePushEvent(codePush) + require.EqualError(t, hookTransformResult.Error, "The commit specified by 'checkout_sha' was not included in the 'commits' array - no match found") + require.False(t, hookTransformResult.ShouldSkip) + require.Nil(t, hookTransformResult.TriggerAPIParams) + } + + t.Log("Not a head ref") + { + codePush := CodePushEventModel{ + ObjectKind: "push", + Ref: "refs/not/head", + CheckoutSHA: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + Commits: []CommitModel{ + { + CommitHash: "f8f37818dc89a67516adfc21896d0c9ec43d05c2", + CommitMessage: `Response: omit the "failed_responses" array if empty`, + }, + }, + } + hookTransformResult := transformCodePushEvent(codePush) + require.True(t, hookTransformResult.ShouldSkip) + require.EqualError(t, hookTransformResult.Error, "Ref (refs/not/head) is not a head ref") + require.Nil(t, hookTransformResult.TriggerAPIParams) + } +} + +func Test_HookProvider_TransformRequest(t *testing.T) { + provider := HookProvider{} + + const sampleCodePushData = `{ + "object_kind": "push", + "ref": "refs/heads/develop", + "checkout_sha": "1606d3dd4c4dc83ee8fed8d3cfd911da851bf740", + "commits": [ + { + "id": "29da60ce2c47a6696bc82f2e6ec4a075695eb7c3", + "message": "first commit message" + }, + { + "id": "1606d3dd4c4dc83ee8fed8d3cfd911da851bf740", + "message": "second commit message" + } + ] +}` + + t.Log("Code Push - should be handled") + { + request := http.Request{ + Header: http.Header{ + "X-Gitlab-Event": {"Push Hook"}, + "Content-Type": {"application/json"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodePushData)), + } + hookTransformResult := provider.TransformRequest(&request) + require.NoError(t, hookTransformResult.Error) + require.False(t, hookTransformResult.ShouldSkip) + require.Equal(t, []bitriseapi.TriggerAPIParamsModel{ + { + BuildParams: bitriseapi.BuildParamsModel{ + CommitHash: "1606d3dd4c4dc83ee8fed8d3cfd911da851bf740", + CommitMessage: "second commit message", + Branch: "develop", + }, + }, + }, hookTransformResult.TriggerAPIParams) + } + + t.Log("Unsuported Content-Type") + { + request := http.Request{ + Header: http.Header{ + "X-Gitlab-Event": {"Push Hook"}, + "Content-Type": {"not/supported"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodePushData)), + } + hookTransformResult := provider.TransformRequest(&request) + require.False(t, hookTransformResult.ShouldSkip) + require.EqualError(t, hookTransformResult.Error, "Content-Type is not supported: not/supported") + } + + t.Log("Unsupported event type - should error") + { + request := http.Request{ + Header: http.Header{ + "X-Gitlab-Event": {"Tag Push Hook"}, + "Content-Type": {"application/json"}, + }, + Body: ioutil.NopCloser(strings.NewReader(sampleCodePushData)), + } + hookTransformResult := provider.TransformRequest(&request) + require.False(t, hookTransformResult.ShouldSkip) + require.EqualError(t, hookTransformResult.Error, "Unsupported Webhook event: Tag Push Hook") + } + + t.Log("No Request Body") + { + request := http.Request{ + Header: http.Header{ + "X-Gitlab-Event": {"Push Hook"}, + "Content-Type": {"application/json"}, + }, + } + hookTransformResult := provider.TransformRequest(&request) + require.False(t, hookTransformResult.ShouldSkip) + require.EqualError(t, hookTransformResult.Error, "Failed to read content of request body: no or empty request body") + } +} diff --git a/version/version.go b/version/version.go index 76cad9f1..bd24fe6a 100644 --- a/version/version.go +++ b/version/version.go @@ -1,4 +1,4 @@ package version // VERSION ... -const VERSION = "1.1.2" +const VERSION = "1.1.3"