Skip to content

Commit 6d20edc

Browse files
authored
Addressing PR comments (#130)
* Initial plan * Add Azure DevOps plugin support * Rename Azure DevOps alias test * Canonicalize azure alias usage and extend Azure DevOps scope tests * Canonicalize state comparisons and handle Azure scope decode errors * Normalize CRLF line endings in docs --------- Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
1 parent 6d3fbd5 commit 6d20edc

15 files changed

Lines changed: 417 additions & 34 deletions

README.md

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

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

cmd/configure_connection_add_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ func TestSelectPlugin_UnknownSlug(t *testing.T) {
5757
}
5858
}
5959

60-
func TestSelectPlugin_UnavailablePlugin(t *testing.T) {
61-
_, err := selectPlugin("azure-devops")
62-
if err == nil {
63-
t.Fatal("expected error for unavailable plugin, got nil")
60+
func TestSelectPlugin_AzureDevOpsAlias(t *testing.T) {
61+
def, err := selectPlugin("azure-devops")
62+
if err != nil {
63+
t.Fatalf("expected alias resolution for azure-devops, got error: %v", err)
6464
}
65-
if !strings.Contains(err.Error(), "coming soon") {
66-
t.Errorf("unexpected error message: %v", err)
65+
if def == nil {
66+
t.Fatal("expected ConnectionDef, got nil")
67+
}
68+
if def.Plugin != "azuredevops_go" {
69+
t.Errorf("expected plugin %q, got %q", "azuredevops_go", def.Plugin)
6770
}
6871
}

cmd/configure_connection_delete.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error {
5656
return err
5757
}
5858
}
59+
canonicalPlugin := canonicalPluginSlug(connDeletePlugin)
5960

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

6667
// ── Resolve plugin + ID ──
67-
plugin := connDeletePlugin
68+
plugin := canonicalPlugin
6869
connID := connDeleteID
6970

7071
if !(pluginFlagSet && idFlagSet) {
@@ -107,7 +108,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error {
107108
statePath, state := devlake.FindStateFile(disc.URL, disc.GrafanaURL)
108109
var updated []devlake.StateConnection
109110
for _, c := range state.Connections {
110-
if c.Plugin == plugin && c.ConnectionID == connID {
111+
if canonicalPluginSlug(c.Plugin) == plugin && c.ConnectionID == connID {
111112
continue
112113
}
113114
updated = append(updated, c)

cmd/configure_connection_test_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func runTestConnection(cmd *cobra.Command, args []string) error {
4747
if _, err := requirePlugin(connTestPlugin); err != nil {
4848
return err
4949
}
50-
plugin = connTestPlugin
50+
plugin = canonicalPluginSlug(connTestPlugin)
5151
connID = connTestID
5252
} else {
5353
// ── Interactive mode: list all connections and let user pick ──

cmd/configure_connection_update.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error {
5656
printBanner("DevLake — Update Connection")
5757

5858
flagMode := updateConnPlugin != "" || updateConnID != 0
59+
canonicalPlugin := canonicalPluginSlug(updateConnPlugin)
5960

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

8081
if flagMode {
81-
plugin = updateConnPlugin
82+
plugin = canonicalPlugin
8283
connID = updateConnID
8384
} else {
8485
// ── Interactive: let user pick ──
@@ -146,7 +147,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error {
146147
// ── Update state file ──
147148
statePath, state := devlake.FindStateFile(disc.URL, disc.GrafanaURL)
148149
for i, c := range state.Connections {
149-
if c.Plugin == plugin && c.ConnectionID == updated.ID {
150+
if canonicalPluginSlug(c.Plugin) == plugin && c.ConnectionID == updated.ID {
150151
state.Connections[i].Name = updated.Name
151152
state.Connections[i].Organization = updated.Organization
152153
state.Connections[i].Enterprise = updated.Enterprise

cmd/configure_scope_add.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,17 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
6464
printBanner("DevLake \u2014 Configure Scopes")
6565

6666
// Determine which plugin to scope
67-
var selectedPlugin string
67+
var (
68+
selectedPlugin string
69+
selectedDef *ConnectionDef
70+
)
6871
if opts.Plugin != "" {
6972
def, err := requirePlugin(opts.Plugin)
7073
if err != nil {
7174
return err
7275
}
73-
selectedPlugin = opts.Plugin
76+
selectedDef = def
77+
selectedPlugin = def.Plugin
7478
// Warn about flags that don't apply to the selected plugin.
7579
warnIrrelevantFlags(cmd, def, collectAllScopeFlagDefs())
7680
} else {
@@ -96,6 +100,7 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
96100
for _, d := range available {
97101
if d.DisplayName == chosen {
98102
selectedPlugin = d.Plugin
103+
selectedDef = d
99104
// Print applicable flags and warn about irrelevant ones after
100105
// interactive plugin selection.
101106
printContextualFlagHelp(d, d.ScopeFlags, "Scope")
@@ -122,7 +127,10 @@ func runScopeAdd(cmd *cobra.Command, args []string, opts *ScopeOpts) error {
122127
fmt.Printf(" %s connection ID: %d\n", pluginDisplayName(selectedPlugin), connID)
123128

124129
org := resolveOrg(state, opts.Org)
125-
def := FindConnectionDef(selectedPlugin)
130+
def := selectedDef
131+
if def == nil {
132+
def = FindConnectionDef(selectedPlugin)
133+
}
126134
if def == nil || def.ScopeFunc == nil {
127135
return fmt.Errorf("scope configuration for %q is not yet supported", selectedPlugin)
128136
}

cmd/configure_scope_delete.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ func runScopeDelete(cmd *cobra.Command, args []string) error {
6060
return err
6161
}
6262
}
63+
canonicalPlugin := canonicalPluginSlug(scopeDeletePlugin)
6364

6465
client, _, err := discoverClient(cfgURL)
6566
if err != nil {
6667
return err
6768
}
6869

69-
selectedPlugin := scopeDeletePlugin
70+
selectedPlugin := canonicalPlugin
7071
selectedConnID := scopeDeleteConnID
7172
selectedScopeID := scopeDeleteScopeID
7273

cmd/configure_scope_list.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func runScopeList(cmd *cobra.Command, args []string) error {
5858
return err
5959
}
6060
}
61+
canonicalPlugin := canonicalPluginSlug(scopeListPlugin)
6162

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

84-
selectedPlugin := scopeListPlugin
85+
selectedPlugin := canonicalPlugin
8586
selectedConnID := scopeListConnID
8687

8788
if !(pluginFlagSet && connIDFlagSet) {

cmd/configure_scopes.go

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,15 @@ func resolveConnectionID(client *devlake.Client, state *devlake.State, plugin st
142142
if flagValue > 0 {
143143
return flagValue, nil
144144
}
145+
canonical := plugin
145146
if state != nil {
146147
for _, c := range state.Connections {
147-
if c.Plugin == plugin {
148+
if canonicalPluginSlug(c.Plugin) == canonical {
148149
return c.ConnectionID, nil
149150
}
150151
}
151152
}
152-
conns, err := client.ListConnections(plugin)
153+
conns, err := client.ListConnections(canonical)
153154
if err != nil {
154155
return 0, fmt.Errorf("could not list %s connections: %w", plugin, err)
155156
}
@@ -472,6 +473,183 @@ func scopeCopilotHandler(client *devlake.Client, connID int, org, enterprise str
472473
return scopeCopilot(client, connID, org, enterprise)
473474
}
474475

476+
// scopeAzureDevOpsHandler browses Azure DevOps projects and repositories via the
477+
// remote-scope API and adds the selected repositories as scopes.
478+
func scopeAzureDevOpsHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) {
479+
fmt.Println("\n🔍 Fetching Azure DevOps projects...")
480+
rootChildren, err := listAzureDevOpsRemoteChildren(client, connID, "")
481+
if err != nil {
482+
return nil, fmt.Errorf("listing Azure DevOps projects: %w", err)
483+
}
484+
485+
var (
486+
projects []devlake.RemoteScopeChild
487+
scopes []devlake.RemoteScopeChild
488+
)
489+
for _, child := range rootChildren {
490+
switch child.Type {
491+
case "group":
492+
projects = append(projects, child)
493+
case "scope":
494+
scopes = append(scopes, child)
495+
}
496+
}
497+
498+
var selectedScopes []devlake.RemoteScopeChild
499+
if len(projects) > 0 {
500+
projectLabels := make([]string, 0, len(projects))
501+
projectByLabel := make(map[string]devlake.RemoteScopeChild)
502+
for _, p := range projects {
503+
label := azureScopeLabel(p)
504+
projectLabels = append(projectLabels, label)
505+
projectByLabel[label] = p
506+
}
507+
508+
fmt.Println()
509+
chosenProjects := prompt.SelectMulti("Select Azure DevOps projects", projectLabels)
510+
if len(chosenProjects) == 0 {
511+
return nil, fmt.Errorf("at least one Azure DevOps project must be selected")
512+
}
513+
514+
for _, label := range chosenProjects {
515+
project := projectByLabel[label]
516+
fmt.Printf("\n🔍 Listing repositories in project %q...\n", label)
517+
children, err := listAzureDevOpsRemoteChildren(client, connID, project.ID)
518+
if err != nil {
519+
return nil, fmt.Errorf("listing repositories in project %q: %w", label, err)
520+
}
521+
var repoLabels []string
522+
repoByLabel := make(map[string]devlake.RemoteScopeChild)
523+
for _, child := range children {
524+
if child.Type != "scope" {
525+
continue
526+
}
527+
l := azureScopeLabel(child)
528+
repoLabels = append(repoLabels, l)
529+
repoByLabel[l] = child
530+
}
531+
if len(repoLabels) == 0 {
532+
fmt.Printf(" ⚠️ No repositories found in project %q\n", label)
533+
continue
534+
}
535+
536+
fmt.Println()
537+
chosenRepos := prompt.SelectMulti("Select repositories to collect", repoLabels)
538+
for _, repoLabel := range chosenRepos {
539+
selectedScopes = append(selectedScopes, repoByLabel[repoLabel])
540+
}
541+
}
542+
} else if len(scopes) > 0 {
543+
scopeLabels := make([]string, 0, len(scopes))
544+
scopeByLabel := make(map[string]devlake.RemoteScopeChild)
545+
for _, s := range scopes {
546+
label := azureScopeLabel(s)
547+
scopeLabels = append(scopeLabels, label)
548+
scopeByLabel[label] = s
549+
}
550+
551+
fmt.Println()
552+
chosenScopes := prompt.SelectMulti("Select Azure DevOps scopes to collect", scopeLabels)
553+
for _, label := range chosenScopes {
554+
selectedScopes = append(selectedScopes, scopeByLabel[label])
555+
}
556+
}
557+
558+
if len(selectedScopes) == 0 {
559+
return nil, fmt.Errorf("no Azure DevOps scopes selected")
560+
}
561+
562+
fmt.Println("\n📝 Adding Azure DevOps scopes...")
563+
var (
564+
data []any
565+
bpScopes []devlake.BlueprintScope
566+
pluginSlug = "azuredevops_go"
567+
)
568+
for _, child := range selectedScopes {
569+
payload := azureDevOpsScopePayload(child, connID)
570+
data = append(data, payload)
571+
572+
scopeID := child.ID
573+
if idVal, ok := payload["id"].(string); ok && idVal != "" {
574+
scopeID = idVal
575+
}
576+
name := azureScopeLabel(child)
577+
if name == "" {
578+
if n, ok := payload["name"].(string); ok {
579+
name = n
580+
}
581+
}
582+
bpScopes = append(bpScopes, devlake.BlueprintScope{
583+
ScopeID: scopeID,
584+
ScopeName: name,
585+
})
586+
}
587+
588+
if err := client.PutScopes(pluginSlug, connID, &devlake.ScopeBatchRequest{Data: data}); err != nil {
589+
return nil, fmt.Errorf("failed to add Azure DevOps scopes: %w", err)
590+
}
591+
fmt.Printf(" ✅ Added %d Azure DevOps scope(s)\n", len(data))
592+
593+
return &devlake.BlueprintConnection{
594+
PluginName: pluginSlug,
595+
ConnectionID: connID,
596+
Scopes: bpScopes,
597+
}, nil
598+
}
599+
600+
func listAzureDevOpsRemoteChildren(client *devlake.Client, connID int, groupID string) ([]devlake.RemoteScopeChild, error) {
601+
var (
602+
children []devlake.RemoteScopeChild
603+
pageToken string
604+
)
605+
for {
606+
resp, err := client.ListRemoteScopes("azuredevops_go", connID, groupID, pageToken)
607+
if err != nil {
608+
return nil, err
609+
}
610+
children = append(children, resp.Children...)
611+
if resp.NextPageToken == "" {
612+
break
613+
}
614+
pageToken = resp.NextPageToken
615+
}
616+
return children, nil
617+
}
618+
619+
func azureDevOpsScopePayload(child devlake.RemoteScopeChild, connID int) map[string]any {
620+
var payload map[string]any
621+
if len(child.Data) > 0 {
622+
if err := json.Unmarshal(child.Data, &payload); err != nil {
623+
fmt.Printf("\n⚠️ Could not decode Azure DevOps scope data for %s: %v\n", child.ID, err)
624+
payload = make(map[string]any)
625+
}
626+
}
627+
if payload == nil {
628+
payload = make(map[string]any)
629+
}
630+
if _, ok := payload["id"]; !ok || payload["id"] == "" {
631+
payload["id"] = child.ID
632+
}
633+
if _, ok := payload["name"]; !ok || payload["name"] == "" {
634+
payload["name"] = child.Name
635+
}
636+
if v, ok := payload["fullName"]; (!ok || v == "") && child.FullName != "" {
637+
payload["fullName"] = child.FullName
638+
}
639+
payload["connectionId"] = connID
640+
return payload
641+
}
642+
643+
func azureScopeLabel(child devlake.RemoteScopeChild) string {
644+
if child.FullName != "" {
645+
return child.FullName
646+
}
647+
if child.Name != "" {
648+
return child.Name
649+
}
650+
return child.ID
651+
}
652+
475653
// scopeGitLabHandler is the ScopeHandler for the gitlab plugin.
476654
// It resolves projects via the DevLake remote-scope API and PUTs the selected
477655
// projects as scopes on the connection.

0 commit comments

Comments
 (0)