Update to cleaner implementation for the bitbucket server implementation

This commit is contained in:
Joachim Hill-Grannec 2016-06-25 22:27:09 -07:00
parent 6624ff0ce6
commit 6e4303aab3
4 changed files with 137 additions and 142 deletions

View file

@ -4,32 +4,31 @@ package bitbucketserver
// quality or security standards expected of this project. Please use with caution. // quality or security standards expected of this project. Please use with caution.
import ( import (
"crypto/md5"
"crypto/rsa" "crypto/rsa"
"crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/hex"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
log "github.com/Sirupsen/logrus"
"github.com/drone/drone/model"
"github.com/drone/drone/remote"
"github.com/drone/drone/remote/bitbucketserver/internal"
"github.com/mrjones/oauth"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"github.com/drone/drone/remote/bitbucketserver/internal"
"github.com/drone/drone/model"
"github.com/drone/drone/remote"
"github.com/mrjones/oauth"
"strings" "strings"
"crypto/tls"
"encoding/hex"
"crypto/md5"
) )
const ( const (
requestTokenURL = "%s/plugins/servlet/oauth/request-token" requestTokenURL = "%s/plugins/servlet/oauth/request-token"
authorizeTokenURL = "%s/plugins/servlet/oauth/authorize" authorizeTokenURL = "%s/plugins/servlet/oauth/authorize"
accessTokenURL = "%s/plugins/servlet/oauth/access-token" accessTokenURL = "%s/plugins/servlet/oauth/access-token"
) )
// Opts defines configuration options. // Opts defines configuration options.
type Opts struct { type Opts struct {
URL string // Stash server url. URL string // Stash server url.
@ -37,27 +36,24 @@ type Opts struct {
Password string // Git machine account password. Password string // Git machine account password.
ConsumerKey string // Oauth1 consumer key. ConsumerKey string // Oauth1 consumer key.
ConsumerRSA string // Oauth1 consumer key file. ConsumerRSA string // Oauth1 consumer key file.
SkipVerify bool // Skip ssl verification. SkipVerify bool // Skip ssl verification.
} }
type Config struct { type Config struct {
URL string URL string
Username string Username string
Password string Password string
PrivateKey *rsa.PrivateKey
ConsumerKey string
SkipVerify bool SkipVerify bool
Consumer *oauth.Consumer
} }
// New returns a Remote implementation that integrates with Bitbucket Server, // New returns a Remote implementation that integrates with Bitbucket Server,
// the on-premise edition of Bitbucket Cloud, formerly known as Stash. // the on-premise edition of Bitbucket Cloud, formerly known as Stash.
func New(opts Opts) (remote.Remote, error) { func New(opts Opts) (remote.Remote, error) {
config := &Config{ config := &Config{
URL: opts.URL, URL: opts.URL,
Username: opts.Username, Username: opts.Username,
Password: opts.Password, Password: opts.Password,
ConsumerKey: opts.ConsumerKey,
SkipVerify: opts.SkipVerify, SkipVerify: opts.SkipVerify,
} }
@ -77,16 +73,16 @@ func New(opts Opts) (remote.Remote, error) {
return nil, err return nil, err
} }
block, _ := pem.Decode(keyFile) block, _ := pem.Decode(keyFile)
config.PrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) PrivateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
config.Consumer = CreateConsumer(opts.URL, opts.ConsumerKey, PrivateKey)
return config, nil return config, nil
} }
func (c *Config) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) { func (c *Config) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) {
requestToken, url, err := c.Consumer().GetRequestTokenAndUrl("oob") requestToken, url, err := c.Consumer.GetRequestTokenAndUrl("oob")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,16 +92,15 @@ func (c *Config) Login(res http.ResponseWriter, req *http.Request) (*model.User,
return nil, nil return nil, nil
} }
requestToken.Token = req.FormValue("oauth_token") requestToken.Token = req.FormValue("oauth_token")
accessToken, err := c.Consumer().AuthorizeToken(requestToken, code) accessToken, err := c.Consumer.AuthorizeToken(requestToken, code)
if err != nil { if err != nil {
return nil, err return nil, err
} }
client := internal.NewClientWithToken(c.URL, c.Consumer(), accessToken.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, accessToken.Token)
return client.FindCurrentUser() return client.FindCurrentUser()
} }
// Auth is not supported by the Stash driver. // Auth is not supported by the Stash driver.
@ -120,33 +115,34 @@ func (*Config) Teams(u *model.User) ([]*model.Team, error) {
} }
func (c *Config) Repo(u *model.User, owner, name string) (*model.Repo, error) { func (c *Config) Repo(u *model.User, owner, name string) (*model.Repo, error) {
log.Debug(fmt.Printf("Start repo lookup with: %+v %s %s\n", u, owner, name))
client := internal.NewClientWithToken(c.URL, c.Consumer(), u.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.FindRepo(owner, name) return client.FindRepo(owner, name)
} }
func (c *Config) Repos(u *model.User) ([]*model.RepoLite, error) { func (c *Config) Repos(u *model.User) ([]*model.RepoLite, error) {
log.Debug(fmt.Printf("Start repos lookup for: %+v\n", u))
client := internal.NewClientWithToken(c.URL,c.Consumer(), u.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.FindRepos() return client.FindRepos()
} }
func (c *Config) Perm(u *model.User, owner, repo string) (*model.Perm, error) { func (c *Config) Perm(u *model.User, owner, repo string) (*model.Perm, error) {
client := internal.NewClientWithToken(c.URL,c.Consumer(), u.Token) log.Debug(fmt.Printf("Start perm lookup for: %+v %s %s\n", u, owner, repo))
client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.FindRepoPerms(owner, repo) return client.FindRepoPerms(owner, repo)
} }
func (c *Config) File(u *model.User, r *model.Repo, b *model.Build, f string) ([]byte, error) { func (c *Config) File(u *model.User, r *model.Repo, b *model.Build, f string) ([]byte, error) {
log.Debug(fmt.Printf("Start file lookup for: %+v %+v %s\n", u, b, f))
client := internal.NewClientWithToken(c.URL, c.Consumer(), u.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.FindFileForRepo(r.Owner, r.Name, f) return client.FindFileForRepo(r.Owner, r.Name, f)
} }
// Status is not supported by the Gogs driver. // Status is not supported by the bitbucketserver driver.
func (*Config) Status(*model.User, *model.Repo, *model.Build, string) error { func (*Config) Status(*model.User, *model.Repo, *model.Build, string) error {
return nil return nil
} }
@ -171,13 +167,13 @@ func (c *Config) Netrc(user *model.User, r *model.Repo) (*model.Netrc, error) {
} }
func (c *Config) Activate(u *model.User, r *model.Repo, link string) error { func (c *Config) Activate(u *model.User, r *model.Repo, link string) error {
client := internal.NewClientWithToken(c.URL, c.Consumer(), u.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.CreateHook(r.Owner, r.Name, link) return client.CreateHook(r.Owner, r.Name, link)
} }
func (c *Config) Deactivate(u *model.User, r *model.Repo, link string) error { func (c *Config) Deactivate(u *model.User, r *model.Repo, link string) error {
client := internal.NewClientWithToken(c.URL, c.Consumer(), u.Token) client := internal.NewClientWithToken(c.URL, c.Consumer, u.Token)
return client.DeleteHook(r.Owner, r.Name, link) return client.DeleteHook(r.Owner, r.Name, link)
} }
@ -206,15 +202,14 @@ func (c *Config) Hook(r *http.Request) (*model.Repo, *model.Build, error) {
return repo, build, nil return repo, build, nil
} }
func CreateConsumer(URL string, ConsumerKey string, PrivateKey *rsa.PrivateKey) *oauth.Consumer {
func (c *Config) Consumer() *oauth.Consumer{
consumer := oauth.NewRSAConsumer( consumer := oauth.NewRSAConsumer(
c.ConsumerKey, ConsumerKey,
c.PrivateKey, PrivateKey,
oauth.ServiceProvider{ oauth.ServiceProvider{
RequestTokenUrl: fmt.Sprintf(requestTokenURL, c.URL), RequestTokenUrl: fmt.Sprintf(requestTokenURL, URL),
AuthorizeTokenUrl: fmt.Sprintf(authorizeTokenURL, c.URL), AuthorizeTokenUrl: fmt.Sprintf(authorizeTokenURL, URL),
AccessTokenUrl: fmt.Sprintf(accessTokenURL, c.URL), AccessTokenUrl: fmt.Sprintf(accessTokenURL, URL),
HttpMethod: "POST", HttpMethod: "POST",
}) })
consumer.HttpClient = &http.Client{ consumer.HttpClient = &http.Client{

View file

@ -1,34 +1,34 @@
package internal package internal
import ( import (
"net/http"
log "github.com/Sirupsen/logrus"
"github.com/mrjones/oauth"
"github.com/drone/drone/model"
"fmt"
"io/ioutil"
"encoding/json"
"strings"
"encoding/hex"
"crypto/md5"
"net/url"
"bytes" "bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/drone/drone/model"
"github.com/mrjones/oauth"
"io/ioutil"
"net/http"
"net/url"
"strings"
) )
const ( const (
currentUserId = "%s/plugins/servlet/applinks/whoami" currentUserId = "%s/plugins/servlet/applinks/whoami"
pathUser = "%s/rest/api/1.0/users/%s" pathUser = "%s/rest/api/1.0/users/%s"
pathRepo = "%s/rest/api/1.0/projects/%s/repos/%s" pathRepo = "%s/rest/api/1.0/projects/%s/repos/%s"
pathRepos = "%s/rest/api/1.0/repos?limit=%s" pathRepos = "%s/rest/api/1.0/repos?limit=%s"
pathHook = "%s/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s" pathHook = "%s/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s"
pathSource = "%s/projects/%s/repos/%s/browse/%s?raw" pathSource = "%s/projects/%s/repos/%s/browse/%s?raw"
hookName = "com.atlassian.stash.plugin.stash-web-post-receive-hooks-plugin:postReceiveHook" hookName = "com.atlassian.stash.plugin.stash-web-post-receive-hooks-plugin:postReceiveHook"
pathHookEnabled = "%s/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s/enabled" pathHookEnabled = "%s/rest/api/1.0/projects/%s/repos/%s/settings/hooks/%s/enabled"
) )
type Client struct { type Client struct {
*http.Client client *http.Client
base string base string
accessToken string accessToken string
} }
@ -36,6 +36,7 @@ func NewClientWithToken(url string, Consumer *oauth.Consumer, AccessToken string
var token oauth.AccessToken var token oauth.AccessToken
token.Token = AccessToken token.Token = AccessToken
client, err := Consumer.MakeHttpClient(&token) client, err := Consumer.MakeHttpClient(&token)
log.Debug(fmt.Printf("Create client: %+v %s\n", token, url))
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -43,7 +44,7 @@ func NewClientWithToken(url string, Consumer *oauth.Consumer, AccessToken string
} }
func (c *Client) FindCurrentUser() (*model.User, error) { func (c *Client) FindCurrentUser() (*model.User, error) {
CurrentUserIdResponse, err := c.Get(fmt.Sprintf(currentUserId, c.base)) CurrentUserIdResponse, err := c.client.Get(fmt.Sprintf(currentUserId, c.base))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -54,15 +55,14 @@ func (c *Client) FindCurrentUser() (*model.User, error) {
} }
login := string(bits) login := string(bits)
// TODO errors should never be ignored like this CurrentUserResponse, err := c.client.Get(fmt.Sprintf(pathUser, c.base, login))
CurrentUserResponse, err := c.Get(fmt.Sprintf(pathUser, c.base, login))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer CurrentUserResponse.Body.Close() defer CurrentUserResponse.Body.Close()
contents, err := ioutil.ReadAll(CurrentUserResponse.Body) contents, err := ioutil.ReadAll(CurrentUserResponse.Body)
if err !=nil { if err != nil {
return nil, err return nil, err
} }
@ -71,18 +71,21 @@ func (c *Client) FindCurrentUser() (*model.User, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &model.User{
ModelUser := &model.User{
Login: login, Login: login,
Email: user.EmailAddress, Email: user.EmailAddress,
Token: c.accessToken, Token: c.accessToken,
Avatar: avatarLink(user.EmailAddress), Avatar: avatarLink(user.EmailAddress),
}, nil }
log.Debug(fmt.Printf("User information: %+v\n", ModelUser))
return ModelUser, nil
} }
func (c *Client) FindRepo(owner string, name string) (*model.Repo, error){ func (c *Client) FindRepo(owner string, name string) (*model.Repo, error) {
urlString := fmt.Sprintf(pathRepo, c.base, owner, name) urlString := fmt.Sprintf(pathRepo, c.base, owner, name)
response, err := c.Get(urlString) response, err := c.client.Get(urlString)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -90,7 +93,7 @@ func (c *Client) FindRepo(owner string, name string) (*model.Repo, error){
contents, err := ioutil.ReadAll(response.Body) contents, err := ioutil.ReadAll(response.Body)
bsRepo := BSRepo{} bsRepo := BSRepo{}
err = json.Unmarshal(contents, &bsRepo) err = json.Unmarshal(contents, &bsRepo)
if err !=nil { if err != nil {
return nil, err return nil, err
} }
repo := &model.Repo{ repo := &model.Repo{
@ -117,14 +120,16 @@ func (c *Client) FindRepo(owner string, name string) (*model.Repo, error){
repo.Link = item.Href repo.Link = item.Href
} }
} }
log.Debug(fmt.Printf("Repo: %+v\n", repo))
return repo, nil return repo, nil
} }
func (c *Client) FindRepos() ([]*model.RepoLite, error) { func (c *Client) FindRepos() ([]*model.RepoLite, error) {
response, err := c.Get(fmt.Sprintf(pathRepos, c.base)) requestUrl := fmt.Sprintf(pathRepos, c.base, "1000")
log.Debug(fmt.Printf("request :%s", requestUrl))
response, err := c.client.Get(requestUrl)
if err != nil { if err != nil {
log.Error(err) return nil, err
} }
defer response.Body.Close() defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body) contents, err := ioutil.ReadAll(response.Body)
@ -136,6 +141,7 @@ func (c *Client) FindRepos() ([]*model.RepoLite, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
log.Debug(fmt.Printf("repoResponse: %+v\n", repoResponse))
var repos = []*model.RepoLite{} var repos = []*model.RepoLite{}
for _, repo := range repoResponse.Values { for _, repo := range repoResponse.Values {
repos = append(repos, &model.RepoLite{ repos = append(repos, &model.RepoLite{
@ -144,7 +150,7 @@ func (c *Client) FindRepos() ([]*model.RepoLite, error) {
Owner: repo.Project.Key, Owner: repo.Project.Key,
}) })
} }
log.Debug(fmt.Printf("repos: %+v\n", repos))
return repos, nil return repos, nil
} }
@ -156,17 +162,18 @@ func (c *Client) FindRepoPerms(owner string, repo string) (*model.Perm, error) {
return perms, err return perms, err
} }
// Must have admin to be able to list hooks. If have access the enable perms // Must have admin to be able to list hooks. If have access the enable perms
_, err = c.Get(fmt.Sprintf(pathHook, c.base, owner, repo,hookName)) _, err = c.client.Get(fmt.Sprintf(pathHook, c.base, owner, repo, hookName))
if err == nil { if err == nil {
perms.Push = true perms.Push = true
perms.Admin = true perms.Admin = true
} }
perms.Pull = true perms.Pull = true
log.Debug(fmt.Printf("Perms: %+v\n", perms))
return perms, nil return perms, nil
} }
func (c *Client) FindFileForRepo(owner string, repo string, fileName string) ([]byte, error) { func (c *Client) FindFileForRepo(owner string, repo string, fileName string) ([]byte, error) {
response, err := c.Get(fmt.Sprintf(pathSource, c.base, owner, repo, fileName)) response, err := c.client.Get(fmt.Sprintf(pathSource, c.base, owner, repo, fileName))
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -181,16 +188,16 @@ func (c *Client) FindFileForRepo(owner string, repo string, fileName string) ([]
return responseBytes, nil return responseBytes, nil
} }
func(c *Client) CreateHook(owner string, name string, callBackLink string) error { func (c *Client) CreateHook(owner string, name string, callBackLink string) error {
// Set hook // Set hook
//TODO: Check existing and add up to 5 //TODO: Check existing and add up to 5
hookBytes := []byte(fmt.Sprintf(`{"hook-url-0":"%s"}`, callBackLink)) hookBytes := []byte(fmt.Sprintf(`{"hook-url-0":"%s"}`, callBackLink))
return c.doPut(fmt.Sprintf(pathHookEnabled,c.base, owner, name, hookName), hookBytes) return c.doPut(fmt.Sprintf(pathHookEnabled, c.base, owner, name, hookName), hookBytes)
} }
func(c *Client) DeleteHook(owner string, name string, link string) error { func (c *Client) DeleteHook(owner string, name string, link string) error {
//TODO: eventially should only delete the link callback //TODO: eventially should only delete the link callback
return c.doDelete(fmt.Sprintf(pathHookEnabled,c.base, owner, name, hookName )) return c.doDelete(fmt.Sprintf(pathHookEnabled, c.base, owner, name, hookName))
} }
func avatarLink(email string) (url string) { func avatarLink(email string) (url string) {
@ -198,16 +205,15 @@ func avatarLink(email string) (url string) {
hasher.Write([]byte(strings.ToLower(email))) hasher.Write([]byte(strings.ToLower(email)))
emailHash := fmt.Sprintf("%v", hex.EncodeToString(hasher.Sum(nil))) emailHash := fmt.Sprintf("%v", hex.EncodeToString(hasher.Sum(nil)))
avatarURL := fmt.Sprintf("https://www.gravatar.com/avatar/%s.jpg", emailHash) avatarURL := fmt.Sprintf("https://www.gravatar.com/avatar/%s.jpg", emailHash)
log.Info(avatarURL) log.Debug(avatarURL)
return avatarURL return avatarURL
} }
//Helper function to help create the hook //Helper function to help create the hook
func(c *Client) doPut(url string, body []byte) error { func (c *Client) doPut(url string, body []byte) error {
request, err := http.NewRequest("PUT", url, bytes.NewBuffer(body)) request, err := http.NewRequest("PUT", url, bytes.NewBuffer(body))
request.Header.Add("Content-Type", "application/json") request.Header.Add("Content-Type", "application/json")
response, err := c.Do(request) response, err := c.client.Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -217,12 +223,12 @@ func(c *Client) doPut(url string, body []byte) error {
} }
//Helper function to do delete on the hook //Helper function to do delete on the hook
func(c *Client) doDelete(url string) error { func (c *Client) doDelete(url string) error {
request, err := http.NewRequest("DELETE", url, nil) request, err := http.NewRequest("DELETE", url, nil)
response, err := c.Do(request) response, err := c.client.Do(request)
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close() defer response.Body.Close()
return nil return nil
} }

View file

@ -1,48 +1,46 @@
package internal package internal
type User struct { type User struct {
Active bool `json:"active"` Active bool `json:"active"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
EmailAddress string `json:"emailAddress"` EmailAddress string `json:"emailAddress"`
ID int `json:"id"` ID int `json:"id"`
Links struct { Links struct {
Self []struct { Self []struct {
Href string `json:"href"` Href string `json:"href"`
} `json:"self"` } `json:"self"`
} `json:"links"` } `json:"links"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Type string `json:"type"` Type string `json:"type"`
} }
type BSRepo struct { type BSRepo struct {
Forkable bool `json:"forkable"` Forkable bool `json:"forkable"`
ID int `json:"id"` ID int `json:"id"`
Links struct { Links struct {
Clone []struct { Clone []struct {
Href string `json:"href"` Href string `json:"href"`
Name string `json:"name"` Name string `json:"name"`
} `json:"clone"` } `json:"clone"`
Self []struct { Self []struct {
Href string `json:"href"` Href string `json:"href"`
} `json:"self"` } `json:"self"`
} `json:"links"` } `json:"links"`
Name string `json:"name"` Name string `json:"name"`
Project struct { Project struct {
Description string `json:"description"` Description string `json:"description"`
ID int `json:"id"` ID int `json:"id"`
Key string `json:"key"` Key string `json:"key"`
Links struct { Links struct {
Self []struct { Self []struct {
Href string `json:"href"` Href string `json:"href"`
} `json:"self"` } `json:"self"`
} `json:"links"` } `json:"links"`
Name string `json:"name"` Name string `json:"name"`
Public bool `json:"public"` Public bool `json:"public"`
Type string `json:"type"` Type string `json:"type"`
} `json:"project"` } `json:"project"`
Public bool `json:"public"` Public bool `json:"public"`
ScmID string `json:"scmId"` ScmID string `json:"scmId"`
Slug string `json:"slug"` Slug string `json:"slug"`
@ -59,28 +57,28 @@ type Repos struct {
Forkable bool `json:"forkable"` Forkable bool `json:"forkable"`
ID int `json:"id"` ID int `json:"id"`
Links struct { Links struct {
Clone []struct { Clone []struct {
Href string `json:"href"` Href string `json:"href"`
Name string `json:"name"` Name string `json:"name"`
} `json:"clone"` } `json:"clone"`
Self []struct { Self []struct {
Href string `json:"href"` Href string `json:"href"`
} `json:"self"` } `json:"self"`
} `json:"links"` } `json:"links"`
Name string `json:"name"` Name string `json:"name"`
Project struct { Project struct {
Description string `json:"description"` Description string `json:"description"`
ID int `json:"id"` ID int `json:"id"`
Key string `json:"key"` Key string `json:"key"`
Links struct { Links struct {
Self []struct { Self []struct {
Href string `json:"href"` Href string `json:"href"`
} `json:"self"` } `json:"self"`
} `json:"links"` } `json:"links"`
Name string `json:"name"` Name string `json:"name"`
Public bool `json:"public"` Public bool `json:"public"`
Type string `json:"type"` Type string `json:"type"`
} `json:"project"` } `json:"project"`
Public bool `json:"public"` Public bool `json:"public"`
ScmID string `json:"scmId"` ScmID string `json:"scmId"`
Slug string `json:"slug"` Slug string `json:"slug"`
@ -101,4 +99,4 @@ type HookDetail struct {
Description string `json:"description"` Description string `json:"description"`
Version string `json:"version"` Version string `json:"version"`
ConfigFormKey string `json:"configFormKey"` ConfigFormKey string `json:"configFormKey"`
} }

View file

@ -84,10 +84,6 @@ type postHook struct {
} `json:"repository"` } `json:"repository"`
} }
type BSRepo struct { type BSRepo struct {
Forkable bool `json:"forkable"` Forkable bool `json:"forkable"`
ID int `json:"id"` ID int `json:"id"`