Skip to content

Commit 704c4af

Browse files
Copilotewega
andauthored
Generic scope ID extraction and remote-scope API client (#115)
* Initial plan * feat: generic scope ID extraction and remote-scope API client - Add ScopeIDField and HasRepoScopes to ConnectionDef; set on github/gh-copilot entries - Replace ScopeListEntry typed struct with json.RawMessage + extractScopeID helper - Add RemoteScopeChild/RemoteScopeResponse types and ListRemoteScopes/SearchRemoteScopes client methods - Remove hardcoded if-plugin-github checks from listConnectionScopes, scope list, scope delete - Add table-driven tests for ExtractScopeID, ScopeListWrapper helpers, and new client methods Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> * fix: ScopeName empty-string fallthrough and deduplicate ScopeFullName call - ScopeName() now skips empty strings (like ExtractScopeID does), so a JSON field of "" correctly falls through to the next candidate key - configure_projects.go: call ScopeFullName() once per scope and reuse the value for both the display name and repo-tracking check - Add "empty fullName falls through to name" test case Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> * fix: cache parseScope, ScopeFullName empty-string, ID fallback, pagination test assertions - ScopeListWrapper gains a shared parseScope() helper with lazy map caching; ScopeName() and ScopeFullName() now unmarshal RawScope at most once per item - ScopeFullName() now treats "" as absent (consistent with ScopeName/ExtractScopeID) - configure_scope_list: deduplicate FindConnectionDef; add scopeIDFor() closure that falls back to ScopeName() when ExtractScopeID returns "" - configure_scope_delete: add id=="" fallback to name so interactive delete always has a usable identifier even when ScopeIDField payload is missing - TestSearchRemoteScopes: assert page, pageSize, and search query params are sent when non-zero and absent when zero Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
1 parent 03971d2 commit 704c4af

File tree

8 files changed

+489
-42
lines changed

8 files changed

+489
-42
lines changed

cmd/configure_projects.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -233,23 +233,27 @@ func listConnectionScopes(client *devlake.Client, c connChoice) (*addedConnectio
233233

234234
var bpScopes []devlake.BlueprintScope
235235
var repos []string
236+
def := FindConnectionDef(c.plugin)
236237
for _, w := range resp.Scopes {
237-
s := w.Scope
238-
// Resolve scope ID: GitHub uses githubId (int), Copilot uses id (string)
239-
scopeID := s.ID
240-
if c.plugin == "github" && s.GithubID > 0 {
241-
scopeID = fmt.Sprintf("%d", s.GithubID)
238+
// Generic scope ID extraction using the plugin's configured ScopeIDField.
239+
var scopeID string
240+
if def != nil && def.ScopeIDField != "" {
241+
scopeID = devlake.ExtractScopeID(w.RawScope, def.ScopeIDField)
242242
}
243-
scopeName := s.FullName
243+
fullName := w.ScopeFullName()
244+
scopeName := fullName
244245
if scopeName == "" {
245-
scopeName = s.Name
246+
scopeName = w.ScopeName()
247+
}
248+
if scopeID == "" {
249+
scopeID = scopeName
246250
}
247251
bpScopes = append(bpScopes, devlake.BlueprintScope{
248252
ScopeID: scopeID,
249253
ScopeName: scopeName,
250254
})
251-
if c.plugin == "github" && s.FullName != "" {
252-
repos = append(repos, s.FullName)
255+
if def != nil && def.HasRepoScopes && fullName != "" {
256+
repos = append(repos, fullName)
253257
}
254258
fmt.Printf(" %s (ID: %s)\n", scopeName, scopeID)
255259
}

cmd/configure_scope_delete.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package cmd
22

33
import (
44
"fmt"
5-
"strconv"
65
"strings"
76

87
"github.com/spf13/cobra"
98

9+
"github.com/DevExpGBB/gh-devlake/internal/devlake"
1010
"github.com/DevExpGBB/gh-devlake/internal/prompt"
1111
)
1212

@@ -101,14 +101,18 @@ func runScopeDelete(cmd *cobra.Command, args []string) error {
101101
}
102102
var entries []scopeEntry
103103
var labels []string
104+
def := FindConnectionDef(selectedPlugin)
104105
for _, s := range resp.Scopes {
105-
id := s.Scope.ID
106-
if id == "" {
107-
id = strconv.Itoa(s.Scope.GithubID)
108-
}
109-
name := s.Scope.FullName
106+
name := s.ScopeFullName()
110107
if name == "" {
111-
name = s.Scope.Name
108+
name = s.ScopeName()
109+
}
110+
var id string
111+
if def != nil && def.ScopeIDField != "" {
112+
id = devlake.ExtractScopeID(s.RawScope, def.ScopeIDField)
113+
}
114+
if id == "" {
115+
id = name
112116
}
113117
label := fmt.Sprintf("[%s] %s", id, name)
114118
entries = append(entries, scopeEntry{id: id, label: label})

cmd/configure_scope_list.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cmd
22

33
import (
44
"fmt"
5-
"strconv"
65
"strings"
76
"text/tabwriter"
87

@@ -107,18 +106,28 @@ func runScopeList(cmd *cobra.Command, args []string) error {
107106
return fmt.Errorf("failed to list scopes: %w", err)
108107
}
109108

109+
def := FindConnectionDef(selectedPlugin)
110+
111+
// scopeIDFor extracts the scope ID using the plugin's ScopeIDField (def.ScopeIDField).
112+
// When ExtractScopeID returns "" (field absent or plugin has no ScopeIDField configured),
113+
// it falls back to the display name so the output always shows a usable identifier.
114+
scopeIDFor := func(s *devlake.ScopeListWrapper) string {
115+
if def != nil && def.ScopeIDField != "" {
116+
if id := devlake.ExtractScopeID(s.RawScope, def.ScopeIDField); id != "" {
117+
return id
118+
}
119+
}
120+
return s.ScopeName()
121+
}
122+
110123
// JSON output path
111124
if outputJSON {
112125
items := make([]scopeListItem, len(resp.Scopes))
113126
for i, s := range resp.Scopes {
114-
scopeID := s.Scope.ID
115-
if scopeID == "" {
116-
scopeID = strconv.Itoa(s.Scope.GithubID)
117-
}
118127
items[i] = scopeListItem{
119-
ID: scopeID,
120-
Name: s.Scope.Name,
121-
FullName: s.Scope.FullName,
128+
ID: scopeIDFor(&s),
129+
Name: s.ScopeName(),
130+
FullName: s.ScopeFullName(),
122131
}
123132
}
124133
return printJSON(items)
@@ -133,11 +142,7 @@ func runScopeList(cmd *cobra.Command, args []string) error {
133142
fmt.Fprintln(w, "Scope ID\tName\tFull Name")
134143
fmt.Fprintln(w, strings.Repeat("\u2500", 10)+"\t"+strings.Repeat("\u2500", 20)+"\t"+strings.Repeat("\u2500", 30))
135144
for _, s := range resp.Scopes {
136-
scopeID := s.Scope.ID
137-
if scopeID == "" {
138-
scopeID = strconv.Itoa(s.Scope.GithubID)
139-
}
140-
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeID, s.Scope.Name, s.Scope.FullName)
145+
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeIDFor(&s), s.ScopeName(), s.ScopeFullName())
141146
}
142147
w.Flush()
143148
fmt.Println()

cmd/connection_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type ConnectionDef struct {
3535
EnvVarNames []string // environment variable names for token resolution
3636
EnvFileKeys []string // .devlake.env keys for token resolution
3737
ScopeFunc ScopeHandler // nil = scope configuration not yet supported
38+
ScopeIDField string // JSON field name for the scope ID (e.g. "githubId", "id")
39+
HasRepoScopes bool // true = scopes carry a FullName that should be tracked as repos
3840
}
3941

4042
// MenuLabel returns the label for interactive menus.
@@ -144,6 +146,8 @@ var connectionRegistry = []*ConnectionDef{
144146
EnvVarNames: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
145147
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
146148
ScopeFunc: scopeGitHubHandler,
149+
ScopeIDField: "githubId",
150+
HasRepoScopes: true,
147151
},
148152
{
149153
Plugin: "gh-copilot",
@@ -161,6 +165,7 @@ var connectionRegistry = []*ConnectionDef{
161165
EnvVarNames: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
162166
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
163167
ScopeFunc: scopeCopilotHandler,
168+
ScopeIDField: "id",
164169
},
165170
{
166171
Plugin: "gitlab",

internal/devlake/client.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,43 @@ func (c *Client) GetPipeline(id int) (*Pipeline, error) {
462462
return doGet[Pipeline](c, fmt.Sprintf("/pipelines/%d", id))
463463
}
464464

465+
// ListRemoteScopes queries the DevLake remote-scope API for a plugin connection.
466+
// groupID and pageToken are optional (pass "" to omit).
467+
func (c *Client) ListRemoteScopes(plugin string, connID int, groupID, pageToken string) (*RemoteScopeResponse, error) {
468+
path := fmt.Sprintf("/plugins/%s/connections/%d/remote-scopes", plugin, connID)
469+
q := url.Values{}
470+
if groupID != "" {
471+
q.Set("groupId", groupID)
472+
}
473+
if pageToken != "" {
474+
q.Set("pageToken", pageToken)
475+
}
476+
if len(q) > 0 {
477+
path += "?" + q.Encode()
478+
}
479+
return doGet[RemoteScopeResponse](c, path)
480+
}
481+
482+
// SearchRemoteScopes queries the DevLake search-remote-scopes API for a plugin connection.
483+
// page and pageSize control pagination; pass 0 to use DevLake defaults.
484+
func (c *Client) SearchRemoteScopes(plugin string, connID int, search string, page, pageSize int) (*RemoteScopeResponse, error) {
485+
path := fmt.Sprintf("/plugins/%s/connections/%d/search-remote-scopes", plugin, connID)
486+
q := url.Values{}
487+
if search != "" {
488+
q.Set("search", search)
489+
}
490+
if page > 0 {
491+
q.Set("page", fmt.Sprintf("%d", page))
492+
}
493+
if pageSize > 0 {
494+
q.Set("pageSize", fmt.Sprintf("%d", pageSize))
495+
}
496+
if len(q) > 0 {
497+
path += "?" + q.Encode()
498+
}
499+
return doGet[RemoteScopeResponse](c, path)
500+
}
501+
465502
// TriggerMigration triggers the DevLake database migration endpoint.
466503
func (c *Client) TriggerMigration() error {
467504
resp, err := c.HTTPClient.Get(c.BaseURL + "/proceed-db-migration")

internal/devlake/client_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,8 +589,8 @@ func TestListScopes(t *testing.T) {
589589
if len(result.Scopes) != 1 {
590590
t.Fatalf("len(Scopes) = %d, want 1", len(result.Scopes))
591591
}
592-
if result.Scopes[0].Scope.Name != "repo1" {
593-
t.Errorf("Name = %q, want %q", result.Scopes[0].Scope.Name, "repo1")
592+
if result.Scopes[0].ScopeName() != "repo1" {
593+
t.Errorf("Name = %q, want %q", result.Scopes[0].ScopeName(), "repo1")
594594
}
595595
}
596596

internal/devlake/types.go

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package devlake
22

3+
import (
4+
"encoding/json"
5+
"strconv"
6+
)
7+
38
// ScopeConfig represents a DevLake scope configuration (e.g., DORA settings).
49
type ScopeConfig struct {
510
ID int `json:"id,omitempty"`
@@ -46,18 +51,80 @@ type ScopeBatchRequest struct {
4651

4752
// ScopeListWrapper wraps a scope object as returned by the DevLake GET scopes API.
4853
// The API nests each scope inside a "scope" key: { "scope": { ... } }.
54+
// RawScope preserves the full plugin-specific payload for generic ID extraction.
4955
type ScopeListWrapper struct {
50-
Scope ScopeListEntry `json:"scope"`
51-
}
52-
53-
// ScopeListEntry represents a scope object returned inside the wrapper.
54-
// ID fields vary by plugin (githubId for GitHub, id for Copilot), so we
55-
// capture both and resolve in the caller.
56-
type ScopeListEntry struct {
57-
GithubID int `json:"githubId,omitempty"`
58-
ID string `json:"id,omitempty"`
59-
Name string `json:"name"`
60-
FullName string `json:"fullName,omitempty"`
56+
RawScope json.RawMessage `json:"scope"`
57+
parsed map[string]json.RawMessage // lazily populated by parseScope
58+
}
59+
60+
// parseScope unmarshals RawScope into a map exactly once per wrapper instance,
61+
// caching the result so callers that invoke both ScopeName and ScopeFullName on
62+
// the same item do not unmarshal the same JSON twice.
63+
func (w *ScopeListWrapper) parseScope() map[string]json.RawMessage {
64+
if w.parsed == nil {
65+
var m map[string]json.RawMessage
66+
if err := json.Unmarshal(w.RawScope, &m); err != nil || m == nil {
67+
m = make(map[string]json.RawMessage)
68+
}
69+
w.parsed = m
70+
}
71+
return w.parsed
72+
}
73+
74+
// ScopeName returns the display name from the raw scope JSON (checks "fullName" then "name").
75+
// Empty string values are skipped so the next candidate key is tried.
76+
// Parsing is cached via parseScope() so calling ScopeName and ScopeFullName on the
77+
// same instance only unmarshals the JSON once.
78+
func (w *ScopeListWrapper) ScopeName() string {
79+
m := w.parseScope()
80+
for _, key := range []string{"fullName", "name"} {
81+
if v, ok := m[key]; ok {
82+
var s string
83+
if err := json.Unmarshal(v, &s); err == nil && s != "" {
84+
return s
85+
}
86+
}
87+
}
88+
return ""
89+
}
90+
91+
// ScopeFullName returns the "fullName" field from the raw scope JSON, or "".
92+
// An empty string value is treated as absent (returns "").
93+
func (w *ScopeListWrapper) ScopeFullName() string {
94+
m := w.parseScope()
95+
if v, ok := m["fullName"]; ok {
96+
var s string
97+
if err := json.Unmarshal(v, &s); err == nil && s != "" {
98+
return s
99+
}
100+
}
101+
return ""
102+
}
103+
104+
// ExtractScopeID extracts the scope ID from a raw JSON scope object using the
105+
// given field name. It tries to decode the value as a string first, then as
106+
// an integer (converted to its decimal string representation).
107+
func ExtractScopeID(raw json.RawMessage, fieldName string) string {
108+
if fieldName == "" {
109+
return ""
110+
}
111+
var m map[string]json.RawMessage
112+
if err := json.Unmarshal(raw, &m); err != nil {
113+
return ""
114+
}
115+
v, ok := m[fieldName]
116+
if !ok {
117+
return ""
118+
}
119+
var s string
120+
if err := json.Unmarshal(v, &s); err == nil && s != "" {
121+
return s
122+
}
123+
var n int64
124+
if err := json.Unmarshal(v, &n); err == nil && n != 0 {
125+
return strconv.FormatInt(n, 10)
126+
}
127+
return ""
61128
}
62129

63130
// ScopeListResponse is the response from GET /plugins/{plugin}/connections/{id}/scopes.
@@ -66,6 +133,22 @@ type ScopeListResponse struct {
66133
Count int `json:"count"`
67134
}
68135

136+
// RemoteScopeChild represents one item (group or scope) from the remote-scope API.
137+
type RemoteScopeChild struct {
138+
Type string `json:"type"` // "group" or "scope"
139+
ID string `json:"id"`
140+
ParentID string `json:"parentId"`
141+
Name string `json:"name"`
142+
FullName string `json:"fullName"`
143+
Data json.RawMessage `json:"data"`
144+
}
145+
146+
// RemoteScopeResponse is the response from GET /plugins/{plugin}/connections/{id}/remote-scopes.
147+
type RemoteScopeResponse struct {
148+
Children []RemoteScopeChild `json:"children"`
149+
NextPageToken string `json:"nextPageToken"`
150+
}
151+
69152
// Project represents a DevLake project.
70153
type Project struct {
71154
Name string `json:"name"`

0 commit comments

Comments
 (0)