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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ For the full guide, see [Day-2 Operations](docs/day-2.md).
| GitLab | ✅ Available | Repos, MRs, pipelines, deployments (DORA) | `read_api`, `read_repository` |
| Bitbucket Cloud | ✅ Available | Repos, PRs, commits | Bitbucket username + app password |
| SonarQube | ✅ Available | Code quality, coverage, code smells (quality gates) | API token (permissions from user account) |
| Azure DevOps | 🔜 Coming soon | Repos, pipelines, deployments (DORA) | (TBD) |
| Azure DevOps | ✅ Available | Repos, pipelines, deployments (DORA) | PAT with repo and pipeline access |

See [Token Handling](docs/token-handling.md) for env key names and multi-plugin `.devlake.env` examples.

Expand Down
15 changes: 9 additions & 6 deletions cmd/configure_connection_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ func TestSelectPlugin_UnknownSlug(t *testing.T) {
}
}

func TestSelectPlugin_UnavailablePlugin(t *testing.T) {
_, err := selectPlugin("azure-devops")
if err == nil {
t.Fatal("expected error for unavailable plugin, got nil")
func TestSelectPlugin_AzureDevOpsAlias(t *testing.T) {
def, err := selectPlugin("azure-devops")
if err != nil {
t.Fatalf("expected alias resolution for azure-devops, got error: %v", err)
}
if !strings.Contains(err.Error(), "coming soon") {
t.Errorf("unexpected error message: %v", err)
if def == nil {
t.Fatal("expected ConnectionDef, got nil")
}
if def.Plugin != "azuredevops_go" {
t.Errorf("expected plugin %q, got %q", "azuredevops_go", def.Plugin)
}
}
5 changes: 3 additions & 2 deletions cmd/configure_connection_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error {
return err
}
}
canonicalPlugin := canonicalPluginSlug(connDeletePlugin)

// ── Discover DevLake ──
client, disc, err := discoverClient(cfgURL)
Expand All @@ -64,7 +65,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error {
}

// ── Resolve plugin + ID ──
plugin := connDeletePlugin
plugin := canonicalPlugin
connID := connDeleteID
Comment on lines 59 to 69
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In flag mode, plugin is now canonicalized via canonicalPluginSlug, but the state update logic later removes entries only when c.Plugin == plugin. If an existing state file contains the legacy alias (e.g. azure-devops), the DevLake API delete will succeed but the state entry will not be removed, leaving stale local state. Consider canonicalizing both sides when filtering state connections (e.g., compare canonicalPluginSlug(c.Plugin) to plugin).

Copilot uses AI. Check for mistakes.

if !(pluginFlagSet && idFlagSet) {
Expand Down Expand Up @@ -107,7 +108,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error {
statePath, state := devlake.FindStateFile(disc.URL, disc.GrafanaURL)
var updated []devlake.StateConnection
for _, c := range state.Connections {
if c.Plugin == plugin && c.ConnectionID == connID {
if canonicalPluginSlug(c.Plugin) == plugin && c.ConnectionID == connID {
continue
}
updated = append(updated, c)
Expand Down
2 changes: 1 addition & 1 deletion cmd/configure_connection_test_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func runTestConnection(cmd *cobra.Command, args []string) error {
if _, err := requirePlugin(connTestPlugin); err != nil {
return err
}
plugin = connTestPlugin
plugin = canonicalPluginSlug(connTestPlugin)
connID = connTestID
} else {
// ── Interactive mode: list all connections and let user pick ──
Expand Down
5 changes: 3 additions & 2 deletions cmd/configure_connection_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error {
printBanner("DevLake — Update Connection")

flagMode := updateConnPlugin != "" || updateConnID != 0
canonicalPlugin := canonicalPluginSlug(updateConnPlugin)

// ── Validate flags before making any network calls ──
if flagMode {
Expand All @@ -78,7 +79,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error {
var connID int

if flagMode {
plugin = updateConnPlugin
plugin = canonicalPlugin
connID = updateConnID
Comment on lines 58 to 83
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In flag mode, plugin is now canonicalized (canonicalPluginSlug(updateConnPlugin)), but the state update loop later matches with if c.Plugin == plugin && c.ConnectionID == updated.ID. If a state file contains the legacy alias (e.g. azure-devops), the connection will update successfully in DevLake but the local state file won’t be updated. Consider canonicalizing plugin slugs when matching state entries (e.g., compare canonicalPluginSlug(c.Plugin) to plugin) so alias handling stays consistent for existing state files.

Copilot uses AI. Check for mistakes.
} else {
// ── Interactive: let user pick ──
Expand Down Expand Up @@ -146,7 +147,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error {
// ── Update state file ──
statePath, state := devlake.FindStateFile(disc.URL, disc.GrafanaURL)
for i, c := range state.Connections {
if c.Plugin == plugin && c.ConnectionID == updated.ID {
if canonicalPluginSlug(c.Plugin) == plugin && c.ConnectionID == updated.ID {
state.Connections[i].Name = updated.Name
state.Connections[i].Organization = updated.Organization
state.Connections[i].Enterprise = updated.Enterprise
Expand Down
14 changes: 11 additions & 3 deletions cmd/configure_scope_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
printBanner("DevLake \u2014 Configure Scopes")

// Determine which plugin to scope
var selectedPlugin string
var (
selectedPlugin string
selectedDef *ConnectionDef
)
if opts.Plugin != "" {
def, err := requirePlugin(opts.Plugin)
if err != nil {
return err
}
selectedPlugin = opts.Plugin
selectedDef = def
selectedPlugin = def.Plugin
// Warn about flags that don't apply to the selected plugin.
Comment on lines 71 to 78
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

runScopeAdd now canonicalizes selectedPlugin to def.Plugin. However, connection auto-detection still relies on resolveConnectionID, which checks state.Connections[i].Plugin == plugin (exact string match). This will break resolving connection IDs from existing state files that stored the legacy alias (e.g. azure-devops) even though the CLI accepts that alias. Consider canonicalizing plugin slugs when comparing against state (e.g., compare canonicalPluginSlug(c.Plugin) to the selected plugin) so alias support works end-to-end.

Copilot uses AI. Check for mistakes.
warnIrrelevantFlags(cmd, def, collectAllScopeFlagDefs())
} else {
Expand All @@ -96,6 +100,7 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
for _, d := range available {
if d.DisplayName == chosen {
selectedPlugin = d.Plugin
selectedDef = d
// Print applicable flags and warn about irrelevant ones after
// interactive plugin selection.
printContextualFlagHelp(d, d.ScopeFlags, "Scope")
Expand All @@ -122,7 +127,10 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
fmt.Printf(" %s connection ID: %d\n", pluginDisplayName(selectedPlugin), connID)

org := resolveOrg(state, opts.Org)
def := FindConnectionDef(selectedPlugin)
def := selectedDef
if def == nil {
def = FindConnectionDef(selectedPlugin)
}
if def == nil || def.ScopeFunc == nil {
return fmt.Errorf("scope configuration for %q is not yet supported", selectedPlugin)
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/configure_scope_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ func runScopeDelete(cmd *cobra.Command, args []string) error {
return err
}
}
canonicalPlugin := canonicalPluginSlug(scopeDeletePlugin)

client, _, err := discoverClient(cfgURL)
if err != nil {
return err
}

selectedPlugin := scopeDeletePlugin
selectedPlugin := canonicalPlugin
selectedConnID := scopeDeleteConnID
selectedScopeID := scopeDeleteScopeID

Expand Down
3 changes: 2 additions & 1 deletion cmd/configure_scope_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func runScopeList(cmd *cobra.Command, args []string) error {
return err
}
}
canonicalPlugin := canonicalPluginSlug(scopeListPlugin)

// In JSON mode, flags are required (interactive prompts are not supported)
if outputJSON && !(pluginFlagSet && connIDFlagSet) {
Expand All @@ -81,7 +82,7 @@ func runScopeList(cmd *cobra.Command, args []string) error {
client = c
}

selectedPlugin := scopeListPlugin
selectedPlugin := canonicalPlugin
selectedConnID := scopeListConnID

if !(pluginFlagSet && connIDFlagSet) {
Expand Down
182 changes: 180 additions & 2 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ func resolveConnectionID(client *devlake.Client, state *devlake.State, plugin st
if flagValue > 0 {
return flagValue, nil
}
canonical := plugin
if state != nil {
for _, c := range state.Connections {
if c.Plugin == plugin {
if canonicalPluginSlug(c.Plugin) == canonical {
return c.ConnectionID, nil
}
}
}
conns, err := client.ListConnections(plugin)
conns, err := client.ListConnections(canonical)
if err != nil {
return 0, fmt.Errorf("could not list %s connections: %w", plugin, err)
Comment on lines +145 to 155
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

resolveConnectionID sets canonical := plugin but then compares canonicalPluginSlug(c.Plugin) against canonical and calls ListConnections(canonical). If plugin is an alias (e.g. azure-devops), this will not match state entries stored under the canonical slug and may call the API with the wrong plugin slug. Canonicalize the input up front (e.g. canonical := canonicalPluginSlug(plugin)) and use that for both the state comparison and the ListConnections call (while keeping user-facing messages based on the original plugin/display name).

Copilot uses AI. Check for mistakes.
}
Expand Down Expand Up @@ -472,6 +473,183 @@ func scopeCopilotHandler(client *devlake.Client, connID int, org, enterprise str
return scopeCopilot(client, connID, org, enterprise)
}

// scopeAzureDevOpsHandler browses Azure DevOps projects and repositories via the
// remote-scope API and adds the selected repositories as scopes.
func scopeAzureDevOpsHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) {
fmt.Println("\n🔍 Fetching Azure DevOps projects...")
rootChildren, err := listAzureDevOpsRemoteChildren(client, connID, "")
if err != nil {
return nil, fmt.Errorf("listing Azure DevOps projects: %w", err)
}

var (
projects []devlake.RemoteScopeChild
scopes []devlake.RemoteScopeChild
)
for _, child := range rootChildren {
switch child.Type {
case "group":
projects = append(projects, child)
case "scope":
scopes = append(scopes, child)
}
}

var selectedScopes []devlake.RemoteScopeChild
if len(projects) > 0 {
projectLabels := make([]string, 0, len(projects))
projectByLabel := make(map[string]devlake.RemoteScopeChild)
for _, p := range projects {
label := azureScopeLabel(p)
projectLabels = append(projectLabels, label)
projectByLabel[label] = p
}

fmt.Println()
chosenProjects := prompt.SelectMulti("Select Azure DevOps projects", projectLabels)
if len(chosenProjects) == 0 {
return nil, fmt.Errorf("at least one Azure DevOps project must be selected")
}

for _, label := range chosenProjects {
project := projectByLabel[label]
fmt.Printf("\n🔍 Listing repositories in project %q...\n", label)
children, err := listAzureDevOpsRemoteChildren(client, connID, project.ID)
if err != nil {
return nil, fmt.Errorf("listing repositories in project %q: %w", label, err)
}
var repoLabels []string
repoByLabel := make(map[string]devlake.RemoteScopeChild)
for _, child := range children {
if child.Type != "scope" {
continue
}
l := azureScopeLabel(child)
repoLabels = append(repoLabels, l)
repoByLabel[l] = child
}
if len(repoLabels) == 0 {
fmt.Printf(" ⚠️ No repositories found in project %q\n", label)
continue
}

fmt.Println()
chosenRepos := prompt.SelectMulti("Select repositories to collect", repoLabels)
for _, repoLabel := range chosenRepos {
selectedScopes = append(selectedScopes, repoByLabel[repoLabel])
}
}
} else if len(scopes) > 0 {
scopeLabels := make([]string, 0, len(scopes))
scopeByLabel := make(map[string]devlake.RemoteScopeChild)
for _, s := range scopes {
label := azureScopeLabel(s)
scopeLabels = append(scopeLabels, label)
scopeByLabel[label] = s
}

fmt.Println()
chosenScopes := prompt.SelectMulti("Select Azure DevOps scopes to collect", scopeLabels)
for _, label := range chosenScopes {
selectedScopes = append(selectedScopes, scopeByLabel[label])
}
}

if len(selectedScopes) == 0 {
return nil, fmt.Errorf("no Azure DevOps scopes selected")
}

fmt.Println("\n📝 Adding Azure DevOps scopes...")
var (
data []any
bpScopes []devlake.BlueprintScope
pluginSlug = "azuredevops_go"
)
for _, child := range selectedScopes {
payload := azureDevOpsScopePayload(child, connID)
data = append(data, payload)

scopeID := child.ID
if idVal, ok := payload["id"].(string); ok && idVal != "" {
scopeID = idVal
}
name := azureScopeLabel(child)
if name == "" {
if n, ok := payload["name"].(string); ok {
name = n
}
}
bpScopes = append(bpScopes, devlake.BlueprintScope{
ScopeID: scopeID,
ScopeName: name,
})
}

if err := client.PutScopes(pluginSlug, connID, &devlake.ScopeBatchRequest{Data: data}); err != nil {
return nil, fmt.Errorf("failed to add Azure DevOps scopes: %w", err)
}
fmt.Printf(" ✅ Added %d Azure DevOps scope(s)\n", len(data))

return &devlake.BlueprintConnection{
PluginName: pluginSlug,
ConnectionID: connID,
Scopes: bpScopes,
}, nil
}

func listAzureDevOpsRemoteChildren(client *devlake.Client, connID int, groupID string) ([]devlake.RemoteScopeChild, error) {
var (
children []devlake.RemoteScopeChild
pageToken string
)
for {
resp, err := client.ListRemoteScopes("azuredevops_go", connID, groupID, pageToken)
if err != nil {
return nil, err
}
children = append(children, resp.Children...)
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return children, nil
}

func azureDevOpsScopePayload(child devlake.RemoteScopeChild, connID int) map[string]any {
var payload map[string]any
if len(child.Data) > 0 {
if err := json.Unmarshal(child.Data, &payload); err != nil {
fmt.Printf("\n⚠️ Could not decode Azure DevOps scope data for %s: %v\n", child.ID, err)
payload = make(map[string]any)
}
}
if payload == nil {
payload = make(map[string]any)
}
if _, ok := payload["id"]; !ok || payload["id"] == "" {
payload["id"] = child.ID
}
if _, ok := payload["name"]; !ok || payload["name"] == "" {
payload["name"] = child.Name
}
if v, ok := payload["fullName"]; (!ok || v == "") && child.FullName != "" {
payload["fullName"] = child.FullName
}
payload["connectionId"] = connID
return payload
}

func azureScopeLabel(child devlake.RemoteScopeChild) string {
if child.FullName != "" {
return child.FullName
}
if child.Name != "" {
return child.Name
}
return child.ID
}
Comment on lines +619 to +651
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The new Azure DevOps scope helper logic (azureScopeLabel, azureDevOpsScopePayload, and pagination in listAzureDevOpsRemoteChildren) isn’t covered by unit tests. Since cmd/configure_scopes.go already has a substantial test suite, adding focused tests for these helpers (e.g., fullName/id/name fallback behavior) would help prevent regressions.

Copilot uses AI. Check for mistakes.

// scopeGitLabHandler is the ScopeHandler for the gitlab plugin.
// It resolves projects via the DevLake remote-scope API and PUTs the selected
// projects as scopes on the connection.
Expand Down
Loading