diff --git a/.gitignore b/.gitignore
index b62fdf26fdc..73141d98f51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,6 +121,7 @@ _debug_bin
postgres-data/
mysql-data/
.docker/
+docker-compose.override.yml
# config files
local.js
diff --git a/backend/Makefile b/backend/Makefile
index 6a30e8a1347..698f9915ec2 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -29,7 +29,7 @@ all: build
go-dep:
go install github.com/vektra/mockery/v2@v2.43.0
go install github.com/swaggo/swag/cmd/swag@v1.16.1
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+ go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.63.4
go-dev-tools:
# go install github.com/atombender/go-jsonschema/cmd/gojsonschema@latest
diff --git a/backend/helpers/srvhelper/scope_service_helper.go b/backend/helpers/srvhelper/scope_service_helper.go
index e0e0d97dc33..544536f01c2 100644
--- a/backend/helpers/srvhelper/scope_service_helper.go
+++ b/backend/helpers/srvhelper/scope_service_helper.go
@@ -268,39 +268,39 @@ func (scopeSrv *ScopeSrvHelper[C, S, SC]) getAffectedTables() ([]string, errors.
if err != nil {
return nil, err
}
- if pluginModel, ok := meta.(plugin.PluginModel); !ok {
+ pluginModel, ok := meta.(plugin.PluginModel)
+ if !ok {
panic(errors.Default.New(fmt.Sprintf("plugin \"%s\" does not implement listing its tables", scopeSrv.pluginName)))
- } else {
- // Unfortunately, can't cache the tables because Python creates some tables on a per-demand basis, so such a cache would possibly get outdated.
- // It's a rare scenario in practice, but might as well play it safe and sacrifice some performance here
- var allTables []string
- if allTables, err = scopeSrv.db.AllTables(); err != nil {
- return nil, err
- }
- // collect raw tables
- for _, table := range allTables {
- if strings.HasPrefix(table, "_raw_"+scopeSrv.pluginName) {
- tables = append(tables, table)
- }
+ }
+ // Unfortunately, can't cache the tables because Python creates some tables on a per-demand basis, so such a cache would possibly get outdated.
+ // It's a rare scenario in practice, but might as well play it safe and sacrifice some performance here
+ var allTables []string
+ if allTables, err = scopeSrv.db.AllTables(); err != nil {
+ return nil, err
+ }
+ // collect raw tables
+ for _, table := range allTables {
+ if strings.HasPrefix(table, "_raw_"+scopeSrv.pluginName) {
+ tables = append(tables, table)
}
- // collect tool tables
- toolModels := pluginModel.GetTablesInfo()
- for _, toolModel := range toolModels {
- if !isScopeModel(toolModel) && hasField(toolModel, "RawDataParams") {
- tables = append(tables, toolModel.TableName())
- }
+ }
+ // collect tool tables
+ toolModels := pluginModel.GetTablesInfo()
+ for _, toolModel := range toolModels {
+ if !isScopeModel(toolModel) && hasField(toolModel, "RawDataParams") {
+ tables = append(tables, toolModel.TableName())
}
- // collect domain tables
- for _, domainModel := range domaininfo.GetDomainTablesInfo() {
- // we only care about tables with RawOrigin
- ok = hasField(domainModel, "RawDataParams")
- if ok {
- tables = append(tables, domainModel.TableName())
- }
+ }
+ // collect domain tables
+ for _, domainModel := range domaininfo.GetDomainTablesInfo() {
+ // we only care about tables with RawOrigin
+ ok = hasField(domainModel, "RawDataParams")
+ if ok {
+ tables = append(tables, domainModel.TableName())
}
- // additional tables
- tables = append(tables, models.CollectorLatestState{}.TableName())
}
+ // additional tables
+ tables = append(tables, models.CollectorLatestState{}.TableName())
scopeSrv.log.Debug("Discovered %d tables used by plugin \"%s\": %v", len(tables), scopeSrv.pluginName, tables)
return tables, nil
}
diff --git a/backend/impls/logruslog/init.go b/backend/impls/logruslog/init.go
index 17df490f97d..0fdb76fc57f 100644
--- a/backend/impls/logruslog/init.go
+++ b/backend/impls/logruslog/init.go
@@ -70,11 +70,11 @@ func init() {
if basePath == "" {
basePath = "./logs"
}
- if abs, err := filepath.Abs(basePath); err != nil {
- panic(err)
- } else {
- basePath = filepath.Join(abs, "devlake.log")
+ abs, absErr := filepath.Abs(basePath)
+ if absErr != nil {
+ panic(absErr)
}
+ basePath = filepath.Join(abs, "devlake.log")
var err errors.Error
Global, err = NewDefaultLogger(inner)
if err != nil {
diff --git a/backend/plugins/bitbucket/api/blueprint_v200.go b/backend/plugins/bitbucket/api/blueprint_v200.go
index a0ca0fe50c1..0e2477e80fe 100644
--- a/backend/plugins/bitbucket/api/blueprint_v200.go
+++ b/backend/plugins/bitbucket/api/blueprint_v200.go
@@ -120,7 +120,13 @@ func makeDataSourcePipelinePlanV200(
if err != nil {
return nil, err
}
- cloneUrl.User = url.UserPassword(connection.Username, connection.Password)
+ // For Bitbucket API tokens, use x-token-auth as username per Bitbucket docs
+ // https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/
+ gitUsername := connection.Username
+ if connection.UsesApiToken {
+ gitUsername = "x-bitbucket-api-token-auth"
+ }
+ cloneUrl.User = url.UserPassword(gitUsername, connection.Password)
stage = append(stage, &coreModels.PipelineTask{
Plugin: "gitextractor",
Options: map[string]interface{}{
diff --git a/backend/plugins/bitbucket/api/connection_api.go b/backend/plugins/bitbucket/api/connection_api.go
index 15851e3ab07..41978143b64 100644
--- a/backend/plugins/bitbucket/api/connection_api.go
+++ b/backend/plugins/bitbucket/api/connection_api.go
@@ -52,12 +52,18 @@ func testConnection(ctx context.Context, connection models.BitbucketConn) (*BitB
}
if res.StatusCode == http.StatusUnauthorized {
- return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when testing connection")
+ return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when testing connection. Please check your credentials.")
}
if res.StatusCode != http.StatusOK {
return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code when testing connection")
}
+
+ // Log deprecation warning if using App Password (not API token)
+ if !connection.UsesApiToken {
+ basicRes.GetLogger().Warn(nil, "Bitbucket App passwords are deprecated and will be deactivated on June 9, 2026. Please migrate to API tokens.")
+ }
+
connection = connection.Sanitize()
body := BitBucketTestConnResponse{}
body.Success = true
diff --git a/backend/plugins/bitbucket/api/connection_api_test.go b/backend/plugins/bitbucket/api/connection_api_test.go
new file mode 100644
index 00000000000..33b26d857cd
--- /dev/null
+++ b/backend/plugins/bitbucket/api/connection_api_test.go
@@ -0,0 +1,264 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 api
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/bitbucket/models"
+ "github.com/apache/incubator-devlake/server/api/shared"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTestConnection_Validation(t *testing.T) {
+ // Test that validation errors are handled correctly
+ connection := models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "", // Invalid: empty endpoint
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "token",
+ },
+ UsesApiToken: true,
+ }
+
+ // Note: This test would require mocking the validator and API client
+ // For now, we're testing the structure
+ assert.NotEmpty(t, connection.Username)
+ assert.NotEmpty(t, connection.Password)
+ assert.True(t, connection.UsesApiToken)
+}
+
+func TestBitbucketConn_UsesApiToken_ApiToken(t *testing.T) {
+ // Test API token connection structure
+ connection := models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.bitbucket.org/2.0/",
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "api_token_123",
+ },
+ UsesApiToken: true,
+ }
+
+ assert.True(t, connection.UsesApiToken)
+ assert.Equal(t, "user@example.com", connection.Username)
+ assert.Equal(t, "https://api.bitbucket.org/2.0/", connection.Endpoint)
+}
+
+func TestBitbucketConn_UsesApiToken_AppPassword(t *testing.T) {
+ // Test app password connection structure
+ connection := models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.bitbucket.org/2.0/",
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "bitbucket_username",
+ Password: "app_password_123",
+ },
+ UsesApiToken: false,
+ }
+
+ assert.False(t, connection.UsesApiToken)
+ assert.Equal(t, "bitbucket_username", connection.Username)
+ assert.Equal(t, "https://api.bitbucket.org/2.0/", connection.Endpoint)
+}
+
+func TestBitbucketConn_Sanitize_RemovesPassword(t *testing.T) {
+ // Test that Sanitize removes sensitive data
+ connection := models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.bitbucket.org/2.0/",
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "secret_token",
+ },
+ UsesApiToken: true,
+ }
+
+ sanitized := connection.Sanitize()
+ assert.Empty(t, sanitized.Password)
+ assert.Equal(t, "user@example.com", sanitized.Username)
+ assert.True(t, sanitized.UsesApiToken)
+}
+
+func TestBitBucketTestConnResponse_Structure(t *testing.T) {
+ // Test the response structure
+ connection := models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.bitbucket.org/2.0/",
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "",
+ },
+ UsesApiToken: true,
+ }
+
+ response := BitBucketTestConnResponse{
+ ApiBody: shared.ApiBody{
+ Success: true,
+ Message: "success",
+ },
+ Connection: &connection,
+ }
+
+ assert.True(t, response.Success)
+ assert.Equal(t, "success", response.Message)
+ assert.NotNil(t, response.Connection)
+ assert.True(t, response.Connection.UsesApiToken)
+}
+
+// TestTestConnection_DeprecationWarning tests that deprecation warnings are logged for app passwords
+func TestTestConnection_DeprecationWarning(t *testing.T) {
+ // This is a conceptual test showing what should be tested
+ // In a real implementation, you would mock the logger and verify the warning is called
+
+ connectionApiToken := models.BitbucketConn{
+ UsesApiToken: true,
+ }
+
+ connectionAppPassword := models.BitbucketConn{
+ UsesApiToken: false,
+ }
+
+ // For API token: no warning should be logged
+ assert.True(t, connectionApiToken.UsesApiToken, "API token connections should not trigger deprecation warning")
+
+ // For App password: warning should be logged
+ assert.False(t, connectionAppPassword.UsesApiToken, "App password connections should trigger deprecation warning")
+}
+
+// TestConnectionAuthentication_BothMethodsUseBasicAuth verifies that both auth methods use Basic Auth
+func TestConnectionAuthentication_BothMethodsUseBasicAuth(t *testing.T) {
+ // API Token connection
+ apiTokenConn := models.BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "api_token",
+ },
+ UsesApiToken: true,
+ }
+
+ // App Password connection
+ appPasswordConn := models.BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "bitbucket_username",
+ Password: "app_password",
+ },
+ UsesApiToken: false,
+ }
+
+ // Both should use BasicAuth for authentication
+ req1, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ err1 := apiTokenConn.SetupAuthentication(req1)
+ assert.Nil(t, err1)
+ assert.NotEmpty(t, req1.Header.Get("Authorization"))
+
+ req2, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ err2 := appPasswordConn.SetupAuthentication(req2)
+ assert.Nil(t, err2)
+ assert.NotEmpty(t, req2.Header.Get("Authorization"))
+}
+
+// TestMergeFromRequest_HandlesUsesApiToken tests that MergeFromRequest properly handles the UsesApiToken field
+func TestMergeFromRequest_HandlesUsesApiToken(t *testing.T) {
+ // Test that the UsesApiToken field is properly handled during merge operations
+ connection := models.BitbucketConnection{
+ BitbucketConn: models.BitbucketConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.bitbucket.org/2.0/",
+ },
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "token",
+ },
+ UsesApiToken: true,
+ },
+ }
+
+ // Simulate a merge with new values
+ newValues := map[string]interface{}{
+ "usesApiToken": false,
+ "username": "new_username",
+ }
+
+ // After merge, UsesApiToken should be updated
+ // This is a structural test - actual merge logic is in the connection.go MergeFromRequest method
+ assert.True(t, connection.UsesApiToken, "Initial value should be true")
+
+ // If we were to apply the merge:
+ connection.UsesApiToken = newValues["usesApiToken"].(bool)
+ connection.Username = newValues["username"].(string)
+
+ assert.False(t, connection.UsesApiToken, "After merge, should be false")
+ assert.Equal(t, "new_username", connection.Username)
+}
+
+func TestConnectionStatusCodes(t *testing.T) {
+ // Test expected status code handling
+ tests := []struct {
+ name string
+ statusCode int
+ expectedError bool
+ errorType string
+ }{
+ {
+ name: "Success - 200 OK",
+ statusCode: http.StatusOK,
+ expectedError: false,
+ },
+ {
+ name: "Unauthorized - 401",
+ statusCode: http.StatusUnauthorized,
+ expectedError: true,
+ errorType: "BadRequest",
+ },
+ {
+ name: "Forbidden - 403",
+ statusCode: http.StatusForbidden,
+ expectedError: true,
+ errorType: "Forbidden",
+ },
+ {
+ name: "Not Found - 404",
+ statusCode: http.StatusNotFound,
+ expectedError: true,
+ errorType: "NotFound",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test that different status codes are handled appropriately
+ if tt.statusCode == http.StatusOK {
+ assert.False(t, tt.expectedError)
+ } else if tt.statusCode == http.StatusUnauthorized {
+ assert.True(t, tt.expectedError)
+ assert.Equal(t, "BadRequest", tt.errorType)
+ } else {
+ assert.True(t, tt.expectedError)
+ }
+ })
+ }
+}
diff --git a/backend/plugins/bitbucket/models/connection.go b/backend/plugins/bitbucket/models/connection.go
index abf1ea88e2b..3396abe1e8b 100644
--- a/backend/plugins/bitbucket/models/connection.go
+++ b/backend/plugins/bitbucket/models/connection.go
@@ -18,6 +18,9 @@ limitations under the License.
package models
import (
+ "net/http"
+
+ "github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
)
@@ -28,11 +31,20 @@ var _ plugin.ApiConnection = (*BitbucketConnection)(nil)
type BitbucketConn struct {
api.RestConnection `mapstructure:",squash"`
api.BasicAuth `mapstructure:",squash"`
+ // UsesApiToken indicates whether the password field contains an API token (true)
+ // or an App password (false). Both use Basic Auth, but API tokens are the new standard.
+ UsesApiToken bool `mapstructure:"usesApiToken" json:"usesApiToken"`
}
-func (connection BitbucketConn) Sanitize() BitbucketConn {
- connection.Password = ""
- return connection
+func (bc BitbucketConn) Sanitize() BitbucketConn {
+ bc.Password = ""
+ return bc
+}
+
+// SetupAuthentication sets up HTTP Basic Authentication
+// Both App passwords and API tokens use Basic Auth with username:credential format
+func (bc *BitbucketConn) SetupAuthentication(req *http.Request) errors.Error {
+ return bc.BasicAuth.SetupAuthentication(req)
}
// BitbucketConnection holds BitbucketConn plus ID/Name for database storage
diff --git a/backend/plugins/bitbucket/models/connection_test.go b/backend/plugins/bitbucket/models/connection_test.go
new file mode 100644
index 00000000000..e11f238a78d
--- /dev/null
+++ b/backend/plugins/bitbucket/models/connection_test.go
@@ -0,0 +1,301 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 models
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBitbucketConn_SetupAuthentication_ApiToken(t *testing.T) {
+ // Test API token authentication
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "api_token_123",
+ },
+ UsesApiToken: true,
+ }
+
+ req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ assert.NoError(t, err)
+
+ authErr := conn.SetupAuthentication(req)
+ assert.Nil(t, authErr)
+ assert.NotEmpty(t, req.Header.Get("Authorization"))
+ assert.Contains(t, req.Header.Get("Authorization"), "Basic")
+}
+
+func TestBitbucketConn_SetupAuthentication_AppPassword(t *testing.T) {
+ // Test app password authentication
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "bitbucket_username",
+ Password: "app_password_123",
+ },
+ UsesApiToken: false,
+ }
+
+ req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ assert.NoError(t, err)
+
+ authErr := conn.SetupAuthentication(req)
+ assert.Nil(t, authErr)
+ assert.NotEmpty(t, req.Header.Get("Authorization"))
+ assert.Contains(t, req.Header.Get("Authorization"), "Basic")
+}
+
+func TestBitbucketConn_Sanitize(t *testing.T) {
+ // Test that Sanitize removes the password
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "secret_password",
+ },
+ UsesApiToken: true,
+ }
+
+ sanitized := conn.Sanitize()
+ assert.Empty(t, sanitized.Password)
+ assert.Equal(t, "user@example.com", sanitized.Username)
+ assert.True(t, sanitized.UsesApiToken)
+}
+
+func TestBitbucketConn_UsesApiToken_Default(t *testing.T) {
+ // Test that UsesApiToken can be set correctly
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "token",
+ },
+ UsesApiToken: true,
+ }
+
+ assert.True(t, conn.UsesApiToken)
+
+ // Test app password mode
+ conn2 := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "username",
+ Password: "password",
+ },
+ UsesApiToken: false,
+ }
+
+ assert.False(t, conn2.UsesApiToken)
+}
+
+func TestBitbucketConnection_Sanitize(t *testing.T) {
+ // Test that BitbucketConnection.Sanitize works correctly
+ connection := BitbucketConnection{
+ BitbucketConn: BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "secret_token",
+ },
+ UsesApiToken: true,
+ },
+ }
+
+ sanitized := connection.Sanitize()
+ assert.Empty(t, sanitized.Password)
+ assert.Equal(t, "user@example.com", sanitized.Username)
+ assert.True(t, sanitized.UsesApiToken)
+}
+
+func TestBitbucketConnection_TableName(t *testing.T) {
+ connection := BitbucketConnection{}
+ assert.Equal(t, "_tool_bitbucket_connections", connection.TableName())
+}
+
+func TestBitbucketConnection_MergeFromRequest_PreservesPassword(t *testing.T) {
+ original := &BitbucketConnection{
+ BitbucketConn: BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "secret_token",
+ },
+ UsesApiToken: true,
+ },
+ }
+
+ target := &BitbucketConnection{}
+ *target = *original // copy
+
+ // Update without password (empty password should preserve original)
+ body := map[string]interface{}{
+ "username": "new_user@example.com",
+ "usesApiToken": false,
+ }
+
+ err := original.MergeFromRequest(target, body)
+ assert.NoError(t, err)
+ assert.Equal(t, "new_user@example.com", target.Username)
+ assert.Equal(t, "secret_token", target.Password) // Should preserve
+ assert.False(t, target.UsesApiToken)
+}
+
+func TestBitbucketConnection_MergeFromRequest_UpdatesPassword(t *testing.T) {
+ original := &BitbucketConnection{
+ BitbucketConn: BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "old_token",
+ },
+ UsesApiToken: true,
+ },
+ }
+
+ target := &BitbucketConnection{}
+ *target = *original
+
+ // Update with new password
+ body := map[string]interface{}{
+ "username": "user@example.com",
+ "password": "new_token",
+ "usesApiToken": true,
+ }
+
+ err := original.MergeFromRequest(target, body)
+ assert.NoError(t, err)
+ assert.Equal(t, "new_token", target.Password) // Should update
+}
+
+func TestBitbucketConnection_MergeFromRequest_TogglesUsesApiToken(t *testing.T) {
+ original := &BitbucketConnection{
+ BitbucketConn: BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "credential",
+ },
+ UsesApiToken: false, // App password
+ },
+ }
+
+ target := &BitbucketConnection{}
+ *target = *original
+
+ // Toggle to API token
+ body := map[string]interface{}{
+ "usesApiToken": true,
+ "password": "api_token_123",
+ }
+
+ err := original.MergeFromRequest(target, body)
+ assert.NoError(t, err)
+ assert.True(t, target.UsesApiToken)
+ assert.Equal(t, "api_token_123", target.Password)
+}
+
+func TestBitbucketConn_SetupAuthentication_BasicAuthFormat(t *testing.T) {
+ // Test that BOTH methods produce Basic Auth (not Bearer)
+ tests := []struct {
+ name string
+ username string
+ password string
+ usesApiToken bool
+ }{
+ {
+ name: "API Token produces Basic Auth",
+ username: "user@example.com",
+ password: "api_token_123",
+ usesApiToken: true,
+ },
+ {
+ name: "App Password produces Basic Auth",
+ username: "bitbucket_username",
+ password: "app_password_123",
+ usesApiToken: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: tt.username,
+ Password: tt.password,
+ },
+ UsesApiToken: tt.usesApiToken,
+ }
+
+ req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ err := conn.SetupAuthentication(req)
+
+ assert.Nil(t, err)
+ authHeader := req.Header.Get("Authorization")
+ assert.NotEmpty(t, authHeader)
+ assert.Contains(t, authHeader, "Basic ", "Should use Basic auth, not Bearer")
+ assert.NotContains(t, authHeader, "Bearer", "Should NOT use Bearer token")
+ })
+ }
+}
+
+func TestBitbucketConn_EmptyPassword(t *testing.T) {
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "",
+ },
+ UsesApiToken: true,
+ }
+
+ req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ err := conn.SetupAuthentication(req)
+
+ // Should still set auth header (though it won't work in practice)
+ assert.Nil(t, err)
+ assert.NotEmpty(t, req.Header.Get("Authorization"))
+}
+
+func TestBitbucketConn_SpecialCharactersInPassword(t *testing.T) {
+ conn := BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "p@ssw0rd!#$%&*()+=",
+ },
+ UsesApiToken: true,
+ }
+
+ req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil)
+ err := conn.SetupAuthentication(req)
+
+ assert.Nil(t, err)
+ assert.NotEmpty(t, req.Header.Get("Authorization"))
+}
+
+func TestBitbucketConnection_Sanitize_PreservesUsesApiToken(t *testing.T) {
+ connection := BitbucketConnection{
+ BitbucketConn: BitbucketConn{
+ BasicAuth: api.BasicAuth{
+ Username: "user@example.com",
+ Password: "secret",
+ },
+ UsesApiToken: true,
+ },
+ }
+
+ sanitized := connection.Sanitize()
+
+ assert.Empty(t, sanitized.Password, "Password should be removed")
+ assert.Equal(t, "user@example.com", sanitized.Username, "Username should be preserved")
+ assert.True(t, sanitized.UsesApiToken, "UsesApiToken flag should be preserved")
+}
diff --git a/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go
new file mode 100644
index 00000000000..9f746d7ae54
--- /dev/null
+++ b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go
@@ -0,0 +1,64 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+var _ plugin.MigrationScript = (*addApiTokenAuth)(nil)
+
+type bitbucketConnection20251001 struct {
+ UsesApiToken bool `mapstructure:"usesApiToken" json:"usesApiToken"`
+}
+
+func (bitbucketConnection20251001) TableName() string {
+ return "_tool_bitbucket_connections"
+}
+
+type addApiTokenAuth struct{}
+
+func (script *addApiTokenAuth) Up(basicRes context.BasicRes) errors.Error {
+ // Add usesApiToken field to support API token tracking
+ // Existing connections will default to false (app password method)
+ err := migrationhelper.AutoMigrateTables(basicRes, &bitbucketConnection20251001{})
+ if err != nil {
+ return err
+ }
+
+ // Set default usesApiToken to false for existing connections
+ // This ensures backward compatibility with existing App password connections
+ db := basicRes.GetDal()
+ err = db.Exec("UPDATE _tool_bitbucket_connections SET uses_api_token = false WHERE uses_api_token IS NULL")
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (*addApiTokenAuth) Version() uint64 {
+ return 20251001000001
+}
+
+func (script *addApiTokenAuth) Name() string {
+ return "add API token authentication support to Bitbucket connections"
+}
diff --git a/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go
new file mode 100644
index 00000000000..b3ec57347c6
--- /dev/null
+++ b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go
@@ -0,0 +1,56 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 migrationscripts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAddApiTokenAuth_Version(t *testing.T) {
+ script := &addApiTokenAuth{}
+ assert.Equal(t, uint64(20251001000001), script.Version())
+}
+
+func TestAddApiTokenAuth_Name(t *testing.T) {
+ script := &addApiTokenAuth{}
+ assert.Equal(t, "add API token authentication support to Bitbucket connections", script.Name())
+}
+
+func TestBitbucketConnection20251001_TableName(t *testing.T) {
+ conn := bitbucketConnection20251001{}
+ assert.Equal(t, "_tool_bitbucket_connections", conn.TableName())
+}
+
+func TestBitbucketConnection20251001_Structure(t *testing.T) {
+ // Test that the migration struct has the correct field
+ conn := bitbucketConnection20251001{
+ UsesApiToken: true,
+ }
+ assert.True(t, conn.UsesApiToken)
+
+ conn2 := bitbucketConnection20251001{
+ UsesApiToken: false,
+ }
+ assert.False(t, conn2.UsesApiToken)
+}
+
+// Note: Full integration test of the Up() method requires a test database setup.
+// The migration is tested in practice when running the actual migrations against a database.
+// For unit testing purposes, we verify the structure and metadata.
diff --git a/backend/plugins/bitbucket/models/migrationscripts/register.go b/backend/plugins/bitbucket/models/migrationscripts/register.go
index 105af236446..c47f7db4f7d 100644
--- a/backend/plugins/bitbucket/models/migrationscripts/register.go
+++ b/backend/plugins/bitbucket/models/migrationscripts/register.go
@@ -42,5 +42,6 @@ func All() []plugin.MigrationScript {
new(reCreatBitBucketPipelineSteps),
new(addMergedByToPr),
new(changeIssueComponentType),
+ new(addApiTokenAuth),
}
}
diff --git a/backend/plugins/github_graphql/tasks/issue_extractor.go b/backend/plugins/github_graphql/tasks/issue_extractor.go
index 0ebd28d18c3..e27679c83e9 100644
--- a/backend/plugins/github_graphql/tasks/issue_extractor.go
+++ b/backend/plugins/github_graphql/tasks/issue_extractor.go
@@ -143,7 +143,7 @@ func convertGithubIssue(milestoneMap map[int]int, issue *GraphqlQueryIssue, conn
GithubCreatedAt: issue.CreatedAt,
GithubUpdatedAt: issue.UpdatedAt,
}
- if issue.AssigneeList.Assignees != nil && len(issue.AssigneeList.Assignees) > 0 {
+ if len(issue.AssigneeList.Assignees) > 0 {
githubIssue.AssigneeId = issue.AssigneeList.Assignees[0].Id
githubIssue.AssigneeName = issue.AssigneeList.Assignees[0].Login
}
diff --git a/backend/server/api/shared/api_output.go b/backend/server/api/shared/api_output.go
index b213f91e963..27e114d8cbc 100644
--- a/backend/server/api/shared/api_output.go
+++ b/backend/server/api/shared/api_output.go
@@ -18,7 +18,6 @@ limitations under the License.
package shared
import (
- "fmt"
"net/http"
"github.com/apache/incubator-devlake/core/errors"
@@ -124,7 +123,7 @@ func ApiOutputSuccess(c *gin.Context, body interface{}, status int) {
func ApiOutputAbort(c *gin.Context, err error) {
if e, ok := err.(errors.Error); ok {
logruslog.Global.Error(err, "HTTP %d abort-error", e.GetType().GetHttpCode())
- _ = c.AbortWithError(e.GetType().GetHttpCode(), fmt.Errorf(e.Messages().Format()))
+ _ = c.AbortWithError(e.GetType().GetHttpCode(), errors.Default.New(e.Messages().Format()))
} else {
logruslog.Global.Error(err, "HTTP %d abort-error (native)", http.StatusInternalServerError)
_ = c.AbortWithError(http.StatusInternalServerError, err)
diff --git a/backend/server/services/remote/plugin/plugin_impl.go b/backend/server/services/remote/plugin/plugin_impl.go
index 6242b160053..2c4b4a4daaa 100644
--- a/backend/server/services/remote/plugin/plugin_impl.go
+++ b/backend/server/services/remote/plugin/plugin_impl.go
@@ -186,20 +186,20 @@ func (p *remotePluginImpl) PrepareTaskData(taskCtx plugin.TaskContext, options m
}, nil
}
-func (p *remotePluginImpl) getScopeAndConfig(db dal.Dal, connectionId uint64, scopeId string) (interface{}, interface{}, errors.Error) {
+func (p *remotePluginImpl) getScopeAndConfig(db dal.Dal, connectionId uint64, scopeId string) (scope interface{}, scopeConfig interface{}, err errors.Error) {
wrappedScope := p.scopeTabler.New()
- err := api.CallDB(db.First, wrappedScope, dal.Where("connection_id = ? AND id = ?", connectionId, scopeId))
+ err = api.CallDB(db.First, wrappedScope, dal.Where("connection_id = ? AND id = ?", connectionId, scopeId))
if err != nil {
return nil, nil, errors.BadInput.New("Invalid scope id")
}
- scope := models.ScopeModel{}
- err = wrappedScope.To(&scope)
+ scopeModel := models.ScopeModel{}
+ err = wrappedScope.To(&scopeModel)
if err != nil {
return nil, nil, errors.BadInput.Wrap(err, "Invalid scope")
}
- if scope.ScopeConfigId != 0 {
+ if scopeModel.ScopeConfigId != 0 {
wrappedScopeConfig := p.scopeConfigTabler.New()
- err = api.CallDB(db.First, wrappedScopeConfig, dal.From(p.scopeConfigTabler.TableName()), dal.Where("id = ?", scope.ScopeConfigId))
+ err = api.CallDB(db.First, wrappedScopeConfig, dal.From(p.scopeConfigTabler.TableName()), dal.Where("id = ?", scopeModel.ScopeConfigId))
if err != nil {
return nil, nil, err
}
diff --git a/config-ui/public/onboard/step-2/bitbucket.md b/config-ui/public/onboard/step-2/bitbucket.md
index 18a04813896..7ffb71dfcc2 100644
--- a/config-ui/public/onboard/step-2/bitbucket.md
+++ b/config-ui/public/onboard/step-2/bitbucket.md
@@ -15,23 +15,38 @@ See the License for the specific language governing permissions and
limitations under the License.
-->
-##### Q1. How to generate a Bitbucket app password?
-
-1. Sign in at [bitbucket.org](https://bitbucket.org).
-2. Select the **Settings** cog in the upper-right corner of the top navigation bar.
-3. Under **Personal settings**, select **Personal Bitbucket settings**.
-4. On the left sidebar, select **App passwords**.
-5. Select **Create app password**.
-6. Give the 'App password' a name.
-7. Select the permissions the 'App password needs'. See **Q2**.
-8. Select the **Create** button.
-
-For detailed instructions, refer to [this doc](https://devlake.apache.org/docs/Configuration/BitBucket/#username-and-app-password).
-
-##### Q2. Which app password permissions should be included in a token?
-
-The following permissions are required to collect data from Bitbucket repositories:
-`Account:Read` `Workspace` `membership:Read` `Repositories:Read` `Projects:Read` `Pull requests:Read` `Issues:Read` `Pipelines:Read` `Runners:Read`
+##### Q1. How to generate a Bitbucket API token?
+
+**⚠️ Important: App passwords are being deprecated!**
+- Creation of App passwords will be discontinued on **September 9, 2025**
+- All existing App passwords will be deactivated on **June 9, 2026**
+- Please use API tokens for all new connections
+
+**To create an API token:**
+
+1. Sign in at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
+2. Select **Create API token with scopes**.
+3. Give the API token a name and an expiry date (ex: 365 days), then select **Next**.
+4. Select **Bitbucket** as the app and select **Next**.
+5. Select the required scopes (see **Q2**) and select **Next**.
+6. Review your token and select **Create token**.
+7. **Copy the generated API token immediately** - it's only displayed once and can't be retrieved later.
+
+For detailed instructions, refer to [Atlassian's API token documentation](https://support.atlassian.com/bitbucket-cloud/docs/create-an-api-token/).
+
+##### Q2. Which permissions (scopes) should be included in an API token?
+
+The following scopes are **required** to collect data from Bitbucket repositories:
+
+- `read:account` - Required to view users profiles
+- `read:issue:bitbucket` - View your issues
+- `read:pipeline:bitbucket` - View your pipelines
+- `read:project:bitbucket` - View your projects
+- `read:pullrequest:bitbucket` - View your pull requests
+- `read:repository:bitbucket` - View your repositories
+- `read:runner:bitbucket` - View your workspaces/repositories' runners
+- `read:user:bitbucket` - View user info (required for connection test)
+- `read:workspace:bitbucket` - View your workspaces
##### Q3. Is connecting to the Bitbucket Server/Data Center possible?
diff --git a/config-ui/src/plugins/register/bitbucket/config.tsx b/config-ui/src/plugins/register/bitbucket/config.tsx
index ba917187198..4de66d6a5d9 100644
--- a/config-ui/src/plugins/register/bitbucket/config.tsx
+++ b/config-ui/src/plugins/register/bitbucket/config.tsx
@@ -20,6 +20,7 @@ import { DOC_URL } from '@/release';
import { IPluginConfig } from '@/types';
import Icon from './assets/icon.svg?react';
+import { Auth } from './connection-fields';
export const BitbucketConfig: IPluginConfig = {
plugin: 'bitbucket',
@@ -30,19 +31,21 @@ export const BitbucketConfig: IPluginConfig = {
docLink: DOC_URL.PLUGIN.BITBUCKET.BASIS,
initialValues: {
endpoint: 'https://api.bitbucket.org/2.0/',
+ usesApiToken: true,
},
fields: [
'name',
- {
- key: 'endpoint',
- subLabel: 'You do not need to enter the endpoint URL, because all versions use the same URL.',
- disabled: true,
- },
- 'username',
- {
- key: 'password',
- label: 'App Password',
- },
+ ({ type, initialValues, values, errors, setValues, setErrors }: any) => (
+
+ ),
'proxy',
{
key: 'rateLimitPerHour',
diff --git a/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx b/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx
new file mode 100644
index 00000000000..f3e74f2cc83
--- /dev/null
+++ b/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ *
+ */
+
+import { useEffect } from 'react';
+import type { RadioChangeEvent } from 'antd';
+import { Radio, Input } from 'antd';
+
+import { Block, ExternalLink } from '@/components';
+import { DOC_URL } from '@/release';
+
+interface Props {
+ type: 'create' | 'update';
+ initialValues: any;
+ values: any;
+ errors: any;
+ setValues: (value: any) => void;
+ setErrors: (value: any) => void;
+}
+
+export const Auth = ({ type, initialValues, values, setValues, setErrors }: Props) => {
+ useEffect(() => {
+ setValues({
+ endpoint: initialValues.endpoint ?? 'https://api.bitbucket.org/2.0/',
+ usesApiToken: initialValues.usesApiToken ?? true,
+ username: initialValues.username,
+ password: initialValues.password,
+ });
+ }, [initialValues.endpoint, initialValues.usesApiToken, initialValues.username, initialValues.password]);
+
+ useEffect(() => {
+ const required = (values.username && values.password) || type === 'update';
+ setErrors({
+ endpoint: !values.endpoint ? 'endpoint is required' : '',
+ auth: required ? '' : 'auth is required',
+ });
+ }, [values]);
+
+ const handleChangeEndpoint = (e: React.ChangeEvent) => {
+ setValues({
+ endpoint: e.target.value,
+ });
+ };
+
+ const handleChangeMethod = (e: RadioChangeEvent) => {
+ setValues({
+ usesApiToken: (e.target as HTMLInputElement).value === 'apiToken',
+ username: undefined,
+ password: undefined,
+ });
+ };
+
+ const handleChangeUsername = (e: React.ChangeEvent) => {
+ setValues({
+ username: e.target.value,
+ });
+ };
+
+ const handleChangePassword = (e: React.ChangeEvent) => {
+ setValues({
+ password: e.target.value,
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ API Token (Recommended)
+ App Password (Deprecated)
+
+
+
+
+
+
+
+
+ {values.usesApiToken
+ ? 'Learn about how to create an API Token'
+ : 'Learn about how to create an App Password (deprecated)'}
+
+ }
+ required
+ >
+
+
+ >
+ );
+};
diff --git a/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts b/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts
new file mode 100644
index 00000000000..77bad54505f
--- /dev/null
+++ b/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ *
+ */
+
+export { Auth } from './auth';
diff --git a/config-ui/src/release/stable.ts b/config-ui/src/release/stable.ts
index 346f309c127..863ddf371da 100644
--- a/config-ui/src/release/stable.ts
+++ b/config-ui/src/release/stable.ts
@@ -46,6 +46,8 @@ const URLS = {
BITBUCKET: {
BASIS: 'https://devlake.apache.org/docs/Configuration/BitBucket',
RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/BitBucket#fixed-rate-limit-optional',
+ API_TOKEN: 'https://devlake.apache.org/docs/Configuration/BitBucket#api-token-recommended',
+ APP_PASSWORD: 'https://devlake.apache.org/docs/Configuration/BitBucket#app-password-deprecated',
TRANSFORMATION:
'https://devlake.apache.org/docs/Configuration/BitBucket#step-3---adding-transformation-rules-optional',
},
diff --git a/config-ui/src/routes/onboard/step-2.tsx b/config-ui/src/routes/onboard/step-2.tsx
index d730c0026a3..a01f2cf1aee 100644
--- a/config-ui/src/routes/onboard/step-2.tsx
+++ b/config-ui/src/routes/onboard/step-2.tsx
@@ -25,8 +25,6 @@ import { Markdown } from '@/components';
import { PATHS } from '@/config';
import { getPluginConfig } from '@/plugins';
import { ConnectionToken } from '@/plugins/components/connection-form/fields/token';
-import { ConnectionUsername } from '@/plugins/components/connection-form/fields/username';
-import { ConnectionPassword } from '@/plugins/components/connection-form/fields/password';
import { operator } from '@/utils';
import { Context } from './context';
@@ -42,6 +40,7 @@ const paramsMap: Record = {
},
bitbucket: {
endpoint: 'https://api.bitbucket.org/2.0/',
+ usesApiToken: true,
},
azuredevops: {},
};
@@ -57,6 +56,14 @@ export const Step2 = () => {
const config = useMemo(() => getPluginConfig(plugin as string), [plugin]);
+ // Get the auth field component for Bitbucket
+ const BitbucketAuthField = useMemo(() => {
+ if (plugin === 'bitbucket' && config?.connection?.fields) {
+ return config.connection.fields[1];
+ }
+ return null;
+ }, [plugin, config]);
+
useEffect(() => {
fetch(`/onboard/step-2/${plugin}.md`)
.then((res) => res.text())
@@ -133,7 +140,7 @@ export const Step2 = () => {
github: 'GitHub',
gitlab: 'GitLab',
azuredevops: 'Azure DevOps',
- }
+ };
return (
<>
@@ -145,8 +152,8 @@ export const Step2 = () => {
label="Personal Access Token"
subLabel={
- Create a personal access token in {platformNames[plugin]}. For self-managed {config.name}, please skip the onboarding
- and configure via Data Connections.
+ Create a personal access token in {platformNames[plugin]}. For self-managed {config.name}, please skip
+ the onboarding and configure via Data Connections.