This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 83b457eac feat(gitlab,bitbucket): warn when adding a repo already 
scoped on another connection (#9047)
83b457eac is described below

commit 83b457eacd8a121367422d3d97fb13ea24b00e56
Author: Francisc Munteanu <[email protected]>
AuthorDate: Fri Aug 14 09:38:21 2026 +0200

    feat(gitlab,bitbucket): warn when adding a repo already scoped on another 
connection (#9047)
    
    * feat(gitlab,bitbucket): extend scope-duplicate warning to GitLab and 
Bitbucket
    
    Add scope-duplicates API endpoints for GitLab and Bitbucket, mirroring the
    existing GitHub implementation, and generalize the config-ui 
data-scope-remote
    component and scope API client so the duplicate-connection warning works for
    all three plugins.
    
    Co-Authored-By: Cursor Agent <[email protected]>
    Co-authored-by: Cursor <[email protected]>
    
    * assert specific error message
    
    ---------
    
    Co-authored-by: Cursor Agent <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 .../plugins/bitbucket/api/scope_duplicates_api.go  | 200 ++++++++++++++++++++
 .../bitbucket/api/scope_duplicates_api_test.go     | 114 ++++++++++++
 backend/plugins/bitbucket/impl/impl.go             |   3 +
 backend/plugins/gitlab/api/scope_duplicates_api.go | 205 +++++++++++++++++++++
 .../gitlab/api/scope_duplicates_api_test.go        | 114 ++++++++++++
 backend/plugins/gitlab/impl/impl.go                |   3 +
 config-ui/src/api/scope/index.ts                   |  16 +-
 .../data-scope-remote/data-scope-remote.tsx        |  55 +++---
 8 files changed, 684 insertions(+), 26 deletions(-)

diff --git a/backend/plugins/bitbucket/api/scope_duplicates_api.go 
b/backend/plugins/bitbucket/api/scope_duplicates_api.go
new file mode 100644
index 000000000..0b7e998dd
--- /dev/null
+++ b/backend/plugins/bitbucket/api/scope_duplicates_api.go
@@ -0,0 +1,200 @@
+/*
+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"
+       "strconv"
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// ScopeDuplicateConnection is a connection that shares a repository scope.
+type ScopeDuplicateConnection struct {
+       ConnectionId   uint64 `json:"connectionId"`
+       ConnectionName string `json:"connectionName"`
+}
+
+// ScopeDuplicateGroup is one repository that appears under multiple 
connections
+// (diagnostics) or already exists under another connection (pre-add check).
+// Unlike GitHub/GitLab, a Bitbucket repo's natural id (BitbucketId) is the
+// "owner/repo" full name string rather than a numeric id.
+type ScopeDuplicateGroup struct {
+       BitbucketId string                     `json:"bitbucketId"`
+       HTMLUrl     string                     `json:"htmlUrl"`
+       FullName    string                     `json:"fullName"`
+       Connections []ScopeDuplicateConnection `json:"connections"`
+}
+
+// ScopeDuplicatesOutput is the response body for GetScopeDuplicates.
+type ScopeDuplicatesOutput struct {
+       Duplicates []ScopeDuplicateGroup `json:"duplicates"`
+}
+
+// scopeDuplicateRow is one joined row from the scoped SQL query.
+type scopeDuplicateRow struct {
+       BitbucketId    string `gorm:"column:bitbucket_id"`
+       HTMLUrl        string `gorm:"column:html_url"`
+       ConnectionId   uint64 `gorm:"column:connection_id"`
+       ConnectionName string `gorm:"column:connection_name"`
+}
+
+// GetScopeDuplicates returns Bitbucket repositories registered under more than
+// one connection, or (with connectionId + bitbucketIds) candidates already
+// present on other connections.
+// @Summary Find Bitbucket scopes duplicated across connections
+// @Description Diagnostics: groups where the same bitbucketId (owner/repo) 
appears on more than one connection.
+// @Description Pre-add check: pass connectionId and bitbucketIds to find 
candidates already registered elsewhere.
+// @Tags plugins/bitbucket
+// @Param connectionId query int false "Current connection id (pre-add check)"
+// @Param bitbucketIds query string false "Comma-separated Bitbucket repo full 
names (owner/repo) to check (pre-add check)"
+// @Success 200 {object} ScopeDuplicatesOutput
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/bitbucket/scope-duplicates [GET]
+func GetScopeDuplicates(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       connectionId, bitbucketIds, err := parseScopeDuplicateQuery(input)
+       if err != nil {
+               return nil, err
+       }
+
+       // Pre-add check with an empty selection: nothing to warn about.
+       if connectionId != nil && len(bitbucketIds) == 0 {
+               return &plugin.ApiResourceOutput{
+                       Body:   ScopeDuplicatesOutput{Duplicates: 
[]ScopeDuplicateGroup{}},
+                       Status: http.StatusOK,
+               }, nil
+       }
+
+       rows, err := queryScopeDuplicateRows(basicRes.GetDal(), connectionId, 
bitbucketIds)
+       if err != nil {
+               return nil, err
+       }
+
+       return &plugin.ApiResourceOutput{
+               Body:   ScopeDuplicatesOutput{Duplicates: 
groupScopeDuplicateRows(rows)},
+               Status: http.StatusOK,
+       }, nil
+}
+
+func parseScopeDuplicateQuery(input *plugin.ApiResourceInput) (*uint64, 
[]string, errors.Error) {
+       var connectionId *uint64
+       if v := input.Query.Get("connectionId"); v != "" {
+               id, err := strconv.ParseUint(v, 10, 64)
+               if err != nil {
+                       return nil, nil, errors.BadInput.Wrap(err, "invalid 
connectionId")
+               }
+               connectionId = &id
+       }
+
+       var bitbucketIds []string
+       if v := input.Query.Get("bitbucketIds"); v != "" {
+               for _, part := range strings.Split(v, ",") {
+                       part = strings.TrimSpace(part)
+                       if part == "" {
+                               continue
+                       }
+                       bitbucketIds = append(bitbucketIds, part)
+               }
+       }
+
+       if len(bitbucketIds) > 0 && connectionId == nil {
+               return nil, nil, errors.BadInput.New("connectionId is required 
when bitbucketIds is provided")
+       }
+
+       return connectionId, bitbucketIds, nil
+}
+
+// queryScopeDuplicateRows loads only the rows needed for the requested mode.
+// Check mode: selected bitbucketIds on any connection other than connectionId.
+// Diagnostics: bitbucketIds that already appear on more than one connection.
+func queryScopeDuplicateRows(db dal.Dal, connectionId *uint64, bitbucketIds 
[]string) ([]scopeDuplicateRow, errors.Error) {
+       clauses := []dal.Clause{
+               dal.Select("r.bitbucket_id, r.html_url, r.connection_id, c.name 
AS connection_name"),
+               dal.From("_tool_bitbucket_repos r"),
+               dal.Join("INNER JOIN _tool_bitbucket_connections c ON c.id = 
r.connection_id"),
+               dal.Orderby("r.bitbucket_id ASC, r.connection_id ASC"),
+       }
+
+       if connectionId != nil {
+               clauses = append(clauses, dal.Where(
+                       "r.bitbucket_id IN ? AND r.connection_id != ?",
+                       bitbucketIds,
+                       *connectionId,
+               ))
+       } else {
+               clauses = append(clauses, dal.Where(`r.bitbucket_id IN (
+                       SELECT bitbucket_id FROM _tool_bitbucket_repos
+                       GROUP BY bitbucket_id
+                       HAVING COUNT(DISTINCT connection_id) > 1
+               )`))
+       }
+
+       var rows []scopeDuplicateRow
+       if err := db.All(&rows, clauses...); err != nil {
+               return nil, err
+       }
+       return rows, nil
+}
+
+// groupScopeDuplicateRows collapses already-filtered SQL rows into API groups.
+func groupScopeDuplicateRows(rows []scopeDuplicateRow) []ScopeDuplicateGroup {
+       if len(rows) == 0 {
+               return []ScopeDuplicateGroup{}
+       }
+
+       result := make([]ScopeDuplicateGroup, 0)
+       var current *ScopeDuplicateGroup
+       seenConns := make(map[uint64]struct{})
+
+       flush := func() {
+               if current != nil {
+                       result = append(result, *current)
+               }
+       }
+
+       for _, row := range rows {
+               if current == nil || current.BitbucketId != row.BitbucketId {
+                       flush()
+                       current = &ScopeDuplicateGroup{
+                               BitbucketId: row.BitbucketId,
+                               HTMLUrl:     row.HTMLUrl,
+                               // Bitbucket's "full name" is the owner/repo id 
itself, see BitbucketRepo.ScopeFullName().
+                               FullName:    row.BitbucketId,
+                               Connections: make([]ScopeDuplicateConnection, 
0, 2),
+                       }
+                       seenConns = make(map[uint64]struct{})
+               }
+               if current.HTMLUrl == "" && row.HTMLUrl != "" {
+                       current.HTMLUrl = row.HTMLUrl
+               }
+               if _, ok := seenConns[row.ConnectionId]; ok {
+                       continue
+               }
+               seenConns[row.ConnectionId] = struct{}{}
+               current.Connections = append(current.Connections, 
ScopeDuplicateConnection{
+                       ConnectionId:   row.ConnectionId,
+                       ConnectionName: row.ConnectionName,
+               })
+       }
+       flush()
+       return result
+}
diff --git a/backend/plugins/bitbucket/api/scope_duplicates_api_test.go 
b/backend/plugins/bitbucket/api/scope_duplicates_api_test.go
new file mode 100644
index 000000000..13a31b493
--- /dev/null
+++ b/backend/plugins/bitbucket/api/scope_duplicates_api_test.go
@@ -0,0 +1,114 @@
+/*
+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/url"
+       "testing"
+
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/stretchr/testify/assert"
+)
+
+func TestGroupScopeDuplicateRows_Empty(t *testing.T) {
+       assert.Empty(t, groupScopeDuplicateRows(nil))
+       assert.Empty(t, groupScopeDuplicateRows([]scopeDuplicateRow{}))
+}
+
+func TestGroupScopeDuplicateRows_GroupsConnections(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {BitbucketId: "o/a", HTMLUrl: "https://bitbucket.org/o/a";, 
ConnectionId: 1, ConnectionName: "Bitbucket Production"},
+               {BitbucketId: "o/a", HTMLUrl: "https://bitbucket.org/o/a";, 
ConnectionId: 2, ConnectionName: "Bitbucket Staging"},
+               {BitbucketId: "o/b", HTMLUrl: "https://bitbucket.org/o/b";, 
ConnectionId: 3, ConnectionName: "Other"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Equal(t, []ScopeDuplicateGroup{
+               {
+                       BitbucketId: "o/a",
+                       HTMLUrl:     "https://bitbucket.org/o/a";,
+                       FullName:    "o/a",
+                       Connections: []ScopeDuplicateConnection{
+                               {ConnectionId: 1, ConnectionName: "Bitbucket 
Production"},
+                               {ConnectionId: 2, ConnectionName: "Bitbucket 
Staging"},
+                       },
+               },
+               {
+                       BitbucketId: "o/b",
+                       HTMLUrl:     "https://bitbucket.org/o/b";,
+                       FullName:    "o/b",
+                       Connections: []ScopeDuplicateConnection{
+                               {ConnectionId: 3, ConnectionName: "Other"},
+                       },
+               },
+       }, got)
+}
+
+func TestGroupScopeDuplicateRows_DedupesSameConnection(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {BitbucketId: "o/a", ConnectionId: 1, ConnectionName: "Prod"},
+               {BitbucketId: "o/a", ConnectionId: 1, ConnectionName: "Prod"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Len(t, got, 1)
+       assert.Equal(t, []ScopeDuplicateConnection{
+               {ConnectionId: 1, ConnectionName: "Prod"},
+       }, got[0].Connections)
+}
+
+func TestGroupScopeDuplicateRows_FillsMissingHTMLUrl(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {BitbucketId: "o/a", ConnectionId: 1, ConnectionName: "A"},
+               {BitbucketId: "o/a", HTMLUrl: "https://bitbucket.org/o/a";, 
ConnectionId: 2, ConnectionName: "B"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Len(t, got, 1)
+       assert.Equal(t, "https://bitbucket.org/o/a";, got[0].HTMLUrl)
+       assert.Equal(t, "o/a", got[0].FullName)
+}
+
+func TestParseScopeDuplicateQuery(t *testing.T) {
+       input := &plugin.ApiResourceInput{Query: url.Values{}}
+       connId, ids, err := parseScopeDuplicateQuery(input)
+       assert.Nil(t, err)
+       assert.Nil(t, connId)
+       assert.Empty(t, ids)
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "connectionId": []string{"3"},
+               "bitbucketIds": []string{"o/a, o/b,o/c"},
+       }}
+       connId, ids, err = parseScopeDuplicateQuery(input)
+       assert.Nil(t, err)
+       assert.Equal(t, uint64(3), *connId)
+       assert.Equal(t, []string{"o/a", "o/b", "o/c"}, ids)
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "bitbucketIds": []string{"o/a"},
+       }}
+       _, _, err = parseScopeDuplicateQuery(input)
+       assert.Contains(t, err.Error(), "connectionId is required when 
bitbucketIds is provided")
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "connectionId": []string{"abc"},
+       }}
+       _, _, err = parseScopeDuplicateQuery(input)
+       assert.Contains(t, err.Error(), "invalid connectionId")
+}
diff --git a/backend/plugins/bitbucket/impl/impl.go 
b/backend/plugins/bitbucket/impl/impl.go
index 9586294ec..051e9b783 100644
--- a/backend/plugins/bitbucket/impl/impl.go
+++ b/backend/plugins/bitbucket/impl/impl.go
@@ -243,6 +243,9 @@ func (p Bitbucket) ApiResources() 
map[string]map[string]plugin.ApiResourceHandle
                "scope-config/:scopeConfigId/projects": {
                        "GET": api.GetProjectsByScopeConfig,
                },
+               "scope-duplicates": {
+                       "GET": api.GetScopeDuplicates,
+               },
        }
 }
 
diff --git a/backend/plugins/gitlab/api/scope_duplicates_api.go 
b/backend/plugins/gitlab/api/scope_duplicates_api.go
new file mode 100644
index 000000000..dd0517d02
--- /dev/null
+++ b/backend/plugins/gitlab/api/scope_duplicates_api.go
@@ -0,0 +1,205 @@
+/*
+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"
+       "strconv"
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// ScopeDuplicateConnection is a connection that shares a project scope.
+type ScopeDuplicateConnection struct {
+       ConnectionId   uint64 `json:"connectionId"`
+       ConnectionName string `json:"connectionName"`
+}
+
+// ScopeDuplicateGroup is one project that appears under multiple connections
+// (diagnostics) or already exists under another connection (pre-add check).
+type ScopeDuplicateGroup struct {
+       GitlabId    int                        `json:"gitlabId"`
+       HTMLUrl     string                     `json:"htmlUrl"`
+       FullName    string                     `json:"fullName"`
+       Connections []ScopeDuplicateConnection `json:"connections"`
+}
+
+// ScopeDuplicatesOutput is the response body for GetScopeDuplicates.
+type ScopeDuplicatesOutput struct {
+       Duplicates []ScopeDuplicateGroup `json:"duplicates"`
+}
+
+// scopeDuplicateRow is one joined row from the scoped SQL query.
+type scopeDuplicateRow struct {
+       GitlabId       int    `gorm:"column:gitlab_id"`
+       HTMLUrl        string `gorm:"column:html_url"`
+       FullName       string `gorm:"column:full_name"`
+       ConnectionId   uint64 `gorm:"column:connection_id"`
+       ConnectionName string `gorm:"column:connection_name"`
+}
+
+// GetScopeDuplicates returns GitLab projects registered under more than one
+// connection, or (with connectionId + gitlabIds) candidates already present on
+// other connections.
+// @Summary Find GitLab scopes duplicated across connections
+// @Description Diagnostics: groups where the same gitlabId appears on more 
than one connection.
+// @Description Pre-add check: pass connectionId and gitlabIds to find 
candidates already registered elsewhere.
+// @Tags plugins/gitlab
+// @Param connectionId query int false "Current connection id (pre-add check)"
+// @Param gitlabIds query string false "Comma-separated GitLab project ids to 
check (pre-add check)"
+// @Success 200 {object} ScopeDuplicatesOutput
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/gitlab/scope-duplicates [GET]
+func GetScopeDuplicates(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       connectionId, gitlabIds, err := parseScopeDuplicateQuery(input)
+       if err != nil {
+               return nil, err
+       }
+
+       // Pre-add check with an empty selection: nothing to warn about.
+       if connectionId != nil && len(gitlabIds) == 0 {
+               return &plugin.ApiResourceOutput{
+                       Body:   ScopeDuplicatesOutput{Duplicates: 
[]ScopeDuplicateGroup{}},
+                       Status: http.StatusOK,
+               }, nil
+       }
+
+       rows, err := queryScopeDuplicateRows(basicRes.GetDal(), connectionId, 
gitlabIds)
+       if err != nil {
+               return nil, err
+       }
+
+       return &plugin.ApiResourceOutput{
+               Body:   ScopeDuplicatesOutput{Duplicates: 
groupScopeDuplicateRows(rows)},
+               Status: http.StatusOK,
+       }, nil
+}
+
+func parseScopeDuplicateQuery(input *plugin.ApiResourceInput) (*uint64, []int, 
errors.Error) {
+       var connectionId *uint64
+       if v := input.Query.Get("connectionId"); v != "" {
+               id, err := strconv.ParseUint(v, 10, 64)
+               if err != nil {
+                       return nil, nil, errors.BadInput.Wrap(err, "invalid 
connectionId")
+               }
+               connectionId = &id
+       }
+
+       var gitlabIds []int
+       if v := input.Query.Get("gitlabIds"); v != "" {
+               for _, part := range strings.Split(v, ",") {
+                       part = strings.TrimSpace(part)
+                       if part == "" {
+                               continue
+                       }
+                       id, err := strconv.Atoi(part)
+                       if err != nil {
+                               return nil, nil, errors.BadInput.Wrap(err, 
"invalid gitlabIds")
+                       }
+                       gitlabIds = append(gitlabIds, id)
+               }
+       }
+
+       if len(gitlabIds) > 0 && connectionId == nil {
+               return nil, nil, errors.BadInput.New("connectionId is required 
when gitlabIds is provided")
+       }
+
+       return connectionId, gitlabIds, nil
+}
+
+// queryScopeDuplicateRows loads only the rows needed for the requested mode.
+// Check mode: selected gitlabIds on any connection other than connectionId.
+// Diagnostics: gitlabIds that already appear on more than one connection.
+func queryScopeDuplicateRows(db dal.Dal, connectionId *uint64, gitlabIds 
[]int) ([]scopeDuplicateRow, errors.Error) {
+       clauses := []dal.Clause{
+               dal.Select("r.gitlab_id, r.web_url AS html_url, 
r.path_with_namespace AS full_name, r.connection_id, c.name AS 
connection_name"),
+               dal.From("_tool_gitlab_projects r"),
+               dal.Join("INNER JOIN _tool_gitlab_connections c ON c.id = 
r.connection_id"),
+               dal.Orderby("r.gitlab_id ASC, r.connection_id ASC"),
+       }
+
+       if connectionId != nil {
+               clauses = append(clauses, dal.Where(
+                       "r.gitlab_id IN ? AND r.connection_id != ?",
+                       gitlabIds,
+                       *connectionId,
+               ))
+       } else {
+               clauses = append(clauses, dal.Where(`r.gitlab_id IN (
+                       SELECT gitlab_id FROM _tool_gitlab_projects
+                       GROUP BY gitlab_id
+                       HAVING COUNT(DISTINCT connection_id) > 1
+               )`))
+       }
+
+       var rows []scopeDuplicateRow
+       if err := db.All(&rows, clauses...); err != nil {
+               return nil, err
+       }
+       return rows, nil
+}
+
+// groupScopeDuplicateRows collapses already-filtered SQL rows into API groups.
+func groupScopeDuplicateRows(rows []scopeDuplicateRow) []ScopeDuplicateGroup {
+       if len(rows) == 0 {
+               return []ScopeDuplicateGroup{}
+       }
+
+       result := make([]ScopeDuplicateGroup, 0)
+       var current *ScopeDuplicateGroup
+       seenConns := make(map[uint64]struct{})
+
+       flush := func() {
+               if current != nil {
+                       result = append(result, *current)
+               }
+       }
+
+       for _, row := range rows {
+               if current == nil || current.GitlabId != row.GitlabId {
+                       flush()
+                       current = &ScopeDuplicateGroup{
+                               GitlabId:    row.GitlabId,
+                               HTMLUrl:     row.HTMLUrl,
+                               FullName:    row.FullName,
+                               Connections: make([]ScopeDuplicateConnection, 
0, 2),
+                       }
+                       seenConns = make(map[uint64]struct{})
+               }
+               if current.HTMLUrl == "" && row.HTMLUrl != "" {
+                       current.HTMLUrl = row.HTMLUrl
+               }
+               if current.FullName == "" && row.FullName != "" {
+                       current.FullName = row.FullName
+               }
+               if _, ok := seenConns[row.ConnectionId]; ok {
+                       continue
+               }
+               seenConns[row.ConnectionId] = struct{}{}
+               current.Connections = append(current.Connections, 
ScopeDuplicateConnection{
+                       ConnectionId:   row.ConnectionId,
+                       ConnectionName: row.ConnectionName,
+               })
+       }
+       flush()
+       return result
+}
diff --git a/backend/plugins/gitlab/api/scope_duplicates_api_test.go 
b/backend/plugins/gitlab/api/scope_duplicates_api_test.go
new file mode 100644
index 000000000..dd4d8d510
--- /dev/null
+++ b/backend/plugins/gitlab/api/scope_duplicates_api_test.go
@@ -0,0 +1,114 @@
+/*
+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/url"
+       "testing"
+
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/stretchr/testify/assert"
+)
+
+func TestGroupScopeDuplicateRows_Empty(t *testing.T) {
+       assert.Empty(t, groupScopeDuplicateRows(nil))
+       assert.Empty(t, groupScopeDuplicateRows([]scopeDuplicateRow{}))
+}
+
+func TestGroupScopeDuplicateRows_GroupsConnections(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {GitlabId: 100, HTMLUrl: "https://gitlab.com/o/a";, FullName: 
"o/a", ConnectionId: 1, ConnectionName: "GitLab Production"},
+               {GitlabId: 100, HTMLUrl: "https://gitlab.com/o/a";, FullName: 
"o/a", ConnectionId: 2, ConnectionName: "GitLab Staging"},
+               {GitlabId: 200, HTMLUrl: "https://gitlab.com/o/b";, FullName: 
"o/b", ConnectionId: 3, ConnectionName: "Other"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Equal(t, []ScopeDuplicateGroup{
+               {
+                       GitlabId: 100,
+                       HTMLUrl:  "https://gitlab.com/o/a";,
+                       FullName: "o/a",
+                       Connections: []ScopeDuplicateConnection{
+                               {ConnectionId: 1, ConnectionName: "GitLab 
Production"},
+                               {ConnectionId: 2, ConnectionName: "GitLab 
Staging"},
+                       },
+               },
+               {
+                       GitlabId: 200,
+                       HTMLUrl:  "https://gitlab.com/o/b";,
+                       FullName: "o/b",
+                       Connections: []ScopeDuplicateConnection{
+                               {ConnectionId: 3, ConnectionName: "Other"},
+                       },
+               },
+       }, got)
+}
+
+func TestGroupScopeDuplicateRows_DedupesSameConnection(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {GitlabId: 100, FullName: "o/a", ConnectionId: 1, 
ConnectionName: "Prod"},
+               {GitlabId: 100, FullName: "o/a", ConnectionId: 1, 
ConnectionName: "Prod"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Len(t, got, 1)
+       assert.Equal(t, []ScopeDuplicateConnection{
+               {ConnectionId: 1, ConnectionName: "Prod"},
+       }, got[0].Connections)
+}
+
+func TestGroupScopeDuplicateRows_FillsMissingLabels(t *testing.T) {
+       rows := []scopeDuplicateRow{
+               {GitlabId: 100, ConnectionId: 1, ConnectionName: "A"},
+               {GitlabId: 100, HTMLUrl: "https://gitlab.com/o/a";, FullName: 
"o/a", ConnectionId: 2, ConnectionName: "B"},
+       }
+
+       got := groupScopeDuplicateRows(rows)
+       assert.Len(t, got, 1)
+       assert.Equal(t, "https://gitlab.com/o/a";, got[0].HTMLUrl)
+       assert.Equal(t, "o/a", got[0].FullName)
+}
+
+func TestParseScopeDuplicateQuery(t *testing.T) {
+       input := &plugin.ApiResourceInput{Query: url.Values{}}
+       connId, ids, err := parseScopeDuplicateQuery(input)
+       assert.Nil(t, err)
+       assert.Nil(t, connId)
+       assert.Empty(t, ids)
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "connectionId": []string{"3"},
+               "gitlabIds":    []string{"10, 20,30"},
+       }}
+       connId, ids, err = parseScopeDuplicateQuery(input)
+       assert.Nil(t, err)
+       assert.Equal(t, uint64(3), *connId)
+       assert.Equal(t, []int{10, 20, 30}, ids)
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "gitlabIds": []string{"10"},
+       }}
+       _, _, err = parseScopeDuplicateQuery(input)
+       assert.Contains(t, err.Error(), "connectionId is required when 
gitlabIds is provided")
+
+       input = &plugin.ApiResourceInput{Query: url.Values{
+               "connectionId": []string{"abc"},
+       }}
+       _, _, err = parseScopeDuplicateQuery(input)
+       assert.Contains(t, err.Error(), "invalid connectionId")
+}
diff --git a/backend/plugins/gitlab/impl/impl.go 
b/backend/plugins/gitlab/impl/impl.go
index 39e245c43..3dbd36720 100644
--- a/backend/plugins/gitlab/impl/impl.go
+++ b/backend/plugins/gitlab/impl/impl.go
@@ -279,6 +279,9 @@ func (p Gitlab) ApiResources() 
map[string]map[string]plugin.ApiResourceHandler {
                "scope-config/:scopeConfigId/projects": {
                        "GET": api.GetProjectsByScopeConfig,
                },
+               "scope-duplicates": {
+                       "GET": api.GetScopeDuplicates,
+               },
        }
 }
 
diff --git a/config-ui/src/api/scope/index.ts b/config-ui/src/api/scope/index.ts
index 8996f4662..359e6b9c7 100644
--- a/config-ui/src/api/scope/index.ts
+++ b/config-ui/src/api/scope/index.ts
@@ -102,21 +102,27 @@ export type ScopeDuplicateConnection = {
   connectionName: string;
 };
 
+// The plugin-specific id field (githubId/gitlabId/bitbucketId) is 
intentionally
+// omitted here since the UI only ever needs 
`htmlUrl`/`fullName`/`connections`.
 export type ScopeDuplicateGroup = {
-  githubId: number;
   htmlUrl: string;
   fullName: string;
   connections: ScopeDuplicateConnection[];
 };
 
+// The query param that carries the comma-separated scope ids is named 
differently per
+// plugin (githubIds/gitlabIds/bitbucketIds), so callers pass it in via 
`idsParam`.
 export const scopeDuplicates = (
   plugin: string,
   data?: {
     connectionId?: ID;
-    githubIds?: string;
+    idsParam?: string;
+    ids?: string;
   },
-): Promise<{ duplicates: ScopeDuplicateGroup[] }> =>
-  request(`/plugins/${plugin}/scope-duplicates`, {
+): Promise<{ duplicates: ScopeDuplicateGroup[] }> => {
+  const { idsParam, ids, ...rest } = data ?? {};
+  return request(`/plugins/${plugin}/scope-duplicates`, {
     method: 'get',
-    data,
+    data: idsParam && ids !== undefined ? { ...rest, [idsParam]: ids } : rest,
   });
+};
diff --git 
a/config-ui/src/plugins/components/data-scope-remote/data-scope-remote.tsx 
b/config-ui/src/plugins/components/data-scope-remote/data-scope-remote.tsx
index 7453cbedc..896629e3f 100644
--- a/config-ui/src/plugins/components/data-scope-remote/data-scope-remote.tsx
+++ b/config-ui/src/plugins/components/data-scope-remote/data-scope-remote.tsx
@@ -39,28 +39,38 @@ interface Props {
   onSubmit?: (origin: any) => void;
 }
 
-const getGithubId = (scope: any): number | undefined => {
-  const fromData = scope?.data?.githubId;
+// Plugins that support the "warn on duplicate scope" check, and the 
field/query-param
+// names their scope-duplicates API uses. Bitbucket's id is a string 
("owner/repo"),
+// GitHub/GitLab's is numeric, but both work fine as strings on the wire.
+const SCOPE_DUPLICATE_FIELDS: Record<string, { dataField: string; queryParam: 
string }> = {
+  github: { dataField: 'githubId', queryParam: 'githubIds' },
+  gitlab: { dataField: 'gitlabId', queryParam: 'gitlabIds' },
+  bitbucket: { dataField: 'bitbucketId', queryParam: 'bitbucketIds' },
+};
+
+const getScopeDuplicateId = (plugin: string, scope: any): string | undefined 
=> {
+  const dataField = SCOPE_DUPLICATE_FIELDS[plugin]?.dataField;
+  const fromData = dataField ? scope?.data?.[dataField] : undefined;
   if (typeof fromData === 'number' && fromData > 0) {
+    return String(fromData);
+  }
+  if (typeof fromData === 'string' && fromData) {
     return fromData;
   }
-  const fromId = Number(scope?.id);
-  if (!Number.isNaN(fromId) && fromId > 0) {
-    return fromId;
+  if (scope?.id !== undefined && scope?.id !== null && String(scope.id) !== 
'') {
+    return String(scope.id);
   }
   return undefined;
 };
 
 const buildDuplicateWarning = (duplicates: ScopeDuplicateGroup[]): string => {
   const connectionNames = Array.from(
-    new Set(
-      duplicates.flatMap((d) => d.connections.map((c) => 
c.connectionName).filter(Boolean)),
-    ),
+    new Set(duplicates.flatMap((d) => d.connections.map((c) => 
c.connectionName).filter(Boolean))),
   );
   const repoLabel =
     duplicates.length === 1
-      ? duplicates[0].fullName || duplicates[0].htmlUrl || 'This repository'
-      : 'One or more selected repositories';
+      ? duplicates[0].fullName || duplicates[0].htmlUrl || 'This item'
+      : 'One or more selected items';
 
   const via =
     connectionNames.length === 1
@@ -69,7 +79,7 @@ const buildDuplicateWarning = (duplicates: 
ScopeDuplicateGroup[]): string => {
         ? `Connections ${connectionNames.map((n) => `"${n}"`).join(', ')}`
         : 'another connection';
 
-  return `${repoLabel} is already connected via ${via}. Collecting it here 
will create duplicate pull requests and issue records, which will inflate all 
metrics for this repository.`;
+  return `${repoLabel} is already connected via ${via}. Collecting it here 
will create duplicate records, which will inflate all metrics for this 
repository.`;
 };
 
 export const DataScopeRemote = ({
@@ -94,19 +104,21 @@ export const DataScopeRemote = ({
 
   const config = useMemo(() => getPluginConfig(plugin).dataScope, [plugin]);
 
-  const githubIdsKey = useMemo(() => {
-    if (plugin !== 'github') {
+  const duplicateFields = SCOPE_DUPLICATE_FIELDS[plugin];
+
+  const idsKey = useMemo(() => {
+    if (!duplicateFields) {
       return '';
     }
     return selectedScope
-      .map(getGithubId)
-      .filter((id): id is number => id !== undefined)
-      .sort((a, b) => a - b)
+      .map((it) => getScopeDuplicateId(plugin, it))
+      .filter((id): id is string => id !== undefined)
+      .sort()
       .join(',');
-  }, [plugin, selectedScope]);
+  }, [plugin, duplicateFields, selectedScope]);
 
   useEffect(() => {
-    if (plugin !== 'github' || !githubIdsKey) {
+    if (!duplicateFields || !idsKey) {
       setDuplicates([]);
       setWarningDismissed(false);
       return;
@@ -118,7 +130,8 @@ export const DataScopeRemote = ({
     API.scope
       .scopeDuplicates(plugin, {
         connectionId,
-        githubIds: githubIdsKey,
+        idsParam: duplicateFields.queryParam,
+        ids: idsKey,
       })
       .then((res) => {
         if (!cancelled) {
@@ -134,7 +147,7 @@ export const DataScopeRemote = ({
     return () => {
       cancelled = true;
     };
-  }, [plugin, connectionId, githubIdsKey]);
+  }, [plugin, connectionId, duplicateFields, idsKey]);
 
   const handleSubmit = async () => {
     const [success, res] = await operator(
@@ -150,7 +163,7 @@ export const DataScopeRemote = ({
     }
   };
 
-  const showWarning = plugin === 'github' && duplicates.length > 0 && 
!warningDismissed;
+  const showWarning = !!duplicateFields && duplicates.length > 0 && 
!warningDismissed;
 
   return (
     <Flex vertical>

Reply via email to