diff --git a/cmd/drone-server/server.go b/cmd/drone-server/server.go index d2faa9a8d..284fb3486 100644 --- a/cmd/drone-server/server.go +++ b/cmd/drone-server/server.go @@ -378,6 +378,58 @@ var flags = []cli.Flag{ Name: "stash-skip-verify", Usage: "stash skip ssl verification", }, + cli.BoolFlag{ + EnvVar: "DRONE_CODING", + Name: "coding", + Usage: "coding driver is enabled", + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_URL", + Name: "coding-server", + Usage: "coding server address", + Value: "https://coding.net", + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_CLIENT", + Name: "coding-client", + Usage: "coding oauth2 client id", + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_SECRET", + Name: "coding-secret", + Usage: "coding oauth2 client secret", + }, + cli.StringSliceFlag{ + EnvVar: "DRONE_CODING_SCOPE", + Name: "coding-scope", + Usage: "coding oauth scope", + Value: &cli.StringSlice{ + "user", + "project", + "project:depot", + }, + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_GIT_MACHINE", + Name: "coding-git-machine", + Usage: "coding machine name", + Value: "git.coding.net", + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_GIT_USERNAME", + Name: "coding-git-username", + Usage: "coding machine user username", + }, + cli.StringFlag{ + EnvVar: "DRONE_CODING_GIT_PASSWORD", + Name: "coding-git-password", + Usage: "coding machine user password", + }, + cli.BoolFlag{ + EnvVar: "DRONE_CODING_SKIP_VERIFY", + Name: "coding-skip-verify", + Usage: "coding skip ssl verification", + }, } func server(c *cli.Context) error { diff --git a/cmd/drone-server/setup.go b/cmd/drone-server/setup.go index e76afbc13..77ffee74a 100644 --- a/cmd/drone-server/setup.go +++ b/cmd/drone-server/setup.go @@ -10,6 +10,7 @@ import ( "github.com/drone/drone/remote" "github.com/drone/drone/remote/bitbucket" "github.com/drone/drone/remote/bitbucketserver" + "github.com/drone/drone/remote/coding" "github.com/drone/drone/remote/gitea" "github.com/drone/drone/remote/github" "github.com/drone/drone/remote/gitlab" @@ -62,6 +63,8 @@ func SetupRemote(c *cli.Context) (remote.Remote, error) { return setupGogs(c) case c.Bool("gitea"): return setupGitea(c) + case c.Bool("coding"): + return setupCoding(c) default: return nil, fmt.Errorf("version control system not configured") } @@ -139,4 +142,18 @@ func setupGithub(c *cli.Context) (remote.Remote, error) { }) } +// helper function to setup the Coding remote from the CLI arguments. +func setupCoding(c *cli.Context) (remote.Remote, error) { + return coding.New(coding.Opts{ + URL: c.String("coding-server"), + Client: c.String("coding-client"), + Secret: c.String("coding-secret"), + Scopes: c.StringSlice("coding-scope"), + Machine: c.String("coding-git-machine"), + Username: c.String("coding-git-username"), + Password: c.String("coding-git-password"), + SkipVerify: c.Bool("coding-skip-verify"), + }) +} + func before(c *cli.Context) error { return nil } diff --git a/remote/coding/coding.go b/remote/coding/coding.go new file mode 100644 index 000000000..4d98ffe9d --- /dev/null +++ b/remote/coding/coding.go @@ -0,0 +1,334 @@ +package coding + +import ( + "crypto/tls" + "fmt" + "net/http" + "strings" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote" + "github.com/drone/drone/remote/coding/internal" + "github.com/drone/drone/shared/httputil" + + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +const ( + defaultURL = "https://coding.net" // Default Coding URL +) + +// Opts defines configuration options. +type Opts struct { + URL string // Coding server url. + Client string // Coding oauth client id. + Secret string // Coding oauth client secret. + Scopes []string // Coding oauth scopes. + Machine string // Optional machine name. + Username string // Optional machine account username. + Password string // Optional machine account password. + SkipVerify bool // Skip ssl verification. +} + +// New returns a Remote implementation that integrates with a Coding Platform or +// Coding Enterprise version control hosting provider. +func New(opts Opts) (remote.Remote, error) { + remote := &Coding{ + URL: defaultURL, + Client: opts.Client, + Secret: opts.Secret, + Scopes: opts.Scopes, + Machine: opts.Machine, + Username: opts.Username, + Password: opts.Password, + SkipVerify: opts.SkipVerify, + } + if opts.URL != defaultURL { + remote.URL = strings.TrimSuffix(opts.URL, "/") + } + + // Hack to enable oauth2 access in coding's implementation + oauth2.RegisterBrokenAuthHeaderProvider(remote.URL) + return remote, nil +} + +type Coding struct { + URL string + Client string + Secret string + Scopes []string + Machine string + Username string + Password string + SkipVerify bool +} + +// Login authenticates the session and returns the +// remote user details. +func (c *Coding) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) { + config := c.newConfig(httputil.GetURL(req)) + + // get the OAuth errors + if err := req.FormValue("error"); err != "" { + return nil, &remote.AuthError{ + Err: err, + Description: req.FormValue("error_description"), + URI: req.FormValue("error_uri"), + } + } + + // get the OAuth code + code := req.FormValue("code") + if len(code) == 0 { + http.Redirect(res, req, config.AuthCodeURL("drone"), http.StatusSeeOther) + return nil, nil + } + + token, err := config.Exchange(c.newContext(), code) + if err != nil { + return nil, err + } + + user, err := c.newClientToken(token.AccessToken, token.RefreshToken).GetCurrentUser() + if err != nil { + return nil, err + } + + return &model.User{ + Login: user.GlobalKey, + Email: user.Email, + Token: token.AccessToken, + Secret: token.RefreshToken, + Expiry: token.Expiry.UTC().Unix(), + Avatar: c.resourceLink(user.Avatar), + }, nil +} + +// Auth authenticates the session and returns the remote user +// login for the given token and secret +func (c *Coding) Auth(token, secret string) (string, error) { + user, err := c.newClientToken(token, secret).GetCurrentUser() + if err != nil { + return "", err + } + return user.GlobalKey, nil +} + +// Refresh refreshes an oauth token and expiration for the given +// user. It returns true if the token was refreshed, false if the +// token was not refreshed, and error if it failed to refersh. +func (c *Coding) Refresh(u *model.User) (bool, error) { + config := c.newConfig("") + source := config.TokenSource(c.newContext(), &oauth2.Token{RefreshToken: u.Secret}) + token, err := source.Token() + if err != nil || len(token.AccessToken) == 0 { + return false, err + } + + u.Token = token.AccessToken + u.Secret = token.RefreshToken + u.Expiry = token.Expiry.UTC().Unix() + return true, nil +} + +// Teams fetches a list of team memberships from the remote system. +func (c *Coding) Teams(u *model.User) ([]*model.Team, error) { + // EMPTY: not implemented in Coding OAuth API + return nil, nil +} + +// TeamPerm fetches the named organization permissions from +// the remote system for the specified user. +func (c *Coding) TeamPerm(u *model.User, org string) (*model.Perm, error) { + // EMPTY: not implemented in Coding OAuth API + return nil, nil +} + +// Repo fetches the named repository from the remote system. +func (c *Coding) Repo(u *model.User, owner, repo string) (*model.Repo, error) { + project, err := c.newClient(u).GetProject(owner, repo) + if err != nil { + return nil, err + } + depot, err := c.newClient(u).GetDepot(owner, repo) + if err != nil { + return nil, err + } + return &model.Repo{ + Owner: project.Owner, + Name: project.Name, + FullName: projectFullName(project.Owner, project.Name), + Avatar: c.resourceLink(project.Icon), + Link: c.resourceLink(project.DepotPath), + Kind: model.RepoGit, + Clone: project.HttpsURL, + Branch: depot.DefaultBranch, + IsPrivate: !project.IsPublic, + }, nil +} + +// Repos fetches a list of repos from the remote system. +func (c *Coding) Repos(u *model.User) ([]*model.Repo, error) { + projectList, err := c.newClient(u).GetProjectList() + if err != nil { + return nil, err + } + + repos := make([]*model.Repo, 0) + for _, project := range projectList { + depot, err := c.newClient(u).GetDepot(project.Owner, project.Name) + if err != nil { + return nil, err + } + repo := &model.Repo{ + Owner: project.Owner, + Name: project.Name, + FullName: projectFullName(project.Owner, project.Name), + Avatar: c.resourceLink(project.Icon), + Link: c.resourceLink(project.DepotPath), + Kind: model.RepoGit, + Clone: project.HttpsURL, + Branch: depot.DefaultBranch, + IsPrivate: !project.IsPublic, + } + repos = append(repos, repo) + } + return repos, nil +} + +// Perm fetches the named repository permissions from +// the remote system for the specified user. +func (c *Coding) Perm(u *model.User, owner, repo string) (*model.Perm, error) { + project, err := c.newClient(u).GetProject(owner, repo) + if err != nil { + return nil, err + } + + if project.Role == "owner" || project.Role == "admin" { + return &model.Perm{Pull: true, Push: true, Admin: true}, nil + } + if project.Role == "member" { + return &model.Perm{Pull: true, Push: true, Admin: false}, nil + } + return &model.Perm{Pull: false, Push: false, Admin: false}, nil +} + +// File fetches a file from the remote repository and returns in string +// format. +func (c *Coding) File(u *model.User, r *model.Repo, b *model.Build, f string) ([]byte, error) { + data, err := c.newClient(u).GetFile(r.Owner, r.Name, b.Commit, f) + if err != nil { + return nil, err + } + return data, nil +} + +// FileRef fetches a file from the remote repository for the given ref +// and returns in string format. +func (c *Coding) FileRef(u *model.User, r *model.Repo, ref, f string) ([]byte, error) { + data, err := c.newClient(u).GetFile(r.Owner, r.Name, ref, f) + if err != nil { + return nil, err + } + return data, nil +} + +// Status sends the commit status to the remote system. +func (c *Coding) Status(u *model.User, r *model.Repo, b *model.Build, link string) error { + // EMPTY: not implemented in Coding OAuth API + return nil +} + +// Netrc returns a .netrc file that can be used to clone +// private repositories from a remote system. +func (c *Coding) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { + if c.Password != "" { + return &model.Netrc{ + Login: c.Username, + Password: c.Password, + Machine: c.Machine, + }, nil + } + return &model.Netrc{ + Login: u.Token, + Password: "x-oauth-basic", + Machine: c.Machine, + }, nil +} + +// Activate activates a repository by creating the post-commit hook. +func (c *Coding) Activate(u *model.User, r *model.Repo, link string) error { + return c.newClient(u).AddWebhook(r.Owner, r.Name, link) +} + +// Deactivate deactivates a repository by removing all previously created +// post-commit hooks matching the given link. +func (c *Coding) Deactivate(u *model.User, r *model.Repo, link string) error { + return c.newClient(u).RemoveWebhook(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 *Coding) Hook(r *http.Request) (*model.Repo, *model.Build, error) { + repo, build, err := parseHook(r) + if build != nil { + build.Avatar = c.resourceLink(build.Avatar) + } + return repo, build, err +} + +// helper function to return the Coding oauth2 context using an HTTPClient that +// disables TLS verification if disabled in the remote settings. +func (c *Coding) 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 Coding oauth2 config +func (c *Coding) newConfig(redirect string) *oauth2.Config { + return &oauth2.Config{ + ClientID: c.Client, + ClientSecret: c.Secret, + Scopes: []string{strings.Join(c.Scopes, ",")}, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth_authorize.html", c.URL), + TokenURL: fmt.Sprintf("%s/api/oauth/access_token_v2", c.URL), + }, + RedirectURL: fmt.Sprintf("%s/authorize", redirect), + } +} + +// helper function to return the Coding oauth2 client +func (c *Coding) newClient(u *model.User) *internal.Client { + return c.newClientToken(u.Token, u.Secret) +} + +// helper function to return the Coding oauth2 client +func (c *Coding) newClientToken(token, secret string) *internal.Client { + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.SkipVerify, + }, + }, + } + return internal.NewClient(c.URL, "/api", token, "drone", client) +} + +func (c *Coding) resourceLink(resourcePath string) string { + if strings.HasPrefix(resourcePath, "http") { + return resourcePath + } + return c.URL + resourcePath +} diff --git a/remote/coding/coding_test.go b/remote/coding/coding_test.go new file mode 100644 index 000000000..149ba996c --- /dev/null +++ b/remote/coding/coding_test.go @@ -0,0 +1,295 @@ +package coding + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote/coding/fixtures" + + "github.com/franela/goblin" + "github.com/gin-gonic/gin" +) + +func Test_coding(t *testing.T) { + gin.SetMode(gin.TestMode) + + s := httptest.NewServer(fixtures.Handler()) + c := &Coding{URL: s.URL} + + g := goblin.Goblin(t) + g.Describe("Coding", func() { + + g.After(func() { + s.Close() + }) + + g.Describe("Creating a remote", func() { + g.It("Should return client with specified options", func() { + remote, _ := New(Opts{ + URL: "https://coding.net", + Client: "KTNF2ALdm3ofbtxLh6IbV95Ro5AKWJUP", + Secret: "zVtxJrKhNhBcNyqCz1NggNAAmehAxnRO3Z0fXmCp", + Scopes: []string{"user", "project", "project:depot"}, + Machine: "git.coding.net", + Username: "someuser", + Password: "password", + SkipVerify: true, + }) + g.Assert(remote.(*Coding).URL).Equal("https://coding.net") + g.Assert(remote.(*Coding).Client).Equal("KTNF2ALdm3ofbtxLh6IbV95Ro5AKWJUP") + g.Assert(remote.(*Coding).Secret).Equal("zVtxJrKhNhBcNyqCz1NggNAAmehAxnRO3Z0fXmCp") + g.Assert(remote.(*Coding).Scopes).Equal([]string{"user", "project", "project:depot"}) + g.Assert(remote.(*Coding).Machine).Equal("git.coding.net") + g.Assert(remote.(*Coding).Username).Equal("someuser") + g.Assert(remote.(*Coding).Password).Equal("password") + g.Assert(remote.(*Coding).SkipVerify).Equal(true) + }) + }) + + g.Describe("Given an authorization request", func() { + g.It("Should redirect to authorize", func() { + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "", nil) + _, err := c.Login(w, r) + g.Assert(err == nil).IsTrue() + g.Assert(w.Code).Equal(http.StatusSeeOther) + }) + g.It("Should return authenticated user", func() { + r, _ := http.NewRequest("GET", "?code=code", nil) + u, err := c.Login(nil, r) + g.Assert(err == nil).IsTrue() + g.Assert(u.Login).Equal(fakeUser.Login) + g.Assert(u.Token).Equal(fakeUser.Token) + g.Assert(u.Secret).Equal(fakeUser.Secret) + }) + }) + + g.Describe("Given an access token", func() { + g.It("Should return the anthenticated user", func() { + login, err := c.Auth( + fakeUser.Token, + fakeUser.Secret, + ) + g.Assert(err == nil).IsTrue() + g.Assert(login).Equal(fakeUser.Login) + }) + g.It("Should handle a failure to resolve user", func() { + _, err := c.Auth( + fakeUserNotFound.Token, + fakeUserNotFound.Secret, + ) + g.Assert(err != nil).IsTrue() + }) + }) + + g.Describe("Given a refresh token", func() { + g.It("Should return a refresh access token", func() { + ok, err := c.Refresh(fakeUserRefresh) + g.Assert(err == nil).IsTrue() + g.Assert(ok).IsTrue() + g.Assert(fakeUserRefresh.Token).Equal("VDZupx0usVRV4oOd1FCu4xUxgk8SY0TK") + g.Assert(fakeUserRefresh.Secret).Equal("BenBQq7TWZ7Cp0aUM47nQjTz2QHNmTWcPctB609n") + }) + g.It("Should handle an invalid refresh token", func() { + ok, _ := c.Refresh(fakeUserRefreshInvalid) + g.Assert(ok).IsFalse() + }) + }) + + g.Describe("When requesting a repository", func() { + g.It("Should return the details", func() { + repo, err := c.Repo( + fakeUser, + fakeRepo.Owner, + fakeRepo.Name, + ) + g.Assert(err == nil).IsTrue() + g.Assert(repo.FullName).Equal(fakeRepo.FullName) + g.Assert(repo.Avatar).Equal(s.URL + fakeRepo.Avatar) + g.Assert(repo.Link).Equal(s.URL + fakeRepo.Link) + g.Assert(repo.Kind).Equal(fakeRepo.Kind) + g.Assert(repo.Clone).Equal(fakeRepo.Clone) + g.Assert(repo.Branch).Equal(fakeRepo.Branch) + g.Assert(repo.IsPrivate).Equal(fakeRepo.IsPrivate) + }) + g.It("Should handle not found errors", func() { + _, err := c.Repo( + fakeUser, + fakeRepoNotFound.Owner, + fakeRepoNotFound.Name, + ) + g.Assert(err != nil).IsTrue() + }) + }) + + g.Describe("When requesting repository permissions", func() { + g.It("Should authorize admin access for project owner", func() { + perm, err := c.Perm(fakeUser, "demo1", "perm_owner") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Pull).IsTrue() + g.Assert(perm.Push).IsTrue() + g.Assert(perm.Admin).IsTrue() + }) + g.It("Should authorize admin access for project admin", func() { + perm, err := c.Perm(fakeUser, "demo1", "perm_admin") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Pull).IsTrue() + g.Assert(perm.Push).IsTrue() + g.Assert(perm.Admin).IsTrue() + }) + g.It("Should authorize read access for project member", func() { + perm, err := c.Perm(fakeUser, "demo1", "perm_member") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Pull).IsTrue() + g.Assert(perm.Push).IsTrue() + g.Assert(perm.Admin).IsFalse() + }) + g.It("Should authorize no access for project guest", func() { + perm, err := c.Perm(fakeUser, "demo1", "perm_guest") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Pull).IsFalse() + g.Assert(perm.Push).IsFalse() + g.Assert(perm.Admin).IsFalse() + }) + g.It("Should handle not found errors", func() { + _, err := c.Perm( + fakeUser, + fakeRepoNotFound.Owner, + fakeRepoNotFound.Name, + ) + g.Assert(err != nil).IsTrue() + }) + }) + + g.Describe("When downloading a file", func() { + g.It("Should return file for specified build", func() { + data, err := c.File(fakeUser, fakeRepo, fakeBuild, ".drone.yml") + g.Assert(err == nil).IsTrue() + g.Assert(string(data)).Equal("pipeline:\n test:\n image: golang:1.6\n commands:\n - go test\n") + }) + g.It("Should return file for specified ref", func() { + data, err := c.FileRef(fakeUser, fakeRepo, "master", ".drone.yml") + g.Assert(err == nil).IsTrue() + g.Assert(string(data)).Equal("pipeline:\n test:\n image: golang:1.6\n commands:\n - go test\n") + }) + }) + + g.Describe("When requesting a netrc config", func() { + g.It("Should return the netrc file for global credential", func() { + remote, _ := New(Opts{ + Machine: "git.coding.net", + Username: "someuser", + Password: "password", + }) + netrc, err := remote.Netrc(fakeUser, nil) + g.Assert(err == nil).IsTrue() + g.Assert(netrc.Login).Equal("someuser") + g.Assert(netrc.Password).Equal("password") + g.Assert(netrc.Machine).Equal("git.coding.net") + }) + g.It("Should return the netrc file for specified user", func() { + remote, _ := New(Opts{ + Machine: "git.coding.net", + }) + netrc, err := remote.Netrc(fakeUser, nil) + g.Assert(err == nil).IsTrue() + g.Assert(netrc.Login).Equal(fakeUser.Token) + g.Assert(netrc.Password).Equal("x-oauth-basic") + g.Assert(netrc.Machine).Equal("git.coding.net") + }) + }) + + g.Describe("When activating a repository", func() { + g.It("Should create the hook", func() { + err := c.Activate(fakeUser, fakeRepo, "http://127.0.0.1") + g.Assert(err == nil).IsTrue() + }) + g.It("Should update the hook when exists", func() { + err := c.Activate(fakeUser, fakeRepo, "http://127.0.0.2") + g.Assert(err == nil).IsTrue() + }) + }) + + g.Describe("When deactivating a repository", func() { + g.It("Should successfully remove hook", func() { + err := c.Deactivate(fakeUser, fakeRepo, "http://127.0.0.3") + g.Assert(err == nil).IsTrue() + }) + g.It("Should successfully deactivate when hook already removed", func() { + err := c.Deactivate(fakeUser, fakeRepo, "http://127.0.0.4") + g.Assert(err == nil).IsTrue() + }) + }) + + g.Describe("When parsing post-commit hook body", func() { + g.It("Should parse the hook", func() { + buf := bytes.NewBufferString(fixtures.PushHook) + req, _ := http.NewRequest("POST", "/hook", buf) + req.Header = http.Header{} + req.Header.Set(hookEvent, hookPush) + + r, _, err := c.Hook(req) + g.Assert(err == nil).IsTrue() + g.Assert(r.FullName).Equal("demo1/test1") + }) + }) + + }) +} + +var ( + fakeUser = &model.User{ + Login: "demo1", + Token: "KTNF2ALdm3ofbtxLh6IbV95Ro5AKWJUP", + Secret: "zVtxJrKhNhBcNyqCz1NggNAAmehAxnRO3Z0fXmCp", + } + + fakeUserNotFound = &model.User{ + Login: "demo1", + Token: "8DpqlE0hI6yr5MLlq8ysAL4p72cKGwT0", + Secret: "8Em2dkFE8Xsze88Ar8LMG7TF4CO3VCQMgpKa0VCm", + } + + fakeUserRefresh = &model.User{ + Login: "demo1", + Secret: "i9i0HQqNR8bTY4rALYEF2itayFJNbnzC1eMFppwT", + } + + fakeUserRefreshInvalid = &model.User{ + Login: "demo1", + Secret: "invalid_refresh_token", + } + + fakeRepo = &model.Repo{ + Owner: "demo1", + Name: "test1", + FullName: "demo1/test1", + Avatar: "/static/project_icon/scenery-5.png", + Link: "/u/gilala/p/abp/git", + Kind: model.RepoGit, + Clone: "https://git.coding.net/demo1/test1.git", + Branch: "master", + IsPrivate: true, + } + + fakeRepoNotFound = &model.Repo{ + Owner: "not_found_owner", + Name: "not_found_project", + } + + fakeRepos = []*model.RepoLite{ + &model.RepoLite{ + Owner: "demo1", + Name: "test1", + FullName: "demo1/test1", + Avatar: "/static/project_icon/scenery-5.png", + }, + } + + fakeBuild = &model.Build{ + Commit: "4504a072cc", + } +) diff --git a/remote/coding/fixtures/handler.go b/remote/coding/fixtures/handler.go new file mode 100644 index 000000000..cf5cbb434 --- /dev/null +++ b/remote/coding/fixtures/handler.go @@ -0,0 +1,313 @@ +package fixtures + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Handler returns an http.Handler that is capable of handing a variety of mock +// Coding requests and returns mock responses. +func Handler() http.Handler { + gin.SetMode(gin.TestMode) + + e := gin.New() + e.POST("/api/oauth/access_token_v2", getToken) + e.GET("/api/account/current_user", getUser) + e.GET("/api/user/:gk/project/:prj", getProject) + e.GET("/api/user/:gk/project/:prj/git", getDepot) + e.GET("/api/user/:gk/project/:prj/git/blob/:ref/:path", getFile) + e.GET("/api/user/:gk/project/:prj/git/hooks", getHooks) + e.POST("/api/user/:gk/project/:prj/git/hook", postHook) + e.PUT("/api/user/:gk/project/:prj/git/hook/:id", putHook) + e.DELETE("/api/user/:gk/project/:prj/git/hook/:id", deleteHook) + + return e +} + +func getToken(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch c.PostForm("grant_type") { + case "refresh_token": + switch c.PostForm("refresh_token") { + case "i9i0HQqNR8bTY4rALYEF2itayFJNbnzC1eMFppwT": + c.String(200, refreshedTokenPayload) + default: + c.String(200, invalidRefreshTokenPayload) + } + case "authorization_code": + fallthrough + default: + switch c.PostForm("code") { + case "code": + c.String(200, tokenPayload) + default: + c.String(200, invalidCodePayload) + } + } +} + +func getUser(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch c.Query("access_token") { + case "KTNF2ALdm3ofbtxLh6IbV95Ro5AKWJUP": + c.String(200, userPayload) + default: + c.String(200, userNotFoundPayload) + } +} + +func getProject(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch fmt.Sprintf("%s/%s", c.Param("gk"), c.Param("prj")) { + case "demo1/test1": + c.String(200, fakeProjectPayload) + case "demo1/perm_owner": + c.String(200, fakePermOwnerPayload) + case "demo1/perm_admin": + c.String(200, fakePermAdminPayload) + case "demo1/perm_member": + c.String(200, fakePermMemberPayload) + case "demo1/perm_guest": + c.String(200, fakePermGuestPayload) + default: + c.String(200, projectNotFoundPayload) + } +} + +func getDepot(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch fmt.Sprintf("%s/%s", c.Param("gk"), c.Param("prj")) { + case "demo1/test1": + c.String(200, fakeDepotPayload) + default: + c.String(200, projectNotFoundPayload) + } +} + +func getProjects(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + c.String(200, fakeProjectsPayload) +} + +func getFile(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch fmt.Sprintf("%s/%s/%s/%s", c.Param("gk"), c.Param("prj"), c.Param("ref"), c.Param("path")) { + case "demo1/test1/master/.drone.yml", "demo1/test1/4504a072cc/.drone.yml": + c.String(200, fakeFilePayload) + default: + c.String(200, fileNotFoundPayload) + } +} + +func getHooks(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + c.String(200, fakeHooksPayload) +} + +func postHook(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch c.PostForm("hook_url") { + case "http://127.0.0.1": + c.String(200, `{"code":0}`) + default: + c.String(200, `{"code":1}`) + } +} + +func putHook(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch c.Param("id") { + case "2": + c.String(200, `{"code":0}`) + default: + c.String(200, `{"code":1}`) + } +} + +func deleteHook(c *gin.Context) { + c.Header("Content-Type", "application/json;charset=UTF-8") + switch c.Param("id") { + case "3": + c.String(200, `{"code":0}`) + default: + c.String(200, `{"code":1}`) + } +} + +const tokenPayload = ` +{ + "access_token":"KTNF2ALdm3ofbtxLh6IbV95Ro5AKWJUP", + "refresh_token":"zVtxJrKhNhBcNyqCz1NggNAAmehAxnRO3Z0fXmCp", + "expires_in":36000 +} +` + +const refreshedTokenPayload = ` +{ + "access_token":"VDZupx0usVRV4oOd1FCu4xUxgk8SY0TK", + "refresh_token":"BenBQq7TWZ7Cp0aUM47nQjTz2QHNmTWcPctB609n", + "expires_in":36000 +} +` + +const invalidRefreshTokenPayload = ` +{ + "code":3006, + "msg":{ + "oauth_refresh_token_error":"Token校验失败" + } +} +` + +const invalidCodePayload = ` +{ + "code":3003, + "msg":{ + "oauth_validate_code_error":"code校验失败" + } +} +` + +const userPayload = ` +{ + "code":0, + "data":{ + "global_key":"demo1", + "email":"demo1@gmail.com", + "avatar":"/static/fruit_avatar/Fruit-20.png" + } +} +` + +const userNotFoundPayload = ` +{ + "code":1, + "msg":{ + "user_not_login":"用户未登录" + } +} +` + +const fakeProjectPayload = ` +{ + "code":0, + "data":{ + "owner_user_name":"demo1", + "name":"test1", + "depot_path":"/u/gilala/p/abp/git", + "https_url":"https://git.coding.net/demo1/test1.git", + "is_public": false, + "icon":"/static/project_icon/scenery-5.png", + "current_user_role":"owner" + } +} +` + +const fakePermOwnerPayload = ` +{ + "code":0, + "data":{ + "current_user_role":"owner" + } +} +` + +const fakePermAdminPayload = ` +{ + "code":0, + "data":{ + "current_user_role":"admin" + } +} +` + +const fakePermMemberPayload = ` +{ + "code":0, + "data":{ + "current_user_role":"member" + } +} +` + +const fakePermGuestPayload = ` +{ + "code":0, + "data":{ + "current_user_role":"guest" + } +} +` + +const fakeDepotPayload = ` +{ + "code":0, + "data":{ + "default_branch":"master" + } +} +` + +const projectNotFoundPayload = ` +{ + "code":1100, + "msg":{ + "project_not_exists":"项目不存在" + } +} +` + +const fakeProjectsPayload = ` +{ + "code":0, + "data":{ + "list":{ + "owner_user_name":"demo1", + "name":"test1", + "icon":"/static/project_icon/scenery-5.png", + }, + "page":1, + "pageSize":1, + "totalPage":1, + "totalRow":1 + } +} +` + +const fakeFilePayload = ` +{ + "code":0, + "data":{ + "file":{ + "data":"pipeline:\n test:\n image: golang:1.6\n commands:\n - go test\n" + } + } +} +` + +const fileNotFoundPayload = ` +{ + "code":0, + "data":{ + "ref":"master" + } +} +` + +const fakeHooksPayload = ` +{ + "code":0, + "data":[ + { + "id":2, + "hook_url":"http://127.0.0.2" + }, + { + "id":3, + "hook_url":"http://127.0.0.3" + } + ] +} +` diff --git a/remote/coding/fixtures/hooks.go b/remote/coding/fixtures/hooks.go new file mode 100644 index 000000000..8253a6af5 --- /dev/null +++ b/remote/coding/fixtures/hooks.go @@ -0,0 +1,175 @@ +package fixtures + +const PushHook = ` +{ + "ref": "refs/heads/master", + "before": "861f2315056e8925e627a6f46518b9df05896e24", + "commits": [ + { + "committer": { + "name": "demo1", + "email": "demo1@gmail.com" + }, + "web_url": "https://coding.net/u/demo1/p/test1/git/commit/5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + "short_message": "new file .drone.yml\n", + "sha": "5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4" + } + ], + "after": "5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + "event": "push", + "repository": { + "owner": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + }, + "https_url": "https://git.coding.net/demo1/test1.git", + "web_url": "https://coding.net/u/demo1/p/test1", + "project_id": "99999999", + "ssh_url": "git@git.coding.net:demo1/test1.git", + "name": "test1", + "description": "" + }, + "user": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + } +} +` + +const DeleteBranchPushHook = ` +{ + "ref": "refs/heads/master", + "before": "861f2315056e8925e627a6f46518b9df05896e24", + "after": "0000000000000000000000000000000000000000", + "event": "push", + "repository": { + "owner": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + }, + "https_url": "https://git.coding.net/demo1/test1.git", + "web_url": "https://coding.net/u/demo1/p/test1", + "project_id": "99999999", + "ssh_url": "git@git.coding.net:demo1/test1.git", + "name": "test1", + "description": "" + }, + "user": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + } +} +` + +const PullRequestHook = ` +{ + "pull_request": { + "target_branch": "master", + "title": "pr1", + "body": "pr message", + "source_sha": "", + "source_repository": { + "owner": { + "path": "/u/demo2", + "web_url": "https://coding.net/u/demo2", + "global_key": "demo2", + "name": "demo2", + "avatar": "/static/fruit_avatar/Fruit-2.png" + }, + "https_url": "https://git.coding.net/demo2/test2.git", + "web_url": "https://coding.net/u/demo2/p/test2", + "project_id": "7777777", + "ssh_url": "git@git.coding.net:demo2/test2.git", + "name": "test2", + "description": "", + "git_url": "git://git.coding.net/demo2/test2.git" + }, + "source_branch": "master", + "number": 1, + "web_url": "https://coding.net/u/demo1/p/test2/git/pull/1", + "merge_commit_sha": "55e77b328b71d3ee4f9e70a5f67231b0acceeadc", + "target_sha": "", + "action": "create", + "id": 7586, + "user": { + "path": "/u/demo2", + "web_url": "https://coding.net/u/demo2", + "global_key": "demo2", + "name": "demo2", + "avatar": "/static/fruit_avatar/Fruit-2.png" + }, + "status": "CANMERGE" + }, + "repository": { + "owner": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + }, + "https_url": "https://git.coding.net/demo1/test2.git", + "web_url": "https://coding.net/u/demo1/p/test2", + "project_id": "6666666", + "ssh_url": "git@git.coding.net:demo1/test2.git", + "name": "test2", + "description": "", + "git_url": "git://git.coding.net/demo1/test2.git" + }, + "event": "pull_request" +} +` + +const MergeRequestHook = ` +{ + "merge_request": { + "target_branch": "master", + "title": "mr1", + "body": "

mr message

", + "source_sha": "", + "source_branch": "branch1", + "number": 1, + "web_url": "https://coding.net/u/demo1/p/test1/git/merge/1", + "merge_commit_sha": "74e6755580c34e9fd81dbcfcbd43ee5f30259436", + "target_sha": "", + "action": "create", + "id": 533428, + "user": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + }, + "status": "CANMERGE" + }, + "repository": { + "owner": { + "path": "/u/demo1", + "web_url": "https://coding.net/u/demo1", + "global_key": "demo1", + "name": "demo1", + "avatar": "/static/fruit_avatar/Fruit-20.png" + }, + "https_url": "https://git.coding.net/demo1/test1.git", + "web_url": "https://coding.net/u/demo1/p/test1", + "project_id": "99999999", + "ssh_url": "git@git.coding.net:demo1/test1.git", + "name": "test1", + "description": "" + }, + "event": "merge_request" +} +` diff --git a/remote/coding/hook.go b/remote/coding/hook.go new file mode 100644 index 000000000..d073f1668 --- /dev/null +++ b/remote/coding/hook.go @@ -0,0 +1,229 @@ +package coding + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "regexp" + "strings" + + "github.com/drone/drone/model" +) + +const ( + hookEvent = "X-Coding-Event" + hookPush = "push" + hookPR = "pull_request" + hookMR = "merge_request" +) + +type User struct { + GlobalKey string `json:"global_key"` + Avatar string `json:"avatar"` +} + +type Repository struct { + Name string `json:"name"` + HttpsURL string `json:"https_url"` + SshURL string `json:"ssh_url"` + WebURL string `json:"web_url"` + Owner *User `json:"owner"` +} + +type Committer struct { + Email string `json:"email"` + Name string `json:"name"` +} + +type Commit struct { + SHA string `json:"sha"` + ShortMessage string `json:"short_message"` + Committer *Committer `json:"committer"` +} + +type PullRequest MergeRequest + +type MergeRequest struct { + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + CommitSHA string `json:"merge_commit_sha"` + Status string `json:"status"` + Action string `json:"action"` + Number float64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + WebURL string `json:"web_url"` + User *User `json:"user"` +} + +type PushHook struct { + Event string `json:"event"` + Repository *Repository `json:"repository"` + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + Commits []*Commit `json:"commits"` + User *User `json:"user"` +} + +type PullRequestHook struct { + Event string `json:"event"` + Repository *Repository `json:"repository"` + PullRequest *PullRequest `json:"pull_request"` +} + +type MergeRequestHook struct { + Event string `json:"event"` + Repository *Repository `json:"repository"` + MergeRequest *MergeRequest `json:"merge_request"` +} + +func parseHook(r *http.Request) (*model.Repo, *model.Build, error) { + raw, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + return nil, nil, err + } + + switch r.Header.Get(hookEvent) { + case hookPush: + return parsePushHook(raw) + case hookPR: + return parsePullRequestHook(raw) + case hookMR: + return parseMergeReuqestHook(raw) + } + return nil, nil, nil +} + +func findLastCommit(commits []*Commit, sha string) *Commit { + var lastCommit *Commit + for _, commit := range commits { + if commit.SHA == sha { + lastCommit = commit + break + } + } + if lastCommit == nil { + lastCommit = &Commit{} + } + if lastCommit.Committer == nil { + lastCommit.Committer = &Committer{} + } + return lastCommit +} + +func convertRepository(repo *Repository) (*model.Repo, error) { + // tricky stuff for a team project without a team owner instead of a user owner + re := regexp.MustCompile(`git@.+:([^/]+)/.+\.git`) + matches := re.FindStringSubmatch(repo.SshURL) + if len(matches) != 2 { + return nil, fmt.Errorf("Unable to resolve owner from ssh url %q", repo.SshURL) + } + + return &model.Repo{ + Owner: matches[1], + Name: repo.Name, + FullName: projectFullName(repo.Owner.GlobalKey, repo.Name), + Link: repo.WebURL, + Kind: model.RepoGit, + }, nil +} + +func parsePushHook(raw []byte) (*model.Repo, *model.Build, error) { + hook := &PushHook{} + err := json.Unmarshal(raw, hook) + if err != nil { + return nil, nil, err + } + + // no build triggered when removing ref + if hook.After == "0000000000000000000000000000000000000000" { + return nil, nil, nil + } + + repo, err := convertRepository(hook.Repository) + if err != nil { + return nil, nil, err + } + + lastCommit := findLastCommit(hook.Commits, hook.After) + build := &model.Build{ + Event: model.EventPush, + Commit: hook.After, + Ref: hook.Ref, + Link: fmt.Sprintf("%s/git/commit/%s", hook.Repository.WebURL, hook.After), + Branch: strings.Replace(hook.Ref, "refs/heads/", "", -1), + Message: lastCommit.ShortMessage, + Email: lastCommit.Committer.Email, + Avatar: hook.User.Avatar, + Author: hook.User.GlobalKey, + Remote: hook.Repository.HttpsURL, + } + return repo, build, nil +} + +func parsePullRequestHook(raw []byte) (*model.Repo, *model.Build, error) { + hook := &PullRequestHook{} + err := json.Unmarshal(raw, hook) + if err != nil { + return nil, nil, err + } + if hook.PullRequest.Status != "CANMERGE" || + (hook.PullRequest.Action != "create" && hook.PullRequest.Action != "synchronize") { + return nil, nil, nil + } + + repo, err := convertRepository(hook.Repository) + if err != nil { + return nil, nil, err + } + build := &model.Build{ + Event: model.EventPull, + Commit: hook.PullRequest.CommitSHA, + Link: hook.PullRequest.WebURL, + Ref: fmt.Sprintf("refs/pull/%d/MERGE", int(hook.PullRequest.Number)), + Branch: hook.PullRequest.TargetBranch, + Message: hook.PullRequest.Body, + Author: hook.PullRequest.User.GlobalKey, + Avatar: hook.PullRequest.User.Avatar, + Title: hook.PullRequest.Title, + Remote: hook.Repository.HttpsURL, + Refspec: fmt.Sprintf("%s:%s", hook.PullRequest.SourceBranch, hook.PullRequest.TargetBranch), + } + + return repo, build, nil +} + +func parseMergeReuqestHook(raw []byte) (*model.Repo, *model.Build, error) { + hook := &MergeRequestHook{} + err := json.Unmarshal(raw, hook) + if err != nil { + return nil, nil, err + } + if hook.MergeRequest.Status != "CANMERGE" || + (hook.MergeRequest.Action != "create" && hook.MergeRequest.Action != "synchronize") { + return nil, nil, nil + } + + repo, err := convertRepository(hook.Repository) + if err != nil { + return nil, nil, err + } + + build := &model.Build{ + Event: model.EventPull, + Commit: hook.MergeRequest.CommitSHA, + Link: hook.MergeRequest.WebURL, + Ref: fmt.Sprintf("refs/merge/%d/MERGE", int(hook.MergeRequest.Number)), + Branch: hook.MergeRequest.TargetBranch, + Message: hook.MergeRequest.Body, + Author: hook.MergeRequest.User.GlobalKey, + Avatar: hook.MergeRequest.User.Avatar, + Title: hook.MergeRequest.Title, + Remote: hook.Repository.HttpsURL, + Refspec: fmt.Sprintf("%s:%s", hook.MergeRequest.SourceBranch, hook.MergeRequest.TargetBranch), + } + return repo, build, nil +} diff --git a/remote/coding/hook_test.go b/remote/coding/hook_test.go new file mode 100644 index 000000000..66483e42f --- /dev/null +++ b/remote/coding/hook_test.go @@ -0,0 +1,192 @@ +package coding + +import ( + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote/coding/fixtures" + + "github.com/franela/goblin" +) + +func Test_hook(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Coding hook", func() { + + g.It("Should parse hook", func() { + + reader := ioutil.NopCloser(strings.NewReader(fixtures.PushHook)) + r := &http.Request{ + Header: map[string][]string{ + hookEvent: {hookPush}, + }, + Body: reader, + } + + repo := &model.Repo{ + Owner: "demo1", + Name: "test1", + FullName: "demo1/test1", + Link: "https://coding.net/u/demo1/p/test1", + Kind: model.RepoGit, + } + + build := &model.Build{ + Event: model.EventPush, + Commit: "5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + Ref: "refs/heads/master", + Link: "https://coding.net/u/demo1/p/test1/git/commit/5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + Branch: "master", + Message: "new file .drone.yml\n", + Email: "demo1@gmail.com", + Avatar: "/static/fruit_avatar/Fruit-20.png", + Author: "demo1", + Remote: "https://git.coding.net/demo1/test1.git", + } + + actualRepo, actualBuild, err := parseHook(r) + g.Assert(err == nil).IsTrue() + g.Assert(actualRepo).Equal(repo) + g.Assert(actualBuild).Equal(build) + }) + + g.It("Should find last commit", func() { + commit1 := &Commit{SHA: "1234567890", Committer: &Committer{}} + commit2 := &Commit{SHA: "abcdef1234", Committer: &Committer{}} + commits := []*Commit{commit1, commit2} + g.Assert(findLastCommit(commits, "abcdef1234")).Equal(commit2) + }) + + g.It("Should find last commit", func() { + commit1 := &Commit{SHA: "1234567890", Committer: &Committer{}} + commit2 := &Commit{SHA: "abcdef1234", Committer: &Committer{}} + commits := []*Commit{commit1, commit2} + emptyCommit := &Commit{Committer: &Committer{}} + g.Assert(findLastCommit(commits, "00000000000")).Equal(emptyCommit) + }) + + g.It("Should convert repository", func() { + repository := &Repository{ + Name: "test_project", + HttpsURL: "https://git.coding.net/kelvin/test_project.git", + SshURL: "git@git.coding.net:kelvin/test_project.git", + WebURL: "https://coding.net/u/kelvin/p/test_project", + Owner: &User{ + GlobalKey: "kelvin", + Avatar: "https://dn-coding-net-production-static.qbox.me/9ed11de3-65e3-4cd8-b6aa-5abe7285ab43.jpeg?imageMogr2/auto-orient/format/jpeg/crop/!209x209a0a0", + }, + } + repo := &model.Repo{ + Owner: "kelvin", + Name: "test_project", + FullName: "kelvin/test_project", + Link: "https://coding.net/u/kelvin/p/test_project", + Kind: model.RepoGit, + } + actual, err := convertRepository(repository) + g.Assert(err == nil).IsTrue() + g.Assert(actual).Equal(repo) + }) + + g.It("Should parse push hook", func() { + + repo := &model.Repo{ + Owner: "demo1", + Name: "test1", + FullName: "demo1/test1", + Link: "https://coding.net/u/demo1/p/test1", + Kind: model.RepoGit, + } + + build := &model.Build{ + Event: model.EventPush, + Commit: "5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + Ref: "refs/heads/master", + Link: "https://coding.net/u/demo1/p/test1/git/commit/5b9912a6ff272e9c93a4c44c278fe9b359ed1ab4", + Branch: "master", + Message: "new file .drone.yml\n", + Email: "demo1@gmail.com", + Avatar: "/static/fruit_avatar/Fruit-20.png", + Author: "demo1", + Remote: "https://git.coding.net/demo1/test1.git", + } + + actualRepo, actualBuild, err := parsePushHook([]byte(fixtures.PushHook)) + g.Assert(err == nil).IsTrue() + g.Assert(actualRepo).Equal(repo) + g.Assert(actualBuild).Equal(build) + }) + + g.It("Should parse delete branch push hook", func() { + actualRepo, actualBuild, err := parsePushHook([]byte(fixtures.DeleteBranchPushHook)) + g.Assert(err == nil).IsTrue() + g.Assert(actualRepo == nil).IsTrue() + g.Assert(actualBuild == nil).IsTrue() + }) + + g.It("Should parse pull request hook", func() { + + repo := &model.Repo{ + Owner: "demo1", + Name: "test2", + FullName: "demo1/test2", + Link: "https://coding.net/u/demo1/p/test2", + Kind: model.RepoGit, + } + + build := &model.Build{ + Event: model.EventPull, + Commit: "55e77b328b71d3ee4f9e70a5f67231b0acceeadc", + Link: "https://coding.net/u/demo1/p/test2/git/pull/1", + Ref: "refs/pull/1/MERGE", + Branch: "master", + Message: "pr message", + Author: "demo2", + Avatar: "/static/fruit_avatar/Fruit-2.png", + Title: "pr1", + Remote: "https://git.coding.net/demo1/test2.git", + Refspec: "master:master", + } + + actualRepo, actualBuild, err := parsePullRequestHook([]byte(fixtures.PullRequestHook)) + g.Assert(err == nil).IsTrue() + g.Assert(actualRepo).Equal(repo) + g.Assert(actualBuild).Equal(build) + }) + + g.It("Should parse merge request hook", func() { + + repo := &model.Repo{ + Owner: "demo1", + Name: "test1", + FullName: "demo1/test1", + Link: "https://coding.net/u/demo1/p/test1", + Kind: model.RepoGit, + } + + build := &model.Build{ + Event: model.EventPull, + Commit: "74e6755580c34e9fd81dbcfcbd43ee5f30259436", + Link: "https://coding.net/u/demo1/p/test1/git/merge/1", + Ref: "refs/merge/1/MERGE", + Branch: "master", + Message: "

mr message

", + Author: "demo1", + Avatar: "/static/fruit_avatar/Fruit-20.png", + Title: "mr1", + Remote: "https://git.coding.net/demo1/test1.git", + Refspec: "branch1:master", + } + + actualRepo, actualBuild, err := parseMergeReuqestHook([]byte(fixtures.MergeRequestHook)) + g.Assert(err == nil).IsTrue() + g.Assert(actualRepo).Equal(repo) + g.Assert(actualBuild).Equal(build) + }) + + }) +} diff --git a/remote/coding/internal/coding.go b/remote/coding/internal/coding.go new file mode 100644 index 000000000..f6fc98fdd --- /dev/null +++ b/remote/coding/internal/coding.go @@ -0,0 +1,84 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +type Client struct { + baseURL string + apiPath string + token string + agent string + client *http.Client +} + +type GenericAPIResponse struct { + Code int `json:"code"` + Data json.RawMessage `json:"data,omitempty"` +} + +func NewClient(baseURL, apiPath, token, agent string, client *http.Client) *Client { + return &Client{ + baseURL: baseURL, + apiPath: apiPath, + token: token, + agent: agent, + client: client, + } +} + +// Generic GET for requesting Coding OAuth API +func (c *Client) Get(u string, params url.Values) ([]byte, error) { + return c.Do(http.MethodGet, u, params) +} + +// Generic method for requesting Coding OAuth API +func (c *Client) Do(method, u string, params url.Values) ([]byte, error) { + if params == nil { + params = url.Values{} + } + params.Set("access_token", c.token) + + rawURL := c.baseURL + c.apiPath + u + + var req *http.Request + var err error + if method != "GET" { + req, err = http.NewRequest(method, rawURL+"?access_token="+c.token, strings.NewReader(params.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + } else { + req, err = http.NewRequest("GET", rawURL+"?"+params.Encode(), nil) + } + if err != nil { + return nil, fmt.Errorf("fail to create request for url %q: %v", rawURL, err) + } + req.Header.Set("User-Agent", c.agent) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fail to request %s %s: %v", req.Method, req.URL, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s %s respond %d", req.Method, req.URL, resp.StatusCode) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fail to read response from %s %s: %v", req.Method, req.URL.String(), err) + } + + apiResp := &GenericAPIResponse{} + err = json.Unmarshal(body, apiResp) + if err != nil { + return nil, fmt.Errorf("fail to parse response from %s %s: %v", req.Method, req.URL.String(), err) + } + if apiResp.Code != 0 { + return nil, fmt.Errorf("Coding OAuth API respond error: %s", string(body)) + } + return apiResp.Data, nil +} diff --git a/remote/coding/internal/error.go b/remote/coding/internal/error.go new file mode 100644 index 000000000..fefb9bc3e --- /dev/null +++ b/remote/coding/internal/error.go @@ -0,0 +1,15 @@ +package internal + +import ( + "fmt" +) + +type APIClientErr struct { + Message string + URL string + Cause error +} + +func (e APIClientErr) Error() string { + return fmt.Sprintf("%s (Requested %s): %v", e.Message, e.URL, e.Cause) +} diff --git a/remote/coding/internal/file.go b/remote/coding/internal/file.go new file mode 100644 index 000000000..358a21ae6 --- /dev/null +++ b/remote/coding/internal/file.go @@ -0,0 +1,31 @@ +package internal + +import ( + "encoding/json" + "fmt" +) + +type Commit struct { + File *File `json:"file"` +} + +type File struct { + Data string `json:"data"` +} + +func (c *Client) GetFile(globalKey, projectName, ref, path string) ([]byte, error) { + u := fmt.Sprintf("/user/%s/project/%s/git/blob/%s/%s", globalKey, projectName, ref, path) + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + commit := &Commit{} + err = json.Unmarshal(resp, commit) + if err != nil { + return nil, APIClientErr{"fail to parse file data", u, err} + } + if commit == nil || commit.File == nil { + return nil, nil + } + return []byte(commit.File.Data), nil +} diff --git a/remote/coding/internal/project.go b/remote/coding/internal/project.go new file mode 100644 index 000000000..f799c78d8 --- /dev/null +++ b/remote/coding/internal/project.go @@ -0,0 +1,93 @@ +package internal + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type Project struct { + Owner string `json:"owner_user_name"` + Name string `json:"name"` + DepotPath string `json:"depot_path"` + HttpsURL string `json:"https_url"` + IsPublic bool `json:"is_public"` + Icon string `json:"icon"` + Role string `json:"current_user_role"` +} + +type Depot struct { + DefaultBranch string `json:"default_branch"` +} + +type ProjectListData struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + TotalPage int `json:"totalPage"` + TotalRow int `json:"totalRow"` + List []*Project `json:"list"` +} + +func (c *Client) GetProject(globalKey, projectName string) (*Project, error) { + u := fmt.Sprintf("/user/%s/project/%s", globalKey, projectName) + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + + project := &Project{} + err = json.Unmarshal(resp, project) + if err != nil { + return nil, APIClientErr{"fail to parse project data", u, err} + } + return project, nil +} + +func (c *Client) GetDepot(globalKey, projectName string) (*Depot, error) { + u := fmt.Sprintf("/user/%s/project/%s/git", globalKey, projectName) + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + + depot := &Depot{} + err = json.Unmarshal(resp, depot) + if err != nil { + return nil, APIClientErr{"fail to parse depot data", u, err} + } + return depot, nil +} + +func (c *Client) GetProjectList() ([]*Project, error) { + u := "/user/projects" + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + data := &ProjectListData{} + err = json.Unmarshal(resp, data) + if err != nil { + return nil, APIClientErr{"fail to parse project list data", u, err} + } + if data.TotalPage == 1 { + return data.List, nil + } + + projectList := make([]*Project, 0) + projectList = append(projectList, data.List...) + for i := 2; i <= data.TotalPage; i++ { + params := url.Values{} + params.Set("page", fmt.Sprintf("%d", i)) + resp, err := c.Get(u, params) + if err != nil { + return nil, err + } + data := &ProjectListData{} + err = json.Unmarshal(resp, data) + if err != nil { + return nil, APIClientErr{"fail to parse project list data", u, err} + } + projectList = append(projectList, data.List...) + } + return projectList, nil +} diff --git a/remote/coding/internal/user.go b/remote/coding/internal/user.go new file mode 100644 index 000000000..0867483a5 --- /dev/null +++ b/remote/coding/internal/user.go @@ -0,0 +1,25 @@ +package internal + +import ( + "encoding/json" +) + +type User struct { + GlobalKey string `json:"global_key"` + Email string `json:"email"` + Avatar string `json:"avatar"` +} + +func (c *Client) GetCurrentUser() (*User, error) { + u := "/account/current_user" + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + user := &User{} + err = json.Unmarshal(resp, user) + if err != nil { + return nil, APIClientErr{"fail to parse current user data", u, err} + } + return user, nil +} diff --git a/remote/coding/internal/webhook.go b/remote/coding/internal/webhook.go new file mode 100644 index 000000000..3bc0e833a --- /dev/null +++ b/remote/coding/internal/webhook.go @@ -0,0 +1,92 @@ +package internal + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type Webhook struct { + Id int `json:"id"` + HookURL string `json:"hook_url"` +} + +func (c *Client) GetWebhooks(globalKey, projectName string) ([]*Webhook, error) { + u := fmt.Sprintf("/user/%s/project/%s/git/hooks", globalKey, projectName) + resp, err := c.Get(u, nil) + if err != nil { + return nil, err + } + webhooks := make([]*Webhook, 0) + err = json.Unmarshal(resp, &webhooks) + if err != nil { + return nil, APIClientErr{"fail to parse webhooks data", u, err} + } + return webhooks, nil +} + +func (c *Client) AddWebhook(globalKey, projectName, link string) error { + webhooks, err := c.GetWebhooks(globalKey, projectName) + if err != nil { + return err + } + webhook := matchingHooks(webhooks, link) + if webhook != nil { + u := fmt.Sprintf("/user/%s/project/%s/git/hook/%d", globalKey, projectName, webhook.Id) + params := url.Values{} + params.Set("hook_url", link) + params.Set("type_pust", "true") + params.Set("type_mr_pr", "true") + + _, err := c.Do("PUT", u, params) + if err != nil { + return APIClientErr{"fail to edit webhook", u, err} + } + return nil + } + + u := fmt.Sprintf("/user/%s/project/%s/git/hook", globalKey, projectName) + params := url.Values{} + params.Set("hook_url", link) + params.Set("type_push", "true") + params.Set("type_mr_pr", "true") + + _, err = c.Do("POST", u, params) + if err != nil { + return APIClientErr{"fail to add webhook", u, err} + } + return nil +} + +func (c *Client) RemoveWebhook(globalKey, projectName, link string) error { + webhooks, err := c.GetWebhooks(globalKey, projectName) + if err != nil { + return err + } + webhook := matchingHooks(webhooks, link) + if webhook == nil { + return nil + } + + u := fmt.Sprintf("/user/%s/project/%s/git/hook/%d", globalKey, projectName, webhook.Id) + _, err = c.Do("DELETE", u, nil) + if err != nil { + return APIClientErr{"fail to remove webhook", u, err} + } + return nil +} + +// helper function to return matching hook. +func matchingHooks(hooks []*Webhook, rawurl string) *Webhook { + link, err := url.Parse(rawurl) + if err != nil { + return nil + } + for _, hook := range hooks { + hookurl, err := url.Parse(hook.HookURL) + if err == nil && hookurl.Host == link.Host { + return hook + } + } + return nil +} diff --git a/remote/coding/util.go b/remote/coding/util.go new file mode 100644 index 000000000..9a553ea04 --- /dev/null +++ b/remote/coding/util.go @@ -0,0 +1,9 @@ +package coding + +import ( + "fmt" +) + +func projectFullName(owner, name string) string { + return fmt.Sprintf("%s/%s", owner, name) +} diff --git a/remote/coding/util_test.go b/remote/coding/util_test.go new file mode 100644 index 000000000..704b82e97 --- /dev/null +++ b/remote/coding/util_test.go @@ -0,0 +1,18 @@ +package coding + +import ( + "testing" + + "github.com/franela/goblin" +) + +func Test_util(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Coding util", func() { + + g.It("Should form project full name", func() { + g.Assert(projectFullName("gk", "prj")).Equal("gk/prj") + }) + }) +}