Repository: xanzy/go-gitlab
Pull request #513 (2018-11-10 01:14:02)

Add ParseWebhook function to automatically parse webhook requests
Google's [go-github library](https://github.com/google/go-github) has this really nice functionality that does all the heavy lifting of parsing webhook requests into the corresponding struct type: https://github.com/google/go-github/blob/99760a16213d6fdde13f4e477438f876b6c9c6eb/github/messages.go#L237

I'm working on an integration for both GitHub and GitLab, and ended up having to write all this logic myself for the GitLab side of things. IMO it's super useful, so I thought I'd upstream it!

Here's how you use it:

```go
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    payload, err := ioutil.ReadAll(r.Body)
    if err != nil { ... }
    event, err := gitlab.ParseWebhook(gitlab.WebhookType(r), payload)
    if err != nil { ... }
    switch event := event.(type) {
        case *gitlab.PushEvent:
            processPushEvent(event)
        case *gitlab.MergeEvent:
            processMergeEvent(event)
        ...
    }
}
```

GitHub's webhooks are... kind of terrible. So there's a bit of gymnastics involved, e.g. in figuring out exactly which kind of comment we received. But it works, and I wrote tests.


Patch:
diff --git a/event_parsing.go b/event_parsing.go
new file mode 100644
index 0000000..94b0e98
--- /dev/null
+++ b/event_parsing.go
@@ -0,0 +1,135 @@
+package gitlab
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+)
+
+const eventTypeHeader = "X-Gitlab-Event"
+
+// WebhookEventType returns the event type for the given request.
+func WebhookEventType(r *http.Request) string {
+	return r.Header.Get(eventTypeHeader)
+}
+
+// ParseWebhook parses the event payload. For recognized event types, a
+// value of the corresponding struct type will be returned. An error will
+// be returned for unrecognized event types.
+//
+// Example usage:
+//
+// func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+//     payload, err := ioutil.ReadAll(r.Body)
+//     if err != nil { ... }
+//     event, err := gitlab.ParseWebhook(gitlab.WebhookEventType(r), payload)
+//     if err != nil { ... }
+//     switch event := event.(type) {
+//     case *gitlab.PushEvent:
+//         processPushEvent(event)
+//     case *gitlab.MergeEvent:
+//         processMergeEvent(event)
+//     ...
+//     }
+// }
+//
+func ParseWebhook(typeHeaderValue string, payload []byte) (interface{}, error) {
+	t, ok := eventTypeMapping[typeHeaderValue]
+	if !ok {
+		return nil, fmt.Errorf("unexpected value for %s header: %s", eventTypeHeader, typeHeaderValue)
+	}
+
+	event := rawWebhookEvent{
+		Type:       t,
+		RawPayload: payload,
+	}
+	return event.ParsePayload()
+}
+
+type eventStructType string
+
+const (
+	eventStructTypePushEvent             eventStructType = "PushEvent"
+	eventStructTypeTagEvent              eventStructType = "TagEvent"
+	eventStructTypeIssueEvent            eventStructType = "IssueEvent"
+	eventStructTypeAmbiguousCommentEvent eventStructType = "ambiguousCommentEvent"
+	eventStructTypeMergeEvent            eventStructType = "MergeEvent"
+	eventStructTypeWikiPageEvent         eventStructType = "WikiPageEvent"
+	eventStructTypePipelineEvent         eventStructType = "PipelineEvent"
+	eventStructTypeBuildEvent            eventStructType = "BuildEvent"
+)
+
+// Keys here are possible values for the X-Gitlab-Event header.
+var eventTypeMapping = map[string]eventStructType{
+	"Push Hook":          eventStructTypePushEvent,
+	"Tag Push Hook":      eventStructTypeTagEvent,
+	"Issue Hook":         eventStructTypeIssueEvent,
+	"Note Hook":          eventStructTypeAmbiguousCommentEvent,
+	"Merge Request Hook": eventStructTypeMergeEvent,
+	"Wiki Page Hook":     eventStructTypeWikiPageEvent,
+	"Pipeline Hook":      eventStructTypePipelineEvent,
+	"Build Hook":         eventStructTypeBuildEvent,
+}
+
+type rawWebhookEvent struct {
+	Type       eventStructType
+	RawPayload json.RawMessage
+}
+
+const (
+	noteableTypeCommit       = "Commit"
+	noteableTypeMergeRequest = "MergeRequest"
+	noteableTypeIssue        = "Issue"
+	noteableTypeSnippet      = "Snippet"
+)
+
+type ambiguousCommentEvent struct {
+	ObjectKind       string `json:"object_kind"`
+	ObjectAttributes struct {
+		NoteableType string `json:"noteable_type"`
+	} `json:"object_attributes"`
+}
+
+func (e *rawWebhookEvent) ParsePayload() (event interface{}, err error) {
+	switch e.Type {
+	case eventStructTypePushEvent:
+		event = &PushEvent{}
+	case eventStructTypeTagEvent:
+		event = &TagEvent{}
+	case eventStructTypeIssueEvent:
+		event = &IssueEvent{}
+	case eventStructTypeAmbiguousCommentEvent:
+		ambi := &ambiguousCommentEvent{}
+		err := json.Unmarshal(e.RawPayload, ambi)
+		if err != nil {
+			return nil, err
+		} else if ambi.ObjectKind != "note" {
+			return nil, fmt.Errorf("unexpected object kind %s", ambi.ObjectKind)
+		}
+		switch ambi.ObjectAttributes.NoteableType {
+		case noteableTypeCommit:
+			event = &CommitCommentEvent{}
+		case noteableTypeMergeRequest:
+			event = &MergeCommentEvent{}
+		case noteableTypeIssue:
+			event = &IssueCommentEvent{}
+		case noteableTypeSnippet:
+			event = &SnippetCommentEvent{}
+		default:
+			return nil, fmt.Errorf("unexpected noteable type %s", ambi.ObjectAttributes.NoteableType)
+		}
+	case eventStructTypeMergeEvent:
+		event = &MergeEvent{}
+	case eventStructTypeWikiPageEvent:
+		event = &WikiPageEvent{}
+	case eventStructTypePipelineEvent:
+		event = &PipelineEvent{}
+	case eventStructTypeBuildEvent:
+		event = &BuildEvent{}
+	default:
+		return nil, fmt.Errorf("unexpected event type: %s", e.Type)
+	}
+
+	err = json.Unmarshal(e.RawPayload, event)
+	return event, err
+}
diff --git a/repository_files.go b/repository_files.go
index cbfd424..5e8ce41 100644
--- a/repository_files.go
+++ b/repository_files.go
@@ -20,6 +20,7 @@ import (
 	"bytes"
 	"fmt"
 	"net/url"
+	"strconv"
 )
 
 // RepositoryFilesService handles communication with the repository files
