diff --git a/remote/github/convert.go b/remote/github/convert.go index a4e76825..0597b33d 100644 --- a/remote/github/convert.go +++ b/remote/github/convert.go @@ -1,6 +1,9 @@ package github import ( + "fmt" + "strings" + "github.com/drone/drone/model" "github.com/google/go-github/github" @@ -22,6 +25,11 @@ const ( descError = "oops, something went wrong" ) +const ( + headRefs = "refs/pull/%d/head" // pull request unmerged + mergeRefs = "refs/pull/%d/merge" // pull request merged with base +) + // convertStatus is a helper function used to convert a Drone status to a // GitHub commit status. func convertStatus(status string) string { @@ -124,3 +132,106 @@ func convertTeam(from github.Organization) *model.Team { Avatar: *from.AvatarURL, } } + +// convertRepoHook is a helper function used to extract the Repository details +// from a webhook and convert to the common Drone repository structure. +func convertRepoHook(from *webhook) *model.Repo { + repo := &model.Repo{ + Owner: from.Repo.Owner.Login, + Name: from.Repo.Name, + FullName: from.Repo.FullName, + Link: from.Repo.HTMLURL, + IsPrivate: from.Repo.Private, + Clone: from.Repo.CloneURL, + Branch: from.Repo.DefaultBranch, + Kind: model.RepoGit, + } + if repo.Branch == "" { + repo.Branch = defaultBranch + } + if repo.Owner == "" { // legacy webhooks + repo.Owner = from.Repo.Owner.Name + } + if repo.FullName == "" { + repo.FullName = repo.Owner + "/" + repo.Name + } + return repo +} + +// convertPushHook is a helper function used to extract the Build details +// from a push webhook and convert to the common Drone Build structure. +func convertPushHook(from *webhook) *model.Build { + build := &model.Build{ + Event: model.EventPush, + Commit: from.Head.ID, + Ref: from.Ref, + Link: from.Head.URL, + Branch: strings.Replace(from.Ref, "refs/heads/", "", -1), + Message: from.Head.Message, + Email: from.Head.Author.Email, + Avatar: from.Sender.Avatar, + Author: from.Sender.Login, + Remote: from.Repo.CloneURL, + } + if len(build.Author) == 0 { + build.Author = from.Head.Author.Username + } + if len(build.Email) == 0 { + // default to gravatar? + } + if strings.HasPrefix(build.Ref, "refs/tags/") { + // just kidding, this is actually a tag event. Why did this come as a push + // event we'll never know! + build.Event = model.EventTag + } + return build +} + +// convertPushHook is a helper function used to extract the Build details +// from a deploy webhook and convert to the common Drone Build structure. +func convertDeployHook(from *webhook) *model.Build { + build := &model.Build{ + Event: model.EventDeploy, + Commit: from.Deployment.Sha, + Link: from.Deployment.URL, + Message: from.Deployment.Desc, + Avatar: from.Sender.Avatar, + Author: from.Sender.Login, + Ref: from.Deployment.Ref, + Branch: from.Deployment.Ref, + Deploy: from.Deployment.Env, + } + // if the ref is a sha or short sha we need to manuallyconstruct the ref. + if strings.HasPrefix(build.Commit, build.Ref) || build.Commit == build.Ref { + build.Branch = from.Repo.DefaultBranch + if build.Branch == "" { + build.Branch = defaultBranch + } + build.Ref = fmt.Sprintf("refs/heads/%s", build.Branch) + } + // if the ref is a branch we should make sure it has refs/heads prefix + if !strings.HasPrefix(build.Ref, "refs/") { // branch or tag + build.Ref = fmt.Sprintf("refs/heads/%s", build.Branch) + } + return build +} + +// convertPullHook is a helper function used to extract the Build details +// from a pull request webhook and convert to the common Drone Build structure. +func convertPullHook(from *webhook, merge bool) *model.Build { + build := &model.Build{ + Event: model.EventPull, + Commit: from.PullRequest.Head.SHA, + Link: from.PullRequest.HTMLURL, + Ref: fmt.Sprintf(headRefs, from.PullRequest.Number), + Branch: from.PullRequest.Head.Ref, + Message: from.PullRequest.Title, + Author: from.PullRequest.User.Login, + Avatar: from.PullRequest.User.Avatar, + Title: from.PullRequest.Title, + } + if merge { + build.Ref = fmt.Sprintf(mergeRefs, from.PullRequest.Number) + } + return build +} diff --git a/remote/github/convert_test.go b/remote/github/convert_test.go index 9a240dd3..7cb1cdc0 100644 --- a/remote/github/convert_test.go +++ b/remote/github/convert_test.go @@ -149,23 +149,101 @@ func Test_helper(t *testing.T) { g.Assert(to[0].Avatar).Equal("http://...") }) - // - // g.It("should convert user", func() { - // token := &oauth2.Token{ - // AccessToken: "foo", - // RefreshToken: "bar", - // Expiry: time.Now(), - // } - // user := &internal.Account{Login: "octocat"} - // user.Links.Avatar.Href = "http://..." - // - // result := convertUser(user, token) - // g.Assert(result.Avatar).Equal(user.Links.Avatar.Href) - // g.Assert(result.Login).Equal(user.Login) - // g.Assert(result.Token).Equal(token.AccessToken) - // g.Assert(result.Token).Equal(token.AccessToken) - // g.Assert(result.Secret).Equal(token.RefreshToken) - // g.Assert(result.Expiry).Equal(token.Expiry.UTC().Unix()) - // }) + g.It("should convert a repository from webhook", func() { + from := &webhook{} + from.Repo.Owner.Login = "octocat" + from.Repo.Owner.Name = "octocat" + from.Repo.Name = "hello-world" + from.Repo.FullName = "octocat/hello-world" + from.Repo.Private = true + from.Repo.HTMLURL = "https://github.com/octocat/hello-world" + from.Repo.CloneURL = "https://github.com/octocat/hello-world.git" + from.Repo.DefaultBranch = "develop" + + repo := convertRepoHook(from) + g.Assert(repo.Owner).Equal(from.Repo.Owner.Login) + g.Assert(repo.Name).Equal(from.Repo.Name) + g.Assert(repo.FullName).Equal(from.Repo.FullName) + g.Assert(repo.IsPrivate).Equal(from.Repo.Private) + g.Assert(repo.Link).Equal(from.Repo.HTMLURL) + g.Assert(repo.Clone).Equal(from.Repo.CloneURL) + g.Assert(repo.Branch).Equal(from.Repo.DefaultBranch) + }) + + g.It("should convert a pull request from webhook", func() { + from := &webhook{} + from.PullRequest.Head.Ref = "master" + from.PullRequest.Head.SHA = "f72fc19" + from.PullRequest.HTMLURL = "https://github.com/octocat/hello-world/pulls/42" + from.PullRequest.Number = 42 + from.PullRequest.Title = "Updated README.md" + from.PullRequest.User.Login = "octocat" + from.PullRequest.User.Avatar = "https://avatars1.githubusercontent.com/u/583231" + + build := convertPullHook(from, true) + g.Assert(build.Event).Equal(model.EventPull) + g.Assert(build.Branch).Equal(from.PullRequest.Head.Ref) + g.Assert(build.Ref).Equal("refs/pull/42/merge") + g.Assert(build.Commit).Equal(from.PullRequest.Head.SHA) + g.Assert(build.Message).Equal(from.PullRequest.Title) + g.Assert(build.Title).Equal(from.PullRequest.Title) + g.Assert(build.Author).Equal(from.PullRequest.User.Login) + g.Assert(build.Avatar).Equal(from.PullRequest.User.Avatar) + }) + + g.It("should convert a deployment from webhook", func() { + from := &webhook{} + from.Deployment.Desc = ":shipit:" + from.Deployment.Env = "production" + from.Deployment.ID = 42 + from.Deployment.Ref = "master" + from.Deployment.Sha = "f72fc19" + from.Deployment.URL = "https://github.com/octocat/hello-world" + from.Sender.Login = "octocat" + from.Sender.Avatar = "https://avatars1.githubusercontent.com/u/583231" + + build := convertDeployHook(from) + g.Assert(build.Event).Equal(model.EventDeploy) + g.Assert(build.Branch).Equal("master") + g.Assert(build.Ref).Equal("refs/heads/master") + g.Assert(build.Commit).Equal(from.Deployment.Sha) + g.Assert(build.Message).Equal(from.Deployment.Desc) + g.Assert(build.Link).Equal(from.Deployment.URL) + g.Assert(build.Author).Equal(from.Sender.Login) + g.Assert(build.Avatar).Equal(from.Sender.Avatar) + }) + + g.It("should convert a push from webhook", func() { + from := &webhook{} + from.Sender.Login = "octocat" + from.Sender.Avatar = "https://avatars1.githubusercontent.com/u/583231" + from.Repo.CloneURL = "https://github.com/octocat/hello-world.git" + from.Head.Author.Email = "octocat@github.com" + from.Head.Message = "updated README.md" + from.Head.URL = "https://github.com/octocat/hello-world" + from.Head.ID = "f72fc19" + from.Ref = "refs/heads/master" + + build := convertPushHook(from) + g.Assert(build.Event).Equal(model.EventPush) + g.Assert(build.Branch).Equal("master") + g.Assert(build.Ref).Equal("refs/heads/master") + g.Assert(build.Commit).Equal(from.Head.ID) + g.Assert(build.Message).Equal(from.Head.Message) + g.Assert(build.Link).Equal(from.Head.URL) + g.Assert(build.Author).Equal(from.Sender.Login) + g.Assert(build.Avatar).Equal(from.Sender.Avatar) + g.Assert(build.Email).Equal(from.Head.Author.Email) + g.Assert(build.Remote).Equal(from.Repo.CloneURL) + }) + + g.It("should convert a tag from webhook", func() { + from := &webhook{} + from.Ref = "refs/tags/v1.0.0" + + build := convertPushHook(from) + g.Assert(build.Event).Equal(model.EventTag) + g.Assert(build.Ref).Equal("refs/tags/v1.0.0") + }) }) } diff --git a/remote/github/fixtures/handler.go b/remote/github/fixtures/handler.go new file mode 100644 index 00000000..8b0b270b --- /dev/null +++ b/remote/github/fixtures/handler.go @@ -0,0 +1,47 @@ +package fixtures + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Handler returns an http.Handler that is capable of handling a variety of mock +// Bitbucket requests and returning mock responses. +func Handler() http.Handler { + gin.SetMode(gin.TestMode) + + e := gin.New() + e.GET("/api/v3/repos/:owner/:name", getRepo) + + return e +} + +func getRepo(c *gin.Context) { + switch c.Param("name") { + case "repo_not_found": + c.String(404, "") + default: + c.String(200, repoPayload) + } +} + +var repoPayload = ` +{ + "owner": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + }, + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "private": true, + "html_url": "https://github.com/octocat/Hello-World", + "clone_url": "https://github.com/octocat/Hello-World.git", + "language": null, + "permissions": { + "admin": true, + "push": true, + "pull": true + } +} +` diff --git a/remote/github/fixtures/hooks.go b/remote/github/fixtures/hooks.go new file mode 100644 index 00000000..a2929aaf --- /dev/null +++ b/remote/github/fixtures/hooks.go @@ -0,0 +1,157 @@ +package fixtures + +// HookPush is a sample push hook. +// https://developer.github.com/v3/activity/events/types/#pushevent +const HookPush = ` +{ + "ref": "refs/heads/changes", + "created": false, + "deleted": false, + "head_commit": { + "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", + "message": "Update README.md", + "timestamp": "2015-05-05T19:40:15-04:00", + "url": "https://github.com/baxterthehacker/public-repo/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", + "author": { + "name": "baxterthehacker", + "email": "baxterthehacker@users.noreply.github.com", + "username": "baxterthehacker" + }, + "committer": { + "name": "baxterthehacker", + "email": "baxterthehacker@users.noreply.github.com", + "username": "baxterthehacker" + } + }, + "repository": { + "id": 35129377, + "name": "public-repo", + "full_name": "baxterthehacker/public-repo", + "owner": { + "name": "baxterthehacker", + "email": "baxterthehacker@users.noreply.github.com" + }, + "private": false, + "html_url": "https://github.com/baxterthehacker/public-repo", + "default_branch": "master" + }, + "pusher": { + "name": "baxterthehacker", + "email": "baxterthehacker@users.noreply.github.com" + }, + "sender": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + } +} +` + +// HookPush is a sample push hook that is marked as deleted, and is expected to +// be ignored. +const HookPushDeleted = ` +{ + "deleted": true +} +` + +// HookPullRequest is a sample hook pull request +// https://developer.github.com/v3/activity/events/types/#pullrequestevent +const HookPullRequest = ` +{ + "action": "opened", + "number": 1, + "pull_request": { + "url": "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", + "html_url": "https://github.com/baxterthehacker/public-repo/pull/1", + "number": 1, + "state": "open", + "title": "Update the README with new information", + "user": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + }, + "head": { + "label": "baxterthehacker:changes", + "ref": "changes", + "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c" + } + }, + "repository": { + "id": 35129377, + "name": "public-repo", + "full_name": "baxterthehacker/public-repo", + "owner": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + }, + "private": true, + "html_url": "https://github.com/baxterthehacker/public-repo", + "clone_url": "https://github.com/baxterthehacker/public-repo.git", + "default_branch": "master" + }, + "sender": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + } +} +` + +// HookPullRequestInvalidAction is a sample hook pull request that has an +// action not equal to synchrize or opened, and is expected to be ignored. +const HookPullRequestInvalidAction = ` +{ + "action": "reopened", + "number": 1 +} +` + +// HookPullRequestInvalidState is a sample hook pull request that has a state +// not equal to open, and is expected to be ignored. +const HookPullRequestInvalidState = ` +{ + "action": "synchronize", + "pull_request": { + "number": 1, + "state": "closed" + } +} +` + +// HookPush is a sample deployment hook. +// https://developer.github.com/v3/activity/events/types/#deploymentevent +const HookDeploy = ` +{ + "deployment": { + "url": "https://api.github.com/repos/baxterthehacker/public-repo/deployments/710692", + "id": 710692, + "sha": "9049f1265b7d61be4a8904a9a27120d2064dab3b", + "ref": "master", + "task": "deploy", + "payload": { + }, + "environment": "production", + "description": null, + "creator": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + } + }, + "repository": { + "id": 35129377, + "name": "public-repo", + "full_name": "baxterthehacker/public-repo", + "owner": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + }, + "private": true, + "html_url": "https://github.com/baxterthehacker/public-repo", + "clone_url": "https://github.com/baxterthehacker/public-repo.git", + "default_branch": "master" + }, + "sender": { + "login": "baxterthehacker", + "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" + } +} +` diff --git a/remote/github/fixtures/pull_request.json b/remote/github/fixtures/pull_request.json deleted file mode 100644 index 11ab41e1..00000000 --- a/remote/github/fixtures/pull_request.json +++ /dev/null @@ -1,423 +0,0 @@ -{ - "action": "opened", - "number": 773, - "pull_request": { - "url": "https://api.github.com/repos/company/project/pulls/773", - "id": 50420217, - "html_url": "https://github.com/company/project/pull/773", - "diff_url": "https://github.com/company/project/pull/773.diff", - "patch_url": "https://github.com/company/project/pull/773.patch", - "issue_url": "https://api.github.com/repos/company/project/issues/773", - "number": 773, - "state": "open", - "locked": false, - "title": "working on feature X", - "user": { - "login": "author", - "id": 55555, - "avatar_url": "https://avatars.githubusercontent.com/u/55555?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/author", - "html_url": "https://github.com/author", - "followers_url": "https://api.github.com/users/author/followers", - "following_url": "https://api.github.com/users/author/following{/other_user}", - "gists_url": "https://api.github.com/users/author/gists{/gist_id}", - "starred_url": "https://api.github.com/users/author/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/author/subscriptions", - "organizations_url": "https://api.github.com/users/author/orgs", - "repos_url": "https://api.github.com/users/author/repos", - "events_url": "https://api.github.com/users/author/events{/privacy}", - "received_events_url": "https://api.github.com/users/author/received_events", - "type": "User", - "site_admin": false - }, - "body": "Check that the server builds without errors.", - "created_at": "2015-11-11T18:48:59Z", - "updated_at": "2015-11-11T18:48:59Z", - "closed_at": null, - "merged_at": null, - "merge_commit_sha": "a628859c51125d68cdf8db83e1a8d71b42a3aa55", - "assignee": null, - "milestone": null, - "commits_url": "https://api.github.com/repos/company/project/pulls/773/commits", - "review_comments_url": "https://api.github.com/repos/company/project/pulls/773/comments", - "review_comment_url": "https://api.github.com/repos/company/project/pulls/comments{/number}", - "comments_url": "https://api.github.com/repos/company/project/issues/773/comments", - "statuses_url": "https://api.github.com/repos/company/project/statuses/f2fe6d044518f607bf61d621200834643e364841", - "head": { - "label": "company:feature_branch", - "ref": "feature_branch", - "sha": "f2fe6d044518f607bf61d621200834643e364841", - "user": { - "login": "company", - "id": 66666, - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/company", - "html_url": "https://github.com/company", - "followers_url": "https://api.github.com/users/company/followers", - "following_url": "https://api.github.com/users/company/following{/other_user}", - "gists_url": "https://api.github.com/users/company/gists{/gist_id}", - "starred_url": "https://api.github.com/users/company/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/company/subscriptions", - "organizations_url": "https://api.github.com/users/company/orgs", - "repos_url": "https://api.github.com/users/company/repos", - "events_url": "https://api.github.com/users/company/events{/privacy}", - "received_events_url": "https://api.github.com/users/company/received_events", - "type": "Organization", - "site_admin": false - }, - "repo": { - "id": 13249623, - "name": "project", - "full_name": "company/project", - "owner": { - "login": "company", - "id": 66666, - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/company", - "html_url": "https://github.com/company", - "followers_url": "https://api.github.com/users/company/followers", - "following_url": "https://api.github.com/users/company/following{/other_user}", - "gists_url": "https://api.github.com/users/company/gists{/gist_id}", - "starred_url": "https://api.github.com/users/company/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/company/subscriptions", - "organizations_url": "https://api.github.com/users/company/orgs", - "repos_url": "https://api.github.com/users/company/repos", - "events_url": "https://api.github.com/users/company/events{/privacy}", - "received_events_url": "https://api.github.com/users/company/received_events", - "type": "Organization", - "site_admin": false - }, - "private": true, - "html_url": "https://github.com/company/project", - "description": "project description", - "fork": false, - "url": "https://api.github.com/repos/company/project", - "forks_url": "https://api.github.com/repos/company/project/forks", - "keys_url": "https://api.github.com/repos/company/project/keys{/key_id}", - "collaborators_url": "https://api.github.com/repos/company/project/collaborators{/collaborator}", - "teams_url": "https://api.github.com/repos/company/project/teams", - "hooks_url": "https://api.github.com/repos/company/project/hooks", - "issue_events_url": "https://api.github.com/repos/company/project/issues/events{/number}", - "events_url": "https://api.github.com/repos/company/project/events", - "assignees_url": "https://api.github.com/repos/company/project/assignees{/user}", - "branches_url": "https://api.github.com/repos/company/project/branches{/branch}", - "tags_url": "https://api.github.com/repos/company/project/tags", - "blobs_url": "https://api.github.com/repos/company/project/git/blobs{/sha}", - "git_tags_url": "https://api.github.com/repos/company/project/git/tags{/sha}", - "git_refs_url": "https://api.github.com/repos/company/project/git/refs{/sha}", - "trees_url": "https://api.github.com/repos/company/project/git/trees{/sha}", - "statuses_url": "https://api.github.com/repos/company/project/statuses/{sha}", - "languages_url": "https://api.github.com/repos/company/project/languages", - "stargazers_url": "https://api.github.com/repos/company/project/stargazers", - "contributors_url": "https://api.github.com/repos/company/project/contributors", - "subscribers_url": "https://api.github.com/repos/company/project/subscribers", - "subscription_url": "https://api.github.com/repos/company/project/subscription", - "commits_url": "https://api.github.com/repos/company/project/commits{/sha}", - "git_commits_url": "https://api.github.com/repos/company/project/git/commits{/sha}", - "comments_url": "https://api.github.com/repos/company/project/comments{/number}", - "issue_comment_url": "https://api.github.com/repos/company/project/issues/comments{/number}", - "contents_url": "https://api.github.com/repos/company/project/contents/{+path}", - "compare_url": "https://api.github.com/repos/company/project/compare/{base}...{head}", - "merges_url": "https://api.github.com/repos/company/project/merges", - "archive_url": "https://api.github.com/repos/company/project/{archive_format}{/ref}", - "downloads_url": "https://api.github.com/repos/company/project/downloads", - "issues_url": "https://api.github.com/repos/company/project/issues{/number}", - "pulls_url": "https://api.github.com/repos/company/project/pulls{/number}", - "milestones_url": "https://api.github.com/repos/company/project/milestones{/number}", - "notifications_url": "https://api.github.com/repos/company/project/notifications{?since,all,participating}", - "labels_url": "https://api.github.com/repos/company/project/labels{/name}", - "releases_url": "https://api.github.com/repos/company/project/releases{/id}", - "created_at": "2013-10-01T16:47:20Z", - "updated_at": "2015-05-08T20:19:10Z", - "pushed_at": "2015-11-11T18:48:59Z", - "git_url": "git://github.com/company/project.git", - "ssh_url": "git@github.com:company/project.git", - "clone_url": "https://github.com/company/project.git", - "svn_url": "https://github.com/company/project", - "homepage": "", - "size": 21672, - "stargazers_count": 1, - "watchers_count": 1, - "language": "Go", - "has_issues": true, - "has_downloads": true, - "has_wiki": true, - "has_pages": false, - "forks_count": 0, - "mirror_url": null, - "open_issues_count": 2, - "forks": 0, - "open_issues": 2, - "watchers": 1, - "default_branch": "master" - } - }, - "base": { - "label": "company:master", - "ref": "master", - "sha": "19f3077e1c7e490247a8f4a563148a7f60ae03fa", - "user": { - "login": "company", - "id": 66666, - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/company", - "html_url": "https://github.com/company", - "followers_url": "https://api.github.com/users/company/followers", - "following_url": "https://api.github.com/users/company/following{/other_user}", - "gists_url": "https://api.github.com/users/company/gists{/gist_id}", - "starred_url": "https://api.github.com/users/company/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/company/subscriptions", - "organizations_url": "https://api.github.com/users/company/orgs", - "repos_url": "https://api.github.com/users/company/repos", - "events_url": "https://api.github.com/users/company/events{/privacy}", - "received_events_url": "https://api.github.com/users/company/received_events", - "type": "Organization", - "site_admin": false - }, - "repo": { - "id": 13249623, - "name": "project", - "full_name": "company/project", - "owner": { - "login": "company", - "id": 66666, - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/company", - "html_url": "https://github.com/company", - "followers_url": "https://api.github.com/users/company/followers", - "following_url": "https://api.github.com/users/company/following{/other_user}", - "gists_url": "https://api.github.com/users/company/gists{/gist_id}", - "starred_url": "https://api.github.com/users/company/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/company/subscriptions", - "organizations_url": "https://api.github.com/users/company/orgs", - "repos_url": "https://api.github.com/users/company/repos", - "events_url": "https://api.github.com/users/company/events{/privacy}", - "received_events_url": "https://api.github.com/users/company/received_events", - "type": "Organization", - "site_admin": false - }, - "private": true, - "html_url": "https://github.com/company/project", - "description": "project description", - "fork": false, - "url": "https://api.github.com/repos/company/project", - "forks_url": "https://api.github.com/repos/company/project/forks", - "keys_url": "https://api.github.com/repos/company/project/keys{/key_id}", - "collaborators_url": "https://api.github.com/repos/company/project/collaborators{/collaborator}", - "teams_url": "https://api.github.com/repos/company/project/teams", - "hooks_url": "https://api.github.com/repos/company/project/hooks", - "issue_events_url": "https://api.github.com/repos/company/project/issues/events{/number}", - "events_url": "https://api.github.com/repos/company/project/events", - "assignees_url": "https://api.github.com/repos/company/project/assignees{/user}", - "branches_url": "https://api.github.com/repos/company/project/branches{/branch}", - "tags_url": "https://api.github.com/repos/company/project/tags", - "blobs_url": "https://api.github.com/repos/company/project/git/blobs{/sha}", - "git_tags_url": "https://api.github.com/repos/company/project/git/tags{/sha}", - "git_refs_url": "https://api.github.com/repos/company/project/git/refs{/sha}", - "trees_url": "https://api.github.com/repos/company/project/git/trees{/sha}", - "statuses_url": "https://api.github.com/repos/company/project/statuses/{sha}", - "languages_url": "https://api.github.com/repos/company/project/languages", - "stargazers_url": "https://api.github.com/repos/company/project/stargazers", - "contributors_url": "https://api.github.com/repos/company/project/contributors", - "subscribers_url": "https://api.github.com/repos/company/project/subscribers", - "subscription_url": "https://api.github.com/repos/company/project/subscription", - "commits_url": "https://api.github.com/repos/company/project/commits{/sha}", - "git_commits_url": "https://api.github.com/repos/company/project/git/commits{/sha}", - "comments_url": "https://api.github.com/repos/company/project/comments{/number}", - "issue_comment_url": "https://api.github.com/repos/company/project/issues/comments{/number}", - "contents_url": "https://api.github.com/repos/company/project/contents/{+path}", - "compare_url": "https://api.github.com/repos/company/project/compare/{base}...{head}", - "merges_url": "https://api.github.com/repos/company/project/merges", - "archive_url": "https://api.github.com/repos/company/project/{archive_format}{/ref}", - "downloads_url": "https://api.github.com/repos/company/project/downloads", - "issues_url": "https://api.github.com/repos/company/project/issues{/number}", - "pulls_url": "https://api.github.com/repos/company/project/pulls{/number}", - "milestones_url": "https://api.github.com/repos/company/project/milestones{/number}", - "notifications_url": "https://api.github.com/repos/company/project/notifications{?since,all,participating}", - "labels_url": "https://api.github.com/repos/company/project/labels{/name}", - "releases_url": "https://api.github.com/repos/company/project/releases{/id}", - "created_at": "2013-10-01T16:47:20Z", - "updated_at": "2015-05-08T20:19:10Z", - "pushed_at": "2015-11-11T18:48:59Z", - "git_url": "git://github.com/company/project.git", - "ssh_url": "git@github.com:company/project.git", - "clone_url": "https://github.com/company/project.git", - "svn_url": "https://github.com/company/project", - "homepage": "", - "size": 21672, - "stargazers_count": 1, - "watchers_count": 1, - "language": "Go", - "has_issues": true, - "has_downloads": true, - "has_wiki": true, - "has_pages": false, - "forks_count": 0, - "mirror_url": null, - "open_issues_count": 2, - "forks": 0, - "open_issues": 2, - "watchers": 1, - "default_branch": "master" - } - }, - "_links": { - "self": { - "href": "https://api.github.com/repos/company/project/pulls/773" - }, - "html": { - "href": "https://github.com/company/project/pull/773" - }, - "issue": { - "href": "https://api.github.com/repos/company/project/issues/773" - }, - "comments": { - "href": "https://api.github.com/repos/company/project/issues/773/comments" - }, - "review_comments": { - "href": "https://api.github.com/repos/company/project/pulls/773/comments" - }, - "review_comment": { - "href": "https://api.github.com/repos/company/project/pulls/comments{/number}" - }, - "commits": { - "href": "https://api.github.com/repos/company/project/pulls/773/commits" - }, - "statuses": { - "href": "https://api.github.com/repos/company/project/statuses/f2fe6d044518f607bf61d621200834643e364841" - } - }, - "merged": false, - "mergeable": true, - "mergeable_state": "clean", - "merged_by": null, - "comments": 0, - "review_comments": 0, - "commits": 1, - "additions": 1, - "deletions": 0, - "changed_files": 1 - }, - "repository": { - "id": 13249623, - "name": "project", - "full_name": "company/project", - "owner": { - "login": "company", - "id": 66666, - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/company", - "html_url": "https://github.com/company", - "followers_url": "https://api.github.com/users/company/followers", - "following_url": "https://api.github.com/users/company/following{/other_user}", - "gists_url": "https://api.github.com/users/company/gists{/gist_id}", - "starred_url": "https://api.github.com/users/company/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/company/subscriptions", - "organizations_url": "https://api.github.com/users/company/orgs", - "repos_url": "https://api.github.com/users/company/repos", - "events_url": "https://api.github.com/users/company/events{/privacy}", - "received_events_url": "https://api.github.com/users/company/received_events", - "type": "Organization", - "site_admin": false - }, - "private": true, - "html_url": "https://github.com/company/project", - "description": "project description", - "fork": false, - "url": "https://api.github.com/repos/company/project", - "forks_url": "https://api.github.com/repos/company/project/forks", - "keys_url": "https://api.github.com/repos/company/project/keys{/key_id}", - "collaborators_url": "https://api.github.com/repos/company/project/collaborators{/collaborator}", - "teams_url": "https://api.github.com/repos/company/project/teams", - "hooks_url": "https://api.github.com/repos/company/project/hooks", - "issue_events_url": "https://api.github.com/repos/company/project/issues/events{/number}", - "events_url": "https://api.github.com/repos/company/project/events", - "assignees_url": "https://api.github.com/repos/company/project/assignees{/user}", - "branches_url": "https://api.github.com/repos/company/project/branches{/branch}", - "tags_url": "https://api.github.com/repos/company/project/tags", - "blobs_url": "https://api.github.com/repos/company/project/git/blobs{/sha}", - "git_tags_url": "https://api.github.com/repos/company/project/git/tags{/sha}", - "git_refs_url": "https://api.github.com/repos/company/project/git/refs{/sha}", - "trees_url": "https://api.github.com/repos/company/project/git/trees{/sha}", - "statuses_url": "https://api.github.com/repos/company/project/statuses/{sha}", - "languages_url": "https://api.github.com/repos/company/project/languages", - "stargazers_url": "https://api.github.com/repos/company/project/stargazers", - "contributors_url": "https://api.github.com/repos/company/project/contributors", - "subscribers_url": "https://api.github.com/repos/company/project/subscribers", - "subscription_url": "https://api.github.com/repos/company/project/subscription", - "commits_url": "https://api.github.com/repos/company/project/commits{/sha}", - "git_commits_url": "https://api.github.com/repos/company/project/git/commits{/sha}", - "comments_url": "https://api.github.com/repos/company/project/comments{/number}", - "issue_comment_url": "https://api.github.com/repos/company/project/issues/comments{/number}", - "contents_url": "https://api.github.com/repos/company/project/contents/{+path}", - "compare_url": "https://api.github.com/repos/company/project/compare/{base}...{head}", - "merges_url": "https://api.github.com/repos/company/project/merges", - "archive_url": "https://api.github.com/repos/company/project/{archive_format}{/ref}", - "downloads_url": "https://api.github.com/repos/company/project/downloads", - "issues_url": "https://api.github.com/repos/company/project/issues{/number}", - "pulls_url": "https://api.github.com/repos/company/project/pulls{/number}", - "milestones_url": "https://api.github.com/repos/company/project/milestones{/number}", - "notifications_url": "https://api.github.com/repos/company/project/notifications{?since,all,participating}", - "labels_url": "https://api.github.com/repos/company/project/labels{/name}", - "releases_url": "https://api.github.com/repos/company/project/releases{/id}", - "created_at": "2013-10-01T16:47:20Z", - "updated_at": "2015-05-08T20:19:10Z", - "pushed_at": "2015-11-11T18:48:59Z", - "git_url": "git://github.com/company/project.git", - "ssh_url": "git@github.com:company/project.git", - "clone_url": "https://github.com/company/project.git", - "svn_url": "https://github.com/company/project", - "homepage": "", - "size": 21672, - "stargazers_count": 1, - "watchers_count": 1, - "language": "Go", - "has_issues": true, - "has_downloads": true, - "has_wiki": true, - "has_pages": false, - "forks_count": 0, - "mirror_url": null, - "open_issues_count": 2, - "forks": 0, - "open_issues": 2, - "watchers": 1, - "default_branch": "master" - }, - "organization": { - "login": "company", - "id": 66666, - "url": "https://api.github.com/orgs/company", - "repos_url": "https://api.github.com/orgs/company/repos", - "events_url": "https://api.github.com/orgs/company/events", - "members_url": "https://api.github.com/orgs/company/members{/member}", - "public_members_url": "https://api.github.com/orgs/company/public_members{/member}", - "avatar_url": "https://avatars.githubusercontent.com/u/66666?v=3", - "description": null - }, - "sender": { - "login": "author", - "id": 55555, - "avatar_url": "https://avatars.githubusercontent.com/u/55555?v=3", - "gravatar_id": "", - "url": "https://api.github.com/users/author", - "html_url": "https://github.com/author", - "followers_url": "https://api.github.com/users/author/followers", - "following_url": "https://api.github.com/users/author/following{/other_user}", - "gists_url": "https://api.github.com/users/author/gists{/gist_id}", - "starred_url": "https://api.github.com/users/author/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/author/subscriptions", - "organizations_url": "https://api.github.com/users/author/orgs", - "repos_url": "https://api.github.com/users/author/repos", - "events_url": "https://api.github.com/users/author/events{/privacy}", - "received_events_url": "https://api.github.com/users/author/received_events", - "type": "User", - "site_admin": false - } -} diff --git a/remote/github/github.go b/remote/github/github.go index a7a92a08..df3d25a2 100644 --- a/remote/github/github.go +++ b/remote/github/github.go @@ -2,7 +2,6 @@ package github import ( "crypto/tls" - "encoding/base32" "fmt" "net" "net/http" @@ -14,9 +13,9 @@ import ( "github.com/drone/drone/model" "github.com/drone/drone/remote" "github.com/drone/drone/shared/httputil" - "github.com/gorilla/securecookie" "github.com/google/go-github/github" + "golang.org/x/net/context" "golang.org/x/oauth2" ) @@ -89,30 +88,38 @@ func (c *client) Login(res http.ResponseWriter, req *http.Request) (*model.User, code := req.FormValue("code") if len(code) == 0 { - rand := base32.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) - http.Redirect(res, req, config.AuthCodeURL(rand), http.StatusSeeOther) + // TODO(bradrydzewski) we really should be using a random value here and + // storing in a cookie for verification in the next stage of the workflow. + + http.Redirect(res, req, config.AuthCodeURL("drone"), http.StatusSeeOther) return nil, nil } - // TODO(bradrydzewski) what is the best way to provide a SkipVerify flag - // when exchanging the token? - - token, err := config.Exchange(oauth2.NoContext, code) + token, err := config.Exchange(c.newContext(), code) if err != nil { return nil, err } client := c.newClientToken(token.AccessToken) - useremail, err := GetUserEmail(client) + user, _, err := client.Users.Get("") if err != nil { return nil, err } + emails, _, err := client.Users.ListEmails(nil) + if err != nil { + return nil, err + } + email := matchingEmail(emails, c.API) + if email == nil { + return nil, fmt.Errorf("No verified Email address for GitHub account") + } + return &model.User{ - Login: *useremail.Login, - Email: *useremail.Email, + Login: *user.Login, + Email: *email.Email, Token: token.AccessToken, - Avatar: *useremail.AvatarURL, + Avatar: *user.AvatarURL, }, nil } @@ -217,7 +224,39 @@ func (c *client) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { }, nil } -// helper function to return the bitbucket oauth2 config +// Deactivate deactives the repository be removing registered push hooks from +// the GitHub repository. +func (c *client) Deactivate(u *model.User, r *model.Repo, link string) error { + client := c.newClientToken(u.Token) + hooks, _, err := client.Repositories.ListHooks(r.Owner, r.Name, nil) + if err != nil { + return err + } + match := matchingHooks(hooks, link) + if match == nil { + return nil + } + _, err = client.Repositories.DeleteHook(r.Owner, r.Name, *match.ID) + return err +} + +// helper function to return the GitHub oauth2 context using an HTTPClient that +// disables TLS verification if disabled in the remote settings. +func (c *client) newContext() context.Context { + if !c.SkipVerify { + return oauth2.NoContext + } + return context.WithValue(nil, oauth2.HTTPClient, &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }) +} + +// helper function to return the GitHub oauth2 config func (c *client) newConfig(redirect string) *oauth2.Config { return &oauth2.Config{ ClientID: c.Client, @@ -230,7 +269,7 @@ func (c *client) newConfig(redirect string) *oauth2.Config { } } -// helper function to return the bitbucket oauth2 client +// helper function to return the GitHub oauth2 client func (c *client) newClientToken(token string) *github.Client { ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, @@ -249,6 +288,50 @@ func (c *client) newClientToken(token string) *github.Client { return github } +// helper function to return matching user email. +func matchingEmail(emails []github.UserEmail, rawurl string) *github.UserEmail { + for _, email := range emails { + if email.Email == nil || email.Primary == nil || email.Verified == nil { + continue + } + if *email.Primary && *email.Verified { + return &email + } + } + // github enterprise does not support verified email addresses so instead + // we'll return the first email address in the list. + if len(emails) != 0 && rawurl != defaultAPI { + return &emails[0] + } + return nil +} + +// helper function to return matching hook. +func matchingHooks(hooks []github.Hook, rawurl string) *github.Hook { + link, err := url.Parse(rawurl) + if err != nil { + return nil + } + for _, hook := range hooks { + if hook.ID == nil { + continue + } + v, ok := hook.Config["url"] + if !ok { + continue + } + s, ok := v.(string) + if !ok { + continue + } + hookurl, err := url.Parse(s) + if err == nil && hookurl.Host == link.Host { + return &hook + } + } + return nil +} + // // TODO(bradrydzewski) refactor below functions // @@ -297,29 +380,28 @@ func deploymentStatus(client *github.Client, r *model.Repo, b *model.Build, link // Activate activates a repository by creating the post-commit hook and // adding the SSH deploy key, if applicable. func (c *client) Activate(u *model.User, r *model.Repo, link string) error { + if err := c.Deactivate(u, r, link); err != nil { + return err + } client := c.newClientToken(u.Token) - _, err := CreateUpdateHook(client, r.Owner, r.Name, link) + hook := &github.Hook{ + Name: github.String("web"), + Events: []string{ + "push", + "pull_request", + "deployment", + }, + Config: map[string]interface{}{ + "url": link, + "content_type": "form", + }, + } + _, _, err := client.Repositories.CreateHook(r.Owner, r.Name, hook) return err } -// Deactivate removes a repository by removing all the post-commit hooks -// which are equal to link and removing the SSH deploy key. -func (c *client) Deactivate(u *model.User, r *model.Repo, link string) error { - client := c.newClientToken(u.Token) - return DeleteHook(client, r.Owner, r.Name, link) -} - // Hook parses the post-commit hook from the Request body // and returns the required data in a standard format. func (c *client) Hook(r *http.Request) (*model.Repo, *model.Build, error) { - switch r.Header.Get("X-Github-Event") { - case "pull_request": - return c.pullRequest(r) - case "push": - return c.push(r) - case "deployment": - return c.deployment(r) - default: - return nil, nil, nil - } + return parseHook(r, c.MergeRef) } diff --git a/remote/github/github_test.go b/remote/github/github_test.go index 01e5649a..8f7cdda4 100644 --- a/remote/github/github_test.go +++ b/remote/github/github_test.go @@ -1,77 +1,158 @@ package github import ( - "bytes" - "io/ioutil" - "net/http" + "net/http/httptest" "testing" + "github.com/drone/drone/model" + "github.com/drone/drone/remote/github/fixtures" + "github.com/franela/goblin" + "github.com/gin-gonic/gin" ) -func TestHook(t *testing.T) { - var ( - github client - r *http.Request - body *bytes.Buffer - ) +func Test_github(t *testing.T) { + gin.SetMode(gin.TestMode) + + s := httptest.NewServer(fixtures.Handler()) + c, _ := New(Opts{ + URL: s.URL, + SkipVerify: true, + }) g := goblin.Goblin(t) + g.Describe("GitHub", func() { - g.Describe("Hook", func() { - g.BeforeEach(func() { - github = client{} - body = bytes.NewBuffer([]byte{}) - r, _ = http.NewRequest("POST", "https://drone.com/hook", body) + g.After(func() { + s.Close() }) - g.Describe("For a Pull Request", func() { - g.BeforeEach(func() { - r.Header.Set("X-Github-Event", "pull_request") + g.Describe("Creating a remote", func() { + g.It("Should return client with specified options", func() { + remote, _ := New(Opts{ + URL: "http://localhost:8080/", + Client: "0ZXh0IjoiI", + Secret: "I1NiIsInR5", + Username: "someuser", + Password: "password", + SkipVerify: true, + PrivateMode: true, + }) + g.Assert(remote.(*client).URL).Equal("http://localhost:8080") + g.Assert(remote.(*client).API).Equal("http://localhost:8080/api/v3/") + g.Assert(remote.(*client).Machine).Equal("localhost") + g.Assert(remote.(*client).Username).Equal("someuser") + g.Assert(remote.(*client).Password).Equal("password") + g.Assert(remote.(*client).Client).Equal("0ZXh0IjoiI") + g.Assert(remote.(*client).Secret).Equal("I1NiIsInR5") + g.Assert(remote.(*client).SkipVerify).Equal(true) + g.Assert(remote.(*client).PrivateMode).Equal(true) }) - - g.It("Should set build author to the pull request author", func() { - hookJSON, ioerr := ioutil.ReadFile("fixtures/pull_request.json") - if ioerr != nil { - panic(ioerr) - } - body.Write(hookJSON) - - _, build, err := github.Hook(r) - g.Assert(err).Equal(nil) - g.Assert(build.Author).Equal("author") - g.Assert(build.Avatar).Equal("https://avatars.githubusercontent.com/u/55555?v=3") + g.It("Should handle malformed url", func() { + _, err := New(Opts{URL: "%gh&%ij"}) + g.Assert(err != nil).IsTrue() }) }) + + g.Describe("Generating a netrc file", func() { + g.It("Should return a netrc with the user token", func() { + remote, _ := New(Opts{ + URL: "http://github.com:443", + }) + netrc, _ := remote.Netrc(fakeUser, nil) + g.Assert(netrc.Machine).Equal("github.com") + g.Assert(netrc.Login).Equal(fakeUser.Token) + g.Assert(netrc.Password).Equal("x-oauth-basic") + }) + g.It("Should return a netrc with the machine account", func() { + remote, _ := New(Opts{ + URL: "http://github.com:443", + Username: "someuser", + Password: "password", + }) + netrc, _ := remote.Netrc(nil, nil) + g.Assert(netrc.Machine).Equal("github.com") + g.Assert(netrc.Login).Equal("someuser") + g.Assert(netrc.Password).Equal("password") + }) + }) + + g.Describe("Requesting a repository", func() { + g.It("Should return the repository details", func() { + repo, err := c.Repo(fakeUser, fakeRepo.Owner, fakeRepo.Name) + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal(fakeRepo.Owner) + g.Assert(repo.Name).Equal(fakeRepo.Name) + g.Assert(repo.FullName).Equal(fakeRepo.FullName) + g.Assert(repo.IsPrivate).IsTrue() + g.Assert(repo.Clone).Equal(fakeRepo.Clone) + g.Assert(repo.Link).Equal(fakeRepo.Link) + }) + g.It("Should handle a not found error", func() { + _, err := c.Repo(fakeUser, fakeRepoNotFound.Owner, fakeRepoNotFound.Name) + g.Assert(err != nil).IsTrue() + }) + }) + + g.Describe("Requesting repository permissions", func() { + g.It("Should return the permission details", func() { + perm, err := c.Perm(fakeUser, fakeRepo.Owner, fakeRepo.Name) + g.Assert(err == nil).IsTrue() + g.Assert(perm.Admin).IsTrue() + g.Assert(perm.Push).IsTrue() + g.Assert(perm.Pull).IsTrue() + }) + g.It("Should handle a not found error", func() { + _, err := c.Perm(fakeUser, fakeRepoNotFound.Owner, fakeRepoNotFound.Name) + g.Assert(err != nil).IsTrue() + }) + }) + + g.It("Should return a user repository list") + + g.It("Should return a user team list") + + g.It("Should register repositroy hooks") + + g.It("Should return a repository file") + + g.Describe("Given an authentication request", func() { + g.It("Should redirect to the GitHub login page") + g.It("Should create an access token") + g.It("Should handle an access token error") + g.It("Should return the authenticated user") + }) }) } -// -// func TestLoad(t *testing.T) { -// conf := "https://github.com?client_id=client&client_secret=secret&scope=scope1,scope2" -// -// g := Load(conf) -// if g.URL != "https://github.com" { -// t.Errorf("g.URL = %q; want https://github.com", g.URL) -// } -// if g.Client != "client" { -// t.Errorf("g.Client = %q; want client", g.Client) -// } -// if g.Secret != "secret" { -// t.Errorf("g.Secret = %q; want secret", g.Secret) -// } -// if g.Scope != "scope1,scope2" { -// t.Errorf("g.Scope = %q; want scope1,scope2", g.Scope) -// } -// if g.API != DefaultAPI { -// t.Errorf("g.API = %q; want %q", g.API, DefaultAPI) -// } -// if g.MergeRef != DefaultMergeRef { -// t.Errorf("g.MergeRef = %q; want %q", g.MergeRef, DefaultMergeRef) -// } -// -// g = Load("") -// if g.Scope != DefaultScope { -// t.Errorf("g.Scope = %q; want %q", g.Scope, DefaultScope) -// } -// } +var ( + fakeUser = &model.User{ + Login: "octocat", + Token: "cfcd2084", + } + + fakeUserNoRepos = &model.User{ + Login: "octocat", + Token: "repos_not_found", + } + + fakeRepo = &model.Repo{ + Owner: "octocat", + Name: "Hello-World", + FullName: "octocat/Hello-World", + Avatar: "https://github.com/images/error/octocat_happy.gif", + Link: "https://github.com/octocat/Hello-World", + Clone: "https://github.com/octocat/Hello-World.git", + IsPrivate: true, + } + + fakeRepoNotFound = &model.Repo{ + Owner: "test_name", + Name: "repo_not_found", + FullName: "test_name/repo_not_found", + } + + fakeBuild = &model.Build{ + Commit: "9ecad50", + } +) diff --git a/remote/github/helper.go b/remote/github/helper.go deleted file mode 100644 index 78d0f56f..00000000 --- a/remote/github/helper.go +++ /dev/null @@ -1,118 +0,0 @@ -package github - -import ( - "fmt" - "io/ioutil" - "net/http" - "strings" - - "github.com/google/go-github/github" -) - -// GetUserEmail is a heper function that retrieves the currently -// authenticated user from GitHub + Email address. -func GetUserEmail(client *github.Client) (*github.User, error) { - user, _, err := client.Users.Get("") - if err != nil { - return nil, err - } - - emails, _, err := client.Users.ListEmails(nil) - if err != nil { - return nil, err - } - - for _, email := range emails { - if *email.Primary && *email.Verified { - user.Email = email.Email - return user, nil - } - } - - // WARNING, HACK - // for out-of-date github enterprise editions the primary - // and verified fields won't exist. - if !strings.HasPrefix(*user.URL, defaultAPI) && len(emails) != 0 { - user.Email = emails[0].Email - return user, nil - } - - return nil, fmt.Errorf("No verified Email address for GitHub account") -} - -// GetHook is a heper function that retrieves a hook by -// hostname. To do this, it will retrieve a list of all hooks -// and iterate through the list. -func GetHook(client *github.Client, owner, name, url string) (*github.Hook, error) { - hooks, _, err := client.Repositories.ListHooks(owner, name, nil) - if err != nil { - return nil, err - } - for _, hook := range hooks { - hookurl, ok := hook.Config["url"].(string) - if !ok { - continue - } - if strings.HasPrefix(hookurl, url) { - return &hook, nil - } - } - return nil, nil -} - -// DeleteHook does exactly what you think it does. -func DeleteHook(client *github.Client, owner, name, url string) error { - hook, err := GetHook(client, owner, name, url) - if err != nil { - return err - } - if hook == nil { - return nil - } - _, err = client.Repositories.DeleteHook(owner, name, *hook.ID) - return err -} - -// CreateHook is a heper function that creates a post-commit hook -// for the specified repository. -func CreateHook(client *github.Client, owner, name, url string) (*github.Hook, error) { - var hook = new(github.Hook) - hook.Name = github.String("web") - hook.Events = []string{"push", "pull_request", "deployment"} - hook.Config = map[string]interface{}{} - hook.Config["url"] = url - hook.Config["content_type"] = "form" - created, _, err := client.Repositories.CreateHook(owner, name, hook) - return created, err -} - -// CreateUpdateHook is a heper function that creates a post-commit hook -// for the specified repository if it does not already exist, otherwise -// it updates the existing hook -func CreateUpdateHook(client *github.Client, owner, name, url string) (*github.Hook, error) { - var hook, _ = GetHook(client, owner, name, url) - if hook != nil { - hook.Name = github.String("web") - hook.Events = []string{"push", "pull_request"} - hook.Config = map[string]interface{}{} - hook.Config["url"] = url - hook.Config["content_type"] = "form" - var updated, _, err = client.Repositories.EditHook(owner, name, *hook.ID, hook) - return updated, err - } - - return CreateHook(client, owner, name, url) -} - -// GetPayload is a helper function that will parse the JSON payload. It will -// first check for a `payload` parameter in a POST, but can fallback to a -// raw JSON body as well. -func GetPayload(req *http.Request) []byte { - var payload = req.FormValue("payload") - if len(payload) == 0 { - defer req.Body.Close() - raw, _ := ioutil.ReadAll(req.Body) - return raw - } - return []byte(payload) -} diff --git a/remote/github/parse.go b/remote/github/parse.go new file mode 100644 index 00000000..36b6fcf2 --- /dev/null +++ b/remote/github/parse.go @@ -0,0 +1,92 @@ +package github + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" + + "github.com/drone/drone/model" +) + +const ( + hookEvent = "X-Github-Event" + hookField = "payload" + hookDeploy = "deployment" + hookPush = "push" + hookPull = "pull_request" + + actionOpen = "opened" + actionSync = "synchronize" + + stateOpen = "open" +) + +// parseHook parses a Bitbucket hook from an http.Request request and returns +// Repo and Build detail. If a hook type is unsupported nil values are returned. +func parseHook(r *http.Request, merge bool) (*model.Repo, *model.Build, error) { + var reader io.Reader = r.Body + + if payload := r.FormValue(hookField); payload != "" { + reader = bytes.NewBufferString(payload) + } + + raw, err := ioutil.ReadAll(reader) + if err != nil { + return nil, nil, err + } + + switch r.Header.Get(hookEvent) { + case hookPush: + return parsePushHook(raw) + case hookDeploy: + return parseDeployHook(raw) + case hookPull: + return parsePullHook(raw, merge) + } + return nil, nil, nil +} + +// parsePushHook parses a push hook and returns the Repo and Build details. +// If the commit type is unsupported nil values are returned. +func parsePushHook(payload []byte) (*model.Repo, *model.Build, error) { + hook := new(webhook) + err := json.Unmarshal(payload, hook) + if err != nil { + return nil, nil, err + } + if hook.Deleted { + return nil, nil, err + } + return convertRepoHook(hook), convertPushHook(hook), nil +} + +// parseDeployHook parses a deployment and returns the Repo and Build details. +// If the commit type is unsupported nil values are returned. +func parseDeployHook(payload []byte) (*model.Repo, *model.Build, error) { + hook := new(webhook) + if err := json.Unmarshal(payload, hook); err != nil { + return nil, nil, err + } + return convertRepoHook(hook), convertDeployHook(hook), nil +} + +// parsePullHook parses a pull request hook and returns the Repo and Build +// details. If the pull request is closed nil values are returned. +func parsePullHook(payload []byte, merge bool) (*model.Repo, *model.Build, error) { + hook := new(webhook) + err := json.Unmarshal(payload, hook) + if err != nil { + return nil, nil, err + } + + // ignore these + if hook.Action != actionOpen && hook.Action != actionSync { + return nil, nil, nil + } + if hook.PullRequest.State != stateOpen { + return nil, nil, nil + } + return convertRepoHook(hook), convertPullHook(hook, merge), nil +} diff --git a/remote/github/parse_test.go b/remote/github/parse_test.go new file mode 100644 index 00000000..8152e16c --- /dev/null +++ b/remote/github/parse_test.go @@ -0,0 +1,97 @@ +package github + +import ( + "bytes" + "net/http" + "testing" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote/github/fixtures" + "github.com/franela/goblin" +) + +func Test_parser(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("GitHub parser", func() { + + g.It("should ignore unsupported hook events", func() { + buf := bytes.NewBufferString(fixtures.HookPullRequest) + req, _ := http.NewRequest("POST", "/hook", buf) + req.Header = http.Header{} + req.Header.Set(hookEvent, "issues") + + r, b, err := parseHook(req, false) + g.Assert(r == nil).IsTrue() + g.Assert(b == nil).IsTrue() + g.Assert(err == nil).IsTrue() + }) + + g.Describe("given a push hook", func() { + g.It("should skip when action is deleted", func() { + raw := []byte(fixtures.HookPushDeleted) + r, b, err := parsePushHook(raw) + g.Assert(r == nil).IsTrue() + g.Assert(b == nil).IsTrue() + g.Assert(err == nil).IsTrue() + }) + g.It("should extract repository and build details", func() { + buf := bytes.NewBufferString(fixtures.HookPush) + req, _ := http.NewRequest("POST", "/hook", buf) + req.Header = http.Header{} + req.Header.Set(hookEvent, hookPush) + + r, b, err := parseHook(req, false) + g.Assert(err == nil).IsTrue() + g.Assert(r != nil).IsTrue() + g.Assert(b != nil).IsTrue() + g.Assert(b.Event).Equal(model.EventPush) + }) + }) + + g.Describe("given a pull request hook", func() { + g.It("should skip when action is not open or sync", func() { + raw := []byte(fixtures.HookPullRequestInvalidAction) + r, b, err := parsePullHook(raw, false) + g.Assert(r == nil).IsTrue() + g.Assert(b == nil).IsTrue() + g.Assert(err == nil).IsTrue() + }) + g.It("should skip when state is not open", func() { + raw := []byte(fixtures.HookPullRequestInvalidState) + r, b, err := parsePullHook(raw, false) + g.Assert(r == nil).IsTrue() + g.Assert(b == nil).IsTrue() + g.Assert(err == nil).IsTrue() + }) + g.It("should extract repository and build details", func() { + buf := bytes.NewBufferString(fixtures.HookPullRequest) + req, _ := http.NewRequest("POST", "/hook", buf) + req.Header = http.Header{} + req.Header.Set(hookEvent, hookPull) + + r, b, err := parseHook(req, false) + g.Assert(err == nil).IsTrue() + g.Assert(r != nil).IsTrue() + g.Assert(b != nil).IsTrue() + g.Assert(b.Event).Equal(model.EventPull) + }) + }) + + g.Describe("given a deployment hook", func() { + g.It("should extract repository and build details", func() { + buf := bytes.NewBufferString(fixtures.HookDeploy) + req, _ := http.NewRequest("POST", "/hook", buf) + req.Header = http.Header{} + req.Header.Set(hookEvent, hookDeploy) + + r, b, err := parseHook(req, false) + g.Assert(err == nil).IsTrue() + g.Assert(r != nil).IsTrue() + g.Assert(b != nil).IsTrue() + g.Assert(b.Event).Equal(model.EventDeploy) + }) + }) + + }) +} diff --git a/remote/github/parser.go b/remote/github/parser.go deleted file mode 100644 index 046d2a7c..00000000 --- a/remote/github/parser.go +++ /dev/null @@ -1,184 +0,0 @@ -package github - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/drone/drone/model" - "github.com/google/go-github/github" -) - -// push parses a hook with event type `push` and returns -// the commit data. -func (c *client) push(r *http.Request) (*model.Repo, *model.Build, error) { - payload := GetPayload(r) - hook := &pushHook{} - err := json.Unmarshal(payload, hook) - if err != nil { - return nil, nil, err - } - if hook.Deleted { - return nil, nil, err - } - - repo := &model.Repo{} - repo.Owner = hook.Repo.Owner.Login - if len(repo.Owner) == 0 { - repo.Owner = hook.Repo.Owner.Name - } - repo.Name = hook.Repo.Name - // Generating rather than using hook.Repo.FullName as it's - // not always present - repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name) - repo.Link = hook.Repo.HTMLURL - repo.IsPrivate = hook.Repo.Private - repo.Clone = hook.Repo.CloneURL - repo.Branch = hook.Repo.DefaultBranch - repo.Kind = model.RepoGit - - build := &model.Build{} - build.Event = model.EventPush - build.Commit = hook.Head.ID - build.Ref = hook.Ref - build.Link = hook.Head.URL - build.Branch = strings.Replace(build.Ref, "refs/heads/", "", -1) - build.Message = hook.Head.Message - // build.Timestamp = hook.Head.Timestamp - build.Email = hook.Head.Author.Email - build.Avatar = hook.Sender.Avatar - build.Author = hook.Sender.Login - build.Remote = hook.Repo.CloneURL - - if len(build.Author) == 0 { - build.Author = hook.Head.Author.Username - // default gravatar? - } - - if strings.HasPrefix(build.Ref, "refs/tags/") { - // just kidding, this is actually a tag event - build.Event = model.EventTag - } - - return repo, build, nil -} - -// pullRequest parses a hook with event type `pullRequest` -// and returns the commit data. -func (c *client) pullRequest(r *http.Request) (*model.Repo, *model.Build, error) { - payload := GetPayload(r) - hook := &struct { - Action string `json:"action"` - PullRequest *github.PullRequest `json:"pull_request"` - Repo *github.Repository `json:"repository"` - }{} - err := json.Unmarshal(payload, hook) - if err != nil { - return nil, nil, err - } - - // ignore these - if hook.Action != "opened" && hook.Action != "synchronize" { - return nil, nil, nil - } - if *hook.PullRequest.State != "open" { - return nil, nil, nil - } - - repo := &model.Repo{} - repo.Owner = *hook.Repo.Owner.Login - repo.Name = *hook.Repo.Name - repo.FullName = *hook.Repo.FullName - repo.Link = *hook.Repo.HTMLURL - repo.IsPrivate = *hook.Repo.Private - repo.Clone = *hook.Repo.CloneURL - repo.Kind = model.RepoGit - repo.Branch = "master" - if hook.Repo.DefaultBranch != nil { - repo.Branch = *hook.Repo.DefaultBranch - } - - build := &model.Build{} - build.Event = model.EventPull - build.Commit = *hook.PullRequest.Head.SHA - build.Link = *hook.PullRequest.HTMLURL - build.Branch = *hook.PullRequest.Head.Ref - build.Message = *hook.PullRequest.Title - build.Author = *hook.PullRequest.User.Login - build.Avatar = *hook.PullRequest.User.AvatarURL - build.Remote = *hook.PullRequest.Base.Repo.CloneURL - build.Title = *hook.PullRequest.Title - - if c.MergeRef { - build.Ref = fmt.Sprintf("refs/pull/%d/merge", *hook.PullRequest.Number) - } else { - build.Ref = fmt.Sprintf("refs/pull/%d/head", *hook.PullRequest.Number) - } - - // build.Timestamp = time.Now().UTC().Format("2006-01-02 15:04:05.000000000 +0000 MST") - - return repo, build, nil -} - -func (c *client) deployment(r *http.Request) (*model.Repo, *model.Build, error) { - payload := GetPayload(r) - hook := &deployHook{} - - err := json.Unmarshal(payload, hook) - if err != nil { - return nil, nil, err - } - - // for older versions of GitHub. Remove. - if hook.Deployment.ID == 0 { - hook.Deployment.ID = hook.ID - hook.Deployment.Sha = hook.Sha - hook.Deployment.Ref = hook.Ref - hook.Deployment.Task = hook.Name - hook.Deployment.Env = hook.Env - hook.Deployment.Desc = hook.Desc - } - - repo := &model.Repo{} - repo.Owner = hook.Repo.Owner.Login - if len(repo.Owner) == 0 { - repo.Owner = hook.Repo.Owner.Name - } - repo.Name = hook.Repo.Name - repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name) - repo.Link = hook.Repo.HTMLURL - repo.IsPrivate = hook.Repo.Private - repo.Clone = hook.Repo.CloneURL - repo.Branch = hook.Repo.DefaultBranch - repo.Kind = model.RepoGit - - // ref can be - // branch, tag, or sha - - build := &model.Build{} - build.Event = model.EventDeploy - build.Commit = hook.Deployment.Sha - build.Link = hook.Deployment.URL - build.Message = hook.Deployment.Desc - build.Avatar = hook.Sender.Avatar - build.Author = hook.Sender.Login - build.Ref = hook.Deployment.Ref - build.Branch = hook.Deployment.Ref - build.Deploy = hook.Deployment.Env - - // if the ref is a sha or short sha we need to manually - // construct the ref. - if strings.HasPrefix(build.Commit, build.Ref) || build.Commit == build.Ref { - build.Branch = repo.Branch - build.Ref = fmt.Sprintf("refs/heads/%s", repo.Branch) - - } - // if the ref is a branch we should make sure it has refs/heads prefix - if !strings.HasPrefix(build.Ref, "refs/") { // branch or tag - build.Ref = fmt.Sprintf("refs/heads/%s", build.Branch) - - } - - return repo, build, nil -} diff --git a/remote/github/parser_test.go b/remote/github/parser_test.go deleted file mode 100644 index d2e73c26..00000000 --- a/remote/github/parser_test.go +++ /dev/null @@ -1 +0,0 @@ -package github diff --git a/remote/github/types.go b/remote/github/types.go index e3eb45ae..485a0e31 100644 --- a/remote/github/types.go +++ b/remote/github/types.go @@ -1,7 +1,8 @@ package github -type pushHook struct { +type webhook struct { Ref string `json:"ref"` + Action string `json:"action"` Deleted bool `json:"deleted"` Head struct { @@ -28,6 +29,7 @@ type pushHook struct { Avatar string `json:"avatar_url"` } `json:"sender"` + // repository details Repo struct { Owner struct { Login string `json:"login"` @@ -42,9 +44,8 @@ type pushHook struct { CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` } `json:"repository"` -} -type deployHook struct { + // deployment hook details Deployment struct { ID int64 `json:"id"` Sha string `json:"sha"` @@ -55,33 +56,21 @@ type deployHook struct { Desc string `json:"description"` } `json:"deployment"` - Sender struct { - Login string `json:"login"` - Avatar string `json:"avatar_url"` - } `json:"sender"` + // pull request details + PullRequest struct { + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` - Repo struct { - Owner struct { - Login string `json:"login"` - Name string `json:"name"` - } `json:"owner"` + User struct { + Login string `json:"login"` + Avatar string `json:"avatar_url"` + } `json:"user"` - Name string `json:"name"` - FullName string `json:"full_name"` - Language string `json:"language"` - Private bool `json:"private"` - HTMLURL string `json:"html_url"` - CloneURL string `json:"clone_url"` - DefaultBranch string `json:"default_branch"` - } `json:"repository"` - - // these are legacy fields that have been added to the deployment section. - // They are here for older versions of GitHub and will be removed. - - ID int64 `json:"id"` - Sha string `json:"sha"` - Ref string `json:"ref"` - Name string `json:"name"` - Env string `json:"environment"` - Desc string `json:"description"` + Head struct { + SHA string + Ref string + } `json:"head"` + } `json:"pull_request"` }