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 00000000000..0b7e998ddb7 --- /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 00000000000..13a31b493fa --- /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 9586294ec22..051e9b78385 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 00000000000..dd0517d02e9 --- /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 00000000000..dd4d8d510ac --- /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 39e245c43c4..3dbd3672022 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 8996f466265..359e6b9c7e3 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 7453cbedc39..896629e3fcc 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 = { + 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 (