Skip to content
Merged
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
205 changes: 205 additions & 0 deletions backend/plugins/github/api/scope_duplicates_api.go
Original file line number Diff line number Diff line change
@@ -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 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).
type ScopeDuplicateGroup struct {
GithubId int `json:"githubId"`
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 {
GithubId int `gorm:"column:github_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 GitHub repositories registered under more than one
// connection, or (with connectionId + githubIds) candidates already present on
// other connections.
// @Summary Find GitHub scopes duplicated across connections
// @Description Diagnostics: groups where the same githubId appears on more than one connection.
// @Description Pre-add check: pass connectionId and githubIds to find candidates already registered elsewhere.
// @Tags plugins/github
// @Param connectionId query int false "Current connection id (pre-add check)"
// @Param githubIds query string false "Comma-separated GitHub repo 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/github/scope-duplicates [GET]
func GetScopeDuplicates(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
connectionId, githubIds, err := parseScopeDuplicateQuery(input)
if err != nil {
return nil, err
}

// Pre-add check with an empty selection: nothing to warn about.
if connectionId != nil && len(githubIds) == 0 {
return &plugin.ApiResourceOutput{
Body: ScopeDuplicatesOutput{Duplicates: []ScopeDuplicateGroup{}},
Status: http.StatusOK,
}, nil
}

rows, err := queryScopeDuplicateRows(basicRes.GetDal(), connectionId, githubIds)
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 githubIds []int
if v := input.Query.Get("githubIds"); 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 githubIds")
}
githubIds = append(githubIds, id)
}
}

if len(githubIds) > 0 && connectionId == nil {
return nil, nil, errors.BadInput.New("connectionId is required when githubIds is provided")
}

return connectionId, githubIds, nil
}

// queryScopeDuplicateRows loads only the rows needed for the requested mode.
// Check mode: selected githubIds on any connection other than connectionId.
// Diagnostics: githubIds that already appear on more than one connection.
func queryScopeDuplicateRows(db dal.Dal, connectionId *uint64, githubIds []int) ([]scopeDuplicateRow, errors.Error) {
clauses := []dal.Clause{
dal.Select("r.github_id, r.html_url, r.full_name, r.connection_id, c.name AS connection_name"),
dal.From("_tool_github_repos r"),
dal.Join("INNER JOIN _tool_github_connections c ON c.id = r.connection_id"),
dal.Orderby("r.github_id ASC, r.connection_id ASC"),
}

if connectionId != nil {
clauses = append(clauses, dal.Where(
"r.github_id IN ? AND r.connection_id != ?",
githubIds,
*connectionId,
))
} else {
clauses = append(clauses, dal.Where(`r.github_id IN (
SELECT github_id FROM _tool_github_repos
GROUP BY github_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.GithubId != row.GithubId {
flush()
current = &ScopeDuplicateGroup{
GithubId: row.GithubId,
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
}
114 changes: 114 additions & 0 deletions backend/plugins/github/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{
{GithubId: 100, HTMLUrl: "https://github.com/o/a", FullName: "o/a", ConnectionId: 1, ConnectionName: "GitHub Production"},
{GithubId: 100, HTMLUrl: "https://github.com/o/a", FullName: "o/a", ConnectionId: 2, ConnectionName: "GitHub Staging"},
{GithubId: 200, HTMLUrl: "https://github.com/o/b", FullName: "o/b", ConnectionId: 3, ConnectionName: "Other"},
}

got := groupScopeDuplicateRows(rows)
assert.Equal(t, []ScopeDuplicateGroup{
{
GithubId: 100,
HTMLUrl: "https://github.com/o/a",
FullName: "o/a",
Connections: []ScopeDuplicateConnection{
{ConnectionId: 1, ConnectionName: "GitHub Production"},
{ConnectionId: 2, ConnectionName: "GitHub Staging"},
},
},
{
GithubId: 200,
HTMLUrl: "https://github.com/o/b",
FullName: "o/b",
Connections: []ScopeDuplicateConnection{
{ConnectionId: 3, ConnectionName: "Other"},
},
},
}, got)
}

func TestGroupScopeDuplicateRows_DedupesSameConnection(t *testing.T) {
rows := []scopeDuplicateRow{
{GithubId: 100, FullName: "o/a", ConnectionId: 1, ConnectionName: "Prod"},
{GithubId: 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{
{GithubId: 100, ConnectionId: 1, ConnectionName: "A"},
{GithubId: 100, HTMLUrl: "https://github.com/o/a", FullName: "o/a", ConnectionId: 2, ConnectionName: "B"},
}

got := groupScopeDuplicateRows(rows)
assert.Len(t, got, 1)
assert.Equal(t, "https://github.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"},
"githubIds": []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{
"githubIds": []string{"10"},
}}
_, _, err = parseScopeDuplicateQuery(input)
assert.Error(t, err)

input = &plugin.ApiResourceInput{Query: url.Values{
"connectionId": []string{"abc"},
}}
_, _, err = parseScopeDuplicateQuery(input)
assert.Error(t, err)
}
3 changes: 3 additions & 0 deletions backend/plugins/github/impl/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ func (p Github) ApiResources() map[string]map[string]plugin.ApiResourceHandler {
"scope-config/:scopeConfigId/projects": {
"GET": api.GetProjectsByScopeConfig,
},
"scope-duplicates": {
"GET": api.GetScopeDuplicates,
},
}
}

Expand Down
24 changes: 24 additions & 0 deletions config-ui/src/api/scope/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,27 @@ export const searchRemote = (
method: 'get',
data,
});

export type ScopeDuplicateConnection = {
connectionId: ID;
connectionName: string;
};

export type ScopeDuplicateGroup = {
githubId: number;
htmlUrl: string;
fullName: string;
connections: ScopeDuplicateConnection[];
};

export const scopeDuplicates = (
plugin: string,
data?: {
connectionId?: ID;
githubIds?: string;
},
): Promise<{ duplicates: ScopeDuplicateGroup[] }> =>
request(`/plugins/${plugin}/scope-duplicates`, {
method: 'get',
data,
});
Loading
Loading