Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
6769eed65d
20 changed files with 722 additions and 146 deletions
8
ISSUE_TEMPLATE.md
Normal file
8
ISSUE_TEMPLATE.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
Thank you for taking the time to use Drone and file an issue or feature request. Before filing an issue please ensure the following boxes are checked, if applicable:
|
||||
|
||||
- [ ] I have searched for existing issues
|
||||
- [ ] I have discussed the issue with the community at https://gitter.im/drone/drone
|
||||
- [ ] I have provided a sample `.drone.yml` file to help the team reproduce
|
||||
- [ ] I have provided details from the build logs
|
||||
- [ ] I have provided details from the server logs by running `docker logs drone`
|
||||
- [ ] I am not using the issue tracker to ask why my build failed
|
|
@ -33,6 +33,7 @@ This section lists all connection options used in the connection string format.
|
|||
* `open=false` allows users to self-register. Defaults to false for security reasons.
|
||||
* `orgs=drone&orgs=docker` restricts access to these GitLab organizations. **Optional**
|
||||
* `skip_verify=false` skip ca verification if self-signed certificate. Defaults to false for security reasons.
|
||||
* `hide_archives=false` hide projects archived in GitLab from the listing.
|
||||
* `clone_mode=token` a strategy for clone authorization, by default use repo token, but can be changed to `oauth` ( This is not secure, because your user token, with full access to your gitlab account will be written to .netrc, and it can be read by all who have access to project builds )
|
||||
|
||||
## Gitlab registration
|
||||
|
|
|
@ -329,6 +329,11 @@ func (e *engine) runJob(c context.Context, r *Task, updater *updater, client doc
|
|||
info, builderr := docker.Wait(client, name)
|
||||
|
||||
switch {
|
||||
case info.State.Running:
|
||||
// A build unblocked before actually being completed.
|
||||
log.Errorf("incomplete build: %s", name)
|
||||
r.Job.ExitCode = 1
|
||||
r.Job.Status = model.StatusError
|
||||
case info.State.ExitCode == 128:
|
||||
r.Job.ExitCode = info.State.ExitCode
|
||||
r.Job.Status = model.StatusKilled
|
||||
|
|
53
remote/gitlab/client/groups.go
Normal file
53
remote/gitlab/client/groups.go
Normal file
|
@ -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
|
||||
}
|
|
@ -15,12 +15,12 @@ const (
|
|||
)
|
||||
|
||||
// Get a list of all projects owned by the authenticated user.
|
||||
func (g *Client) AllProjects() ([]*Project, error) {
|
||||
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)
|
||||
contents, err := g.Projects(i, per_page, hide_archives)
|
||||
if err != nil {
|
||||
return projects, err
|
||||
}
|
||||
|
@ -42,12 +42,17 @@ func (g *Client) AllProjects() ([]*Project, error) {
|
|||
}
|
||||
|
||||
// Get a list of projects owned by the authenticated user.
|
||||
func (c *Client) Projects(page int, per_page int) ([]*Project, error) {
|
||||
|
||||
url, opaque := c.ResourceUrl(projectsUrl, nil, QMap{
|
||||
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
|
||||
|
||||
|
|
|
@ -48,12 +48,14 @@ type Project struct {
|
|||
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 {
|
||||
|
@ -65,8 +67,13 @@ 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"`
|
||||
}
|
||||
|
||||
|
@ -122,6 +129,7 @@ type HookPayload struct {
|
|||
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"`
|
||||
|
|
|
@ -5,8 +5,18 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
var encodeMap = map[string]string{
|
||||
".": "%252E",
|
||||
}
|
||||
|
||||
func encodeParameter(value string) string {
|
||||
return strings.Replace(url.QueryEscape(value), "/", "%2F", 0)
|
||||
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
|
||||
|
|
|
@ -31,6 +31,7 @@ type Gitlab struct {
|
|||
Open bool
|
||||
PrivateMode bool
|
||||
SkipVerify bool
|
||||
HideArchives bool
|
||||
Search bool
|
||||
}
|
||||
|
||||
|
@ -50,6 +51,7 @@ func Load(env envconfig.Env) *Gitlab {
|
|||
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") {
|
||||
|
@ -101,6 +103,28 @@ func (g *Gitlab) Login(res http.ResponseWriter, req *http.Request) (*model.User,
|
|||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(g.AllowedOrgs) != 0 {
|
||||
groups, err := client.AllGroups()
|
||||
if err != nil {
|
||||
return nil, false, 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
|
||||
|
@ -113,7 +137,7 @@ func (g *Gitlab) Login(res http.ResponseWriter, req *http.Request) (*model.User,
|
|||
user.Avatar = g.URL + "/" + login.AvatarUrl
|
||||
}
|
||||
|
||||
return user, true, nil
|
||||
return user, g.Open, nil
|
||||
}
|
||||
|
||||
func (g *Gitlab) Auth(token, secret string) (string, error) {
|
||||
|
@ -145,6 +169,12 @@ func (g *Gitlab) Repo(u *model.User, owner, name string) (*model.Repo, error) {
|
|||
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
|
||||
}
|
||||
|
@ -164,7 +194,7 @@ func (g *Gitlab) Repos(u *model.User) ([]*model.RepoLite, error) {
|
|||
|
||||
var repos = []*model.RepoLite{}
|
||||
|
||||
all, err := client.AllProjects()
|
||||
all, err := client.AllProjects(g.HideArchives)
|
||||
if err != nil {
|
||||
return repos, err
|
||||
}
|
||||
|
@ -173,15 +203,20 @@ func (g *Gitlab) Repos(u *model.User) ([]*model.RepoLite, error) {
|
|||
var parts = strings.Split(repo.PathWithNamespace, "/")
|
||||
var owner = parts[0]
|
||||
var name = parts[1]
|
||||
var avatar = repo.AvatarUrl
|
||||
|
||||
if len(avatar) != 0 && !strings.HasPrefix(avatar, "http") {
|
||||
avatar = fmt.Sprintf("%s/%s", g.URL, avatar)
|
||||
}
|
||||
|
||||
repos = append(repos, &model.RepoLite{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
FullName: repo.PathWithNamespace,
|
||||
Avatar: avatar,
|
||||
})
|
||||
|
||||
// TODO: add repo.AvatarUrl
|
||||
}
|
||||
|
||||
return repos, err
|
||||
}
|
||||
|
||||
|
@ -337,48 +372,109 @@ func (g *Gitlab) Hook(req *http.Request) (*model.Repo, *model.Build, error) {
|
|||
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 = parsed.ObjectAttributes.Target.WebUrl
|
||||
repo.Clone = parsed.ObjectAttributes.Target.HttpUrl
|
||||
}
|
||||
|
||||
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"
|
||||
build.Message = parsed.ObjectAttributes.LastCommit.Message
|
||||
build.Commit = parsed.ObjectAttributes.LastCommit.Id
|
||||
//build.Remote = parsed.ObjectAttributes.Source.HttpUrl
|
||||
|
||||
if parsed.ObjectAttributes.SourceProjectId == parsed.ObjectAttributes.TargetProjectId {
|
||||
build.Ref = fmt.Sprintf("refs/heads/%s", parsed.ObjectAttributes.SourceBranch)
|
||||
} else {
|
||||
build.Ref = fmt.Sprintf("refs/merge-requests/%d/head", parsed.ObjectAttributes.IId)
|
||||
lastCommit := obj.LastCommit
|
||||
if lastCommit == nil {
|
||||
return nil, nil, fmt.Errorf("last_commit key expected in merge request hook")
|
||||
}
|
||||
|
||||
build.Branch = parsed.ObjectAttributes.SourceBranch
|
||||
// build.Timestamp = parsed.ObjectAttributes.LastCommit.Timestamp
|
||||
build.Message = lastCommit.Message
|
||||
build.Commit = lastCommit.Id
|
||||
//build.Remote = parsed.ObjectAttributes.Source.HttpUrl
|
||||
|
||||
build.Author = parsed.ObjectAttributes.LastCommit.Author.Name
|
||||
build.Email = parsed.ObjectAttributes.LastCommit.Author.Email
|
||||
build.Title = parsed.ObjectAttributes.Title
|
||||
build.Link = parsed.ObjectAttributes.Url
|
||||
if obj.SourceProjectId == obj.TargetProjectId {
|
||||
build.Ref = fmt.Sprintf("refs/heads/%s", obj.SourceBranch)
|
||||
} else {
|
||||
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) {
|
||||
var cloneUrl = parsed.Repository.GitHttpUrl
|
||||
|
||||
repo := &model.Repo{}
|
||||
repo.Owner = req.FormValue("owner")
|
||||
repo.Name = req.FormValue("name")
|
||||
repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name)
|
||||
repo.Link = parsed.Repository.URL
|
||||
repo.Clone = cloneUrl
|
||||
repo.Branch = "master"
|
||||
|
||||
switch parsed.Repository.VisibilityLevel {
|
||||
// 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:
|
||||
|
@ -386,9 +482,26 @@ func push(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Bu
|
|||
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
|
||||
|
@ -406,6 +519,9 @@ func push(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Bu
|
|||
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
|
||||
}
|
||||
|
@ -436,25 +552,6 @@ func (g *Gitlab) Oauth2Transport(r *http.Request) *oauth2.Transport {
|
|||
}
|
||||
}
|
||||
|
||||
// Accessor method, to allowed remote organizations field.
|
||||
func (g *Gitlab) GetOrgs() []string {
|
||||
return g.AllowedOrgs
|
||||
}
|
||||
|
||||
// Accessor method, to open field.
|
||||
func (g *Gitlab) GetOpen() bool {
|
||||
return g.Open
|
||||
}
|
||||
|
||||
// return default scope for GitHub
|
||||
func (g *Gitlab) Scope() string {
|
||||
return DefaultScope
|
||||
}
|
||||
|
||||
func (g *Gitlab) String() string {
|
||||
return "gitlab"
|
||||
}
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusRunning = "running"
|
||||
|
|
|
@ -32,6 +32,25 @@ func Test_Gitlab(t *testing.T) {
|
|||
|
||||
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() {
|
||||
|
@ -115,7 +134,8 @@ func Test_Gitlab(t *testing.T) {
|
|||
|
||||
// Test hook method
|
||||
g.Describe("Hook", func() {
|
||||
g.It("Should parse push hoook", 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",
|
||||
|
@ -125,12 +145,34 @@ func Test_Gitlab(t *testing.T) {
|
|||
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.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",
|
||||
|
@ -140,13 +182,33 @@ func Test_Gitlab(t *testing.T) {
|
|||
|
||||
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",
|
||||
|
@ -156,6 +218,24 @@ func Test_Gitlab(t *testing.T) {
|
|||
|
||||
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")
|
||||
|
@ -164,4 +244,5 @@ func Test_Gitlab(t *testing.T) {
|
|||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
package gitlab
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/drone/drone/remote/gitlab/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 {
|
||||
|
@ -79,6 +86,26 @@ 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
|
||||
}
|
||||
|
|
188
remote/gitlab/testdata/hooks.go
vendored
188
remote/gitlab/testdata/hooks.go
vendored
|
@ -1,6 +1,46 @@
|
|||
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",
|
||||
|
@ -45,6 +85,86 @@ var TagHook = []byte(`
|
|||
`)
|
||||
|
||||
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": {
|
||||
|
@ -101,6 +221,74 @@ var MergeRequestHook = []byte(`
|
|||
`)
|
||||
|
||||
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",
|
||||
|
|
43
remote/gitlab/testdata/projects.go
vendored
43
remote/gitlab/testdata/projects.go
vendored
|
@ -1,7 +1,7 @@
|
|||
package testdata
|
||||
|
||||
// sample repository list
|
||||
var projectsPayload = []byte(`
|
||||
var allProjectsPayload = []byte(`
|
||||
[
|
||||
{
|
||||
"id": 4,
|
||||
|
@ -73,6 +73,47 @@ var projectsPayload = []byte(`
|
|||
"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
|
||||
}
|
||||
]
|
||||
|
|
7
remote/gitlab/testdata/testdata.go
vendored
7
remote/gitlab/testdata/testdata.go
vendored
|
@ -16,7 +16,12 @@ func NewServer() *httptest.Server {
|
|||
// evaluate the path to serve a dummy data file
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/projects":
|
||||
w.Write(projectsPayload)
|
||||
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)
|
||||
|
|
|
@ -25,8 +25,9 @@ func Options(c *gin.Context) {
|
|||
if c.Request.Method != "OPTIONS" {
|
||||
c.Next()
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization")
|
||||
c.Header("Access-Control-Allow-Headers", "authorization, origin, content-type, accept")
|
||||
c.Header("Allow", "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.AbortWithStatus(200)
|
||||
|
|
|
@ -2,6 +2,7 @@ package session
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/drone/drone/model"
|
||||
"github.com/drone/drone/remote"
|
||||
|
@ -104,6 +105,8 @@ func Perm(c *gin.Context) *model.Perm {
|
|||
}
|
||||
|
||||
func SetPerm() gin.HandlerFunc {
|
||||
PUBLIC_MODE := os.Getenv("PUBLIC_MODE")
|
||||
|
||||
return func(c *gin.Context) {
|
||||
user := User(c)
|
||||
repo := Repo(c)
|
||||
|
@ -164,6 +167,11 @@ func SetPerm() gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// all build logs are visible in public mode
|
||||
if PUBLIC_MODE != "" {
|
||||
perm.Pull = true
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
log.Debugf("%s granted %+v permission to %s",
|
||||
user.Login, perm, repo.FullName)
|
||||
|
|
44
router/middleware/session/repo_test.go
Normal file
44
router/middleware/session/repo_test.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/drone/drone/model"
|
||||
"github.com/franela/goblin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSetPerm(t *testing.T) {
|
||||
g := goblin.Goblin(t)
|
||||
g.Describe("SetPerm", func() {
|
||||
g.BeforeEach(func() {
|
||||
os.Unsetenv("PUBLIC_MODE")
|
||||
})
|
||||
g.It("Should set pull to false (private repo, user not logged in)", func() {
|
||||
c := gin.Context{}
|
||||
c.Set("repo", &model.Repo{
|
||||
IsPrivate: true,
|
||||
})
|
||||
SetPerm()(&c)
|
||||
v, ok := c.Get("perm")
|
||||
g.Assert(ok).IsTrue("perm was not set")
|
||||
p, ok := v.(*model.Perm)
|
||||
g.Assert(ok).IsTrue("perm was the wrong type")
|
||||
g.Assert(p.Pull).IsFalse("pull should be false")
|
||||
})
|
||||
g.It("Should set pull to true (private repo, user not logged in, public mode)", func() {
|
||||
os.Setenv("PUBLIC_MODE", "true")
|
||||
c := gin.Context{}
|
||||
c.Set("repo", &model.Repo{
|
||||
IsPrivate: true,
|
||||
})
|
||||
SetPerm()(&c)
|
||||
v, ok := c.Get("perm")
|
||||
g.Assert(ok).IsTrue("perm was not set")
|
||||
p, ok := v.(*model.Perm)
|
||||
g.Assert(ok).IsTrue("perm was the wrong type")
|
||||
g.Assert(p.Pull).IsTrue("pull should be true")
|
||||
})
|
||||
})
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
package docker
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"errors"
|
||||
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/samalba/dockerclient"
|
||||
)
|
||||
|
||||
|
@ -77,33 +77,21 @@ func Wait(client dockerclient.Client, name string) (*dockerclient.ContainerInfo,
|
|||
client.KillContainer(name, "9")
|
||||
}()
|
||||
|
||||
errc := make(chan error, 1)
|
||||
infoc := make(chan *dockerclient.ContainerInfo, 1)
|
||||
go func() {
|
||||
|
||||
// blocks and waits for the container to finish
|
||||
// by streaming the logs (to /dev/null). Ideally
|
||||
// we could use the `wait` function instead
|
||||
rc, err := client.ContainerLogs(name, LogOptsTail)
|
||||
if err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
io.Copy(ioutil.Discard, rc)
|
||||
rc.Close()
|
||||
for attempts := 0; attempts < 5; attempts++ {
|
||||
done := client.Wait(name)
|
||||
<-done
|
||||
|
||||
info, err := client.InspectContainer(name)
|
||||
if err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
infoc <- info
|
||||
}()
|
||||
|
||||
select {
|
||||
case info := <-infoc:
|
||||
return info, nil
|
||||
case err := <-errc:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !info.State.Running {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
log.Debugf("attempting to resume waiting after %d attempts.\n", attempts)
|
||||
}
|
||||
|
||||
return nil, errors.New("reached maximum wait attempts")
|
||||
}
|
||||
|
|
BIN
static/images/dummy.png
Normal file
BIN
static/images/dummy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -32,7 +32,10 @@ block content
|
|||
each $build in $group.Builds
|
||||
a.card[href=$repo.Name+"/"+$build.Number][data-build=$build.Number]
|
||||
div.card-header
|
||||
if $build.Avatar != ""
|
||||
img[src=$build.Avatar]
|
||||
else
|
||||
img[src="/static/images/dummy.png"]
|
||||
div.card-block
|
||||
div
|
||||
div.status[class=$build.Status] #{$build.Status}
|
||||
|
|
|
@ -20,7 +20,10 @@ block content
|
|||
div.col-sm-4
|
||||
a.card[href="/"+$repo.FullName]
|
||||
div.card-header
|
||||
if $repo.Avatar != ""
|
||||
img.avatar[src=$repo.Avatar]
|
||||
else
|
||||
img.avatar[src="/static/images/dummy.png"]
|
||||
div.card-block
|
||||
h3.login #{$repo.Name}
|
||||
div.full_name.hidden #{$repo.FullName}
|
||||
|
|
Loading…
Reference in a new issue