// Copyright 2019 Drone IO, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parser import ( "errors" "fmt" "net/http" "net/http/httputil" "os" "strconv" "strings" "time" "github.com/drone/drone/core" "github.com/drone/go-scm/scm" ) // TODO(bradrydzewski): stash, push hook missing link // TODO(bradrydzewski): stash, tag hook missing timestamp // TODO(bradrydzewski): stash, tag hook missing commit message // TODO(bradrydzewski): stash, tag hook missing link // TODO(bradrydzewski): stash, pull request hook missing link // TODO(bradrydzewski): stash, hooks missing repository clone http url // TODO(bradrydzewski): stash, hooks missing repository clone ssh url // TODO(bradrydzewski): stash, hooks missing repository html link // TODO(bradrydzewski): gogs, push hook missing author avatar, using sender instead. // TODO(bradrydzewski): gogs, pull request hook missing commit sha. // TODO(bradrydzewski): gogs, tag hook missing commit sha. // TODO(bradrydzewski): gogs, sender missing Name field. // TODO(bradrydzewski): gogs, push hook missing repository html url // TODO(bradrydzewski): gitea, push hook missing author avatar, using sender instead. // TODO(bradrydzewski): gitea, tag hook missing commit sha. // TODO(bradrydzewski): gitea, sender missing Name field. // TODO(bradrydzewski): gitea, push hook missing repository html url // TODO(bradrydzewski): bitbucket, pull request hook missing author email. // TODO(bradrydzewski): bitbucket, hooks missing default repository branch. // TODO(bradrydzewski): github, push hook timestamp is negative value. // TODO(bradrydzewski): github, pull request message is empty // represents a deleted ref in the github webhook. const emptyCommit = "0000000000000000000000000000000000000000" // this is intended for local testing and instructs the handler // to print the contents of the hook to stdout. var debugPrintHook = false func init() { debugPrintHook, _ = strconv.ParseBool( os.Getenv("DRONE_DEBUG_DUMP_HOOK"), ) } // New returns a new HookParser. func New(client *scm.Client) core.HookParser { return &parser{client} } type parser struct { client *scm.Client } func (p *parser) Parse(req *http.Request, secretFunc func(string) string) (*core.Hook, *core.Repository, error) { if debugPrintHook { // if DRONE_DEBUG_DUMP_HOOK=true print the http.Request // headers and body to stdout. out, _ := httputil.DumpRequest(req, true) os.Stderr.Write(out) } // callback function provides the webhook parser with // a per-repository secret key used to verify the webhook // payload signature for authenticity. fn := func(webhook scm.Webhook) (string, error) { if webhook == nil { // HACK(bradrydzewski) if the incoming webhook is nil // we assume it is an unknown event or action. A more // permanent fix is to update go-scm to return an // scm.ErrUnknownAction error. return "", scm.ErrUnknownEvent } repo := webhook.Repository() slug := scm.Join(repo.Namespace, repo.Name) secret := secretFunc(slug) if secret == "" { return secret, errors.New("Cannot find repository") } return secret, nil } payload, err := p.client.Webhooks.Parse(req, fn) if err == scm.ErrUnknownEvent { return nil, nil, nil } if err != nil { return nil, nil, err } var repo *core.Repository var hook *core.Hook switch v := payload.(type) { case *scm.PushHook: // github sends push hooks when tags and branches are // deleted. These hooks should be ignored. if v.Commit.Sha == emptyCommit { return nil, nil, nil } // github sends push hooks when tags are created. The // push hook contains more information than the tag hook, // so we choose to use the push hook for tags. if strings.HasPrefix(v.Ref, "refs/tags/") { hook = &core.Hook{ Trigger: core.TriggerHook, // core.TriggerHook Event: core.EventTag, Action: core.ActionCreate, Link: v.Commit.Link, Timestamp: v.Commit.Author.Date.Unix(), Message: v.Commit.Message, Before: v.Before, After: v.Commit.Sha, Source: scm.TrimRef(v.BaseRef), Target: scm.TrimRef(v.BaseRef), Ref: v.Ref, Author: v.Commit.Author.Login, AuthorName: v.Commit.Author.Name, AuthorEmail: v.Commit.Author.Email, AuthorAvatar: v.Commit.Author.Avatar, Sender: v.Sender.Login, } } else { hook = &core.Hook{ Trigger: core.TriggerHook, //core.TriggerHook, Event: core.EventPush, Link: v.Commit.Link, Timestamp: v.Commit.Author.Date.Unix(), Message: v.Commit.Message, Before: v.Before, After: v.Commit.Sha, Ref: v.Ref, Source: strings.TrimPrefix(v.Ref, "refs/heads/"), Target: strings.TrimPrefix(v.Ref, "refs/heads/"), Author: v.Commit.Author.Login, AuthorName: v.Commit.Author.Name, AuthorEmail: v.Commit.Author.Email, AuthorAvatar: v.Commit.Author.Avatar, Sender: v.Sender.Login, } } repo = &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), Link: v.Repo.Link, Branch: v.Repo.Branch, Private: v.Repo.Private, HTTPURL: v.Repo.Clone, SSHURL: v.Repo.CloneSSH, } // gogs and gitea do not include the author avatar in // the webhook, but they do include the sender avatar. // use the sender avatar when necessary. if hook.AuthorAvatar == "" { hook.AuthorAvatar = v.Sender.Avatar } return hook, repo, nil case *scm.TagHook: if v.Action != scm.ActionCreate { return nil, nil, nil } // when a tag is created github sends both a push hook // and a tag create hook. The push hook contains more // information, so we choose to use the push hook and // ignore the native tag hook. if p.client.Driver == scm.DriverGithub || p.client.Driver == scm.DriverGitea || p.client.Driver == scm.DriverGitlab { return nil, nil, nil } // the tag hook does not include the commit link, message // or timestamp. In some cases it does not event include // the sha (gogs). Note that we may need to fetch additional // details to augment the webhook. hook = &core.Hook{ Trigger: core.TriggerHook, // core.TriggerHook, Event: core.EventTag, Action: core.ActionCreate, Link: "", Timestamp: 0, Message: "", After: v.Ref.Sha, Ref: v.Ref.Name, Source: v.Ref.Name, Target: v.Ref.Name, Author: v.Sender.Login, AuthorName: v.Sender.Name, AuthorEmail: v.Sender.Email, AuthorAvatar: v.Sender.Avatar, Sender: v.Sender.Login, } repo = &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), Link: v.Repo.Link, Branch: v.Repo.Branch, Private: v.Repo.Private, HTTPURL: v.Repo.Clone, SSHURL: v.Repo.CloneSSH, } // TODO(bradrydzewski) can we use scm.ExpandRef here? if !strings.HasPrefix(hook.Ref, "refs/tags/") { hook.Ref = fmt.Sprintf("refs/tags/%s", hook.Ref) } if hook.AuthorAvatar == "" { hook.AuthorAvatar = v.Sender.Avatar } return hook, repo, nil case *scm.PullRequestHook: // TODO(bradrydzewski) cleanup the pr close hook code. if v.Action == scm.ActionClose { return &core.Hook{ Trigger: core.TriggerHook, Event: core.EventPullRequest, Action: core.ActionClose, After: v.PullRequest.Sha, Ref: v.PullRequest.Ref, }, &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), }, nil } if v.Action != scm.ActionOpen && v.Action != scm.ActionSync { return nil, nil, nil } // Pull Requests are not supported for Bitbucket due // to lack of refs (e.g. refs/pull-requests/42/from). // Please contact Bitbucket Support if you would like to // see this feature enabled: // https://bitbucket.org/site/master/issues/5814/repository-refs-for-pull-requests if p.client.Driver == scm.DriverBitbucket { return nil, nil, nil } hook = &core.Hook{ Trigger: core.TriggerHook, // core.TriggerHook, Event: core.EventPullRequest, Action: v.Action.String(), Link: v.PullRequest.Link, Timestamp: v.PullRequest.Created.Unix(), Title: v.PullRequest.Title, Message: v.PullRequest.Body, Before: v.PullRequest.Base.Sha, After: v.PullRequest.Sha, Ref: v.PullRequest.Ref, Fork: v.PullRequest.Fork, Source: v.PullRequest.Source, Target: v.PullRequest.Target, Author: v.PullRequest.Author.Login, AuthorName: v.PullRequest.Author.Name, AuthorEmail: v.PullRequest.Author.Email, AuthorAvatar: v.PullRequest.Author.Avatar, Sender: v.Sender.Login, } // HACK this is a workaround for github. The pull // request title is populated, but not the message. if hook.Message == "" { hook.Message = hook.Title } repo = &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), Link: v.Repo.Link, Branch: v.Repo.Branch, Private: v.Repo.Private, HTTPURL: v.Repo.Clone, SSHURL: v.Repo.CloneSSH, } if hook.AuthorAvatar == "" { hook.AuthorAvatar = v.Sender.Avatar } return hook, repo, nil case *scm.BranchHook: // TODO(bradrydzewski) cleanup the branch hook code. if v.Action == scm.ActionDelete { return &core.Hook{ Trigger: core.TriggerHook, Event: core.EventPush, After: v.Ref.Sha, Action: core.ActionDelete, Target: scm.TrimRef(v.Ref.Name), }, &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), }, nil } if v.Action != scm.ActionCreate { return nil, nil, nil } if p.client.Driver != scm.DriverStash { return nil, nil, nil } hook = &core.Hook{ Trigger: core.TriggerHook, // core.TriggerHook, Event: core.EventPush, Link: "", Timestamp: 0, Message: "", After: v.Ref.Sha, Ref: v.Ref.Name, Source: v.Ref.Name, Target: v.Ref.Name, Author: v.Sender.Login, AuthorName: v.Sender.Name, AuthorEmail: v.Sender.Email, AuthorAvatar: v.Sender.Avatar, Sender: v.Sender.Login, } repo = &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), Link: v.Repo.Link, Branch: v.Repo.Branch, Private: v.Repo.Private, HTTPURL: v.Repo.Clone, SSHURL: v.Repo.CloneSSH, } return hook, repo, nil case *scm.DeployHook: hook = &core.Hook{ Trigger: core.TriggerHook, Event: core.EventPromote, Link: v.TargetURL, Timestamp: time.Now().Unix(), Message: v.Desc, After: v.Ref.Sha, Ref: v.Ref.Path, Source: v.Ref.Name, Target: v.Ref.Name, Author: v.Sender.Login, AuthorName: v.Sender.Name, AuthorEmail: v.Sender.Email, AuthorAvatar: v.Sender.Avatar, Sender: v.Sender.Login, Deployment: v.Target, DeploymentID: v.Number, Params: toMap(v.Data), } repo = &core.Repository{ UID: v.Repo.ID, Namespace: v.Repo.Namespace, Name: v.Repo.Name, Slug: scm.Join(v.Repo.Namespace, v.Repo.Name), Link: v.Repo.Link, Branch: v.Repo.Branch, Private: v.Repo.Private, HTTPURL: v.Repo.Clone, SSHURL: v.Repo.CloneSSH, } return hook, repo, nil default: return nil, nil, nil } } func toMap(src interface{}) map[string]string { set, ok := src.(map[string]interface{}) if !ok { return nil } dst := map[string]string{} for k, v := range set { dst[k] = fmt.Sprint(v) } return dst }