diff --git a/cmd/server/flags.go b/cmd/server/flags.go index 8d79986cf2..d90f14f676 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -438,6 +438,43 @@ var flags = append([]cli.Flag{ Usage: "gitlab skip ssl verification", }, // + // Bitbucket DataCenter/Server (previously Stash) + // + &cli.BoolFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC"}, + Name: "bitbucket-dc", + Usage: "Bitbucket DataCenter/Server driver is enabled", + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC_URL"}, + Name: "bitbucket-dc-server", + Usage: "Bitbucket DataCenter/Server server address", + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC_CLIENT_ID"}, + Name: "bitbucket-dc-client-id", + Usage: "Bitbucket DataCenter/Server OAuth 2.0 client id", + FilePath: os.Getenv("WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE"), + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC_CLIENT_SECRET"}, + Name: "bitbucket-dc-client-secret", + Usage: "Bitbucket DataCenter/Server OAuth 2.0 client secret", + FilePath: os.Getenv("WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE"), + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC_GIT_USERNAME"}, + Name: "bitbucket-dc-git-username", + Usage: "Bitbucket DataCenter/Server service account username", + FilePath: os.Getenv("WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE"), + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_BITBUCKET_DC_GIT_PASSWORD"}, + Name: "bitbucket-dc-git-password", + Usage: "Bitbucket DataCenter/Server service account password", + FilePath: os.Getenv("WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE"), + }, + // // development flags // &cli.StringFlag{ diff --git a/cmd/server/setup.go b/cmd/server/setup.go index 63479c901f..797d347b81 100644 --- a/cmd/server/setup.go +++ b/cmd/server/setup.go @@ -33,6 +33,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/server/cache" "go.woodpecker-ci.org/woodpecker/v2/server/forge" "go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucket" + "go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucketdatacenter" "go.woodpecker-ci.org/woodpecker/v2/server/forge/gitea" "go.woodpecker-ci.org/woodpecker/v2/server/forge/github" "go.woodpecker-ci.org/woodpecker/v2/server/forge/gitlab" @@ -121,6 +122,8 @@ func setupForge(c *cli.Context) (forge.Forge, error) { return setupGitLab(c) case c.Bool("bitbucket"): return setupBitbucket(c) + case c.Bool("bitbucket-dc"): + return setupBitbucketDatacenter(c) case c.Bool("gitea"): return setupGitea(c) default: @@ -157,6 +160,19 @@ func setupGitea(c *cli.Context) (forge.Forge, error) { return gitea.New(opts) } +// setupBitbucketDatacenter helper function to setup the Bitbucket DataCenter/Server forge from the CLI arguments. +func setupBitbucketDatacenter(c *cli.Context) (forge.Forge, error) { + opts := bitbucketdatacenter.Opts{ + URL: c.String("bitbucket-dc-server"), + Username: c.String("bitbucket-dc-git-username"), + Password: c.String("bitbucket-dc-git-password"), + ClientID: c.String("bitbucket-dc-client-id"), + ClientSecret: c.String("bitbucket-dc-client-secret"), + } + log.Trace().Msgf("Forge (bitbucketdatacenter) opts: %#v", opts) + return bitbucketdatacenter.New(opts) +} + // setupGitLab helper function to setup the GitLab forge from the CLI arguments. func setupGitLab(c *cli.Context) (forge.Forge, error) { return gitlab.New(gitlab.Opts{ diff --git a/docs/docs/30-administration/11-forges/10-overview.md b/docs/docs/30-administration/11-forges/10-overview.md index 6a0122e61f..4446896f0f 100644 --- a/docs/docs/30-administration/11-forges/10-overview.md +++ b/docs/docs/30-administration/11-forges/10-overview.md @@ -2,12 +2,12 @@ ## Supported features -| Feature | [GitHub](20-github.md) | [Gitea / Forgejo](30-gitea.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | -| ------------------------------------------------------------- | :--------------------: | :----------------------------: | :--------------------: | :--------------------------: | -| Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | -| Event: Deploy | :white_check_mark: | :x: | :x: | :x: | -| [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | +| Feature | [GitHub](20-github.md) | [Gitea / Forgejo](30-gitea.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | +| ------------------------------------------------------------- | :--------------------: | :----------------------------: | :--------------------: | :--------------------------: | :------------------------------------------------: | +| Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | +| Event: Deploy | :white_check_mark: | :x: | :x: | :x: | :x: | +| [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | diff --git a/docs/docs/30-administration/11-forges/60-bitbucket_datacenter.md b/docs/docs/30-administration/11-forges/60-bitbucket_datacenter.md new file mode 100644 index 0000000000..e85242c056 --- /dev/null +++ b/docs/docs/30-administration/11-forges/60-bitbucket_datacenter.md @@ -0,0 +1,99 @@ +--- +toc_max_heading_level: 2 +--- + +# Bitbucket Datacenter / Server + +:::warning +Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. +::: + +To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: + +```diff +# docker-compose.yml +version: '3' + +services: + woodpecker-server: + [...] + environment: + - [...] ++ - WOODPECKER_BITBUCKET_DC=true ++ - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo ++ - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar ++ - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx ++ - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy ++ - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com + + woodpecker-agent: + [...] +``` + +## Service Account + +Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. + +## Registration + +Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incomming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. + +See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). + +## Configuration + +This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. + +### `WOODPECKER_BITBUCKET_DC` + +> Default: `false` + +Enables the Bitbucket Server driver. + +### `WOODPECKER_BITBUCKET_DC_URL` + +> Default: empty + +Configures the Bitbucket Server address. + +### `WOODPECKER_BITBUCKET_DC_CLIENT_ID` + +> Default: empty + +Configures your Bitbucket Server OAUth 2.0 client id. + +### `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` + +> Default: empty + +Configures your Bitbucket Server OAUth 2.0 client secret. + +### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` + +> Default: empty + +This username is used to authenticate and clone all private repositories. + +### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` + +> Default: empty + +Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath + +### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` + +> Default: empty + +The password is used to authenticate and clone all private repositories. + +### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` + +> Default: empty + +Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath + +### `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` + +> Default: `false` + +Configure if SSL verification should be skipped. diff --git a/go.mod b/go.mod index 6b6ca54b9c..b3bc9352af 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/moby/moby v24.0.9+incompatible github.com/moby/term v0.5.0 github.com/muesli/termenv v0.15.2 + github.com/neticdk/go-bitbucket v1.0.0 github.com/oklog/ulid/v2 v2.1.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.18.0 @@ -111,6 +112,7 @@ require ( github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/libdns/libdns v0.2.1 // indirect diff --git a/go.sum b/go.sum index e62558662e..f1448c61f5 100644 --- a/go.sum +++ b/go.sum @@ -245,6 +245,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kinbiko/jsonassert v1.1.1 h1:DB12divY+YB+cVpHULLuKePSi6+ui4M/shHSzJISkSE= @@ -327,6 +329,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/neticdk/go-bitbucket v1.0.0 h1:FPvHEgPHoDwD2VHbpyu2R2gnoWQ867RxZd2FivS4wSw= +github.com/neticdk/go-bitbucket v1.0.0/go.mod h1:IrHeWO1CrNi0DlOvfhAA9bGRSeNSUB6/SAfzmwbA5aU= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/server/forge/bitbucketdatacenter/bitbucketdatacenter.go b/server/forge/bitbucketdatacenter/bitbucketdatacenter.go new file mode 100644 index 0000000000..7b678594ad --- /dev/null +++ b/server/forge/bitbucketdatacenter/bitbucketdatacenter.go @@ -0,0 +1,617 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitbucketdatacenter + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + bb "github.com/neticdk/go-bitbucket/bitbucket" + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" + + "go.woodpecker-ci.org/woodpecker/v2/server" + "go.woodpecker-ci.org/woodpecker/v2/server/forge" + "go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucketdatacenter/internal" + "go.woodpecker-ci.org/woodpecker/v2/server/forge/common" + forge_types "go.woodpecker-ci.org/woodpecker/v2/server/forge/types" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/store" +) + +// Opts defines configuration options. +type Opts struct { + URL string // Bitbucket server url for API access. + Username string // Git machine account username. + Password string // Git machine account password. + ClientID string // OAuth 2.0 client id + ClientSecret string // OAuth 2.0 client secret +} + +type client struct { + url string + urlAPI string + clientID string + clientSecret string + username string + password string +} + +// New returns a Forge implementation that integrates with Bitbucket DataCenter/Server, +// the on-premise edition of Bitbucket Cloud, formerly known as Stash. +func New(opts Opts) (forge.Forge, error) { + config := &client{ + url: opts.URL, + urlAPI: fmt.Sprintf("%s/rest", opts.URL), + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + username: opts.Username, + password: opts.Password, + } + + switch { + case opts.Username == "": + return nil, fmt.Errorf("must have a git machine account username") + case opts.Password == "": + return nil, fmt.Errorf("must have a git machine account password") + case opts.ClientID == "": + return nil, fmt.Errorf("must have an oauth 2.0 client id") + case opts.ClientSecret == "": + return nil, fmt.Errorf("must have an oauth 2.0 client secret") + } + + return config, nil +} + +// Name returns the string name of this driver +func (c *client) Name() string { + return "bitbucket_dc" +} + +// URL returns the root url of a configured forge +func (c *client) URL() string { + return c.url +} + +func (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { + config := c.newOAuth2Config() + + // TODO: Add proper state and pkce... + redirectURL := config.AuthCodeURL("woodpecker") + + if req.Error != "" { + return nil, redirectURL, &forge_types.AuthError{ + Err: req.Error, + Description: req.ErrorDescription, + URI: req.ErrorURI, + } + } + + if len(req.Code) == 0 { + return nil, redirectURL, nil + } + + token, err := config.Exchange(ctx, req.Code) + if err != nil { + return nil, redirectURL, err + } + + client := internal.NewClientWithToken(ctx, config.TokenSource(ctx, &oauth2.Token{ + AccessToken: token.AccessToken, + }), c.url) + userSlug, err := client.FindCurrentUser(ctx) + if err != nil { + return nil, "", err + } + + bc, err := c.newClient(ctx, &model.User{Token: token.AccessToken}) + if err != nil { + return nil, "", fmt.Errorf("unable to create bitbucket client: %w", err) + } + + user, _, err := bc.Users.GetUser(ctx, userSlug) + if err != nil { + return nil, "", fmt.Errorf("unable to query for user: %w", err) + } + + u := convertUser(user, c.url) + updateUserCredentials(u, token) + return u, "", nil +} + +func (c *client) Auth(ctx context.Context, accessToken, _ string) (string, error) { + config := c.newOAuth2Config() + token := &oauth2.Token{ + AccessToken: accessToken, + } + client := internal.NewClientWithToken(ctx, config.TokenSource(ctx, token), c.url) + return client.FindCurrentUser(ctx) +} + +func (c *client) Refresh(ctx context.Context, u *model.User) (bool, error) { + config := c.newOAuth2Config() + t := &oauth2.Token{ + RefreshToken: u.Secret, + } + ts := config.TokenSource(ctx, t) + + tok, err := ts.Token() + if err != nil { + return false, fmt.Errorf("unable to refresh OAuth 2.0 token from bitbucket datacenter: %w", err) + } + updateUserCredentials(u, tok) + return true, nil +} + +func (c *client) Repo(ctx context.Context, u *model.User, rID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + var repo *bb.Repository + if rID.IsValid() { + opts := &bb.RepositorySearchOptions{Permission: bb.PermissionRepoWrite, ListOptions: bb.ListOptions{Limit: 250}} + for { + repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) + if err != nil { + return nil, fmt.Errorf("unable to search repositories: %w", err) + } + for _, r := range repos { + if rID == convertID(r.ID) { + repo = r + break + } + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + if repo == nil { + return nil, fmt.Errorf("unable to find repository with id: %s", rID) + } + } else { + repo, _, err = bc.Projects.GetRepository(ctx, owner, name) + if err != nil { + return nil, fmt.Errorf("unable to get repository: %w", err) + } + } + + b, _, err := bc.Projects.GetDefaultBranch(ctx, repo.Project.Key, repo.Slug) + if err != nil { + return nil, fmt.Errorf("unable to fetch default branch: %w", err) + } + + perms := &model.Perm{Pull: true, Push: true} + _, _, err = bc.Projects.ListWebhooks(ctx, repo.Project.Key, repo.Slug, &bb.ListOptions{}) + if err == nil { + perms.Admin = true + } + + return convertRepo(repo, perms, b.DisplayID), nil +} + +func (c *client) Repos(ctx context.Context, u *model.User) ([]*model.Repo, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + opts := &bb.RepositorySearchOptions{Permission: bb.PermissionRepoWrite, ListOptions: bb.ListOptions{Limit: 250}} + var all []*model.Repo + for { + repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) + if err != nil { + return nil, fmt.Errorf("unable to search repositories: %w", err) + } + for _, r := range repos { + perms := &model.Perm{Pull: true, Push: true, Admin: false} + all = append(all, convertRepo(r, perms, "")) + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + // Add admin permissions to relevant repositories + opts = &bb.RepositorySearchOptions{Permission: bb.PermissionRepoAdmin, ListOptions: bb.ListOptions{Limit: 250}} + for { + repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) + if err != nil { + return nil, fmt.Errorf("unable to search repositories: %w", err) + } + for _, r := range repos { + for i, c := range all { + if c.ForgeRemoteID == convertID(r.ID) { + all[i].Perm = &model.Perm{Pull: true, Push: true, Admin: true} + break + } + } + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + return all, nil +} + +func (c *client) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + b, _, err := bc.Projects.GetTextFileContent(ctx, r.Owner, r.Name, f, p.Commit) + if err != nil { + return nil, err + } + return b, nil +} + +func (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, path string) ([]*forge_types.FileMeta, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + opts := &bb.FilesListOptions{At: p.Commit} + var all []*forge_types.FileMeta + for { + list, resp, err := bc.Projects.ListFiles(ctx, r.Owner, r.Name, path, opts) + if err != nil { + if resp.StatusCode == http.StatusNotFound { + break // requested directory might not exist + } + return nil, err + } + for _, f := range list { + fullpath := fmt.Sprintf("%s/%s", path, f) + data, err := c.File(ctx, u, r, p, fullpath) + if err != nil { + return nil, err + } + all = append(all, &forge_types.FileMeta{Name: fullpath, Data: data}) + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + return all, nil +} + +func (c *client) Status(ctx context.Context, u *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { + bc, err := c.newClient(ctx, u) + if err != nil { + return fmt.Errorf("unable to create bitbucket client: %w", err) + } + status := &bb.BuildStatus{ + State: convertStatus(pipeline.Status), + URL: common.GetPipelineStatusURL(repo, pipeline, workflow), + Key: common.GetPipelineStatusContext(repo, pipeline, workflow), + Description: common.GetPipelineStatusDescription(pipeline.Status), + } + _, err = bc.Projects.CreateBuildStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, status) + return err +} + +func (c *client) Netrc(_ *model.User, r *model.Repo) (*model.Netrc, error) { + host, err := common.ExtractHostFromCloneURL(r.Clone) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + return &model.Netrc{ + Login: c.username, + Password: c.password, + Machine: host, + }, nil +} + +// Branches returns the names of all branches for the named repository. +func (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + opts := &bb.BranchSearchOptions{ListOptions: convertListOptions(p)} + var all []string + for { + branches, resp, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, opts) + if err != nil { + return nil, fmt.Errorf("unable to list branches: %w", err) + } + for _, b := range branches { + all = append(all, b.DisplayID) + } + if !p.All || resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + return all, nil +} + +func (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, b string) (*model.Commit, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + branches, _, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, &bb.BranchSearchOptions{Filter: b}) + if err != nil { + return nil, err + } + if len(branches) == 0 { + return nil, fmt.Errorf("no matching branches returned") + } + for _, branch := range branches { + if branch.DisplayID == b { + return &model.Commit{ + SHA: branch.LatestCommit, + ForgeURL: fmt.Sprintf("%s/commits/%s", r.ForgeURL, branch.LatestCommit), + }, nil + } + } + return nil, fmt.Errorf("no matching branches found") +} + +func (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + opts := &bb.PullRequestSearchOptions{ListOptions: convertListOptions(p)} + var all []*model.PullRequest + for { + prs, resp, err := bc.Projects.SearchPullRequests(ctx, r.Owner, r.Name, opts) + if err != nil { + return nil, fmt.Errorf("unable to list pull-requests: %w", err) + } + for _, pr := range prs { + all = append(all, &model.PullRequest{Index: convertID(pr.ID), Title: pr.Title}) + } + if !p.All || resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + return all, nil +} + +func (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + bc, err := c.newClient(ctx, u) + if err != nil { + return fmt.Errorf("unable to create bitbucket client: %w", err) + } + + err = c.Deactivate(ctx, u, r, link) + if err != nil { + return fmt.Errorf("unable to deactive old webhooks: %w", err) + } + + webhook := &bb.Webhook{ + Name: "Woodpecker", + URL: link, + Events: []bb.EventKey{bb.EventKeyRepoRefsChanged, bb.EventKeyPullRequestFrom, bb.EventKeyPullRequestMerged, bb.EventKeyPullRequestDeclined, bb.EventKeyPullRequestDeleted}, + Active: true, + Config: &bb.WebhookConfiguration{ + Secret: r.Hash, + }, + } + _, _, err = bc.Projects.CreateWebhook(ctx, r.Owner, r.Name, webhook) + return err +} + +func (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + bc, err := c.newClient(ctx, u) + if err != nil { + return fmt.Errorf("unable to create bitbucket client: %w", err) + } + + lu, err := url.Parse(link) + if err != nil { + return err + } + + opts := &bb.ListOptions{} + var ids []uint64 + for { + hooks, resp, err := bc.Projects.ListWebhooks(ctx, r.Owner, r.Name, opts) + if err != nil { + return err + } + for _, h := range hooks { + hu, err := url.Parse(h.URL) + if err == nil && hu.Host == lu.Host { + ids = append(ids, h.ID) + } + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + for _, id := range ids { + _, err = bc.Projects.DeleteWebhook(ctx, r.Owner, r.Name, id) + if err != nil { + return err + } + } + + return nil +} + +func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { + ev, payload, err := bb.ParsePayloadWithoutSignature(r) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse payload from webhook invocation: %w", err) + } + + var repo *model.Repo + var pipe *model.Pipeline + switch e := ev.(type) { + case *bb.RepositoryPushEvent: + repo = convertRepo(&e.Repository, nil, "") + pipe = convertRepositoryPushEvent(e, c.url) + case *bb.PullRequestEvent: + repo = convertRepo(&e.PullRequest.Source.Repository, nil, "") + pipe = convertPullRequestEvent(e, c.url) + default: + return nil, nil, nil + } + + user, repo, err := c.getUserAndRepo(ctx, repo) + if err != nil { + return nil, nil, err + } + + err = bb.ValidateSignature(r, payload, []byte(repo.Hash)) + if err != nil { + return nil, nil, fmt.Errorf("unable to validate signature on incoming webhook payload: %w", err) + } + + pipe, err = c.updatePipelineFromCommit(ctx, user, repo, pipe) + if err != nil { + return nil, nil, err + } + + if pipe == nil { + return nil, nil, nil + } + + return repo, pipe, nil +} + +func (c *client) getUserAndRepo(ctx context.Context, r *model.Repo) (*model.User, *model.Repo, error) { + _store, ok := store.TryFromContext(ctx) + if !ok { + log.Error().Msg("could not get store from context") + return nil, nil, fmt.Errorf("unable to get store from context") + } + + repo, err := _store.GetRepoForgeID(r.ForgeRemoteID) + if err != nil { + return nil, nil, fmt.Errorf("unable to get repo: %w", err) + } + log.Trace().Any("repo", repo).Msg("got repo") + + user, err := _store.GetUser(repo.UserID) + if err != nil { + return nil, nil, fmt.Errorf("unable to get user: %w", err) + } + log.Trace().Any("user", user).Msg("got user") + + return user, repo, nil +} + +func (c *client) updatePipelineFromCommit(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline) (*model.Pipeline, error) { + if p == nil { + return nil, nil + } + + bc, err := c.newClient(ctx, u) + if err != nil { + return nil, fmt.Errorf("unable to create bitbucket client: %w", err) + } + + commit, _, err := bc.Projects.GetCommit(ctx, r.Owner, r.Name, p.Commit) + if err != nil { + return nil, fmt.Errorf("unable to read commit: %w", err) + } + p.Message = commit.Message + + opts := &bb.ListOptions{} + for { + changes, resp, err := bc.Projects.ListChanges(ctx, r.Owner, r.Name, p.Commit, opts) + if err != nil { + return nil, fmt.Errorf("unable to list commit changes: %w", err) + } + for _, ch := range changes { + p.ChangedFiles = append(p.ChangedFiles, ch.Path.Title) + } + if resp.LastPage { + break + } + opts.Start = resp.NextPageStart + } + + return p, nil +} + +// Teams is not supported. +func (*client) Teams(_ context.Context, _ *model.User) ([]*model.Team, error) { + var teams []*model.Team + return teams, nil +} + +// TeamPerm is not supported. +func (*client) TeamPerm(_ *model.User, _ string) (*model.Perm, error) { + return nil, nil +} + +// OrgMembership returns if user is member of organization and if user +// is admin/owner in this organization. +func (c *client) OrgMembership(_ context.Context, _ *model.User, _ string) (*model.OrgPerm, error) { + // TODO: Not implemented currently + return nil, nil +} + +// Org fetches the organization from the forge by name. If the name is a user an org with type user is returned. +func (c *client) Org(_ context.Context, _ *model.User, owner string) (*model.Org, error) { + if strings.HasPrefix(owner, "~") { + return &model.Org{ + Name: owner, + IsUser: true, + }, nil + } + return &model.Org{ + Name: owner, + IsUser: false, + }, nil +} + +func (c *client) newOAuth2Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth2/latest/authorize", c.urlAPI), + TokenURL: fmt.Sprintf("%s/oauth2/latest/token", c.urlAPI), + }, + Scopes: []string{string(bb.PermissionRepoRead), string(bb.PermissionRepoWrite), string(bb.PermissionRepoAdmin)}, + RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), + } +} + +func (c *client) newClient(ctx context.Context, u *model.User) (*bb.Client, error) { + config := c.newOAuth2Config() + t := &oauth2.Token{ + AccessToken: u.Token, + } + client := config.Client(ctx, t) + return bb.NewClient(c.urlAPI, client) +} diff --git a/server/forge/bitbucketdatacenter/bitbucketdatacenter_test.go b/server/forge/bitbucketdatacenter/bitbucketdatacenter_test.go new file mode 100644 index 0000000000..e1779a2958 --- /dev/null +++ b/server/forge/bitbucketdatacenter/bitbucketdatacenter_test.go @@ -0,0 +1,96 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitbucketdatacenter + +import ( + "context" + "testing" + "time" + + "github.com/franela/goblin" + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucketdatacenter/fixtures" + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +func TestBitbucketDC(t *testing.T) { + gin.SetMode(gin.TestMode) + + s := fixtures.Server() + c := &client{ + urlAPI: s.URL, + } + + ctx := context.Background() + g := goblin.Goblin(t) + g.Describe("Bitbucket DataCenter/Server", func() { + g.After(func() { + s.Close() + }) + + g.Describe("Creating a forge", func() { + g.It("Should return client with specified options", func() { + forge, err := New(Opts{ + URL: "http://localhost:8080", + Username: "0ZXh0IjoiI", + Password: "I1NiIsInR5", + ClientID: "client-id", + ClientSecret: "client-secret", + }) + g.Assert(err).IsNil() + g.Assert(forge).IsNotNil() + cl, ok := forge.(*client) + g.Assert(ok).IsTrue() + g.Assert(cl.url).Equal("http://localhost:8080") + g.Assert(cl.username).Equal("0ZXh0IjoiI") + g.Assert(cl.password).Equal("I1NiIsInR5") + g.Assert(cl.clientID).Equal("client-id") + g.Assert(cl.clientSecret).Equal("client-secret") + }) + }) + + g.Describe("Requesting a repository", func() { + g.It("should return repository details", func() { + repo, err := c.Repo(ctx, fakeUser, model.ForgeRemoteID("1234"), "PRJ", "repo-slug") + g.Assert(err).IsNil() + g.Assert(repo.Name).Equal("repo-slug-2") + g.Assert(repo.Owner).Equal("PRJ") + g.Assert(repo.Perm).Equal(&model.Perm{Pull: true, Push: true}) + g.Assert(repo.Branch).Equal("main") + }) + }) + + g.Describe("Getting organization", func() { + g.It("should map organization", func() { + org, err := c.Org(ctx, fakeUser, "ORG") + g.Assert(err).IsNil() + g.Assert(org.Name).Equal("ORG") + g.Assert(org.IsUser).IsFalse() + }) + g.It("should map user organization", func() { + org, err := c.Org(ctx, fakeUser, "~ORG") + g.Assert(err).IsNil() + g.Assert(org.Name).Equal("~ORG") + g.Assert(org.IsUser).IsTrue() + }) + }) + }) +} + +var fakeUser = &model.User{ + Token: "fake", + Expiry: time.Now().Add(1 * time.Hour).Unix(), +} diff --git a/server/forge/bitbucketdatacenter/convert.go b/server/forge/bitbucketdatacenter/convert.go new file mode 100644 index 0000000000..28e974f761 --- /dev/null +++ b/server/forge/bitbucketdatacenter/convert.go @@ -0,0 +1,161 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitbucketdatacenter + +import ( + "fmt" + "strings" + "time" + + bb "github.com/neticdk/go-bitbucket/bitbucket" + "golang.org/x/oauth2" + + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +func convertStatus(status model.StatusValue) bb.BuildStatusState { + switch status { + case model.StatusPending, model.StatusRunning: + return bb.BuildStatusStateInProgress + case model.StatusSuccess: + return bb.BuildStatusStateSuccessful + default: + return bb.BuildStatusStateFailed + } +} + +func convertID(id uint64) model.ForgeRemoteID { + return model.ForgeRemoteID(fmt.Sprintf("%d", id)) +} + +func convertRepo(from *bb.Repository, perm *model.Perm, branch string) *model.Repo { + r := &model.Repo{ + ForgeRemoteID: convertID(from.ID), + Name: from.Slug, + Owner: from.Project.Key, + Branch: branch, + SCMKind: model.RepoGit, + IsSCMPrivate: true, // Since we have to use Netrc it has to always be private :/ TODO: Is this really true? + FullName: fmt.Sprintf("%s/%s", from.Project.Key, from.Slug), + Perm: perm, + PREnabled: true, + } + + for _, l := range from.Links["clone"] { + if l.Name == "http" { + r.Clone = l.Href + } + } + + if l, ok := from.Links["self"]; ok && len(l) > 0 { + r.ForgeURL = l[0].Href + } + + return r +} + +func convertRepositoryPushEvent(ev *bb.RepositoryPushEvent, baseURL string) *model.Pipeline { + if len(ev.Changes) == 0 { + return nil + } + change := ev.Changes[0] + if change.ToHash == "0000000000000000000000000000000000000000" { + // No ToHash present - could be "DELETE" + return nil + } + if change.Type == bb.RepositoryPushEventChangeTypeDelete { + return nil + } + + pipeline := &model.Pipeline{ + Commit: change.ToHash, + Branch: change.Ref.DisplayID, + Message: "", + Avatar: bitbucketAvatarURL(baseURL, ev.Actor.Slug), + Author: authorLabel(ev.Actor.Name), + Email: ev.Actor.Email, + Timestamp: time.Time(ev.Date).UTC().Unix(), + Ref: ev.Changes[0].RefId, + ForgeURL: fmt.Sprintf("%s/projects/%s/repos/%s/commits/%s", baseURL, ev.Repository.Project.Key, ev.Repository.Slug, change.ToHash), + } + + if strings.HasPrefix(ev.Changes[0].RefId, "refs/tags/") { + pipeline.Event = model.EventTag + } else { + pipeline.Event = model.EventPush + } + + return pipeline +} + +func convertPullRequestEvent(ev *bb.PullRequestEvent, baseURL string) *model.Pipeline { + pipeline := &model.Pipeline{ + Commit: ev.PullRequest.Source.Latest, + Branch: ev.PullRequest.Source.DisplayID, + Title: ev.PullRequest.Title, + Message: "", + Avatar: bitbucketAvatarURL(baseURL, ev.Actor.Slug), + Author: authorLabel(ev.Actor.Name), + Email: ev.Actor.Email, + Timestamp: time.Time(ev.Date).UTC().Unix(), + Ref: fmt.Sprintf("refs/pull-requests/%d/from", ev.PullRequest.ID), + ForgeURL: fmt.Sprintf("%s/projects/%s/repos/%s/commits/%s", baseURL, ev.PullRequest.Source.Repository.Project.Key, ev.PullRequest.Source.Repository.Slug, ev.PullRequest.Source.Latest), + Refspec: fmt.Sprintf("%s:%s", ev.PullRequest.Source.DisplayID, ev.PullRequest.Target.DisplayID), + } + + if ev.EventKey == bb.EventKeyPullRequestMerged || ev.EventKey == bb.EventKeyPullRequestDeclined || ev.EventKey == bb.EventKeyPullRequestDeleted { + pipeline.Event = model.EventPullClosed + } else { + pipeline.Event = model.EventPull + } + + return pipeline +} + +func authorLabel(name string) string { + var result string + if len(name) > 40 { + result = name[0:37] + "..." + } else { + result = name + } + return result +} + +func convertUser(user *bb.User, baseURL string) *model.User { + return &model.User{ + ForgeRemoteID: model.ForgeRemoteID(fmt.Sprintf("%d", user.ID)), + Login: user.Slug, + Email: user.Email, + Avatar: bitbucketAvatarURL(baseURL, user.Slug), + } +} + +func bitbucketAvatarURL(baseURL, slug string) string { + return fmt.Sprintf("%s/users/%s/avatar.png", baseURL, slug) +} + +func convertListOptions(p *model.ListOptions) bb.ListOptions { + if p.All { + return bb.ListOptions{} + } + return bb.ListOptions{Limit: uint(p.PerPage), Start: uint((p.Page - 1) * p.PerPage)} +} + +func updateUserCredentials(u *model.User, t *oauth2.Token) { + u.Token = t.AccessToken + u.Secret = t.RefreshToken + u.Expiry = t.Expiry.UTC().Unix() +} diff --git a/server/forge/bitbucketdatacenter/convert_test.go b/server/forge/bitbucketdatacenter/convert_test.go new file mode 100644 index 0000000000..1e2e15ae83 --- /dev/null +++ b/server/forge/bitbucketdatacenter/convert_test.go @@ -0,0 +1,305 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitbucketdatacenter + +import ( + "testing" + "time" + + "github.com/franela/goblin" + bb "github.com/neticdk/go-bitbucket/bitbucket" + + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +//nolint:misspell +func TestHelper(t *testing.T) { + g := goblin.Goblin(t) + g.Describe("Bitbucket Server converter", func() { + g.It("should convert status", func() { + tests := []struct { + from model.StatusValue + to bb.BuildStatusState + }{ + { + from: model.StatusPending, + to: bb.BuildStatusStateInProgress, + }, + { + from: model.StatusRunning, + to: bb.BuildStatusStateInProgress, + }, + { + from: model.StatusSuccess, + to: bb.BuildStatusStateSuccessful, + }, + { + from: model.StatusValue("other"), + to: bb.BuildStatusStateFailed, + }, + } + for _, tt := range tests { + to := convertStatus(tt.from) + g.Assert(to).Equal(tt.to) + } + }) + + g.It("should convert repository", func() { + from := &bb.Repository{ + ID: uint64(1234), + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + Links: map[string][]bb.Link{ + "clone": { + { + Name: "http", + Href: "https://git.domain/clone", + }, + }, + "self": { + { + Href: "https://git.domain/self", + }, + }, + }, + } + perm := &model.Perm{} + to := convertRepo(from, perm, "main") + g.Assert(to.ForgeRemoteID).Equal(model.ForgeRemoteID("1234")) + g.Assert(to.Name).Equal("REPO") + g.Assert(to.Owner).Equal("PRJ") + g.Assert(to.Branch).Equal("main") + g.Assert(to.SCMKind).Equal(model.RepoGit) + g.Assert(to.FullName).Equal("PRJ/REPO") + g.Assert(to.Perm).Equal(perm) + }) + + g.It("should convert repository push event", func() { + now := time.Now() + tests := []struct { + from *bb.RepositoryPushEvent + to *model.Pipeline + }{ + { + from: &bb.RepositoryPushEvent{}, + to: nil, + }, + { + from: &bb.RepositoryPushEvent{ + Changes: []bb.RepositoryPushEventChange{ + { + FromHash: "1234567890abcdef", + ToHash: "0000000000000000000000000000000000000000", + }, + }, + }, + to: nil, + }, + { + from: &bb.RepositoryPushEvent{ + Changes: []bb.RepositoryPushEventChange{ + { + FromHash: "0000000000000000000000000000000000000000", + ToHash: "1234567890abcdef", + Type: bb.RepositoryPushEventChangeTypeDelete, + }, + }, + }, + to: nil, + }, + { + from: &bb.RepositoryPushEvent{ + Event: bb.Event{ + Date: bb.ISOTime(now), + Actor: bb.User{ + Name: "John Doe", + Email: "john.doe@mail.com", + Slug: "john.doe_mail.com", + }, + }, + Repository: bb.Repository{ + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + }, + Changes: []bb.RepositoryPushEventChange{ + { + Ref: bb.RepositoryPushEventRef{ + ID: "refs/head/branch", + DisplayID: "branch", + }, + RefId: "refs/head/branch", + ToHash: "1234567890abcdef", + }, + }, + }, + to: &model.Pipeline{ + Commit: "1234567890abcdef", + Branch: "branch", + Message: "", + Avatar: "https://base.url/users/john.doe_mail.com/avatar.png", + Author: "John Doe", + Email: "john.doe@mail.com", + Timestamp: now.UTC().Unix(), + Ref: "refs/head/branch", + ForgeURL: "https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef", + Event: model.EventPush, + }, + }, + } + for _, tt := range tests { + to := convertRepositoryPushEvent(tt.from, "https://base.url") + g.Assert(to).Equal(tt.to) + } + }) + + g.It("should convert pull request event", func() { + now := time.Now() + from := &bb.PullRequestEvent{ + Event: bb.Event{ + Date: bb.ISOTime(now), + EventKey: bb.EventKeyPullRequestFrom, + Actor: bb.User{ + Name: "John Doe", + Email: "john.doe@mail.com", + Slug: "john.doe_mail.com", + }, + }, + PullRequest: bb.PullRequest{ + ID: 123, + Title: "my title", + Source: bb.PullRequestRef{ + ID: "refs/head/branch", + DisplayID: "branch", + Latest: "1234567890abcdef", + Repository: bb.Repository{ + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + }, + }, + Target: bb.PullRequestRef{ + ID: "refs/head/main", + DisplayID: "main", + Latest: "abcdef1234567890", + Repository: bb.Repository{ + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + }, + }, + }, + } + to := convertPullRequestEvent(from, "https://base.url") + g.Assert(to.Commit).Equal("1234567890abcdef") + g.Assert(to.Branch).Equal("branch") + g.Assert(to.Avatar).Equal("https://base.url/users/john.doe_mail.com/avatar.png") + g.Assert(to.Author).Equal("John Doe") + g.Assert(to.Email).Equal("john.doe@mail.com") + g.Assert(to.Timestamp).Equal(now.UTC().Unix()) + g.Assert(to.Ref).Equal("refs/pull-requests/123/from") + g.Assert(to.ForgeURL).Equal("https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef") + g.Assert(to.Event).Equal(model.EventPull) + g.Assert(to.Refspec).Equal("branch:main") + }) + + g.It("should close pull request", func() { + now := time.Now() + from := &bb.PullRequestEvent{ + Event: bb.Event{ + Date: bb.ISOTime(now), + EventKey: bb.EventKeyPullRequestMerged, + Actor: bb.User{ + Name: "John Doe", + Email: "john.doe@mail.com", + Slug: "john.doe_mail.com", + }, + }, + PullRequest: bb.PullRequest{ + ID: 123, + Title: "my title", + Source: bb.PullRequestRef{ + ID: "refs/head/branch", + DisplayID: "branch", + Latest: "1234567890abcdef", + Repository: bb.Repository{ + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + }, + }, + Target: bb.PullRequestRef{ + ID: "refs/head/main", + DisplayID: "main", + Latest: "abcdef1234567890", + Repository: bb.Repository{ + Slug: "REPO", + Project: &bb.Project{ + Key: "PRJ", + }, + }, + }, + }, + } + to := convertPullRequestEvent(from, "https://base.url") + g.Assert(to.Commit).Equal("1234567890abcdef") + g.Assert(to.Branch).Equal("branch") + g.Assert(to.Avatar).Equal("https://base.url/users/john.doe_mail.com/avatar.png") + g.Assert(to.Author).Equal("John Doe") + g.Assert(to.Email).Equal("john.doe@mail.com") + g.Assert(to.Timestamp).Equal(now.UTC().Unix()) + g.Assert(to.Ref).Equal("refs/pull-requests/123/from") + g.Assert(to.ForgeURL).Equal("https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef") + g.Assert(to.Event).Equal(model.EventPullClosed) + g.Assert(to.Refspec).Equal("branch:main") + }) + + g.It("should truncate author", func() { + tests := []struct { + from string + to string + }{ + { + from: "Some Short Author", + to: "Some Short Author", + }, + { + from: "Some Very Long Author That May Include Multiple Names Here", + to: "Some Very Long Author That May Includ...", + }, + } + for _, tt := range tests { + g.Assert(authorLabel(tt.from)).Equal(tt.to) + } + }) + + g.It("should convert user", func() { + from := &bb.User{ + Slug: "slug", + Email: "john.doe@mail.com", + } + to := convertUser(from, "https://base.url") + g.Assert(to.Login).Equal("slug") + g.Assert(to.Avatar).Equal("https://base.url/users/slug/avatar.png") + g.Assert(to.Email).Equal("john.doe@mail.com") + }) + }) +} diff --git a/server/forge/bitbucketdatacenter/fixtures/handler.go b/server/forge/bitbucketdatacenter/fixtures/handler.go new file mode 100644 index 0000000000..262b250929 --- /dev/null +++ b/server/forge/bitbucketdatacenter/fixtures/handler.go @@ -0,0 +1,66 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fixtures + +import ( + "net/http/httptest" + + "github.com/neticdk/go-bitbucket/bitbucket" + "github.com/neticdk/go-bitbucket/mock" +) + +func Server() *httptest.Server { + return mock.NewMockServer( + mock.WithRequestMatch(mock.SearchRepositories, bitbucket.RepositoryList{ + ListResponse: bitbucket.ListResponse{ + LastPage: true, + }, + Repositories: []*bitbucket.Repository{ + { + ID: uint64(123), + Slug: "repo-slug-1", + Name: "REPO Name 1", + Project: &bitbucket.Project{ + ID: uint64(456), + Key: "PRJ", + }, + }, + { + ID: uint64(1234), + Slug: "repo-slug-2", + Name: "REPO Name 2", + Project: &bitbucket.Project{ + ID: uint64(456), + Key: "PRJ", + }, + }, + }, + }), + mock.WithRequestMatch(mock.GetRepository, bitbucket.Repository{ + ID: uint64(123), + Slug: "repo-slug", + Name: "REPO Name", + Project: &bitbucket.Project{ + ID: uint64(456), + Key: "PRJ", + }, + }), + mock.WithRequestMatch(mock.GetDefaultBranch, bitbucket.Branch{ + ID: "refs/head/main", + DisplayID: "main", + Default: true, + }), + ) +} diff --git a/server/forge/bitbucketdatacenter/internal/client.go b/server/forge/bitbucketdatacenter/internal/client.go new file mode 100644 index 0000000000..a712ff2cbb --- /dev/null +++ b/server/forge/bitbucketdatacenter/internal/client.go @@ -0,0 +1,66 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "golang.org/x/oauth2" +) + +const ( + currentUserID = "%s/plugins/servlet/applinks/whoami" +) + +type Client struct { + client *http.Client + base string +} + +func NewClientWithToken(ctx context.Context, ts oauth2.TokenSource, url string) *Client { + return &Client{ + client: oauth2.NewClient(ctx, ts), + base: url, + } +} + +// FindCurrentUser is returning the current user id - however it is not really part of the API so it is not part of the Bitbucket go client +func (c *Client) FindCurrentUser(ctx context.Context) (string, error) { + url := fmt.Sprintf(currentUserID, c.base) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("unable to create http request: %w", err) + } + + resp, err := c.client.Do(req) + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + return "", fmt.Errorf("unable to query logged in user id: %w", err) + } + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("unable to read data from user id query: %w", err) + } + login := string(buf) + login = strings.ReplaceAll(login, "@", "_") // Apparently the "whoami" endpoint may return the "wrong" username - converting to user slug + return login, nil +} diff --git a/server/forge/bitbucketdatacenter/internal/client_test.go b/server/forge/bitbucketdatacenter/internal/client_test.go new file mode 100644 index 0000000000..d8412e3c6c --- /dev/null +++ b/server/forge/bitbucketdatacenter/internal/client_test.go @@ -0,0 +1,53 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/franela/goblin" + "golang.org/x/oauth2" +) + +func TestCurrentUser(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`tal@netic.dk`)) + })) + + g := goblin.Goblin(t) + g.Describe("Bitbucket Current User", func() { + g.After(func() { + s.Close() + }) + g.It("should return current user id", func() { + ctx := context.Background() + ts := mockSource("bearer-token") + client := NewClientWithToken(ctx, ts, s.URL) + uid, err := client.FindCurrentUser(ctx) + g.Assert(err).IsNil() + g.Assert(uid).Equal("tal_netic.dk") + }) + }) +} + +type mockSource string + +func (ds mockSource) Token() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: string(ds)}, nil +} diff --git a/web/src/components/atomic/Icon.vue b/web/src/components/atomic/Icon.vue index 2c1ad18630..cc2befbe74 100644 --- a/web/src/components/atomic/Icon.vue +++ b/web/src/components/atomic/Icon.vue @@ -32,7 +32,7 @@ - + @@ -85,6 +85,7 @@ export type IconNames = | 'gitea' | 'gitlab' | 'bitbucket' + | 'bitbucket_dc' | 'question' | 'list' | 'loading' diff --git a/web/src/compositions/useConfig.ts b/web/src/compositions/useConfig.ts index 9bc36291ce..8688a6be97 100644 --- a/web/src/compositions/useConfig.ts +++ b/web/src/compositions/useConfig.ts @@ -6,7 +6,7 @@ declare global { WOODPECKER_VERSION: string | undefined; WOODPECKER_SKIP_VERSION_CHECK: boolean | undefined; WOODPECKER_CSRF: string | undefined; - WOODPECKER_FORGE: 'github' | 'gitlab' | 'gitea' | 'bitbucket' | undefined; + WOODPECKER_FORGE: 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'bitbucket_dc' | undefined; WOODPECKER_ROOT_PATH: string | undefined; WOODPECKER_ENABLE_SWAGGER: boolean | undefined; } diff --git a/web/src/views/repo/RepoPullRequest.vue b/web/src/views/repo/RepoPullRequest.vue index 2002a4969f..73355a40ab 100644 --- a/web/src/views/repo/RepoPullRequest.vue +++ b/web/src/views/repo/RepoPullRequest.vue @@ -32,6 +32,8 @@ const pipelines = computed(() => b.ref .replaceAll('refs/pull/', '') .replaceAll('refs/merge-requests/', '') + .replaceAll('refs/pull-requests/', '') + .replaceAll('/from', '') .replaceAll('/merge', '') .replaceAll('/head', '') === pullRequest.value, ),