From 913d8701f22e8e1167d932b24af8a252c3ec5a1d Mon Sep 17 00:00:00 2001 From: Brad Rydzewski Date: Thu, 14 Sep 2017 07:50:07 -0700 Subject: [PATCH] add gitlab v3 option for backward compat --- .drone.yml | 4 +- cmd/drone-server/server.go | 5 + cmd/drone-server/setup.go | 12 + remote/gitlab3/client/drone.go | 27 ++ remote/gitlab3/client/gitlab.go | 96 ++++ remote/gitlab3/client/groups.go | 53 +++ remote/gitlab3/client/hook.go | 41 ++ remote/gitlab3/client/project.go | 161 +++++++ remote/gitlab3/client/types.go | 138 ++++++ remote/gitlab3/client/user.go | 21 + remote/gitlab3/client/util.go | 43 ++ remote/gitlab3/gitlab.go | 699 ++++++++++++++++++++++++++++ remote/gitlab3/gitlab_test.go | 247 ++++++++++ remote/gitlab3/helper.go | 125 +++++ remote/gitlab3/testdata/hooks.go | 334 +++++++++++++ remote/gitlab3/testdata/oauth.go | 3 + remote/gitlab3/testdata/projects.go | 212 +++++++++ remote/gitlab3/testdata/testdata.go | 60 +++ remote/gitlab3/testdata/users.go | 24 + version/version.go | 2 +- 20 files changed, 2304 insertions(+), 3 deletions(-) create mode 100644 remote/gitlab3/client/drone.go create mode 100644 remote/gitlab3/client/gitlab.go create mode 100644 remote/gitlab3/client/groups.go create mode 100644 remote/gitlab3/client/hook.go create mode 100644 remote/gitlab3/client/project.go create mode 100644 remote/gitlab3/client/types.go create mode 100644 remote/gitlab3/client/user.go create mode 100644 remote/gitlab3/client/util.go create mode 100644 remote/gitlab3/gitlab.go create mode 100644 remote/gitlab3/gitlab_test.go create mode 100644 remote/gitlab3/helper.go create mode 100644 remote/gitlab3/testdata/hooks.go create mode 100644 remote/gitlab3/testdata/oauth.go create mode 100644 remote/gitlab3/testdata/projects.go create mode 100644 remote/gitlab3/testdata/testdata.go create mode 100644 remote/gitlab3/testdata/users.go diff --git a/.drone.yml b/.drone.yml index ab538e4d4..29d3215bc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -80,7 +80,7 @@ pipeline: image: plugins/docker repo: drone/drone secrets: [ docker_username, docker_password ] - tag: [ 0.8, 0.8.0, 0.8.0-rc.5 ] + tag: [ 0.8, 0.8.0 ] when: event: tag @@ -89,7 +89,7 @@ pipeline: repo: drone/agent dockerfile: Dockerfile.agent secrets: [ docker_username, docker_password ] - tag: [ 0.8, 0.8.0, 0.8.0-rc.5 ] + tag: [ 0.8, 0.8.0 ] when: event: tag diff --git a/cmd/drone-server/server.go b/cmd/drone-server/server.go index 3e200239c..75d104059 100644 --- a/cmd/drone-server/server.go +++ b/cmd/drone-server/server.go @@ -359,6 +359,11 @@ var flags = []cli.Flag{ Name: "gitlab-private-mode", Usage: "gitlab is running in private mode", }, + cli.BoolFlag{ + EnvVar: "DRONE_GITLAB_V3_API", + Name: "gitlab-v3-api", + Usage: "gitlab is running the v3 api", + }, cli.BoolFlag{ EnvVar: "DRONE_STASH", Name: "stash", diff --git a/cmd/drone-server/setup.go b/cmd/drone-server/setup.go index a10d66c44..041016660 100644 --- a/cmd/drone-server/setup.go +++ b/cmd/drone-server/setup.go @@ -15,6 +15,7 @@ import ( "github.com/drone/drone/remote/gitea" "github.com/drone/drone/remote/github" "github.com/drone/drone/remote/gitlab" + "github.com/drone/drone/remote/gitlab3" "github.com/drone/drone/remote/gogs" "github.com/drone/drone/server/web" "github.com/drone/drone/store" @@ -121,6 +122,17 @@ func setupStash(c *cli.Context) (remote.Remote, error) { // helper function to setup the Gitlab remote from the CLI arguments. func setupGitlab(c *cli.Context) (remote.Remote, error) { + if c.Bool("gitlab-v3-api") { + return gitlab3.New(gitlab3.Opts{ + URL: c.String("gitlab-server"), + Client: c.String("gitlab-client"), + Secret: c.String("gitlab-secret"), + Username: c.String("gitlab-git-username"), + Password: c.String("gitlab-git-password"), + PrivateMode: c.Bool("gitlab-private-mode"), + SkipVerify: c.Bool("gitlab-skip-verify"), + }) + } return gitlab.New(gitlab.Opts{ URL: c.String("gitlab-server"), Client: c.String("gitlab-client"), diff --git a/remote/gitlab3/client/drone.go b/remote/gitlab3/client/drone.go new file mode 100644 index 000000000..8915e8455 --- /dev/null +++ b/remote/gitlab3/client/drone.go @@ -0,0 +1,27 @@ +package client + +const ( + droneServiceUrl = "/projects/:id/services/drone-ci" +) + +func (c *Client) AddDroneService(id string, params QMap) error { + url, opaque := c.ResourceUrl( + droneServiceUrl, + QMap{":id": id}, + params, + ) + + _, err := c.Do("PUT", url, opaque, nil) + return err +} + +func (c *Client) DeleteDroneService(id string) error { + url, opaque := c.ResourceUrl( + droneServiceUrl, + QMap{":id": id}, + nil, + ) + + _, err := c.Do("DELETE", url, opaque, nil) + return err +} diff --git a/remote/gitlab3/client/gitlab.go b/remote/gitlab3/client/gitlab.go new file mode 100644 index 000000000..1b4bad749 --- /dev/null +++ b/remote/gitlab3/client/gitlab.go @@ -0,0 +1,96 @@ +package client + +import ( + "bytes" + "crypto/tls" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +type Client struct { + BaseUrl string + ApiPath string + Token string + Client *http.Client +} + +func New(baseUrl, apiPath, token string, skipVerify bool) *Client { + config := &tls.Config{InsecureSkipVerify: skipVerify} + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: config, + } + client := &http.Client{Transport: tr} + + return &Client{ + BaseUrl: baseUrl, + ApiPath: apiPath, + Token: token, + Client: client, + } +} + +func (c *Client) ResourceUrl(u string, params, query QMap) (string, string) { + if params != nil { + for key, val := range params { + u = strings.Replace(u, key, encodeParameter(val), -1) + } + } + + query_params := url.Values{} + + if query != nil { + for key, val := range query { + query_params.Set(key, val) + } + } + + u = c.BaseUrl + c.ApiPath + u + "?" + query_params.Encode() + p, err := url.Parse(u) + if err != nil { + return u, "" + } + + opaque := "//" + p.Host + p.Path + return u, opaque +} + +func (c *Client) Do(method, url, opaque string, body []byte) ([]byte, error) { + var req *http.Request + var err error + + if body != nil { + reader := bytes.NewReader(body) + req, err = http.NewRequest(method, url, reader) + } else { + req, err = http.NewRequest(method, url, nil) + } + if err != nil { + return nil, fmt.Errorf("Error while building gitlab request") + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + + if len(opaque) > 0 { + req.URL.Opaque = opaque + } + + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("Client.Do error: %q", err) + } + defer resp.Body.Close() + contents, err := ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Printf("%s", err) + } + + if resp.StatusCode >= 400 { + err = fmt.Errorf("*Gitlab.buildAndExecRequest failed: <%d> %s", resp.StatusCode, req.URL) + } + + return contents, err +} diff --git a/remote/gitlab3/client/groups.go b/remote/gitlab3/client/groups.go new file mode 100644 index 000000000..0e8f30d28 --- /dev/null +++ b/remote/gitlab3/client/groups.go @@ -0,0 +1,53 @@ +package client + +import ( + "encoding/json" + "strconv" +) + +const ( + groupsUrl = "/groups" +) + +// Get a list of all projects owned by the authenticated user. +func (g *Client) AllGroups() ([]*Namespace, error) { + var perPage = 100 + var groups []*Namespace + + for i := 1; true; i++ { + contents, err := g.Groups(i, perPage) + if err != nil { + return groups, err + } + + for _, value := range contents { + groups = append(groups, value) + } + + if len(groups) == 0 { + break + } + + if len(groups)/i < perPage { + break + } + } + + return groups, nil +} + +func (g *Client) Groups(page, perPage int) ([]*Namespace, error) { + url, opaque := g.ResourceUrl(groupsUrl, nil, QMap{ + "page": strconv.Itoa(page), + "per_page": strconv.Itoa(perPage), + }) + + var groups []*Namespace + + contents, err := g.Do("GET", url, opaque, nil) + if err == nil { + err = json.Unmarshal(contents, &groups) + } + + return groups, err +} diff --git a/remote/gitlab3/client/hook.go b/remote/gitlab3/client/hook.go new file mode 100644 index 000000000..8ea89689e --- /dev/null +++ b/remote/gitlab3/client/hook.go @@ -0,0 +1,41 @@ +package client + +import ( + "encoding/json" + "fmt" +) + +// ParseHook parses hook payload from GitLab +func ParseHook(payload []byte) (*HookPayload, error) { + hp := HookPayload{} + if err := json.Unmarshal(payload, &hp); err != nil { + return nil, err + } + + // Basic sanity check + switch { + case len(hp.ObjectKind) == 0: + // Assume this is a post-receive within repository + if len(hp.After) == 0 { + return nil, fmt.Errorf("Invalid hook received, commit hash not found.") + } + case hp.ObjectKind == "push": + if hp.Repository == nil { + return nil, fmt.Errorf("Invalid push hook received, attributes not found") + } + case hp.ObjectKind == "tag_push": + if hp.Repository == nil { + return nil, fmt.Errorf("Invalid tag push hook received, attributes not found") + } + case hp.ObjectKind == "issue": + fallthrough + case hp.ObjectKind == "merge_request": + if hp.ObjectAttributes == nil { + return nil, fmt.Errorf("Invalid hook received, attributes not found.") + } + default: + return nil, fmt.Errorf("Invalid hook received, payload format not recognized.") + } + + return &hp, nil +} diff --git a/remote/gitlab3/client/project.go b/remote/gitlab3/client/project.go new file mode 100644 index 000000000..f21f1a48a --- /dev/null +++ b/remote/gitlab3/client/project.go @@ -0,0 +1,161 @@ +package client + +import ( + "encoding/json" + "strconv" + "strings" +) + +const ( + searchUrl = "/projects/search/:query" + projectsUrl = "/projects" + projectUrl = "/projects/:id" + repoUrlRawFile = "/projects/:id/repository/blobs/:sha" + repoUrlRawFileRef = "/projects/:id/repository/files" + commitStatusUrl = "/projects/:id/statuses/:sha" +) + +// Get a list of all projects owned by the authenticated user. +func (g *Client) AllProjects(hide_archives bool) ([]*Project, error) { + var per_page = 100 + var projects []*Project + + for i := 1; true; i++ { + contents, err := g.Projects(i, per_page, hide_archives) + if err != nil { + return projects, err + } + + for _, value := range contents { + projects = append(projects, value) + } + + if len(projects) == 0 { + break + } + + if len(projects)/i < per_page { + break + } + } + + return projects, nil +} + +// Get a list of projects owned by the authenticated user. +func (c *Client) Projects(page int, per_page int, hide_archives bool) ([]*Project, error) { + projectsOptions := QMap{ + "page": strconv.Itoa(page), + "per_page": strconv.Itoa(per_page), + } + + if hide_archives { + projectsOptions["archived"] = "false" + } + + url, opaque := c.ResourceUrl(projectsUrl, nil, projectsOptions) + + var projects []*Project + + contents, err := c.Do("GET", url, opaque, nil) + if err == nil { + err = json.Unmarshal(contents, &projects) + } + + return projects, err +} + +// Get a project by id +func (c *Client) Project(id string) (*Project, error) { + url, opaque := c.ResourceUrl(projectUrl, QMap{":id": id}, nil) + + var project *Project + + contents, err := c.Do("GET", url, opaque, nil) + if err == nil { + err = json.Unmarshal(contents, &project) + } + + return project, err +} + +// Get Raw file content +func (c *Client) RepoRawFile(id, sha, filepath string) ([]byte, error) { + url, opaque := c.ResourceUrl( + repoUrlRawFile, + QMap{ + ":id": id, + ":sha": sha, + }, + QMap{ + "filepath": filepath, + }, + ) + + contents, err := c.Do("GET", url, opaque, nil) + + return contents, err +} + +func (c *Client) RepoRawFileRef(id, ref, filepath string) ([]byte, error) { + url, opaque := c.ResourceUrl( + repoUrlRawFileRef, + QMap{ + ":id": id, + }, + QMap{ + "filepath": filepath, + "ref": ref, + }, + ) + + contents, err := c.Do("GET", url, opaque, nil) + + return contents, err +} + +// +func (c *Client) SetStatus(id, sha, state, desc, ref, link string) error { + url, opaque := c.ResourceUrl( + commitStatusUrl, + QMap{ + ":id": id, + ":sha": sha, + }, + QMap{ + "state": state, + "ref": ref, + "target_url": link, + "description": desc, + "context": "ci/drone", + }, + ) + + _, err := c.Do("POST", url, opaque, nil) + return err +} + +// Get a list of projects by query owned by the authenticated user. +func (c *Client) SearchProjectId(namespace string, name string) (id int, err error) { + + url, opaque := c.ResourceUrl(searchUrl, nil, QMap{ + ":query": strings.ToLower(name), + }) + + var projects []*Project + + contents, err := c.Do("GET", url, opaque, nil) + if err == nil { + err = json.Unmarshal(contents, &projects) + } else { + return id, err + } + + for _, project := range projects { + if project.Namespace.Name == namespace && strings.ToLower(project.Name) == strings.ToLower(name) { + id = project.Id + } + } + + return id, err +} diff --git a/remote/gitlab3/client/types.go b/remote/gitlab3/client/types.go new file mode 100644 index 000000000..0c6424e03 --- /dev/null +++ b/remote/gitlab3/client/types.go @@ -0,0 +1,138 @@ +package client + +type QMap map[string]string + +type User struct { + Id int `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + AvatarUrl string `json:"avatar_url,omitempty"` + Name string `json:"name,omitempty"` +} + +type ProjectAccess struct { + AccessLevel int `json:"access_level,omitempty"` + NotificationLevel int `json:"notification_level,omitempty"` +} + +type GroupAccess struct { + AccessLevel int `json:"access_level,omitempty"` + NotificationLevel int `json:"notification_level,omitempty"` +} + +type Permissions struct { + ProjectAccess *ProjectAccess `json:"project_access,omitempty"` + GroupAccess *GroupAccess `json:"group_access,omitempty"` +} + +type Member struct { + Id int + Username string + Email string + Name string + State string + CreatedAt string `json:"created_at,omitempty"` + // AccessLevel int +} + +type Project struct { + Id int `json:"id,omitempty"` + Owner *Member `json:"owner,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Public bool `json:"public,omitempty"` + Path string `json:"path,omitempty"` + PathWithNamespace string `json:"path_with_namespace,omitempty"` + Namespace *Namespace `json:"namespace,omitempty"` + SshRepoUrl string `json:"ssh_url_to_repo"` + HttpRepoUrl string `json:"http_url_to_repo"` + Url string `json:"web_url"` + AvatarUrl string `json:"avatar_url"` + Permissions *Permissions `json:"permissions,omitempty"` +} + +type Namespace struct { + Id int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` +} + +type Person struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type hProject struct { + Name string `json:"name"` + SshUrl string `json:"ssh_url"` + HttpUrl string `json:"http_url"` + GitSshUrl string `json:"git_ssh_url"` + GitHttpUrl string `json:"git_http_url"` + AvatarUrl string `json:"avatar_url"` + VisibilityLevel int `json:"visibility_level"` + WebUrl string `json:"web_url"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Namespace string `json:"namespace"` +} + +type hRepository struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + GitHttpUrl string `json:"git_http_url,omitempty"` + GitSshUrl string `json:"git_ssh_url,omitempty"` + VisibilityLevel int `json:"visibility_level,omitempty"` +} + +type hCommit struct { + Id string `json:"id,omitempty"` + Message string `json:"message,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + URL string `json:"url,omitempty"` + Author *Person `json:"author,omitempty"` +} + +type HookObjAttr struct { + Id int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + AssigneeId int `json:"assignee_id,omitempty"` + AuthorId int `json:"author_id,omitempty"` + ProjectId int `json:"project_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Position int `json:"position,omitempty"` + BranchName string `json:"branch_name,omitempty"` + Description string `json:"description,omitempty"` + MilestoneId int `json:"milestone_id,omitempty"` + State string `json:"state,omitempty"` + IId int `json:"iid,omitempty"` + TargetBranch string `json:"target_branch,omitempty"` + SourceBranch string `json:"source_branch,omitempty"` + SourceProjectId int `json:"source_project_id,omitempty"` + StCommits string `json:"st_commits,omitempty"` + StDiffs string `json:"st_diffs,omitempty"` + MergeStatus string `json:"merge_status,omitempty"` + TargetProjectId int `json:"target_project_id,omitempty"` + Url string `json:"url,omiyempty"` + Source *hProject `json:"source,omitempty"` + Target *hProject `json:"target,omitempty"` + LastCommit *hCommit `json:"last_commit,omitempty"` +} + +type HookPayload struct { + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` + Ref string `json:"ref,omitempty"` + UserId int `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + ProjectId int `json:"project_id,omitempty"` + Project *hProject `json:"project,omitempty"` + Repository *hRepository `json:"repository,omitempty"` + Commits []hCommit `json:"commits,omitempty"` + TotalCommitsCount int `json:"total_commits_count,omitempty"` + ObjectKind string `json:"object_kind,omitempty"` + ObjectAttributes *HookObjAttr `json:"object_attributes,omitempty"` +} diff --git a/remote/gitlab3/client/user.go b/remote/gitlab3/client/user.go new file mode 100644 index 000000000..bafc7c47d --- /dev/null +++ b/remote/gitlab3/client/user.go @@ -0,0 +1,21 @@ +package client + +import ( + "encoding/json" +) + +const ( + currentUserUrl = "/user" +) + +func (c *Client) CurrentUser() (User, error) { + url, opaque := c.ResourceUrl(currentUserUrl, nil, nil) + var user User + + contents, err := c.Do("GET", url, opaque, nil) + if err == nil { + err = json.Unmarshal(contents, &user) + } + + return user, err +} diff --git a/remote/gitlab3/client/util.go b/remote/gitlab3/client/util.go new file mode 100644 index 000000000..c7418525b --- /dev/null +++ b/remote/gitlab3/client/util.go @@ -0,0 +1,43 @@ +package client + +import ( + "net/url" + "strings" +) + +var encodeMap = map[string]string{ + ".": "%252E", +} + +func encodeParameter(value string) string { + value = url.QueryEscape(value) + + for before, after := range encodeMap { + value = strings.Replace(value, before, after, -1) + } + + return value +} + +// Tag returns current tag for push event hook payload +// This function returns empty string for any other events +func (h *HookPayload) Tag() string { + return strings.TrimPrefix(h.Ref, "refs/tags/") +} + +// Branch returns current branch for push event hook payload +// This function returns empty string for any other events +func (h *HookPayload) Branch() string { + return strings.TrimPrefix(h.Ref, "refs/heads/") +} + +// Head returns the latest changeset for push event hook payload +func (h *HookPayload) Head() hCommit { + c := hCommit{} + for _, cm := range h.Commits { + if h.After == cm.Id { + return cm + } + } + return c +} diff --git a/remote/gitlab3/gitlab.go b/remote/gitlab3/gitlab.go new file mode 100644 index 000000000..5bc62958e --- /dev/null +++ b/remote/gitlab3/gitlab.go @@ -0,0 +1,699 @@ +package gitlab3 + +import ( + "crypto/tls" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote" + "github.com/drone/drone/shared/httputil" + "github.com/drone/drone/shared/oauth2" + + "github.com/drone/drone/remote/gitlab3/client" +) + +const DefaultScope = "api" + +// Opts defines configuration options. +type Opts struct { + URL string // Gogs server url. + Client string // Oauth2 client id. + Secret string // Oauth2 client secret. + Username string // Optional machine account username. + Password string // Optional machine account password. + PrivateMode bool // Gogs is running in private mode. + SkipVerify bool // Skip ssl verification. +} + +// New returns a Remote implementation that integrates with Gitlab, an open +// source Git service. See https://gitlab.com +func New(opts Opts) (remote.Remote, error) { + url, err := url.Parse(opts.URL) + if err != nil { + return nil, err + } + host, _, err := net.SplitHostPort(url.Host) + if err == nil { + url.Host = host + } + return &Gitlab{ + URL: opts.URL, + Client: opts.Client, + Secret: opts.Secret, + Machine: url.Host, + Username: opts.Username, + Password: opts.Password, + PrivateMode: opts.PrivateMode, + SkipVerify: opts.SkipVerify, + }, nil +} + +type Gitlab struct { + URL string + Client string + Secret string + Machine string + Username string + Password string + PrivateMode bool + SkipVerify bool + HideArchives bool + Search bool +} + +func Load(config string) *Gitlab { + url_, err := url.Parse(config) + if err != nil { + panic(err) + } + params := url_.Query() + url_.RawQuery = "" + + gitlab := Gitlab{} + gitlab.URL = url_.String() + gitlab.Client = params.Get("client_id") + gitlab.Secret = params.Get("client_secret") + // gitlab.AllowedOrgs = params["orgs"] + gitlab.SkipVerify, _ = strconv.ParseBool(params.Get("skip_verify")) + gitlab.HideArchives, _ = strconv.ParseBool(params.Get("hide_archives")) + // gitlab.Open, _ = strconv.ParseBool(params.Get("open")) + + // switch params.Get("clone_mode") { + // case "oauth": + // gitlab.CloneMode = "oauth" + // default: + // gitlab.CloneMode = "token" + // } + + // this is a temp workaround + gitlab.Search, _ = strconv.ParseBool(params.Get("search")) + + return &gitlab +} + +// Login authenticates the session and returns the +// remote user details. +func (g *Gitlab) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) { + + var config = &oauth2.Config{ + ClientId: g.Client, + ClientSecret: g.Secret, + Scope: DefaultScope, + AuthURL: fmt.Sprintf("%s/oauth/authorize", g.URL), + TokenURL: fmt.Sprintf("%s/oauth/token", g.URL), + RedirectURL: fmt.Sprintf("%s/authorize", httputil.GetURL(req)), + } + + trans_ := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: g.SkipVerify}, + } + + // 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 + var code = req.FormValue("code") + if len(code) == 0 { + http.Redirect(res, req, config.AuthCodeURL("drone"), http.StatusSeeOther) + return nil, nil + } + + var trans = &oauth2.Transport{Config: config, Transport: trans_} + var token_, err = trans.Exchange(code) + if err != nil { + return nil, fmt.Errorf("Error exchanging token. %s", err) + } + + client := NewClient(g.URL, token_.AccessToken, g.SkipVerify) + login, err := client.CurrentUser() + if err != nil { + return nil, err + } + + // if len(g.AllowedOrgs) != 0 { + // groups, err := client.AllGroups() + // if err != nil { + // return nil, fmt.Errorf("Could not check org membership. %s", err) + // } + // + // var member bool + // for _, group := range groups { + // for _, allowedOrg := range g.AllowedOrgs { + // if group.Path == allowedOrg { + // member = true + // break + // } + // } + // } + // + // if !member { + // return nil, false, fmt.Errorf("User does not belong to correct group. Must belong to %v", g.AllowedOrgs) + // } + // } + + user := &model.User{} + user.Login = login.Username + user.Email = login.Email + user.Token = token_.AccessToken + user.Secret = token_.RefreshToken + + if strings.HasPrefix(login.AvatarUrl, "http") { + user.Avatar = login.AvatarUrl + } else { + user.Avatar = g.URL + "/" + login.AvatarUrl + } + + return user, nil +} + +func (g *Gitlab) Auth(token, secret string) (string, error) { + client := NewClient(g.URL, token, g.SkipVerify) + login, err := client.CurrentUser() + if err != nil { + return "", err + } + return login.Username, nil +} + +func (g *Gitlab) Teams(u *model.User) ([]*model.Team, error) { + client := NewClient(g.URL, u.Token, g.SkipVerify) + groups, err := client.AllGroups() + if err != nil { + return nil, err + } + var teams []*model.Team + for _, group := range groups { + teams = append(teams, &model.Team{ + Login: group.Name, + }) + } + return teams, nil +} + +// Repo fetches the named repository from the remote system. +func (g *Gitlab) Repo(u *model.User, owner, name string) (*model.Repo, error) { + client := NewClient(g.URL, u.Token, g.SkipVerify) + id, err := GetProjectId(g, client, owner, name) + if err != nil { + return nil, err + } + repo_, err := client.Project(id) + if err != nil { + return nil, err + } + + repo := &model.Repo{} + repo.Owner = owner + repo.Name = name + repo.FullName = repo_.PathWithNamespace + repo.Link = repo_.Url + repo.Clone = repo_.HttpRepoUrl + repo.Branch = "master" + + repo.Avatar = repo_.AvatarUrl + + if len(repo.Avatar) != 0 && !strings.HasPrefix(repo.Avatar, "http") { + repo.Avatar = fmt.Sprintf("%s/%s", g.URL, repo.Avatar) + } + + if repo_.DefaultBranch != "" { + repo.Branch = repo_.DefaultBranch + } + + if g.PrivateMode { + repo.IsPrivate = true + } else { + repo.IsPrivate = !repo_.Public + } + + return repo, err +} + +// Repos fetches a list of repos from the remote system. +func (g *Gitlab) Repos(u *model.User) ([]*model.Repo, error) { + client := NewClient(g.URL, u.Token, g.SkipVerify) + + var repos = []*model.Repo{} + + all, err := client.AllProjects(g.HideArchives) + if err != nil { + return repos, err + } + + for _, repo_ := range all { + var parts = strings.Split(repo_.PathWithNamespace, "/") + var owner = parts[0] + var name = parts[1] + + repo := &model.Repo{} + repo.Owner = owner + repo.Name = name + repo.FullName = repo_.PathWithNamespace + repo.Link = repo_.Url + repo.Clone = repo_.HttpRepoUrl + repo.Branch = "master" + + if repo_.DefaultBranch != "" { + repo.Branch = repo_.DefaultBranch + } + + if g.PrivateMode { + repo.IsPrivate = true + } else { + repo.IsPrivate = !repo_.Public + } + + repos = append(repos, repo) + } + + return repos, err +} + +// Perm fetches the named repository from the remote system. +func (g *Gitlab) Perm(u *model.User, owner, name string) (*model.Perm, error) { + + client := NewClient(g.URL, u.Token, g.SkipVerify) + id, err := GetProjectId(g, client, owner, name) + if err != nil { + return nil, err + } + + repo, err := client.Project(id) + if err != nil { + return nil, err + } + + // repo owner is granted full access + if repo.Owner != nil && repo.Owner.Username == u.Login { + return &model.Perm{Push: true, Pull: true, Admin: true}, nil + } + + // check permission for current user + m := &model.Perm{} + m.Admin = IsAdmin(repo) + m.Pull = IsRead(repo) + m.Push = IsWrite(repo) + return m, nil +} + +// File fetches a file from the remote repository and returns in string format. +func (g *Gitlab) File(user *model.User, repo *model.Repo, build *model.Build, f string) ([]byte, error) { + var client = NewClient(g.URL, user.Token, g.SkipVerify) + id, err := GetProjectId(g, client, repo.Owner, repo.Name) + if err != nil { + return nil, err + } + + out, err := client.RepoRawFile(id, build.Commit, f) + if err != nil { + return nil, err + } + return out, err +} + +// FileRef fetches the file from the GitHub repository and returns its contents. +func (g *Gitlab) FileRef(u *model.User, r *model.Repo, ref, f string) ([]byte, error) { + var client = NewClient(g.URL, u.Token, g.SkipVerify) + id, err := GetProjectId(g, client, r.Owner, r.Name) + if err != nil { + return nil, err + } + + out, err := client.RepoRawFileRef(id, ref, f) + if err != nil { + return nil, err + } + return out, err +} + +// NOTE Currently gitlab doesn't support status for commits and events, +// also if we want get MR status in gitlab we need implement a special plugin for gitlab, +// gitlab uses API to fetch build status on client side. But for now we skip this. +func (g *Gitlab) Status(u *model.User, repo *model.Repo, b *model.Build, link string) error { + client := NewClient(g.URL, u.Token, g.SkipVerify) + + status := getStatus(b.Status) + desc := getDesc(b.Status) + + client.SetStatus( + ns(repo.Owner, repo.Name), + b.Commit, + status, + desc, + strings.Replace(b.Ref, "refs/heads/", "", -1), + link, + ) + + // Gitlab statuses it's a new feature, just ignore error + // if gitlab version not support this + return nil +} + +// Netrc returns a .netrc file that can be used to clone +// private repositories from a remote system. +// func (g *Gitlab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { +// url_, err := url.Parse(g.URL) +// if err != nil { +// return nil, err +// } +// netrc := &model.Netrc{} +// netrc.Machine = url_.Host +// +// switch g.CloneMode { +// case "oauth": +// netrc.Login = "oauth2" +// netrc.Password = u.Token +// case "token": +// t := token.New(token.HookToken, r.FullName) +// netrc.Login = "drone-ci-token" +// netrc.Password, err = t.Sign(r.Hash) +// } +// return netrc, err +// } + +// Netrc returns a netrc file capable of authenticating Gitlab requests and +// cloning Gitlab repositories. The netrc will use the global machine account +// when configured. +func (g *Gitlab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { + if g.Password != "" { + return &model.Netrc{ + Login: g.Username, + Password: g.Password, + Machine: g.Machine, + }, nil + } + return &model.Netrc{ + Login: "oauth2", + Password: u.Token, + Machine: g.Machine, + }, nil +} + +// Activate activates a repository by adding a Post-commit hook and +// a Public Deploy key, if applicable. +func (g *Gitlab) Activate(user *model.User, repo *model.Repo, link string) error { + var client = NewClient(g.URL, user.Token, g.SkipVerify) + id, err := GetProjectId(g, client, repo.Owner, repo.Name) + if err != nil { + return err + } + + uri, err := url.Parse(link) + if err != nil { + return err + } + + droneUrl := fmt.Sprintf("%s://%s", uri.Scheme, uri.Host) + droneToken := uri.Query().Get("access_token") + ssl_verify := strconv.FormatBool(!g.SkipVerify) + + return client.AddDroneService(id, map[string]string{ + "token": droneToken, + "drone_url": droneUrl, + "enable_ssl_verification": ssl_verify, + }) +} + +// Deactivate removes a repository by removing all the post-commit hooks +// which are equal to link and removing the SSH deploy key. +func (g *Gitlab) Deactivate(user *model.User, repo *model.Repo, link string) error { + var client = NewClient(g.URL, user.Token, g.SkipVerify) + id, err := GetProjectId(g, client, repo.Owner, repo.Name) + if err != nil { + return err + } + + return client.DeleteDroneService(id) +} + +// ParseHook parses the post-commit hook from the Request body +// and returns the required data in a standard format. +func (g *Gitlab) Hook(req *http.Request) (*model.Repo, *model.Build, error) { + defer req.Body.Close() + var payload, _ = ioutil.ReadAll(req.Body) + var parsed, err = client.ParseHook(payload) + if err != nil { + return nil, nil, err + } + + switch parsed.ObjectKind { + case "merge_request": + return mergeRequest(parsed, req) + case "tag_push", "push": + return push(parsed, req) + default: + return nil, nil, nil + } +} + +func mergeRequest(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Build, error) { + + repo := &model.Repo{} + + obj := parsed.ObjectAttributes + if obj == nil { + return nil, nil, fmt.Errorf("object_attributes key expected in merge request hook") + } + + target := obj.Target + source := obj.Source + + if target == nil && source == nil { + return nil, nil, fmt.Errorf("target and source keys expected in merge request hook") + } else if target == nil { + return nil, nil, fmt.Errorf("target key expected in merge request hook") + } else if source == nil { + return nil, nil, fmt.Errorf("source key exptected in merge request hook") + } + + if target.PathWithNamespace != "" { + var err error + if repo.Owner, repo.Name, err = ExtractFromPath(target.PathWithNamespace); err != nil { + return nil, nil, err + } + repo.FullName = target.PathWithNamespace + } else { + repo.Owner = req.FormValue("owner") + repo.Name = req.FormValue("name") + repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name) + } + + repo.Link = target.WebUrl + + if target.GitHttpUrl != "" { + repo.Clone = target.GitHttpUrl + } else { + repo.Clone = target.HttpUrl + } + + if target.DefaultBranch != "" { + repo.Branch = target.DefaultBranch + } else { + repo.Branch = "master" + } + + if target.AvatarUrl != "" { + repo.Avatar = target.AvatarUrl + } + + build := &model.Build{} + build.Event = "pull_request" + + lastCommit := obj.LastCommit + if lastCommit == nil { + return nil, nil, fmt.Errorf("last_commit key expected in merge request hook") + } + + build.Message = lastCommit.Message + build.Commit = lastCommit.Id + //build.Remote = parsed.ObjectAttributes.Source.HttpUrl + + build.Ref = fmt.Sprintf("refs/merge-requests/%d/head", obj.IId) + + build.Branch = obj.SourceBranch + + author := lastCommit.Author + if author == nil { + return nil, nil, fmt.Errorf("author key expected in merge request hook") + } + + build.Author = author.Name + build.Email = author.Email + + if len(build.Email) != 0 { + build.Avatar = GetUserAvatar(build.Email) + } + + build.Title = obj.Title + build.Link = obj.Url + + return repo, build, nil +} + +func push(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Build, error) { + repo := &model.Repo{} + + // Since gitlab 8.5, used project instead repository key + // see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md#web-hooks + if project := parsed.Project; project != nil { + var err error + if repo.Owner, repo.Name, err = ExtractFromPath(project.PathWithNamespace); err != nil { + return nil, nil, err + } + + repo.Avatar = project.AvatarUrl + repo.Link = project.WebUrl + repo.Clone = project.GitHttpUrl + repo.FullName = project.PathWithNamespace + repo.Branch = project.DefaultBranch + + switch project.VisibilityLevel { + case 0: + repo.IsPrivate = true + case 10: + repo.IsPrivate = true + case 20: + repo.IsPrivate = false + } + } else if repository := parsed.Repository; repository != nil { + repo.Owner = req.FormValue("owner") + repo.Name = req.FormValue("name") + repo.Link = repository.URL + repo.Clone = repository.GitHttpUrl + repo.Branch = "master" + repo.FullName = fmt.Sprintf("%s/%s", req.FormValue("owner"), req.FormValue("name")) + + switch repository.VisibilityLevel { + case 0: + repo.IsPrivate = true + case 10: + repo.IsPrivate = true + case 20: + repo.IsPrivate = false + } + } else { + return nil, nil, fmt.Errorf("No project/repository keys given") + } + + build := &model.Build{} + build.Event = model.EventPush + build.Commit = parsed.After + build.Branch = parsed.Branch() + build.Ref = parsed.Ref + // hook.Commit.Remote = cloneUrl + + var head = parsed.Head() + build.Message = head.Message + // build.Timestamp = head.Timestamp + + // extracts the commit author (ideally email) + // from the post-commit hook + switch { + case head.Author != nil: + build.Email = head.Author.Email + build.Author = parsed.UserName + if len(build.Email) != 0 { + build.Avatar = GetUserAvatar(build.Email) + } + case head.Author == nil: + build.Author = parsed.UserName + } + + if strings.HasPrefix(build.Ref, "refs/tags/") { + build.Event = model.EventTag + } + + return repo, build, nil +} + +// ¯\_(ツ)_/¯ +func (g *Gitlab) Oauth2Transport(r *http.Request) *oauth2.Transport { + return &oauth2.Transport{ + Config: &oauth2.Config{ + ClientId: g.Client, + ClientSecret: g.Secret, + Scope: DefaultScope, + AuthURL: fmt.Sprintf("%s/oauth/authorize", g.URL), + TokenURL: fmt.Sprintf("%s/oauth/token", g.URL), + RedirectURL: fmt.Sprintf("%s/authorize", httputil.GetURL(r)), + //settings.Server.Scheme, settings.Server.Hostname), + }, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: g.SkipVerify}, + }, + } +} + +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusSuccess = "success" + StatusFailure = "failed" + StatusCanceled = "canceled" +) + +const ( + DescPending = "the build is pending" + DescRunning = "the buils is running" + DescSuccess = "the build was successful" + DescFailure = "the build failed" + DescCanceled = "the build canceled" + DescBlocked = "the build is pending approval" + DescDeclined = "the build was rejected" +) + +// getStatus is a helper functin that converts a Drone +// status to a GitHub status. +func getStatus(status string) string { + switch status { + case model.StatusPending, model.StatusBlocked: + return StatusPending + case model.StatusRunning: + return StatusRunning + case model.StatusSuccess: + return StatusSuccess + case model.StatusFailure, model.StatusError: + return StatusFailure + case model.StatusKilled: + return StatusCanceled + default: + return StatusFailure + } +} + +// getDesc is a helper function that generates a description +// message for the build based on the status. +func getDesc(status string) string { + switch status { + case model.StatusPending: + return DescPending + case model.StatusRunning: + return DescRunning + case model.StatusSuccess: + return DescSuccess + case model.StatusFailure, model.StatusError: + return DescFailure + case model.StatusKilled: + return DescCanceled + case model.StatusBlocked: + return DescBlocked + case model.StatusDeclined: + return DescDeclined + default: + return DescFailure + } +} diff --git a/remote/gitlab3/gitlab_test.go b/remote/gitlab3/gitlab_test.go new file mode 100644 index 000000000..7d69493c0 --- /dev/null +++ b/remote/gitlab3/gitlab_test.go @@ -0,0 +1,247 @@ +package gitlab3 + +import ( + "bytes" + "net/http" + "testing" + + "github.com/drone/drone/model" + "github.com/drone/drone/remote/gitlab3/testdata" + "github.com/franela/goblin" +) + +func Test_Gitlab(t *testing.T) { + // setup a dummy github server + var server = testdata.NewServer() + defer server.Close() + + env := server.URL + "?client_id=test&client_secret=test" + + gitlab := Load(env) + + var user = model.User{ + Login: "test_user", + Token: "e3b0c44298fc1c149afbf4c8996fb", + } + + var repo = model.Repo{ + Name: "diaspora-client", + Owner: "diaspora", + } + + g := goblin.Goblin(t) + g.Describe("Gitlab Plugin", func() { + // Test projects method + g.Describe("AllProjects", func() { + g.It("Should return only non-archived projects is hidden", func() { + gitlab.HideArchives = true + _projects, err := gitlab.Repos(&user) + + g.Assert(err == nil).IsTrue() + g.Assert(len(_projects)).Equal(1) + }) + + g.It("Should return all the projects", func() { + gitlab.HideArchives = false + _projects, err := gitlab.Repos(&user) + + g.Assert(err == nil).IsTrue() + g.Assert(len(_projects)).Equal(2) + }) + }) + + // Test repository method + g.Describe("Repo", func() { + g.It("Should return valid repo", func() { + _repo, err := gitlab.Repo(&user, "diaspora", "diaspora-client") + + g.Assert(err == nil).IsTrue() + g.Assert(_repo.Name).Equal("diaspora-client") + g.Assert(_repo.Owner).Equal("diaspora") + g.Assert(_repo.IsPrivate).Equal(true) + }) + + g.It("Should return error, when repo not exist", func() { + _, err := gitlab.Repo(&user, "not-existed", "not-existed") + + g.Assert(err != nil).IsTrue() + }) + }) + + // Test permissions method + g.Describe("Perm", func() { + g.It("Should return repo permissions", func() { + perm, err := gitlab.Perm(&user, "diaspora", "diaspora-client") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Admin).Equal(true) + g.Assert(perm.Pull).Equal(true) + g.Assert(perm.Push).Equal(true) + }) + g.It("Should return repo permissions when user is admin", func() { + perm, err := gitlab.Perm(&user, "brightbox", "puppet") + g.Assert(err == nil).IsTrue() + g.Assert(perm.Admin).Equal(true) + g.Assert(perm.Pull).Equal(true) + g.Assert(perm.Push).Equal(true) + }) + g.It("Should return error, when repo is not exist", func() { + _, err := gitlab.Perm(&user, "not-existed", "not-existed") + + g.Assert(err != nil).IsTrue() + }) + }) + + // Test activate method + g.Describe("Activate", func() { + g.It("Should be success", func() { + err := gitlab.Activate(&user, &repo, "http://example.com/api/hook/test/test?access_token=token") + + g.Assert(err == nil).IsTrue() + }) + + g.It("Should be failed, when token not given", func() { + err := gitlab.Activate(&user, &repo, "http://example.com/api/hook/test/test") + + g.Assert(err != nil).IsTrue() + }) + }) + + // Test deactivate method + g.Describe("Deactivate", func() { + g.It("Should be success", func() { + err := gitlab.Deactivate(&user, &repo, "http://example.com/api/hook/test/test?access_token=token") + + g.Assert(err == nil).IsTrue() + }) + }) + + // Test login method + // g.Describe("Login", func() { + // g.It("Should return user", func() { + // user, err := gitlab.Login("valid_token", "") + + // g.Assert(err == nil).IsTrue() + // g.Assert(user == nil).IsFalse() + // }) + + // g.It("Should return error, when token is invalid", func() { + // _, err := gitlab.Login("invalid_token", "") + + // g.Assert(err != nil).IsTrue() + // }) + // }) + + // Test hook method + g.Describe("Hook", func() { + g.Describe("Push hook", func() { + g.It("Should parse actual push hoook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.PushHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal("mike") + g.Assert(repo.Name).Equal("diaspora") + g.Assert(repo.Avatar).Equal("http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg") + g.Assert(repo.Branch).Equal("develop") + g.Assert(build.Ref).Equal("refs/heads/master") + + }) + + g.It("Should parse legacy push hoook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.LegacyPushHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal("diaspora") + g.Assert(repo.Name).Equal("diaspora-client") + g.Assert(repo.Avatar).Equal("") + g.Assert(repo.Branch).Equal("master") + g.Assert(build.Ref).Equal("refs/heads/master") + + }) + }) + + g.Describe("Tag push hook", func() { + g.It("Should parse tag push hook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.TagHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal("jsmith") + g.Assert(repo.Name).Equal("example") + g.Assert(repo.Avatar).Equal("http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg") + g.Assert(repo.Branch).Equal("develop") + g.Assert(build.Ref).Equal("refs/tags/v1.0.0") + + }) + + g.It("Should parse legacy tag push hook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.LegacyTagHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal("diaspora") + g.Assert(repo.Name).Equal("diaspora-client") + g.Assert(build.Ref).Equal("refs/tags/v1.0.0") + + }) + }) + + g.Describe("Merge request hook", func() { + g.It("Should parse merge request hook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.MergeRequestHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Avatar).Equal("http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg") + g.Assert(repo.Branch).Equal("develop") + g.Assert(repo.Owner).Equal("awesome_space") + g.Assert(repo.Name).Equal("awesome_project") + + g.Assert(build.Title).Equal("MS-Viewport") + }) + + g.It("Should parse legacy merge request hook", func() { + req, _ := http.NewRequest( + "POST", + "http://example.com/api/hook?owner=diaspora&name=diaspora-client", + bytes.NewReader(testdata.LegacyMergeRequestHook), + ) + + repo, build, err := gitlab.Hook(req) + + g.Assert(err == nil).IsTrue() + g.Assert(repo.Owner).Equal("diaspora") + g.Assert(repo.Name).Equal("diaspora-client") + + g.Assert(build.Title).Equal("MS-Viewport") + }) + }) + }) + }) +} diff --git a/remote/gitlab3/helper.go b/remote/gitlab3/helper.go new file mode 100644 index 000000000..93f66ef18 --- /dev/null +++ b/remote/gitlab3/helper.go @@ -0,0 +1,125 @@ +package gitlab3 + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/drone/drone/remote/gitlab3/client" +) + +const ( + gravatarBase = "https://www.gravatar.com/avatar" +) + +// NewClient is a helper function that returns a new GitHub +// client using the provided OAuth token. +func NewClient(url, accessToken string, skipVerify bool) *client.Client { + client := client.New(url, "/api/v3", accessToken, skipVerify) + return client +} + +// IsRead is a helper function that returns true if the +// user has Read-only access to the repository. +func IsRead(proj *client.Project) bool { + var user = proj.Permissions.ProjectAccess + var group = proj.Permissions.GroupAccess + + switch { + case proj.Public: + return true + case user != nil && user.AccessLevel >= 20: + return true + case group != nil && group.AccessLevel >= 20: + return true + default: + return false + } +} + +// IsWrite is a helper function that returns true if the +// user has Read-Write access to the repository. +func IsWrite(proj *client.Project) bool { + var user = proj.Permissions.ProjectAccess + var group = proj.Permissions.GroupAccess + + switch { + case user != nil && user.AccessLevel >= 30: + return true + case group != nil && group.AccessLevel >= 30: + return true + default: + return false + } +} + +// IsAdmin is a helper function that returns true if the +// user has Admin access to the repository. +func IsAdmin(proj *client.Project) bool { + var user = proj.Permissions.ProjectAccess + var group = proj.Permissions.GroupAccess + + switch { + case user != nil && user.AccessLevel >= 40: + return true + case group != nil && group.AccessLevel >= 40: + return true + default: + return false + } +} + +// GetKeyTitle is a helper function that generates a title for the +// RSA public key based on the username and domain name. +func GetKeyTitle(rawurl string) (string, error) { + var uri, err = url.Parse(rawurl) + if err != nil { + return "", err + } + return fmt.Sprintf("drone@%s", uri.Host), nil +} + +func ns(owner, name string) string { + return fmt.Sprintf("%s%%2F%s", owner, name) +} + +func GetUserAvatar(email string) string { + hasher := md5.New() + hasher.Write([]byte(email)) + + return fmt.Sprintf( + "%s/%v.jpg?s=%s", + gravatarBase, + hex.EncodeToString(hasher.Sum(nil)), + "128", + ) +} + +func ExtractFromPath(str string) (string, string, error) { + s := strings.Split(str, "/") + if len(s) < 2 { + return "", "", fmt.Errorf("Minimum match not found") + } + return s[0], s[1], nil +} + +func GetUserEmail(c *client.Client, defaultURL string) (*client.Client, error) { + return c, nil +} + +func GetProjectId(r *Gitlab, c *client.Client, owner, name string) (projectId string, err error) { + if r.Search { + _projectId, err := c.SearchProjectId(owner, name) + if err != nil || _projectId == 0 { + return "", err + } + projectId := strconv.Itoa(_projectId) + return projectId, nil + } else { + projectId := ns(owner, name) + return projectId, nil + } +} diff --git a/remote/gitlab3/testdata/hooks.go b/remote/gitlab3/testdata/hooks.go new file mode 100644 index 000000000..d7e01450b --- /dev/null +++ b/remote/gitlab3/testdata/hooks.go @@ -0,0 +1,334 @@ +package testdata + +var TagHook = []byte(` +{ + "object_kind": "tag_push", + "ref": "refs/tags/v1.0.0", + "before": "0000000000000000000000000000000000000000", + "after": "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7", + "user_id": 1, + "user_name": "John Smith", + "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s=80", + "project_id": 1, + "project":{ + "name":"Example", + "description":"", + "web_url":"http://example.com/jsmith/example", + "avatar_url":"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", + "git_ssh_url":"git@example.com:jsmith/example.git", + "git_http_url":"http://example.com/jsmith/example.git", + "namespace":"Jsmith", + "visibility_level":0, + "path_with_namespace":"jsmith/example", + "default_branch":"develop", + "homepage":"http://example.com/jsmith/example", + "url":"git@example.com:jsmith/example.git", + "ssh_url":"git@example.com:jsmith/example.git", + "http_url":"http://example.com/jsmith/example.git" + }, + "repository":{ + "name": "jsmith", + "url": "ssh://git@example.com/jsmith/example.git", + "description": "", + "homepage": "http://example.com/jsmith/example", + "git_http_url":"http://example.com/jsmith/example.git", + "git_ssh_url":"git@example.com:jsmith/example.git", + "visibility_level":0 + }, + "commits": [], + "total_commits_count": 0 +} +`) + +var LegacyTagHook = []byte(` +{ + "object_kind": "tag_push", + "ref": "refs/tags/v1.0.0", + "before": "0000000000000000000000000000000000000000", + "after": "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7", + "user_id": 1, + "user_name": "John Smith", + "project_id": 1, + "repository": { + "name": "jsmith", + "url": "ssh://git@example.com/jsmith/example.git", + "description": "", + "homepage": "http://example.com/jsmith/example", + "git_http_url":"http://example.com/jsmith/example.git", + "git_ssh_url":"git@example.com:jsmith/example.git", + "visibility_level":0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + } + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + } + ], + "total_commits_count": 4 +} +`) + +var MergeRequestHook = []byte(` +{ + "object_kind": "merge_request", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "object_attributes": { + "id": 99, + "target_branch": "master", + "source_branch": "ms-viewport", + "source_project_id": 14, + "author_id": 51, + "assignee_id": 6, + "title": "MS-Viewport", + "created_at": "2013-12-03T17:23:34Z", + "updated_at": "2013-12-03T17:23:34Z", + "st_commits": null, + "st_diffs": null, + "milestone_id": null, + "state": "opened", + "merge_status": "unchecked", + "target_project_id": 14, + "iid": 1, + "description": "", + "source":{ + "name":"Awesome Project", + "description":"Aut reprehenderit ut est.", + "web_url":"http://example.com/awesome_space/awesome_project", + "avatar_url":"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", + "git_ssh_url":"git@example.com:awesome_space/awesome_project.git", + "git_http_url":"http://example.com/awesome_space/awesome_project.git", + "namespace":"Awesome Space", + "visibility_level":20, + "path_with_namespace":"awesome_space/awesome_project", + "default_branch":"master", + "homepage":"http://example.com/awesome_space/awesome_project", + "url":"http://example.com/awesome_space/awesome_project.git", + "ssh_url":"git@example.com:awesome_space/awesome_project.git", + "http_url":"http://example.com/awesome_space/awesome_project.git" + }, + "target": { + "name":"Awesome Project", + "description":"Aut reprehenderit ut est.", + "web_url":"http://example.com/awesome_space/awesome_project", + "avatar_url":"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", + "git_ssh_url":"git@example.com:awesome_space/awesome_project.git", + "git_http_url":"http://example.com/awesome_space/awesome_project.git", + "namespace":"Awesome Space", + "visibility_level":20, + "path_with_namespace":"awesome_space/awesome_project", + "default_branch":"develop", + "homepage":"http://example.com/awesome_space/awesome_project", + "url":"http://example.com/awesome_space/awesome_project.git", + "ssh_url":"git@example.com:awesome_space/awesome_project.git", + "http_url":"http://example.com/awesome_space/awesome_project.git" + }, + "last_commit": { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + }, + "work_in_progress": false, + "url": "http://example.com/diaspora/merge_requests/1", + "action": "open", + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + } + } +} +`) + +var LegacyMergeRequestHook = []byte(` +{ + "object_kind": "merge_request", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "object_attributes": { + "id": 99, + "target_branch": "master", + "source_branch": "ms-viewport", + "source_project_id": 14, + "author_id": 51, + "assignee_id": 6, + "title": "MS-Viewport", + "created_at": "2013-12-03T17:23:34Z", + "updated_at": "2013-12-03T17:23:34Z", + "st_commits": null, + "st_diffs": null, + "milestone_id": null, + "state": "opened", + "merge_status": "unchecked", + "target_project_id": 14, + "iid": 1, + "description": "", + "source": { + "name": "awesome_project", + "ssh_url": "ssh://git@example.com/awesome_space/awesome_project.git", + "http_url": "http://example.com/awesome_space/awesome_project.git", + "visibility_level": 20, + "namespace": "awesome_space" + }, + "target": { + "name": "awesome_project", + "ssh_url": "ssh://git@example.com/awesome_space/awesome_project.git", + "http_url": "http://example.com/awesome_space/awesome_project.git", + "visibility_level": 20, + "namespace": "awesome_space" + }, + "last_commit": { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + }, + "url": "http://example.com/diaspora/merge_requests/1", + "action": "open" + } +} +`) + +var PushHook = []byte(` +{ + "object_kind": "push", + "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", + "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "ref": "refs/heads/master", + "user_id": 4, + "user_name": "John Smith", + "user_email": "john@example.com", + "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", + "project_id": 15, + "project":{ + "name":"Diaspora", + "description":"", + "web_url":"http://example.com/mike/diaspora", + "avatar_url":"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", + "git_ssh_url":"git@example.com:mike/diaspora.git", + "git_http_url":"http://example.com/mike/diaspora.git", + "namespace":"Mike", + "visibility_level":0, + "path_with_namespace":"mike/diaspora", + "default_branch":"develop", + "homepage":"http://example.com/mike/diaspora", + "url":"git@example.com:mike/diasporadiaspora.git", + "ssh_url":"git@example.com:mike/diaspora.git", + "http_url":"http://example.com/mike/diaspora.git" + }, + "repository":{ + "name": "Diaspora", + "url": "git@example.com:mike/diasporadiaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora", + "git_http_url":"http://example.com/mike/diaspora.git", + "git_ssh_url":"git@example.com:mike/diaspora.git", + "visibility_level":0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + }, + "added": ["CHANGELOG"], + "modified": ["app/controller/application.rb"], + "removed": [] + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + }, + "added": ["CHANGELOG"], + "modified": ["app/controller/application.rb"], + "removed": [] + } + ], + "total_commits_count": 4 +} +`) + +var LegacyPushHook = []byte(` +{ + "object_kind": "push", + "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", + "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "ref": "refs/heads/master", + "user_id": 4, + "user_name": "John Smith", + "user_email": "john@example.com", + "project_id": 15, + "repository": { + "name": "Diaspora", + "url": "git@example.com:mike/diasporadiaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora", + "git_http_url":"http://example.com/mike/diaspora.git", + "git_ssh_url":"git@example.com:mike/diaspora.git", + "visibility_level":0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + } + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + } + ], + "total_commits_count": 4 +} +`) diff --git a/remote/gitlab3/testdata/oauth.go b/remote/gitlab3/testdata/oauth.go new file mode 100644 index 000000000..95119d70a --- /dev/null +++ b/remote/gitlab3/testdata/oauth.go @@ -0,0 +1,3 @@ +package testdata + +var accessTokenPayload = []byte(`access_token=sekret&scope=api&token_type=bearer`) diff --git a/remote/gitlab3/testdata/projects.go b/remote/gitlab3/testdata/projects.go new file mode 100644 index 000000000..1dd069e3f --- /dev/null +++ b/remote/gitlab3/testdata/projects.go @@ -0,0 +1,212 @@ +package testdata + +// sample repository list +var allProjectsPayload = []byte(` +[ + { + "id": 4, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", + "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", + "web_url": "http://example.com/diaspora/diaspora-client", + "owner": { + "id": 3, + "name": "Diaspora", + "username": "some_user", + "created_at": "2013-09-30T13: 46: 02Z" + }, + "name": "Diaspora Client", + "name_with_namespace": "Diaspora / Diaspora Client", + "path": "diaspora-client", + "path_with_namespace": "diaspora/diaspora-client", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "created_at": "2013-09-30T13: 46: 02Z", + "last_activity_at": "2013-09-30T13: 46: 02Z", + "namespace": { + "created_at": "2013-09-30T13: 46: 02Z", + "description": "", + "id": 3, + "name": "Diaspora", + "owner_id": 1, + "path": "diaspora", + "updated_at": "2013-09-30T13: 46: 02Z" + }, + "archived": false + }, + { + "id": 6, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", + "http_url_to_repo": "http://example.com/brightbox/puppet.git", + "web_url": "http://example.com/brightbox/puppet", + "owner": { + "id": 1, + "name": "Brightbox", + "username": "test_user", + "created_at": "2013-09-30T13:46:02Z" + }, + "name": "Puppet", + "name_with_namespace": "Brightbox / Puppet", + "path": "puppet", + "path_with_namespace": "brightbox/puppet", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "created_at": "2013-09-30T13:46:02Z", + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "created_at": "2013-09-30T13:46:02Z", + "description": "", + "id": 4, + "name": "Brightbox", + "owner_id": 1, + "path": "brightbox", + "updated_at": "2013-09-30T13:46:02Z" + }, + "archived": true + } +] +`) + +var notArchivedProjectsPayload = []byte(` +[ + { + "id": 4, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", + "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", + "web_url": "http://example.com/diaspora/diaspora-client", + "owner": { + "id": 3, + "name": "Diaspora", + "username": "some_user", + "created_at": "2013-09-30T13: 46: 02Z" + }, + "name": "Diaspora Client", + "name_with_namespace": "Diaspora / Diaspora Client", + "path": "diaspora-client", + "path_with_namespace": "diaspora/diaspora-client", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "created_at": "2013-09-30T13: 46: 02Z", + "last_activity_at": "2013-09-30T13: 46: 02Z", + "namespace": { + "created_at": "2013-09-30T13: 46: 02Z", + "description": "", + "id": 3, + "name": "Diaspora", + "owner_id": 1, + "path": "diaspora", + "updated_at": "2013-09-30T13: 46: 02Z" + }, + "archived": false + } +] +`) + +var project4Paylod = []byte(` +{ + "id": 4, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", + "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", + "web_url": "http://example.com/diaspora/diaspora-client", + "owner": { + "id": 3, + "name": "Diaspora", + "username": "some_user", + "created_at": "2013-09-30T13: 46: 02Z" + }, + "name": "Diaspora Client", + "name_with_namespace": "Diaspora / Diaspora Client", + "path": "diaspora-client", + "path_with_namespace": "diaspora/diaspora-client", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "created_at": "2013-09-30T13: 46: 02Z", + "last_activity_at": "2013-09-30T13: 46: 02Z", + "namespace": { + "created_at": "2013-09-30T13: 46: 02Z", + "description": "", + "id": 3, + "name": "Diaspora", + "owner_id": 1, + "path": "diaspora", + "updated_at": "2013-09-30T13: 46: 02Z" + }, + "archived": false, + "permissions": { + "project_access": { + "access_level": 10, + "notification_level": 3 + }, + "group_access": { + "access_level": 50, + "notification_level": 3 + } + } +} +`) + +var project6Paylod = []byte(` +{ + "id": 6, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", + "http_url_to_repo": "http://example.com/brightbox/puppet.git", + "web_url": "http://example.com/brightbox/puppet", + "owner": { + "id": 1, + "name": "Brightbox", + "username": "test_user", + "created_at": "2013-09-30T13:46:02Z" + }, + "name": "Puppet", + "name_with_namespace": "Brightbox / Puppet", + "path": "puppet", + "path_with_namespace": "brightbox/puppet", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "created_at": "2013-09-30T13:46:02Z", + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "created_at": "2013-09-30T13:46:02Z", + "description": "", + "id": 4, + "name": "Brightbox", + "owner_id": 1, + "path": "brightbox", + "updated_at": "2013-09-30T13:46:02Z" + }, + "archived": false, + "permissions": { + "project_access": null, + "group_access": null + } +} +`) diff --git a/remote/gitlab3/testdata/testdata.go b/remote/gitlab3/testdata/testdata.go new file mode 100644 index 000000000..2fa2322b6 --- /dev/null +++ b/remote/gitlab3/testdata/testdata.go @@ -0,0 +1,60 @@ +package testdata + +import ( + "net/http" + "net/http/httptest" +) + +// setup a mock server for testing purposes. +func NewServer() *httptest.Server { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + // handle requests and serve mock data + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + //println(r.URL.Path + " " + r.Method) + // evaluate the path to serve a dummy data file + switch r.URL.Path { + case "/api/v3/projects": + if r.URL.Query().Get("archived") == "false" { + w.Write(notArchivedProjectsPayload) + } else { + w.Write(allProjectsPayload) + } + + return + case "/api/v3/projects/diaspora/diaspora-client": + w.Write(project4Paylod) + return + case "/api/v3/projects/brightbox/puppet": + w.Write(project6Paylod) + return + case "/api/v3/projects/diaspora/diaspora-client/services/drone-ci": + switch r.Method { + case "PUT": + if r.FormValue("token") == "" { + w.WriteHeader(404) + } else { + w.WriteHeader(201) + } + case "DELETE": + w.WriteHeader(201) + } + + return + case "/oauth/token": + w.Write(accessTokenPayload) + return + case "/api/v3/user": + w.Write(currentUserPayload) + return + } + + // else return a 404 + http.NotFound(w, r) + }) + + // return the server to the client which + // will need to know the base URL path + return server +} diff --git a/remote/gitlab3/testdata/users.go b/remote/gitlab3/testdata/users.go new file mode 100644 index 000000000..e7d563697 --- /dev/null +++ b/remote/gitlab3/testdata/users.go @@ -0,0 +1,24 @@ +package testdata + +var currentUserPayload = []byte(` +{ + "id": 1, + "username": "john_smith", + "email": "john@example.com", + "name": "John Smith", + "private_token": "dd34asd13as", + "state": "active", + "created_at": "2012-05-23T08:00:58Z", + "bio": null, + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "", + "theme_id": 1, + "color_scheme_id": 2, + "is_admin": false, + "can_create_group": true, + "can_create_project": true, + "projects_limit": 100 +} +`) diff --git a/version/version.go b/version/version.go index 28eeb76a2..1cf538f55 100644 --- a/version/version.go +++ b/version/version.go @@ -10,7 +10,7 @@ var ( // VersionPatch is for backwards-compatible bug fixes. VersionPatch int64 = 0 // VersionPre indicates prerelease. - VersionPre string = "rc.5" + VersionPre string // VersionDev indicates development branch. Releases will be empty string. VersionDev string )