2016-04-19 04:40:49 +00:00
|
|
|
package bitbucketserver
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// WARNING! This is an work-in-progress patch and does not yet conform to the coding,
|
|
|
|
// quality or security standards expected of this project. Please use with caution.
|
2016-04-19 05:32:40 +00:00
|
|
|
|
2016-04-19 04:40:49 +00:00
|
|
|
import (
|
2016-05-01 23:30:00 +00:00
|
|
|
"crypto/rsa"
|
|
|
|
"crypto/x509"
|
2016-04-20 18:59:47 +00:00
|
|
|
"encoding/json"
|
2016-05-01 23:30:00 +00:00
|
|
|
"encoding/pem"
|
2016-04-20 18:59:47 +00:00
|
|
|
"fmt"
|
2016-04-19 04:40:49 +00:00
|
|
|
"io/ioutil"
|
2016-04-20 18:59:47 +00:00
|
|
|
"net/http"
|
|
|
|
"net/url"
|
2016-04-29 19:39:56 +00:00
|
|
|
|
|
|
|
log "github.com/Sirupsen/logrus"
|
|
|
|
"github.com/drone/drone/model"
|
|
|
|
"github.com/drone/drone/remote"
|
|
|
|
"github.com/mrjones/oauth"
|
2016-06-12 01:42:55 +00:00
|
|
|
"strings"
|
2016-04-19 04:40:49 +00:00
|
|
|
)
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// Opts defines configuration options.
|
|
|
|
type Opts struct {
|
|
|
|
URL string // Stash server url.
|
|
|
|
Username string // Git machine account username.
|
|
|
|
Password string // Git machine account password.
|
|
|
|
ConsumerKey string // Oauth1 consumer key.
|
|
|
|
ConsumerRSA string // Oauth1 consumer key file.
|
|
|
|
|
|
|
|
SkipVerify bool // Skip ssl verification.
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// New returns a Remote implementation that integrates with Bitbucket Server,
|
|
|
|
// the on-premise edition of Bitbucket Cloud, formerly known as Stash.
|
|
|
|
func New(opts Opts) (remote.Remote, error) {
|
|
|
|
bb := &client{
|
|
|
|
URL: opts.URL,
|
|
|
|
ConsumerKey: opts.ConsumerKey,
|
|
|
|
ConsumerRSA: opts.ConsumerRSA,
|
|
|
|
GitUserName: opts.Username,
|
|
|
|
GitPassword: opts.Password,
|
2016-04-29 19:39:56 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
switch {
|
|
|
|
case bb.GitUserName == "":
|
|
|
|
return nil, fmt.Errorf("Must have a git machine account username")
|
|
|
|
case bb.GitPassword == "":
|
|
|
|
return nil, fmt.Errorf("Must have a git machine account password")
|
|
|
|
case bb.ConsumerKey == "":
|
|
|
|
return nil, fmt.Errorf("Must have a oauth1 consumer key")
|
|
|
|
case bb.ConsumerRSA == "":
|
|
|
|
return nil, fmt.Errorf("Must have a oauth1 consumer key file")
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
keyfile, err := ioutil.ReadFile(bb.ConsumerRSA)
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, err
|
2016-04-19 20:29:52 +00:00
|
|
|
}
|
2016-05-01 23:30:00 +00:00
|
|
|
block, _ := pem.Decode(keyfile)
|
|
|
|
bb.PrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2016-04-19 20:29:52 +00:00
|
|
|
}
|
2016-04-19 20:26:52 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// TODO de-referencing is a bit weird and may not behave as expected, and could
|
|
|
|
// have race conditions. Instead store the parsed key (I already did this above)
|
|
|
|
// and then pass the parsed private key when creating the Bitbucket client.
|
|
|
|
bb.Consumer = *NewClient(bb.ConsumerRSA, bb.ConsumerKey, bb.URL)
|
|
|
|
return bb, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
type client struct {
|
|
|
|
URL string
|
|
|
|
ConsumerKey string
|
|
|
|
GitUserName string
|
|
|
|
GitPassword string
|
|
|
|
ConsumerRSA string
|
|
|
|
PrivateKey *rsa.PrivateKey
|
|
|
|
Consumer oauth.Consumer
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) {
|
|
|
|
requestToken, url, err := c.Consumer.GetRequestTokenAndUrl("oob")
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var code = req.FormValue("oauth_verifier")
|
|
|
|
if len(code) == 0 {
|
|
|
|
http.Redirect(res, req, url, http.StatusSeeOther)
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
requestToken.Token = req.FormValue("oauth_token")
|
|
|
|
accessToken, err := c.Consumer.AuthorizeToken(requestToken, code)
|
2016-04-20 18:59:47 +00:00
|
|
|
if err != nil {
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
client, err := c.Consumer.MakeHttpClient(accessToken)
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
response, err := client.Get(fmt.Sprintf("%s/plugins/servlet/applinks/whoami", c.URL))
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-05-01 23:30:00 +00:00
|
|
|
return nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
bits, err := ioutil.ReadAll(response.Body)
|
2016-05-01 23:30:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
login := string(bits)
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// TODO errors should never be ignored like this
|
|
|
|
response1, err := client.Get(fmt.Sprintf("%s/rest/api/1.0/users/%s", c.URL, login))
|
2016-06-12 01:42:55 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-06-13 05:18:31 +00:00
|
|
|
defer response1.Body.Close()
|
|
|
|
|
2016-04-19 04:40:49 +00:00
|
|
|
contents, err := ioutil.ReadAll(response1.Body)
|
2016-06-12 01:42:55 +00:00
|
|
|
if err !=nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-06-13 05:18:31 +00:00
|
|
|
|
2016-06-12 01:42:55 +00:00
|
|
|
var user User
|
|
|
|
err = json.Unmarshal(contents, &user)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-05-01 23:30:00 +00:00
|
|
|
return &model.User{
|
|
|
|
Login: login,
|
2016-06-12 01:42:55 +00:00
|
|
|
Email: user.EmailAddress,
|
2016-05-01 23:30:00 +00:00
|
|
|
Token: accessToken.Token,
|
2016-06-12 01:42:55 +00:00
|
|
|
Avatar: avatarLink(user.EmailAddress),
|
2016-05-01 23:30:00 +00:00
|
|
|
}, nil
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// Auth is not supported by the Stash driver.
|
|
|
|
func (*client) Auth(token, secret string) (string, error) {
|
|
|
|
return "", fmt.Errorf("Not Implemented")
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// Teams is not supported by the Stash driver.
|
|
|
|
func (*client) Teams(u *model.User) ([]*model.Team, error) {
|
|
|
|
var teams []*model.Team
|
|
|
|
return teams, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Repo(u *model.User, owner, name string) (*model.Repo, error) {
|
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
2016-06-14 03:08:56 +00:00
|
|
|
repo , err := c.FindRepo(client,owner,name)
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-06-12 01:42:55 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
2016-04-20 18:59:47 +00:00
|
|
|
return repo, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Repos(u *model.User) ([]*model.RepoLite, error) {
|
|
|
|
|
2016-04-19 04:40:49 +00:00
|
|
|
var repos = []*model.RepoLite{}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
response, err := client.Get(fmt.Sprintf("%s/rest/api/1.0/repos?limit=10000", c.URL))
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-04-19 16:47:02 +00:00
|
|
|
log.Error(err)
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
contents, err := ioutil.ReadAll(response.Body)
|
2016-06-12 01:42:55 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
var repoResponse Repos
|
2016-06-12 01:42:55 +00:00
|
|
|
err = json.Unmarshal(contents, &repoResponse)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
|
|
|
|
for _, repo := range repoResponse.Values {
|
|
|
|
repos = append(repos, &model.RepoLite{
|
|
|
|
Name: repo.Slug,
|
2016-04-20 18:59:47 +00:00
|
|
|
FullName: repo.Project.Key + "/" + repo.Slug,
|
|
|
|
Owner: repo.Project.Key,
|
2016-04-19 04:40:49 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2016-04-20 18:59:47 +00:00
|
|
|
return repos, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Perm(u *model.User, owner, repo string) (*model.Perm, error) {
|
2016-06-14 03:08:56 +00:00
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
2016-04-19 04:40:49 +00:00
|
|
|
perms := new(model.Perm)
|
2016-06-14 03:08:56 +00:00
|
|
|
|
|
|
|
// If you don't have access return none right away
|
|
|
|
_, err := c.FindRepo(client, owner, repo)
|
|
|
|
if err != nil {
|
|
|
|
return perms, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Must have admin to be able to list hooks. If have access the enable perms
|
|
|
|
_, err = client.Get(fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s", c.URL, owner, repo,"com.atlassian.stash.plugin.stash-web-post-receive-hooks-plugin:postReceiveHook"))
|
|
|
|
if err == nil {
|
|
|
|
perms.Push = true
|
|
|
|
perms.Admin = true
|
|
|
|
}
|
2016-04-19 04:40:49 +00:00
|
|
|
perms.Pull = true
|
2016-04-20 18:59:47 +00:00
|
|
|
return perms, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) File(u *model.User, r *model.Repo, b *model.Build, f string) ([]byte, error) {
|
2016-04-20 18:59:47 +00:00
|
|
|
log.Info(fmt.Sprintf("Staring file for bitbucketServer login: %s repo: %s buildevent: %s string: %s", u.Login, r.Name, b.Event, f))
|
2016-04-19 04:40:49 +00:00
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
|
|
|
fileURL := fmt.Sprintf("%s/projects/%s/repos/%s/browse/%s?raw", c.URL, r.Owner, r.Name, f)
|
2016-04-19 04:40:49 +00:00
|
|
|
log.Info(fileURL)
|
|
|
|
response, err := client.Get(fileURL)
|
|
|
|
if err != nil {
|
2016-04-19 16:47:02 +00:00
|
|
|
log.Error(err)
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
if response.StatusCode == 404 {
|
2016-04-20 18:59:47 +00:00
|
|
|
return nil, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
responseBytes, err := ioutil.ReadAll(response.Body)
|
|
|
|
if err != nil {
|
2016-04-19 16:47:02 +00:00
|
|
|
log.Error(err)
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-04-20 18:59:47 +00:00
|
|
|
return responseBytes, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
// Status is not supported by the Gogs driver.
|
|
|
|
func (*client) Status(*model.User, *model.Repo, *model.Build, string) error {
|
2016-04-20 18:59:47 +00:00
|
|
|
return nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Netrc(user *model.User, r *model.Repo) (*model.Netrc, error) {
|
2016-06-12 01:42:55 +00:00
|
|
|
u, err := url.Parse(c.URL)
|
2016-06-13 05:18:31 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2016-06-12 01:42:55 +00:00
|
|
|
//remove the port
|
|
|
|
tmp := strings.Split(u.Host, ":")
|
|
|
|
var host = tmp[0]
|
|
|
|
|
2016-04-19 04:40:49 +00:00
|
|
|
if err != nil {
|
2016-04-19 16:47:02 +00:00
|
|
|
return nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
return &model.Netrc{
|
2016-06-12 01:42:55 +00:00
|
|
|
Machine: host,
|
2016-05-01 23:30:00 +00:00
|
|
|
Login: c.GitUserName,
|
|
|
|
Password: c.GitPassword,
|
2016-04-19 04:40:49 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Activate(u *model.User, r *model.Repo, link string) error {
|
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
|
|
|
hook, err := c.CreateHook(client, r.Owner, r.Name, "com.atlassian.stash.plugin.stash-web-post-receive-hooks-plugin:postReceiveHook", link)
|
2016-04-20 18:59:47 +00:00
|
|
|
if err != nil {
|
2016-04-19 04:40:49 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
log.Info(hook)
|
2016-04-20 18:59:47 +00:00
|
|
|
return nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Deactivate(u *model.User, r *model.Repo, link string) error {
|
|
|
|
client := NewClientWithToken(&c.Consumer, u.Token)
|
|
|
|
err := c.DeleteHook(client, r.Owner, r.Name, "com.atlassian.stash.plugin.stash-web-post-receive-hooks-plugin:postReceiveHook", link)
|
2016-04-20 18:59:47 +00:00
|
|
|
if err != nil {
|
2016-04-19 04:40:49 +00:00
|
|
|
return err
|
|
|
|
}
|
2016-04-20 18:59:47 +00:00
|
|
|
return nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
func (c *client) Hook(r *http.Request) (*model.Repo, *model.Build, error) {
|
|
|
|
hook := new(postHook)
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(hook); err != nil {
|
|
|
|
return nil, nil, err
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
2016-05-01 23:30:00 +00:00
|
|
|
build := &model.Build{
|
|
|
|
Event: model.EventPush,
|
|
|
|
Ref: hook.RefChanges[0].RefID, // TODO check for index Values
|
|
|
|
Author: hook.Changesets.Values[0].ToCommit.Author.EmailAddress, // TODO check for index Values
|
|
|
|
Commit: hook.RefChanges[0].ToHash, // TODO check for index value
|
|
|
|
Avatar: avatarLink(hook.Changesets.Values[0].ToCommit.Author.EmailAddress),
|
|
|
|
}
|
|
|
|
|
|
|
|
repo := &model.Repo{
|
|
|
|
Name: hook.Repository.Slug,
|
|
|
|
Owner: hook.Repository.Project.Key,
|
|
|
|
FullName: fmt.Sprintf("%s/%s", hook.Repository.Project.Key, hook.Repository.Slug),
|
|
|
|
Branch: "master",
|
|
|
|
Kind: model.RepoGit,
|
|
|
|
}
|
|
|
|
|
|
|
|
return repo, build, nil
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type HookDetail struct {
|
2016-04-29 19:39:56 +00:00
|
|
|
Key string `json:"key"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
Description string `json:"description"`
|
|
|
|
Version string `json:"version"`
|
|
|
|
ConfigFormKey string `json:"configFormKey"`
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type Hook struct {
|
2016-04-29 19:39:56 +00:00
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
Details *HookDetail `json:"details"`
|
2016-04-19 04:40:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Enable hook for named repository
|
2016-05-01 23:30:00 +00:00
|
|
|
func (bs *client) CreateHook(client *http.Client, project, slug, hook_key, link string) (*Hook, error) {
|
2016-04-19 04:40:49 +00:00
|
|
|
|
|
|
|
// Set hook
|
2016-04-20 18:59:47 +00:00
|
|
|
hookBytes := []byte(fmt.Sprintf(`{"hook-url-0":"%s"}`, link))
|
2016-04-19 04:40:49 +00:00
|
|
|
|
|
|
|
// Enable hook
|
|
|
|
enablePath := fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s/enabled",
|
|
|
|
project, slug, hook_key)
|
|
|
|
|
2016-04-20 18:59:47 +00:00
|
|
|
doPut(client, bs.URL+enablePath, hookBytes)
|
2016-04-19 04:40:49 +00:00
|
|
|
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Disable hook for named repository
|
2016-05-01 23:30:00 +00:00
|
|
|
func (bs *client) DeleteHook(client *http.Client, project, slug, hook_key, link string) error {
|
2016-04-19 04:40:49 +00:00
|
|
|
enablePath := fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s/enabled",
|
|
|
|
project, slug, hook_key)
|
2016-04-20 18:59:47 +00:00
|
|
|
doDelete(client, bs.URL+enablePath)
|
2016-04-19 04:40:49 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2016-06-14 03:08:56 +00:00
|
|
|
|
|
|
|
func (c *client) FindRepo(client *http.Client, owner string, name string) (*model.Repo, error){
|
|
|
|
|
|
|
|
urlString := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s", c.URL, owner, name)
|
|
|
|
|
|
|
|
response, err := client.Get(urlString)
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
contents, err := ioutil.ReadAll(response.Body)
|
|
|
|
bsRepo := BSRepo{}
|
|
|
|
err = json.Unmarshal(contents, &bsRepo)
|
|
|
|
if err !=nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
repo := &model.Repo{
|
|
|
|
Name: bsRepo.Slug,
|
|
|
|
Owner: bsRepo.Project.Key,
|
|
|
|
Branch: "master",
|
|
|
|
Kind: model.RepoGit,
|
|
|
|
IsPrivate: true, // TODO(josmo) possibly set this as a setting - must always be private to use netrc
|
|
|
|
FullName: fmt.Sprintf("%s/%s", bsRepo.Project.Key, bsRepo.Slug),
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, item := range bsRepo.Links.Clone {
|
|
|
|
if item.Name == "http" {
|
|
|
|
uri, err := url.Parse(item.Href)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
uri.User = nil
|
|
|
|
repo.Clone = uri.String()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for _, item := range bsRepo.Links.Self {
|
|
|
|
if item.Href != "" {
|
|
|
|
repo.Link = item.Href
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return repo, nil
|
|
|
|
}
|