@@ -66,7 +67,11 @@ func (s *RepositoryFilesService) GetFile(pid interface{}, fileName string, opt *
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/repository/files/%s", url.QueryEscape(project), url.PathEscape(fileName))
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
 
 	req, err := s.client.NewRequest("GET", u, opt, options)
 	if err != nil {
@@ -82,6 +87,59 @@ func (s *RepositoryFilesService) GetFile(pid interface{}, fileName string, opt *
 	return f, resp, err
 }
 
+// GetFileMetaDataOptions represents the available GetFileMetaData() options.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ce/api/repository_files.html#get-file-from-repository
+type GetFileMetaDataOptions struct {
+	Ref *string `url:"ref,omitempty" json:"ref,omitempty"`
+}
+
+// GetFileMetaData allows you to receive meta information about a file in
+// repository like name, size.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ce/api/repository_files.html#get-file-from-repository
+func (s *RepositoryFilesService) GetFileMetaData(pid interface{}, fileName string, opt *GetFileMetaDataOptions, options ...OptionFunc) (*File, *Response, error) {
+	project, err := parseID(pid)
+	if err != nil {
+		return nil, nil, err
+	}
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
+
+	req, err := s.client.NewRequest("HEAD", u, opt, options)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	resp, err := s.client.Do(req, nil)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	f := &File{
+		BlobID:   resp.Header.Get("X-Gitlab-Blob-Id"),
+		CommitID: resp.Header.Get("X-Gitlab-Last-Commit-Id"),
+		Encoding: resp.Header.Get("X-Gitlab-Encoding"),
+		FileName: resp.Header.Get("X-Gitlab-File-Name"),
+		FilePath: resp.Header.Get("X-Gitlab-File-Path"),
+		Ref:      resp.Header.Get("X-Gitlab-Ref"),
+	}
+
+	if sizeString := resp.Header.Get("X-Gitlab-Size"); sizeString != "" {
+		f.Size, err = strconv.Atoi(sizeString)
+		if err != nil {
+			return nil, resp, err
+		}
+	}
+
+	return f, resp, err
+}
+
 // GetRawFileOptions represents the available GetRawFile() options.
 //
 // GitLab API docs:
@@ -99,7 +157,11 @@ func (s *RepositoryFilesService) GetRawFile(pid interface{}, fileName string, op
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/repository/files/%s/raw", url.QueryEscape(project), url.PathEscape(fileName))
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s/raw",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
 
 	req, err := s.client.NewRequest("GET", u, opt, options)
 	if err != nil {
@@ -149,7 +211,11 @@ func (s *RepositoryFilesService) CreateFile(pid interface{}, fileName string, op
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/repository/files/%s", url.QueryEscape(project), url.PathEscape(fileName))
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
 
 	req, err := s.client.NewRequest("POST", u, opt, options)
 	if err != nil {
@@ -188,7 +254,11 @@ func (s *RepositoryFilesService) UpdateFile(pid interface{}, fileName string, op
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/repository/files/%s", url.QueryEscape(project), url.PathEscape(fileName))
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
 
 	req, err := s.client.NewRequest("PUT", u, opt, options)
 	if err != nil {
@@ -224,7 +294,11 @@ func (s *RepositoryFilesService) DeleteFile(pid interface{}, fileName string, op
 	if err != nil {
 		return nil, err
 	}
-	u := fmt.Sprintf("projects/%s/repository/files/%s", url.QueryEscape(project), url.PathEscape(fileName))
+	u := fmt.Sprintf(
+		"projects/%s/repository/files/%s",
+		url.QueryEscape(project),
+		url.PathEscape(fileName),
+	)
 
 	req, err := s.client.NewRequest("DELETE", u, opt, options)
 	if err != nil {


Test patch:
diff --git a/event_parsing_test.go b/event_parsing_test.go
new file mode 100644
index 0000000..acd700d
--- /dev/null
+++ b/event_parsing_test.go
@@ -0,0 +1,1242 @@
+package gitlab
+
+import (
+	"net/http"
+	"testing"
+)
+
+func TestWebhookEventType(t *testing.T) {
+	req, err := http.NewRequest(http.MethodGet, "https://gitlab.com", nil)
+	if err != nil {
+		t.Errorf("Error creating HTTP request: %s", err)
+	}
+	req.Header.Set("X-Gitlab-Event", "Push Hook")
+
+	eventType := WebhookEventType(req)
+	if eventType != "Push Hook" {
+		t.Errorf("WebhookEventType is %s, want %s", eventType, "Push Hook")
+	}
+}
+
+func TestParsePushHook(t *testing.T) {
+	raw := `{
+  "object_kind": "push",
+  "before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
+  "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+  "ref": "refs/heads/master",
+  "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+  "user_id": 4,
+  "user_name": "John Smith",
+  "user_username": "jsmith",
+  "user_email": "john@example.com",
+  "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80",
+  "project_id": 15,
+  "project":{
+    "id": 15,
+    "name":"Diaspora",
+    "description":"",
+    "web_url":"http://example.com/mike/diaspora",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:mike/diaspora.git",
+    "git_http_url":"http://example.com/mike/diaspora.git",
+    "namespace":"Mike",
+    "visibility_level":0,
+    "path_with_namespace":"mike/diaspora",
+    "default_branch":"master",
+    "homepage":"http://example.com/mike/diaspora",
+    "url":"git@example.com:mike/diaspora.git",
+    "ssh_url":"git@example.com:mike/diaspora.git",
+    "http_url":"http://example.com/mike/diaspora.git"
+  },
+  "repository":{
+    "name": "Diaspora",
+    "url": "git@example.com:mike/diaspora.git",
+    "description": "",
+    "homepage": "http://example.com/mike/diaspora",
+    "git_http_url":"http://example.com/mike/diaspora.git",
+    "git_ssh_url":"git@example.com:mike/diaspora.git",
+    "visibility_level":0
+  },
+  "commits": [
+    {
+      "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
+      "message": "Update Catalan translation to e38cb41.",
+      "timestamp": "2011-12-12T14:27:31+02:00",
+      "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
+      "author": {
+        "name": "Jordi Mallach",
+        "email": "jordi@softcatala.org"
+      },
+      "added": ["CHANGELOG"],
+      "modified": ["app/controller/application.rb"],
+      "removed": []
+    },
+    {
+      "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+      "message": "fixed readme",
+      "timestamp": "2012-01-03T23:36:29+02:00",
+      "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+      "author": {
+        "name": "GitLab dev user",
+        "email": "gitlabdev@dv6700.(none)"
+      },
+      "added": ["CHANGELOG"],
+      "modified": ["app/controller/application.rb"],
+      "removed": []
+    }
+  ],
+  "total_commits_count": 4
+}`
+
+	parsedEvent, err := ParseWebhook("Push Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing push hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*PushEvent)
+	if !ok {
+		t.Errorf("Expected PushEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "push" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "push")
+	}
+
+	if event.ProjectID != 15 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 15)
+	}
+
+	if event.UserName != "John Smith" {
+		t.Errorf("Username is %s, want %s", event.UserName, "John Smith")
+	}
+
+	if event.Commits[0] == nil || event.Commits[0].Timestamp == nil {
+		t.Errorf("Commit Timestamp isn't nil")
+	}
+
+	if event.Commits[0] == nil || event.Commits[0].Author.Name != "Jordi Mallach" {
+		t.Errorf("Commit Username is %s, want %s", event.UserName, "Jordi Mallach")
+	}
+}
+
+func TestParseTagHook(t *testing.T) {
+	raw := `{
+  "object_kind": "tag_push",
+  "before": "0000000000000000000000000000000000000000",
+  "after": "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7",
+  "ref": "refs/tags/v1.0.0",
+  "checkout_sha": "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7",
+  "user_id": 1,
+  "user_name": "John Smith",
+  "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80",
+  "project_id": 1,
+  "project":{
+    "id": 1,
+    "name":"Example",
+    "description":"",
+    "web_url":"http://example.com/jsmith/example",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:jsmith/example.git",
+    "git_http_url":"http://example.com/jsmith/example.git",
+    "namespace":"Jsmith",
+    "visibility_level":0,
+    "path_with_namespace":"jsmith/example",
+    "default_branch":"master",
+    "homepage":"http://example.com/jsmith/example",
+    "url":"git@example.com:jsmith/example.git",
+    "ssh_url":"git@example.com:jsmith/example.git",
+    "http_url":"http://example.com/jsmith/example.git"
+  },
+  "repository":{
+    "name": "Example",
+    "url": "ssh://git@example.com/jsmith/example.git",
+    "description": "",
+    "homepage": "http://example.com/jsmith/example",
+    "git_http_url":"http://example.com/jsmith/example.git",
+    "git_ssh_url":"git@example.com:jsmith/example.git",
+    "visibility_level":0
+  },
+  "commits": [],
+  "total_commits_count": 0
+}`
+
+	parsedEvent, err := ParseWebhook("Tag Push Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing tag hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*TagEvent)
+	if !ok {
+		t.Errorf("Expected TagEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "tag_push" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "tag_push")
+	}
+
+	if event.ProjectID != 1 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 1)
+	}
+
+	if event.UserName != "John Smith" {
+		t.Errorf("Username is %s, want %s", event.UserName, "John Smith")
+	}
+
+	if event.Ref != "refs/tags/v1.0.0" {
+		t.Errorf("Ref is %s, want %s", event.Ref, "refs/tags/v1.0.0")
+	}
+}
+
+func TestParseIssueHook(t *testing.T) {
+	raw := `{
+  "object_kind": "issue",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project": {
+    "id": 1,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlabhq/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
+    "namespace":"GitlabHQ",
+    "visibility_level":20,
+    "path_with_namespace":"gitlabhq/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlabhq/gitlab-test",
+    "url":"http://example.com/gitlabhq/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "http_url":"http://example.com/gitlabhq/gitlab-test.git"
+  },
+  "repository": {
+    "name": "Gitlab Test",
+    "url": "http://example.com/gitlabhq/gitlab-test.git",
+    "description": "Aut reprehenderit ut est.",
+    "homepage": "http://example.com/gitlabhq/gitlab-test"
+  },
+  "object_attributes": {
+    "id": 301,
+    "title": "New API: create/update/delete file",
+    "assignee_ids": [51],
+    "assignee_id": 51,
+    "author_id": 51,
+    "project_id": 14,
+    "created_at": "2013-12-03T17:15:43Z",
+    "updated_at": "2013-12-03T17:15:43Z",
+    "position": 0,
+    "branch_name": null,
+    "description": "Create new API for manipulations with repository",
+    "milestone_id": null,
+    "state": "opened",
+    "iid": 23,
+    "url": "http://example.com/diaspora/issues/23",
+    "action": "open"
+  },
+  "assignees": [{
+    "name": "User1",
+    "username": "user1",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  }],
+  "assignee": {
+    "name": "User1",
+    "username": "user1",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "labels": [{
+    "id": 206,
+    "title": "API",
+    "color": "#ffffff",
+    "project_id": 14,
+    "created_at": "2013-12-03T17:15:43Z",
+    "updated_at": "2013-12-03T17:15:43Z",
+    "template": false,
+    "description": "API related issues",
+    "type": "ProjectLabel",
+    "group_id": 41
+  }],
+  "changes": {
+    "updated_by_id": [null, 1],
+    "updated_at": ["2017-09-15 16:50:55 UTC", "2017-09-15 16:52:00 UTC"],
+    "labels": {
+      "previous": [{
+        "id": 206,
+        "title": "API",
+        "color": "#ffffff",
+        "project_id": 14,
+        "created_at": "2013-12-03T17:15:43Z",
+        "updated_at": "2013-12-03T17:15:43Z",
+        "template": false,
+        "description": "API related issues",
+        "type": "ProjectLabel",
+        "group_id": 41
+      }],
+      "current": [{
+        "id": 205,
+        "title": "Platform",
+        "color": "#123123",
+        "project_id": 14,
+        "created_at": "2013-12-03T17:15:43Z",
+        "updated_at": "2013-12-03T17:15:43Z",
+        "template": false,
+        "description": "Platform related issues",
+        "type": "ProjectLabel",
+        "group_id": 41
+      }]
+    }
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Issue Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing issue hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*IssueEvent)
+	if !ok {
+		t.Errorf("Expected IssueEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "issue" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "issue")
+	}
+
+	if event.Project.Name != "Gitlab Test" {
+		t.Errorf("Project name is %v, want %v", event.Project.Name, "Gitlab Test")
+	}
+
+	if event.ObjectAttributes.State != "opened" {
+		t.Errorf("Issue state is %v, want %v", event.ObjectAttributes.State, "opened")
+	}
+
+	if event.Assignee.Username != "user1" {
+		t.Errorf("Assignee username is %v, want %v", event.Assignee.Username, "user1")
+	}
+}
+
+func TestParseCommitCommentHook(t *testing.T) {
+	raw := `{
+  "object_kind": "note",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project_id": 5,
+  "project":{
+    "id": 5,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlabhq/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
+    "namespace":"GitlabHQ",
+    "visibility_level":20,
+    "path_with_namespace":"gitlabhq/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlabhq/gitlab-test",
+    "url":"http://example.com/gitlabhq/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "http_url":"http://example.com/gitlabhq/gitlab-test.git"
+  },
+  "repository":{
+    "name": "Gitlab Test",
+    "url": "http://example.com/gitlab-org/gitlab-test.git",
+    "description": "Aut reprehenderit ut est.",
+    "homepage": "http://example.com/gitlab-org/gitlab-test"
+  },
+  "object_attributes": {
+    "id": 1243,
+    "note": "This is a commit comment. How does this work?",
+    "noteable_type": "Commit",
+    "author_id": 1,
+    "created_at": "2015-05-17 18:08:09 UTC",
+    "updated_at": "2015-05-17 18:08:09 UTC",
+    "project_id": 5,
+    "attachment":null,
+    "line_code": "bec9703f7a456cd2b4ab5fb3220ae016e3e394e3_0_1",
+    "commit_id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660",
+    "noteable_id": null,
+    "system": false,
+    "st_diff": {
+      "diff": "--- /dev/null\n+++ b/six\n@@ -0,0 +1 @@\n+Subproject commit 409f37c4f05865e4fb208c771485f211a22c4c2d\n",
+      "new_path": "six",
+      "old_path": "six",
+      "a_mode": "0",
+      "b_mode": "160000",
+      "new_file": true,
+      "renamed_file": false,
+      "deleted_file": false
+    },
+    "url": "http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660#note_1243"
+  },
+  "commit": {
+    "id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660",
+    "message": "Add submodule\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+    "timestamp": "2014-02-27T10:06:20+02:00",
+    "url": "http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660",
+    "author": {
+      "name": "Dmitriy Zaporozhets",
+      "email": "dmitriy.zaporozhets@gmail.com"
+    }
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Note Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing note hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*CommitCommentEvent)
+	if !ok {
+		t.Errorf("Expected CommitCommentEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "note" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "note")
+	}
+
+	if event.ProjectID != 5 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 5)
+	}
+
+	if event.ObjectAttributes.NoteableType != "Commit" {
+		t.Errorf("NoteableType is %v, want %v", event.ObjectAttributes.NoteableType, "Commit")
+	}
+
+	if event.Commit.ID != "cfe32cf61b73a0d5e9f13e774abde7ff789b1660" {
+		t.Errorf("CommitID is %v, want %v", event.Commit.ID, "cfe32cf61b73a0d5e9f13e774abde7ff789b1660")
+	}
+}
+
+func TestParseMergeRequestCommentHook(t *testing.T) {
+	raw := `{
+  "object_kind": "note",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project_id": 5,
+  "project":{
+    "id": 5,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlab-org/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlab-org/gitlab-test.git",
+    "namespace":"Gitlab Org",
+    "visibility_level":10,
+    "path_with_namespace":"gitlab-org/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlab-org/gitlab-test",
+    "url":"http://example.com/gitlab-org/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "http_url":"http://example.com/gitlab-org/gitlab-test.git"
+  },
+  "repository":{
+    "name": "Gitlab Test",
+    "url": "http://localhost/gitlab-org/gitlab-test.git",
+    "description": "Aut reprehenderit ut est.",
+    "homepage": "http://example.com/gitlab-org/gitlab-test"
+  },
+  "object_attributes": {
+    "id": 1244,
+    "note": "This MR needs work.",
+    "noteable_type": "MergeRequest",
+    "author_id": 1,
+    "created_at": "2015-05-17 18:21:36 UTC",
+    "updated_at": "2015-05-17 18:21:36 UTC",
+    "project_id": 5,
+    "attachment": null,
+    "line_code": null,
+    "commit_id": "",
+    "noteable_id": 7,
+    "system": false,
+    "st_diff": null,
+    "url": "http://example.com/gitlab-org/gitlab-test/merge_requests/1#note_1244"
+  },
+  "merge_request": {
+    "id": 7,
+    "target_branch": "markdown",
+    "source_branch": "master",
+    "source_project_id": 5,
+    "author_id": 8,
+    "assignee_id": 28,
+    "title": "Tempora et eos debitis quae laborum et.",
+    "created_at": "2015-03-01 20:12:53 UTC",
+    "updated_at": "2015-03-21 18:27:27 UTC",
+    "milestone_id": 11,
+    "state": "opened",
+    "merge_status": "cannot_be_merged",
+    "target_project_id": 5,
+    "iid": 1,
+    "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.",
+    "position": 0,
+    "source":{
+      "name":"Gitlab Test",
+      "description":"Aut reprehenderit ut est.",
+      "web_url":"http://example.com/gitlab-org/gitlab-test",
+      "avatar_url":null,
+      "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+      "git_http_url":"http://example.com/gitlab-org/gitlab-test.git",
+      "namespace":"Gitlab Org",
+      "visibility_level":10,
+      "path_with_namespace":"gitlab-org/gitlab-test",
+      "default_branch":"master",
+      "homepage":"http://example.com/gitlab-org/gitlab-test",
+      "url":"http://example.com/gitlab-org/gitlab-test.git",
+      "ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+      "http_url":"http://example.com/gitlab-org/gitlab-test.git"
+    },
+    "target": {
+      "name":"Gitlab Test",
+      "description":"Aut reprehenderit ut est.",
+      "web_url":"http://example.com/gitlab-org/gitlab-test",
+      "avatar_url":null,
+      "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+      "git_http_url":"http://example.com/gitlab-org/gitlab-test.git",
+      "namespace":"Gitlab Org",
+      "visibility_level":10,
+      "path_with_namespace":"gitlab-org/gitlab-test",
+      "default_branch":"master",
+      "homepage":"http://example.com/gitlab-org/gitlab-test",
+      "url":"http://example.com/gitlab-org/gitlab-test.git",
+      "ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+      "http_url":"http://example.com/gitlab-org/gitlab-test.git"
+    },
+    "last_commit": {
+      "id": "562e173be03b8ff2efb05345d12df18815438a4b",
+      "message": "Merge branch 'another-branch' into 'master'\n\nCheck in this test\n",
+      "timestamp": "2015-04-08T21:00:25-07:00",
+      "url": "http://example.com/gitlab-org/gitlab-test/commit/562e173be03b8ff2efb05345d12df18815438a4b",
+      "author": {
+        "name": "John Smith",
+        "email": "john@example.com"
+      }
+    },
+    "work_in_progress": false,
+    "assignee": {
+      "name": "User1",
+      "username": "user1",
+      "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+    }
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Note Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing note hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*MergeCommentEvent)
+	if !ok {
+		t.Errorf("Expected MergeCommentEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "note" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "note")
+	}
+
+	if event.ProjectID != 5 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 5)
+	}
+
+	if event.ObjectAttributes.NoteableType != "MergeRequest" {
+		t.Errorf("NoteableType is %v, want %v", event.ObjectAttributes.NoteableType, "MergeRequest")
+	}
+
+	if event.MergeRequest.ID != 7 {
+		t.Errorf("MergeRequest ID is %v, want %v", event.MergeRequest.ID, 7)
+	}
+}
+
+func TestParseIssueCommentHook(t *testing.T) {
+	raw := `{
+  "object_kind": "note",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project_id": 5,
+  "project":{
+    "id": 5,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlab-org/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlab-org/gitlab-test.git",
+    "namespace":"Gitlab Org",
+    "visibility_level":10,
+    "path_with_namespace":"gitlab-org/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlab-org/gitlab-test",
+    "url":"http://example.com/gitlab-org/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "http_url":"http://example.com/gitlab-org/gitlab-test.git"
+  },
+  "repository":{
+    "name":"diaspora",
+    "url":"git@example.com:mike/diaspora.git",
+    "description":"",
+    "homepage":"http://example.com/mike/diaspora"
+  },
+  "object_attributes": {
+    "id": 1241,
+    "note": "Hello world",
+    "noteable_type": "Issue",
+    "author_id": 1,
+    "created_at": "2015-05-17 17:06:40 UTC",
+    "updated_at": "2015-05-17 17:06:40 UTC",
+    "project_id": 5,
+    "attachment": null,
+    "line_code": null,
+    "commit_id": "",
+    "noteable_id": 92,
+    "system": false,
+    "st_diff": null,
+    "url": "http://example.com/gitlab-org/gitlab-test/issues/17#note_1241"
+  },
+  "issue": {
+    "id": 92,
+    "title": "test",
+    "assignee_ids": [],
+    "assignee_id": null,
+    "author_id": 1,
+    "project_id": 5,
+    "created_at": "2016-01-04T15:31:46.176Z",
+    "updated_at": "2016-01-04T15:31:46.176Z",
+    "position": 0,
+    "branch_name": null,
+    "description": "test",
+    "milestone_id": null,
+    "state": "closed",
+    "iid": 17
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Note Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing note hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*IssueCommentEvent)
+	if !ok {
+		t.Errorf("Expected IssueCommentEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "note" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "note")
+	}
+
+	if event.ProjectID != 5 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 5)
+	}
+
+	if event.ObjectAttributes.NoteableType != "Issue" {
+		t.Errorf("NoteableType is %v, want %v", event.ObjectAttributes.NoteableType, "Issue")
+	}
+
+	if event.Issue.Title != "test" {
+		t.Errorf("Issue title is %v, want %v", event.Issue.Title, "test")
+	}
+}
+
+func TestParseSnippetCommentHook(t *testing.T) {
+	raw := `{
+  "object_kind": "note",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project_id": 5,
+  "project":{
+    "id": 5,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlab-org/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlab-org/gitlab-test.git",
+    "namespace":"Gitlab Org",
+    "visibility_level":10,
+    "path_with_namespace":"gitlab-org/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlab-org/gitlab-test",
+    "url":"http://example.com/gitlab-org/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlab-org/gitlab-test.git",
+    "http_url":"http://example.com/gitlab-org/gitlab-test.git"
+  },
+  "repository":{
+    "name":"Gitlab Test",
+    "url":"http://example.com/gitlab-org/gitlab-test.git",
+    "description":"Aut reprehenderit ut est.",
+    "homepage":"http://example.com/gitlab-org/gitlab-test"
+  },
+  "object_attributes": {
+    "id": 1245,
+    "note": "Is this snippet doing what it's supposed to be doing?",
+    "noteable_type": "Snippet",
+    "author_id": 1,
+    "created_at": "2015-05-17 18:35:50 UTC",
+    "updated_at": "2015-05-17 18:35:50 UTC",
+    "project_id": 5,
+    "attachment": null,
+    "line_code": null,
+    "commit_id": "",
+    "noteable_id": 53,
+    "system": false,
+    "st_diff": null,
+    "url": "http://example.com/gitlab-org/gitlab-test/snippets/53#note_1245"
+  },
+  "snippet": {
+    "id": 53,
+    "title": "test",
+    "content": "puts 'Hello world'",
+    "author_id": 1,
+    "project_id": 5,
+    "created_at": "2016-01-04T15:31:46.176Z",
+    "updated_at": "2016-01-04T15:31:46.176Z",
+    "file_name": "test.rb",
+    "expires_at": null,
+    "type": "ProjectSnippet",
+    "visibility_level": 0
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Note Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing note hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*SnippetCommentEvent)
+	if !ok {
+		t.Errorf("Expected SnippetCommentEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "note" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "note")
+	}
+
+	if event.ProjectID != 5 {
+		t.Errorf("ProjectID is %v, want %v", event.ProjectID, 5)
+	}
+
+	if event.ObjectAttributes.NoteableType != "Snippet" {
+		t.Errorf("NoteableType is %v, want %v", event.ObjectAttributes.NoteableType, "Snippet")
+	}
+
+	if event.Snippet.Title != "test" {
+		t.Errorf("Snippet title is %v, want %v", event.Snippet.Title, "test")
+	}
+}
+
+func TestParseMergeRequestHook(t *testing.T) {
+	raw := `{
+  "object_kind": "merge_request",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+  },
+  "project": {
+    "id": 1,
+    "name":"Gitlab Test",
+    "description":"Aut reprehenderit ut est.",
+    "web_url":"http://example.com/gitlabhq/gitlab-test",
+    "avatar_url":null,
+    "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
+    "namespace":"GitlabHQ",
+    "visibility_level":20,
+    "path_with_namespace":"gitlabhq/gitlab-test",
+    "default_branch":"master",
+    "homepage":"http://example.com/gitlabhq/gitlab-test",
+    "url":"http://example.com/gitlabhq/gitlab-test.git",
+    "ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+    "http_url":"http://example.com/gitlabhq/gitlab-test.git"
+  },
+  "repository": {
+    "name": "Gitlab Test",
+    "url": "http://example.com/gitlabhq/gitlab-test.git",
+    "description": "Aut reprehenderit ut est.",
+    "homepage": "http://example.com/gitlabhq/gitlab-test"
+  },
+  "object_attributes": {
+    "id": 99,
+    "target_branch": "master",
+    "source_branch": "ms-viewport",
+    "source_project_id": 14,
+    "author_id": 51,
+    "assignee_id": 6,
+    "title": "MS-Viewport",
+    "created_at": "2013-12-03T17:23:34Z",
+    "updated_at": "2013-12-03T17:23:34Z",
+    "milestone_id": null,
+    "state": "opened",
+    "merge_status": "unchecked",
+    "target_project_id": 14,
+    "iid": 1,
+    "description": "",
+    "source": {
+      "name":"Awesome Project",
+      "description":"Aut reprehenderit ut est.",
+      "web_url":"http://example.com/awesome_space/awesome_project",
+      "avatar_url":null,
+      "git_ssh_url":"git@example.com:awesome_space/awesome_project.git",
+      "git_http_url":"http://example.com/awesome_space/awesome_project.git",
+      "namespace":"Awesome Space",
+      "visibility_level":20,
+      "path_with_namespace":"awesome_space/awesome_project",
+      "default_branch":"master",
+      "homepage":"http://example.com/awesome_space/awesome_project",
+      "url":"http://example.com/awesome_space/awesome_project.git",
+      "ssh_url":"git@example.com:awesome_space/awesome_project.git",
+      "http_url":"http://example.com/awesome_space/awesome_project.git"
+    },
+    "target": {
+      "name":"Awesome Project",
+      "description":"Aut reprehenderit ut est.",
+      "web_url":"http://example.com/awesome_space/awesome_project",
+      "avatar_url":null,
+      "git_ssh_url":"git@example.com:awesome_space/awesome_project.git",
+      "git_http_url":"http://example.com/awesome_space/awesome_project.git",
+      "namespace":"Awesome Space",
+      "visibility_level":20,
+      "path_with_namespace":"awesome_space/awesome_project",
+      "default_branch":"master",
+      "homepage":"http://example.com/awesome_space/awesome_project",
+      "url":"http://example.com/awesome_space/awesome_project.git",
+      "ssh_url":"git@example.com:awesome_space/awesome_project.git",
+      "http_url":"http://example.com/awesome_space/awesome_project.git"
+    },
+    "last_commit": {
+      "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+      "message": "fixed readme",
+      "timestamp": "2012-01-03T23:36:29+02:00",
+      "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+      "author": {
+        "name": "GitLab dev user",
+        "email": "gitlabdev@dv6700.(none)"
+      }
+    },
+    "work_in_progress": false,
+    "url": "http://example.com/diaspora/merge_requests/1",
+    "action": "open",
+    "assignee": {
+      "name": "User1",
+      "username": "user1",
+      "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+    }
+  },
+  "labels": [{
+    "id": 206,
+    "title": "API",
+    "color": "#ffffff",
+    "project_id": 14,
+    "created_at": "2013-12-03T17:15:43Z",
+    "updated_at": "2013-12-03T17:15:43Z",
+    "template": false,
+    "description": "API related issues",
+    "type": "ProjectLabel",
+    "group_id": 41
+  }],
+  "changes": {
+    "updated_by_id": {
+      "previous": null,
+      "current": 1
+    },
+    "updated_at": ["2017-09-15 16:50:55 UTC", "2017-09-15 16:52:00 UTC"],
+    "labels": {
+      "previous": [{
+        "id": 206,
+        "title": "API",
+        "color": "#ffffff",
+        "project_id": 14,
+        "created_at": "2013-12-03T17:15:43Z",
+        "updated_at": "2013-12-03T17:15:43Z",
+        "template": false,
+        "description": "API related issues",
+        "type": "ProjectLabel",
+        "group_id": 41
+      }],
+      "current": [{
+        "id": 205,
+        "title": "Platform",
+        "color": "#123123",
+        "project_id": 14,
+        "created_at": "2013-12-03T17:15:43Z",
+        "updated_at": "2013-12-03T17:15:43Z",
+        "template": false,
+        "description": "Platform related issues",
+        "type": "ProjectLabel",
+        "group_id": 41
+      }]
+    }
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Merge Request Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing merge request hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*MergeEvent)
+	if !ok {
+		t.Errorf("Expected MergeEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "merge_request" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "merge_request")
+	}
+
+	if event.ObjectAttributes.MergeStatus != "unchecked" {
+		t.Errorf("MergeStatus is %v, want %v", event.ObjectAttributes.MergeStatus, "unchecked")
+	}
+
+	if event.ObjectAttributes.LastCommit.ID != "da1560886d4f094c3e6c9ef40349f7d38b5d27d7" {
+		t.Errorf("LastCommit ID is %v, want %v", event.ObjectAttributes.LastCommit.ID, "da1560886d4f094c3e6c9ef40349f7d38b5d27d7")
+	}
+
+	if event.ObjectAttributes.WorkInProgress {
+		t.Errorf("WorkInProgress is %v, want %v", event.ObjectAttributes.WorkInProgress, false)
+	}
+}
+
+func TestParseWikiPageHook(t *testing.T) {
+	raw := `{
+  "object_kind": "wiki_page",
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon"
+  },
+  "project": {
+    "id": 1,
+    "name": "awesome-project",
+    "description": "This is awesome",
+    "web_url": "http://example.com/root/awesome-project",
+    "avatar_url": null,
+    "git_ssh_url": "git@example.com:root/awesome-project.git",
+    "git_http_url": "http://example.com/root/awesome-project.git",
+    "namespace": "root",
+    "visibility_level": 0,
+    "path_with_namespace": "root/awesome-project",
+    "default_branch": "master",
+    "homepage": "http://example.com/root/awesome-project",
+    "url": "git@example.com:root/awesome-project.git",
+    "ssh_url": "git@example.com:root/awesome-project.git",
+    "http_url": "http://example.com/root/awesome-project.git"
+  },
+  "wiki": {
+    "web_url": "http://example.com/root/awesome-project/wikis/home",
+    "git_ssh_url": "git@example.com:root/awesome-project.wiki.git",
+    "git_http_url": "http://example.com/root/awesome-project.wiki.git",
+    "path_with_namespace": "root/awesome-project.wiki",
+    "default_branch": "master"
+  },
+  "object_attributes": {
+    "title": "Awesome",
+    "content": "awesome content goes here",
+    "format": "markdown",
+    "message": "adding an awesome page to the wiki",
+    "slug": "awesome",
+    "url": "http://example.com/root/awesome-project/wikis/awesome",
+    "action": "create"
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Wiki Page Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing wiki page hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*WikiPageEvent)
+	if !ok {
+		t.Errorf("Expected WikiPageEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "wiki_page" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "wiki_page")
+	}
+
+	if event.Project.Name != "awesome-project" {
+		t.Errorf("Project name is %v, want %v", event.Project.Name, "awesome-project")
+	}
+
+	if event.Wiki.WebURL != "http://example.com/root/awesome-project/wikis/home" {
+		t.Errorf("Wiki web URL is %v, want %v", event.Wiki.WebURL, "http://example.com/root/awesome-project/wikis/home")
+	}
+
+	if event.ObjectAttributes.Message != "adding an awesome page to the wiki" {
+		t.Errorf("Message is %v, want %v", event.ObjectAttributes.Message, "adding an awesome page to the wiki")
+	}
+}
+
+func TestParsePipelineHook(t *testing.T) {
+	raw := `{
+   "object_kind": "pipeline",
+   "object_attributes":{
+      "id": 31,
+      "ref": "master",
+      "tag": false,
+      "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+      "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+      "status": "success",
+      "stages":[
+         "build",
+         "test",
+         "deploy"
+      ],
+      "created_at": "2016-08-12 15:23:28 UTC",
+      "finished_at": "2016-08-12 15:26:29 UTC",
+      "duration": 63,
+      "variables": [
+        {
+          "key": "NESTOR_PROD_ENVIRONMENT",
+          "value": "us-west-1"
+        }
+      ]
+   },
+   "user":{
+      "name": "Administrator",
+      "username": "root",
+      "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+   },
+   "project":{
+      "id": 1,
+      "name": "Gitlab Test",
+      "description": "Atque in sunt eos similique dolores voluptatem.",
+      "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test",
+      "avatar_url": null,
+      "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git",
+      "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git",
+      "namespace": "Gitlab Org",
+      "visibility_level": 20,
+      "path_with_namespace": "gitlab-org/gitlab-test",
+      "default_branch": "master"
+   },
+   "commit":{
+      "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+      "message": "test\n",
+      "timestamp": "2016-08-12T17:23:21+02:00",
+      "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+      "author":{
+         "name": "User",
+         "email": "user@gitlab.com"
+      }
+   },
+   "builds":[
+      {
+         "id": 380,
+         "stage": "deploy",
+         "name": "production",
+         "status": "skipped",
+         "created_at": "2016-08-12 15:23:28 UTC",
+         "started_at": null,
+         "finished_at": null,
+         "when": "manual",
+         "manual": true,
+         "user":{
+            "name": "Administrator",
+            "username": "root",
+            "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+         },
+         "runner": null,
+         "artifacts_file":{
+            "filename": null,
+            "size": null
+         }
+      },
+      {
+         "id": 377,
+         "stage": "test",
+         "name": "test-image",
+         "status": "success",
+         "created_at": "2016-08-12 15:23:28 UTC",
+         "started_at": "2016-08-12 15:26:12 UTC",
+         "finished_at": null,
+         "when": "on_success",
+         "manual": false,
+         "user":{
+            "name": "Administrator",
+            "username": "root",
+            "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+         },
+         "runner": null,
+         "artifacts_file":{
+            "filename": null,
+            "size": null
+         }
+      },
+      {
+         "id": 378,
+         "stage": "test",
+         "name": "test-build",
+         "status": "success",
+         "created_at": "2016-08-12 15:23:28 UTC",
+         "started_at": "2016-08-12 15:26:12 UTC",
+         "finished_at": "2016-08-12 15:26:29 UTC",
+         "when": "on_success",
+         "manual": false,
+         "user":{
+            "name": "Administrator",
+            "username": "root",
+            "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+         },
+         "runner": null,
+         "artifacts_file":{
+            "filename": null,
+            "size": null
+         }
+      },
+      {
+         "id": 376,
+         "stage": "build",
+         "name": "build-image",
+         "status": "success",
+         "created_at": "2016-08-12 15:23:28 UTC",
+         "started_at": "2016-08-12 15:24:56 UTC",
+         "finished_at": "2016-08-12 15:25:26 UTC",
+         "when": "on_success",
+         "manual": false,
+         "user":{
+            "name": "Administrator",
+            "username": "root",
+            "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+         },
+         "runner": null,
+         "artifacts_file":{
+            "filename": null,
+            "size": null
+         }
+      },
+      {
+         "id": 379,
+         "stage": "deploy",
+         "name": "staging",
+         "status": "created",
+         "created_at": "2016-08-12 15:23:28 UTC",
+         "started_at": null,
+         "finished_at": null,
+         "when": "on_success",
+         "manual": false,
+         "user":{
+            "name": "Administrator",
+            "username": "root",
+            "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+         },
+         "runner": null,
+         "artifacts_file":{
+            "filename": null,
+            "size": null
+         }
+      }
+   ]
+}`
+
+	parsedEvent, err := ParseWebhook("Pipeline Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing pipeline hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*PipelineEvent)
+	if !ok {
+		t.Errorf("Expected PipelineEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "pipeline" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "pipeline")
+	}
+
+	if event.ObjectAttributes.Duration != 63 {
+		t.Errorf("Duration is %v, want %v", event.ObjectAttributes.Duration, 63)
+	}
+
+	if event.Commit.ID != "bcbb5ec396a2c0f828686f14fac9b80b780504f2" {
+		t.Errorf("Commit ID is %v, want %v", event.Commit.ID, "bcbb5ec396a2c0f828686f14fac9b80b780504f2")
+	}
+
+	if event.Builds[0].ID != 380 {
+		t.Errorf("Builds[0] ID is %v, want %v", event.Builds[0].ID, 380)
+	}
+}
+
+func TestParseBuildHook(t *testing.T) {
+	raw := `{
+  "object_kind": "build",
+  "ref": "gitlab-script-trigger",
+  "tag": false,
+  "before_sha": "2293ada6b400935a1378653304eaf6221e0fdb8f",
+  "sha": "2293ada6b400935a1378653304eaf6221e0fdb8f",
+  "build_id": 1977,
+  "build_name": "test",
+  "build_stage": "test",
+  "build_status": "created",
+  "build_started_at": null,
+  "build_finished_at": null,
+  "build_duration": null,
+  "build_allow_failure": false,
+  "build_failure_reason": "script_failure",
+  "project_id": 380,
+  "project_name": "gitlab-org/gitlab-test",
+  "user": {
+    "id": 3,
+    "name": "User",
+    "email": "user@gitlab.com"
+  },
+  "commit": {
+    "id": 2366,
+    "sha": "2293ada6b400935a1378653304eaf6221e0fdb8f",
+    "message": "test\n",
+    "author_name": "User",
+    "author_email": "user@gitlab.com",
+    "status": "created",
+    "duration": null,
+    "started_at": null,
+    "finished_at": null
+  },
+  "repository": {
+    "name": "gitlab_test",
+    "description": "Atque in sunt eos similique dolores voluptatem.",
+    "homepage": "http://192.168.64.1:3005/gitlab-org/gitlab-test",
+    "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git",
+    "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git",
+    "visibility_level": 20
+  }
+}`
+
+	parsedEvent, err := ParseWebhook("Build Hook", []byte(raw))
+	if err != nil {
+		t.Errorf("Error parsing build hook: %s", err)
+	}
+
+	event, ok := parsedEvent.(*BuildEvent)
+	if !ok {
+		t.Errorf("Expected BuildEvent, but parsing produced %T", parsedEvent)
+	}
+
+	if event.ObjectKind != "build" {
+		t.Errorf("ObjectKind is %v, want %v", event.ObjectKind, "build")
+	}
+
+	if event.BuildID != 1977 {
+		t.Errorf("BuildID is %v, want %v", event.BuildID, 1977)
+	}
+
+	if event.BuildAllowFailure {
+		t.Errorf("BuildAllowFailure is %v, want %v", event.BuildAllowFailure, false)
+	}
+
+	if event.Commit.Sha != "2293ada6b400935a1378653304eaf6221e0fdb8f" {
+		t.Errorf("Commit SHA is %v, want %v", event.Commit.Sha, "2293ada6b400935a1378653304eaf6221e0fdb8f")
+	}
+}