Add bitbucket datacenter (server) support (#2503)

This pull-requests re-introduces the Bitbucket Server support with a
more or less complete rewrite of the forge implementation. We have a lot
of on-premises git repositories hosted in Bitbucket Server and need a CI
solution for running that and Woodpecker looks promising.

The implementation is based on external Bitbucket Server REST client
library which we are maintaining and have created in another context.
Besides the original support for Bitbucket the re-implementation also
adds support for handling Bitbucket pull-request events.
This commit is contained in:
Thor Anker Kvisgård Lange 2024-02-20 15:58:02 +01:00 committed by GitHub
parent 0c9bbf91a3
commit 364d708923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1536 additions and 11 deletions

View file

@ -438,6 +438,43 @@ var flags = append([]cli.Flag{
Usage: "gitlab skip ssl verification", 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 // development flags
// //
&cli.StringFlag{ &cli.StringFlag{

View file

@ -33,6 +33,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server/cache" "go.woodpecker-ci.org/woodpecker/v2/server/cache"
"go.woodpecker-ci.org/woodpecker/v2/server/forge" "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/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/gitea"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/github" "go.woodpecker-ci.org/woodpecker/v2/server/forge/github"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/gitlab" "go.woodpecker-ci.org/woodpecker/v2/server/forge/gitlab"
@ -121,6 +122,8 @@ func setupForge(c *cli.Context) (forge.Forge, error) {
return setupGitLab(c) return setupGitLab(c)
case c.Bool("bitbucket"): case c.Bool("bitbucket"):
return setupBitbucket(c) return setupBitbucket(c)
case c.Bool("bitbucket-dc"):
return setupBitbucketDatacenter(c)
case c.Bool("gitea"): case c.Bool("gitea"):
return setupGitea(c) return setupGitea(c)
default: default:
@ -157,6 +160,19 @@ func setupGitea(c *cli.Context) (forge.Forge, error) {
return gitea.New(opts) 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. // setupGitLab helper function to setup the GitLab forge from the CLI arguments.
func setupGitLab(c *cli.Context) (forge.Forge, error) { func setupGitLab(c *cli.Context) (forge.Forge, error) {
return gitlab.New(gitlab.Opts{ return gitlab.New(gitlab.Opts{

View file

@ -2,12 +2,12 @@
## Supported features ## Supported features
| Feature | [GitHub](20-github.md) | [Gitea / Forgejo](30-gitea.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | | 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: | | 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: | | 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: | | 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: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Event: Deploy | :white_check_mark: | :x: | :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: | | [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: | | [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |

View file

@ -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.

2
go.mod
View file

@ -36,6 +36,7 @@ require (
github.com/moby/moby v24.0.9+incompatible github.com/moby/moby v24.0.9+incompatible
github.com/moby/term v0.5.0 github.com/moby/term v0.5.0
github.com/muesli/termenv v0.15.2 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/oklog/ulid/v2 v2.1.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.18.0 github.com/prometheus/client_golang v1.18.0
@ -111,6 +112,7 @@ require (
github.com/imdario/mergo v0.3.16 // indirect github.com/imdario/mergo v0.3.16 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // 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/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/libdns/libdns v0.2.1 // indirect github.com/libdns/libdns v0.2.1 // indirect

4
go.sum
View file

@ -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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 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= 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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 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= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

View file

@ -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)
}

View file

@ -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(),
}

View file

@ -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()
}

View file

@ -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")
})
})
}

View file

@ -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,
}),
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -32,7 +32,7 @@
<i-mdi-error-outline v-else-if="name === 'error'" class="h-5 w-5" /> <i-mdi-error-outline v-else-if="name === 'error'" class="h-5 w-5" />
<i-simple-icons-gitea v-else-if="name === 'gitea'" class="h-8 w-8" /> <i-simple-icons-gitea v-else-if="name === 'gitea'" class="h-8 w-8" />
<i-ph-gitlab-logo-simple-fill v-else-if="name === 'gitlab'" class="h-8 w-8" /> <i-ph-gitlab-logo-simple-fill v-else-if="name === 'gitlab'" class="h-8 w-8" />
<i-mdi-bitbucket v-else-if="name === 'bitbucket'" class="h-8 w-8" /> <i-mdi-bitbucket v-else-if="name === 'bitbucket' || name === 'bitbucket_dc'" class="h-8 w-8" />
<i-vaadin-question-circle-o v-else-if="name === 'question'" class="h-6 w-6" /> <i-vaadin-question-circle-o v-else-if="name === 'question'" class="h-6 w-6" />
<i-ic-twotone-add v-else-if="name === 'plus'" class="h-6 w-6" /> <i-ic-twotone-add v-else-if="name === 'plus'" class="h-6 w-6" />
<i-mdi-format-list-bulleted v-else-if="name === 'list'" class="h-6 w-6" /> <i-mdi-format-list-bulleted v-else-if="name === 'list'" class="h-6 w-6" />
@ -85,6 +85,7 @@ export type IconNames =
| 'gitea' | 'gitea'
| 'gitlab' | 'gitlab'
| 'bitbucket' | 'bitbucket'
| 'bitbucket_dc'
| 'question' | 'question'
| 'list' | 'list'
| 'loading' | 'loading'

View file

@ -6,7 +6,7 @@ declare global {
WOODPECKER_VERSION: string | undefined; WOODPECKER_VERSION: string | undefined;
WOODPECKER_SKIP_VERSION_CHECK: boolean | undefined; WOODPECKER_SKIP_VERSION_CHECK: boolean | undefined;
WOODPECKER_CSRF: string | 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_ROOT_PATH: string | undefined;
WOODPECKER_ENABLE_SWAGGER: boolean | undefined; WOODPECKER_ENABLE_SWAGGER: boolean | undefined;
} }

View file

@ -32,6 +32,8 @@ const pipelines = computed(() =>
b.ref b.ref
.replaceAll('refs/pull/', '') .replaceAll('refs/pull/', '')
.replaceAll('refs/merge-requests/', '') .replaceAll('refs/merge-requests/', '')
.replaceAll('refs/pull-requests/', '')
.replaceAll('/from', '')
.replaceAll('/merge', '') .replaceAll('/merge', '')
.replaceAll('/head', '') === pullRequest.value, .replaceAll('/head', '') === pullRequest.value,
), ),