Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 13 additions & 9 deletions cmd/configure_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,23 +233,27 @@ func listConnectionScopes(client *devlake.Client, c connChoice) (*addedConnectio

var bpScopes []devlake.BlueprintScope
var repos []string
def := FindConnectionDef(c.plugin)
for _, w := range resp.Scopes {
s := w.Scope
// Resolve scope ID: GitHub uses githubId (int), Copilot uses id (string)
scopeID := s.ID
if c.plugin == "github" && s.GithubID > 0 {
scopeID = fmt.Sprintf("%d", s.GithubID)
// Generic scope ID extraction using the plugin's configured ScopeIDField.
var scopeID string
if def != nil && def.ScopeIDField != "" {
scopeID = devlake.ExtractScopeID(w.RawScope, def.ScopeIDField)
}
scopeName := s.FullName
scopeName := w.ScopeFullName()
if scopeName == "" {
scopeName = s.Name
scopeName = w.ScopeName()
}
if scopeID == "" {
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this loop, ScopeFullName() is called multiple times (scopeName := w.ScopeFullName() and later fullName := w.ScopeFullName()). Since these helpers unmarshal the raw JSON each call, this does redundant work. Consider calling ScopeFullName() once per scope (or just use ScopeName() for the display name since it already prefers fullName) and reuse the value for repo tracking.

Copilot uses AI. Check for mistakes.
scopeID = scopeName
}
bpScopes = append(bpScopes, devlake.BlueprintScope{
ScopeID: scopeID,
ScopeName: scopeName,
})
if c.plugin == "github" && s.FullName != "" {
repos = append(repos, s.FullName)
fullName := w.ScopeFullName()
if def != nil && def.HasRepoScopes && fullName != "" {
repos = append(repos, fullName)
}
fmt.Printf(" %s (ID: %s)\n", scopeName, scopeID)
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/configure_scope_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package cmd

import (
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"

"github.com/DevExpGBB/gh-devlake/internal/devlake"
"github.com/DevExpGBB/gh-devlake/internal/prompt"
)

Expand Down Expand Up @@ -101,14 +101,15 @@ func runScopeDelete(cmd *cobra.Command, args []string) error {
}
var entries []scopeEntry
var labels []string
def := FindConnectionDef(selectedPlugin)
for _, s := range resp.Scopes {
id := s.Scope.ID
if id == "" {
id = strconv.Itoa(s.Scope.GithubID)
var id string
if def != nil && def.ScopeIDField != "" {
id = devlake.ExtractScopeID(s.RawScope, def.ScopeIDField)
}
Comment on lines 105 to 113
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In interactive delete, if ExtractScopeID yields an empty string, the label becomes [] name and selectedScopeID will remain empty, causing invalid scope selection. Add a fallback (e.g., use s.ScopeFullName()/s.ScopeName() when id == "") so scope deletion still works when the plugin payload doesn’t include the expected ID field.

See below for a potential fix:

			name := s.ScopeFullName()
			if name == "" {
				name = s.ScopeName()
			}
			var id string
			if def != nil && def.ScopeIDField != "" {
				id = devlake.ExtractScopeID(s.RawScope, def.ScopeIDField)
			}
			if id == "" {
				id = name

Copilot uses AI. Check for mistakes.
name := s.Scope.FullName
name := s.ScopeFullName()
if name == "" {
name = s.Scope.Name
name = s.ScopeName()
}
label := fmt.Sprintf("[%s] %s", id, name)
entries = append(entries, scopeEntry{id: id, label: label})
Expand Down
21 changes: 11 additions & 10 deletions cmd/configure_scope_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package cmd

import (
"fmt"
"strconv"
"strings"
"text/tabwriter"

Expand Down Expand Up @@ -110,15 +109,16 @@ func runScopeList(cmd *cobra.Command, args []string) error {
// JSON output path
if outputJSON {
items := make([]scopeListItem, len(resp.Scopes))
def := FindConnectionDef(selectedPlugin)
for i, s := range resp.Scopes {
scopeID := s.Scope.ID
if scopeID == "" {
scopeID = strconv.Itoa(s.Scope.GithubID)
var scopeID string
if def != nil && def.ScopeIDField != "" {
scopeID = devlake.ExtractScopeID(s.RawScope, def.ScopeIDField)
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If ExtractScopeID returns "" (e.g., unexpected payload / missing scope ID field), scopeID stays empty and the list output (and JSON) will emit blank IDs. Consider falling back to s.ScopeFullName() or s.ScopeName() when scopeID == "" so the command remains usable even when ID extraction fails.

Copilot uses AI. Check for mistakes.
items[i] = scopeListItem{
ID: scopeID,
Name: s.Scope.Name,
FullName: s.Scope.FullName,
Name: s.ScopeName(),
FullName: s.ScopeFullName(),
}
}
return printJSON(items)
Expand All @@ -132,12 +132,13 @@ func runScopeList(cmd *cobra.Command, args []string) error {
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Scope ID\tName\tFull Name")
fmt.Fprintln(w, strings.Repeat("\u2500", 10)+"\t"+strings.Repeat("\u2500", 20)+"\t"+strings.Repeat("\u2500", 30))
def := FindConnectionDef(selectedPlugin)
for _, s := range resp.Scopes {
scopeID := s.Scope.ID
if scopeID == "" {
scopeID = strconv.Itoa(s.Scope.GithubID)
var scopeID string
if def != nil && def.ScopeIDField != "" {
scopeID = devlake.ExtractScopeID(s.RawScope, def.ScopeIDField)
}
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeID, s.Scope.Name, s.Scope.FullName)
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeID, s.ScopeName(), s.ScopeFullName())
}
w.Flush()
fmt.Println()
Expand Down
5 changes: 5 additions & 0 deletions cmd/connection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type ConnectionDef struct {
EnvVarNames []string // environment variable names for token resolution
EnvFileKeys []string // .devlake.env keys for token resolution
ScopeFunc ScopeHandler // nil = scope configuration not yet supported
ScopeIDField string // JSON field name for the scope ID (e.g. "githubId", "id")
HasRepoScopes bool // true = scopes carry a FullName that should be tracked as repos
}

// MenuLabel returns the label for interactive menus.
Expand Down Expand Up @@ -144,6 +146,8 @@ var connectionRegistry = []*ConnectionDef{
EnvVarNames: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
ScopeFunc: scopeGitHubHandler,
ScopeIDField: "githubId",
HasRepoScopes: true,
},
{
Plugin: "gh-copilot",
Expand All @@ -161,6 +165,7 @@ var connectionRegistry = []*ConnectionDef{
EnvVarNames: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
ScopeFunc: scopeCopilotHandler,
ScopeIDField: "id",
},
{
Plugin: "gitlab",
Expand Down
37 changes: 37 additions & 0 deletions internal/devlake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,43 @@ func (c *Client) GetPipeline(id int) (*Pipeline, error) {
return doGet[Pipeline](c, fmt.Sprintf("/pipelines/%d", id))
}

// ListRemoteScopes queries the DevLake remote-scope API for a plugin connection.
// groupID and pageToken are optional (pass "" to omit).
func (c *Client) ListRemoteScopes(plugin string, connID int, groupID, pageToken string) (*RemoteScopeResponse, error) {
path := fmt.Sprintf("/plugins/%s/connections/%d/remote-scopes", plugin, connID)
q := url.Values{}
if groupID != "" {
q.Set("groupId", groupID)
}
if pageToken != "" {
q.Set("pageToken", pageToken)
}
if len(q) > 0 {
path += "?" + q.Encode()
}
return doGet[RemoteScopeResponse](c, path)
}

// SearchRemoteScopes queries the DevLake search-remote-scopes API for a plugin connection.
// page and pageSize control pagination; pass 0 to use DevLake defaults.
func (c *Client) SearchRemoteScopes(plugin string, connID int, search string, page, pageSize int) (*RemoteScopeResponse, error) {
path := fmt.Sprintf("/plugins/%s/connections/%d/search-remote-scopes", plugin, connID)
q := url.Values{}
if search != "" {
q.Set("search", search)
}
if page > 0 {
q.Set("page", fmt.Sprintf("%d", page))
}
if pageSize > 0 {
q.Set("pageSize", fmt.Sprintf("%d", pageSize))
}
if len(q) > 0 {
path += "?" + q.Encode()
}
return doGet[RemoteScopeResponse](c, path)
}

// TriggerMigration triggers the DevLake database migration endpoint.
func (c *Client) TriggerMigration() error {
resp, err := c.HTTPClient.Get(c.BaseURL + "/proceed-db-migration")
Expand Down
4 changes: 2 additions & 2 deletions internal/devlake/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,8 +589,8 @@ func TestListScopes(t *testing.T) {
if len(result.Scopes) != 1 {
t.Fatalf("len(Scopes) = %d, want 1", len(result.Scopes))
}
if result.Scopes[0].Scope.Name != "repo1" {
t.Errorf("Name = %q, want %q", result.Scopes[0].Scope.Name, "repo1")
if result.Scopes[0].ScopeName() != "repo1" {
t.Errorf("Name = %q, want %q", result.Scopes[0].ScopeName(), "repo1")
}
}

Expand Down
92 changes: 81 additions & 11 deletions internal/devlake/types.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package devlake

import (
"encoding/json"
"strconv"
)

// ScopeConfig represents a DevLake scope configuration (e.g., DORA settings).
type ScopeConfig struct {
ID int `json:"id,omitempty"`
Expand Down Expand Up @@ -46,18 +51,67 @@ type ScopeBatchRequest struct {

// ScopeListWrapper wraps a scope object as returned by the DevLake GET scopes API.
// The API nests each scope inside a "scope" key: { "scope": { ... } }.
// RawScope preserves the full plugin-specific payload for generic ID extraction.
type ScopeListWrapper struct {
Scope ScopeListEntry `json:"scope"`
}

// ScopeListEntry represents a scope object returned inside the wrapper.
// ID fields vary by plugin (githubId for GitHub, id for Copilot), so we
// capture both and resolve in the caller.
type ScopeListEntry struct {
GithubID int `json:"githubId,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name"`
FullName string `json:"fullName,omitempty"`
RawScope json.RawMessage `json:"scope"`
}

// ScopeName returns the display name from the raw scope JSON (checks "fullName" then "name").
func (w *ScopeListWrapper) ScopeName() string {
var m map[string]json.RawMessage
if err := json.Unmarshal(w.RawScope, &m); err != nil {
return ""
}
for _, key := range []string{"fullName", "name"} {
if v, ok := m[key]; ok {
Comment on lines +74 to +81
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScopeName() and ScopeFullName() each json.Unmarshal the same RawScope into a map. Callers often invoke both per item (e.g., scope list), doubling parse work. Consider a shared private helper (parse-once) or caching the decoded map/fields on ScopeListWrapper to avoid repeated unmarshalling in loops.

Copilot uses AI. Check for mistakes.
var s string
if err := json.Unmarshal(v, &s); err == nil {
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScopeName() returns the first decoded string value even if it’s empty. If the JSON contains "fullName": "" alongside a non-empty "name", this will return "" and never fall back to name. Consider treating empty strings as missing (like ExtractScopeID does) so ScopeName() will continue to the next key when the decoded value is empty.

Suggested change
if err := json.Unmarshal(v, &s); err == nil {
if err := json.Unmarshal(v, &s); err == nil && s != "" {

Copilot uses AI. Check for mistakes.
return s
}
}
}
return ""
}

// ScopeFullName returns the "fullName" field from the raw scope JSON, or "".
func (w *ScopeListWrapper) ScopeFullName() string {
var m map[string]json.RawMessage
if err := json.Unmarshal(w.RawScope, &m); err != nil {
return ""
}
if v, ok := m["fullName"]; ok {
var s string
if err := json.Unmarshal(v, &s); err == nil {
return s
}
}
return ""
}

// ExtractScopeID extracts the scope ID from a raw JSON scope object using the
// given field name. It tries to decode the value as a string first, then as
// an integer (converted to its decimal string representation).
func ExtractScopeID(raw json.RawMessage, fieldName string) string {
if fieldName == "" {
return ""
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
v, ok := m[fieldName]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(v, &s); err == nil && s != "" {
return s
}
var n int64
if err := json.Unmarshal(v, &n); err == nil && n != 0 {
return strconv.FormatInt(n, 10)
}
Comment on lines +119 to +126
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtractScopeID() only attempts to decode numeric IDs as int64. This will fail for plugins that return unsigned IDs (e.g., uint64) or numbers larger than MaxInt64, causing scope ID extraction to silently return "". Consider unmarshaling into json.Number (via a Decoder with UseNumber) or trying uint64 in addition to int64 so all numeric ID fields are supported.

Copilot uses AI. Check for mistakes.
return ""
}

// ScopeListResponse is the response from GET /plugins/{plugin}/connections/{id}/scopes.
Expand All @@ -66,6 +120,22 @@ type ScopeListResponse struct {
Count int `json:"count"`
}

// RemoteScopeChild represents one item (group or scope) from the remote-scope API.
type RemoteScopeChild struct {
Type string `json:"type"` // "group" or "scope"
ID string `json:"id"`
ParentID string `json:"parentId"`
Name string `json:"name"`
FullName string `json:"fullName"`
Data json.RawMessage `json:"data"`
}

// RemoteScopeResponse is the response from GET /plugins/{plugin}/connections/{id}/remote-scopes.
type RemoteScopeResponse struct {
Children []RemoteScopeChild `json:"children"`
NextPageToken string `json:"nextPageToken"`
}

// Project represents a DevLake project.
type Project struct {
Name string `json:"name"`
Expand Down
Loading
Loading