Skip to content

Generic scope ID extraction and remote-scope API client#115

Merged
ewega merged 4 commits intomainfrom
copilot/generic-scope-id-extraction
Mar 5, 2026
Merged

Generic scope ID extraction and remote-scope API client#115
ewega merged 4 commits intomainfrom
copilot/generic-scope-id-extraction

Conversation

Copy link
Contributor

Copilot AI commented Mar 4, 2026

  • Add ScopeIDField and HasRepoScopes fields to ConnectionDef; set values on github/gh-copilot entries
  • Replace ScopeListEntry typed struct with json.RawMessage + helpers
  • Add RemoteScopeChild / RemoteScopeResponse types
  • Add ListRemoteScopes / SearchRemoteScopes client methods
  • Refactor listConnectionScopes() to use generic extraction; deduplicate ScopeFullName() call
  • Remove GithubID fallback from scope list / scope delete
  • Fix ScopeName() to skip empty strings
  • Fix ScopeFullName() to skip empty strings (consistent with ScopeName / ExtractScopeID)
  • Cache parsed JSON in ScopeListWrapper (shared parseScope() lazy helper, parse-once per item)
  • Add fallback to ScopeName() when ExtractScopeID returns "" in scope list and scope delete
  • Add page/pageSize/search query-param assertions in TestSearchRemoteScopes
  • go build ./..., go test ./..., go vet ./... pass
  • CodeQL: 0 alerts
Original prompt

This section details on the original issue you should resolve

<issue_title>Generic scope ID extraction and remote-scope API client</issue_title>
<issue_description>## Problem

Hardcoded scope ID resolution

listConnectionScopes() in cmd/configure_projects.go:240 contains:

go if c.plugin == "github" && s.GithubID > 0 { scopeID = fmt.Sprintf("%d", s.GithubID) }

And line 251:
go if c.plugin == "github" && s.FullName != "" { repos = append(repos, s.FullName) }

These are hardcoded plugin names outside the registry. Every upstream plugin uses a different scope ID field:

Plugin Scope ID Field Type
github githubId int
gh-copilot id string
gitlab gitlabId int
azuredevops_go azuredevopsId string
jira boardId uint64
jenkins fullName string
bitbucket bitbucketId string

Limited ScopeListEntry struct

ScopeListEntry in types.go:56-61 only captures GithubID (int) and ID (string). When GitLab scopes return gitlabId, it's silently dropped.

No remote-scope API client

DevLake provides a standardized remote-scope API across all plugins:

  • GET /plugins/{plugin}/connections/{id}/remote-scopes?groupId=...&pageToken=...
  • GET /plugins/{plugin}/connections/{id}/search-remote-scopes?search=...&page=...&pageSize=...

Currently scope handlers call external APIs directly (e.g., gh.ListRepos). New plugins should use the DevLake backend's remote-scope API instead — it handles authentication, pagination, and normalizes the response format across all plugins.

Proposed Solution

1. Add ScopeIDField and HasRepoScopes to ConnectionDef

