Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions backend/plugins/bitbucket/api/scope_duplicates_api.go
Original file line number Diff line number Diff line change
@@ -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
}
114 changes: 114 additions & 0 deletions backend/plugins/bitbucket/api/scope_duplicates_api_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions backend/plugins/bitbucket/impl/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}

Expand Down
Loading