go type ConnectionDef struct { // ... existing fields ... ScopeIDField string // JSON field name for scope ID (e.g., "githubId", "gitlabId", "id") HasRepoScopes bool // true = scopes have FullName that should be tracked as repos }

2. Generic scope ID extraction

Replace ScopeListWrapper.Scope (typed ScopeListEntry) with json.RawMessage and add a generic extractor:

go type ScopeListWrapper struct { Scope json.RawMessage json:"scope"// parsed fields for display scopeID string scopeName string fullName string }

Add helper:
go func extractScopeID(raw json.RawMessage, fieldName string) string { var m map[string]json.RawMessage json.Unmarshal(raw, &m) if v, ok := m[fieldName]; ok { // try string, then number var s string if json.Unmarshal(v, &s) == nil { return s } var n int if json.Unmarshal(v, &n) == nil { return strconv.Itoa(n) } } return "" }

3. Remove hardcoded checks in listConnectionScopes()

Replace:
go if c.plugin == "github" && s.GithubID > 0 { ... }
With:
go def := FindConnectionDef(c.plugin) scopeID := extractScopeID(w.RawScope, def.ScopeIDField)

Replace:
go if c.plugin == "github" && s.FullName != "" { repos = ... }
With:
go if def != nil && def.HasRepoScopes && fullName != "" { repos = ... }

4. Fix scope list and delete commands

configure_scope_list.go:94-95 and configure_scope_delete.go:103-104 also use s.Scope.GithubID fallback. Update these to use the same generic extraction.

5. Add remote-scope API client methods

go // RemoteScopeChild represents a 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.RawMessagejson:"data"`
}

type RemoteScopeResponse struct {
Children []RemoteScopeChild json:"children"
NextPageToken string json:"nextPageToken"
}

func (c *Client) ListRemoteScopes(plugin string, connID int, groupID, pageToken string) (*RemoteScopeResponse, error)
func (c *Client) SearchRemoteScopes(plugin string, connID int, search string, page, pageSize int) (*RemoteScopeResponse, error)
`

Scope of Changes

  • cmd/connection_types.go — add ScopeIDField, HasRepoScopes to ConnectionDef; set values on existing github/gh-copilot entries
  • internal/devlake/types.go — replace ScopeListEntry with raw JSON approach; add RemoteScopeResponse, RemoteScopeChild
  • internal/devlake/client.go — add ListRemoteScopes, SearchRemoteScopes methods
  • cmd/configure_projects.go — refactor listConnectionScopes() to use generic extraction
  • cmd/configure_scope_list.go — remove GithubID fallback
  • cmd/configure_scope_delete.go — remove GithubID fallback
  • Tests for extraction helper and new client methods

Acceptance Criteria

  • No hardcoded plugin names in listConnectionScopes(), scope list, or scope delete commands
  • ScopeIDField on ConnectionDef drives scope ID extraction for all plugins
  • [ ...

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

- 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>
Copilot AI changed the title [WIP] Refactor scope ID extraction for multiple plugins Generic scope ID extraction and remote-scope API client Mar 4, 2026
Copilot finished work on behalf of ewega March 4, 2026 21:48
@ewega ewega marked this pull request as ready for review March 4, 2026 22:30
Copilot AI review requested due to automatic review settings March 4, 2026 22:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR generalizes DevLake scope handling so scope ID resolution and scope-name display work across plugins without hardcoded "github" checks, and it adds typed client support for DevLake’s remote-scope APIs.

Changes:

  • Add ScopeIDField and HasRepoScopes to ConnectionDef and populate them for existing plugins.
  • Replace typed scope parsing with json.RawMessage (RawScope) plus helpers (ScopeName, ScopeFullName, ExtractScopeID) to preserve plugin-specific payloads.
  • Add ListRemoteScopes / SearchRemoteScopes client methods and new tests for the helpers and client calls.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
cmd/connection_types.go Extends ConnectionDef with ScopeIDField and HasRepoScopes, sets values for GitHub and Copilot.
cmd/configure_projects.go Removes hardcoded GitHub scope-ID/repo logic and uses generic extraction + helpers.
cmd/configure_scope_list.go Uses generic scope ID extraction and wrapper helpers for display/JSON output.
cmd/configure_scope_delete.go Uses generic scope ID extraction and wrapper helpers when building interactive deletion choices.
internal/devlake/types.go Switches scope wrapper to RawScope, adds name/full-name helpers, adds ExtractScopeID, and defines remote-scope response types.
internal/devlake/client.go Adds remote-scope API client methods using the existing doGet[T] pattern.
internal/devlake/client_test.go Updates scope list test to use the new wrapper helpers.
internal/devlake/types_test.go Adds table-driven tests for extraction/helpers and remote-scope client methods.

for _, key := range []string{"fullName", "name"} {
if v, ok := m[key]; ok {
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.
Comment on lines +243 to +247
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.
Comment on lines +248 to +255
expectedPath := "/plugins/gitlab/connections/2/search-remote-scopes"
if r.URL.Path != expectedPath {
t.Errorf("path = %s, want %s", r.URL.Path, expectedPath)
}
if tt.search != "" && r.URL.Query().Get("search") != tt.search {
t.Errorf("search = %q, want %q", r.URL.Query().Get("search"), tt.search)
}
w.WriteHeader(tt.statusCode)
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.

TestSearchRemoteScopes sets page and pageSize in the test cases, but the handler assertions never verify that the client actually sends page/pageSize query parameters. This can let regressions slip through where pagination params are omitted or misnamed; consider adding assertions for page and pageSize when non-zero (and optionally asserting they’re absent when zero).

Copilot uses AI. Check for mistakes.
@ewega
Copy link
Contributor

ewega commented Mar 4, 2026

@copilot Please fix these two review comments:

  1. ScopeName() empty string bug (types.go:68) — When a JSON field contains an empty string (e.g., "fullName": ""), ScopeName() returns "" instead of falling back to the next key. Add && s != "" to the condition, matching how ExtractScopeID works:
if err := json.Unmarshal(v, &s); err == nil && s != "" {
  1. Redundant ScopeFullName() calls (configure_projects.go:247) — Call ScopeFullName() once per scope and reuse the value instead of calling it multiple times (it unmarshals JSON each call).

… 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>
Copy link
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot Please fix these two review comments:

  1. ScopeName() empty string bug (types.go:68) — When a JSON field contains an empty string (e.g., "fullName": ""), ScopeName() returns "" ...

Fixed in 5ec9da9:

  1. ScopeName() now adds && s != "" to the unmarshal condition, so an empty "fullName": "" correctly falls through to "name". Added a test case covering this scenario.
  2. ScopeFullName() is called once per scope in the loop and its result reused for both the display name fallback and the repo-tracking check.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment on lines 113 to 117
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.
Comment on lines 105 to 109
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)
}
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.
Comment on lines +59 to +66
// 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 {
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.
Comment on lines +254 to +261
expectedPath := "/plugins/gitlab/connections/2/search-remote-scopes"
if r.URL.Path != expectedPath {
t.Errorf("path = %s, want %s", r.URL.Path, expectedPath)
}
if tt.search != "" && r.URL.Query().Get("search") != tt.search {
t.Errorf("search = %q, want %q", r.URL.Query().Get("search"), tt.search)
}
w.WriteHeader(tt.statusCode)
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.

TestSearchRemoteScopes passes non-zero page/pageSize but never asserts that the request includes page and pageSize query params (or that search is URL-encoded). Add assertions on r.URL.Query() for these fields so the test actually covers the pagination/query-building logic in SearchRemoteScopes.

Copilot uses AI. Check for mistakes.
@ewega
Copy link
Contributor

ewega commented Mar 5, 2026

@copilot Please address these code review comments:

  1. ScopeName() / ScopeFullName() empty-string handling — Both helpers return the first decoded string even if it's empty. Treat empty strings as missing (like ExtractScopeID does) so they continue to the next key. Change the condition to if err == nil && s != "".

  2. Cache parsed JSON — ScopeName() and ScopeFullName() each unmarshal RawScope into a map independently. Callers often invoke both per item, doubling parse work. Add a shared private helper or lazy-cached decoded map to avoid repeated unmarshalling in loops.

  3. Fallback when ExtractScopeID returns "" — In configure_scope_list.go and configure_scope_delete.go, if ExtractScopeID yields an empty string, the output shows blank IDs or causes invalid scope selection. Add a fallback to ScopeFullName() or ScopeName() when scopeID == "".

  4. Test pagination assertions — TestSearchRemoteScopes sets page and pageSize but never asserts the client sends them as query params. Add assertions on
    .URL.Query() for page, pageSize, and search params.

  5. Reduce redundant ScopeFullName() calls — In configure_projects.go, ScopeFullName() is called multiple times per scope. Call it once and reuse the value.

…ation 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>
Copy link
Contributor Author

Copilot AI commented Mar 5, 2026

@copilot Please address these code review comments:

  1. ScopeName() / ScopeFullName() empty-string handling — Both helpers return the first decoded string even if it's empty. Treat empty strings...

Fixed in 61e4270:

  1. ScopeFullName() empty-string — added && s != "" to match ScopeName/ExtractScopeID behaviour.
  2. Cached parsed JSONScopeListWrapper now has a private parseScope() method that lazily unmarshals RawScope into a map[string]json.RawMessage and caches it; both ScopeName() and ScopeFullName() delegate to it, so a single item is parsed at most once regardless of how many helpers are called.
  3. Fallback in scope list — extracted a scopeIDFor() closure that returns s.ScopeName() when ExtractScopeID yields ""; also deduplicated the FindConnectionDef call (moved before the JSON/table branch).
  4. Fallback in scope delete — added if id == "" { id = name } so interactive delete always has a usable identifier when the plugin payload doesn't include the expected ID field.
  5. Test pagination assertionsTestSearchRemoteScopes handler now asserts page, pageSize, and search query params: required and correct when non-zero, absent when zero.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment on lines +119 to +126
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)
}
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.
@ewega ewega merged commit 704c4af into main Mar 5, 2026
11 checks passed
Copilot AI added a commit that referenced this pull request Mar 5, 2026
…nDef and bring in scope extraction changes

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic scope ID extraction and remote-scope API client

3 participants