diff --git a/README.md b/README.md index 3554d55..003e73d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/configure_connection_add_test.go b/cmd/configure_connection_add_test.go index 5a115ff..03df3f2 100644 --- a/cmd/configure_connection_add_test.go +++ b/cmd/configure_connection_add_test.go @@ -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) } } diff --git a/cmd/configure_connection_delete.go b/cmd/configure_connection_delete.go index 0c4b6b8..a781a65 100644 --- a/cmd/configure_connection_delete.go +++ b/cmd/configure_connection_delete.go @@ -56,6 +56,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error { return err } } + canonicalPlugin := canonicalPluginSlug(connDeletePlugin) // ── Discover DevLake ── client, disc, err := discoverClient(cfgURL) @@ -64,7 +65,7 @@ func runDeleteConnection(cmd *cobra.Command, args []string) error { } // ── Resolve plugin + ID ── - plugin := connDeletePlugin + plugin := canonicalPlugin connID := connDeleteID if !(pluginFlagSet && idFlagSet) { @@ -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) diff --git a/cmd/configure_connection_test_cmd.go b/cmd/configure_connection_test_cmd.go index 3bdcdfd..407f87e 100644 --- a/cmd/configure_connection_test_cmd.go +++ b/cmd/configure_connection_test_cmd.go @@ -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 ── diff --git a/cmd/configure_connection_update.go b/cmd/configure_connection_update.go index 49a9014..dfc410c 100644 --- a/cmd/configure_connection_update.go +++ b/cmd/configure_connection_update.go @@ -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 { @@ -78,7 +79,7 @@ func runUpdateConnection(cmd *cobra.Command, args []string) error { var connID int if flagMode { - plugin = updateConnPlugin + plugin = canonicalPlugin connID = updateConnID } else { // ── Interactive: let user pick ── @@ -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 diff --git a/cmd/configure_scope_add.go b/cmd/configure_scope_add.go index 92f613c..25c92dd 100644 --- a/cmd/configure_scope_add.go +++ b/cmd/configure_scope_add.go @@ -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. warnIrrelevantFlags(cmd, def, collectAllScopeFlagDefs()) } else { @@ -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") @@ -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) } diff --git a/cmd/configure_scope_delete.go b/cmd/configure_scope_delete.go index 16dc7c4..43134a3 100644 --- a/cmd/configure_scope_delete.go +++ b/cmd/configure_scope_delete.go @@ -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 diff --git a/cmd/configure_scope_list.go b/cmd/configure_scope_list.go index 8a270fe..93dd301 100644 --- a/cmd/configure_scope_list.go +++ b/cmd/configure_scope_list.go @@ -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) { @@ -81,7 +82,7 @@ func runScopeList(cmd *cobra.Command, args []string) error { client = c } - selectedPlugin := scopeListPlugin + selectedPlugin := canonicalPlugin selectedConnID := scopeListConnID if !(pluginFlagSet && connIDFlagSet) { diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 2f0b8ed..4a29805 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -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) } @@ -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 +} + // 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. diff --git a/cmd/configure_scopes_test.go b/cmd/configure_scopes_test.go index b7a4dc2..897067a 100644 --- a/cmd/configure_scopes_test.go +++ b/cmd/configure_scopes_test.go @@ -1,11 +1,14 @@ package cmd import ( + "encoding/json" "os" "path/filepath" "testing" "github.com/spf13/cobra" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" ) func TestCopilotScopeID(t *testing.T) { @@ -33,6 +36,95 @@ func TestCopilotScopeID(t *testing.T) { } } +func TestAzureScopeLabel(t *testing.T) { + tests := []struct { + name string + in devlake.RemoteScopeChild + want string + }{ + { + name: "prefers full name", + in: devlake.RemoteScopeChild{FullName: "org/project/repo", Name: "repo", ID: "123"}, + want: "org/project/repo", + }, + { + name: "falls back to name", + in: devlake.RemoteScopeChild{Name: "project", ID: "456"}, + want: "project", + }, + { + name: "falls back to id", + in: devlake.RemoteScopeChild{ID: "789"}, + want: "789", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := azureScopeLabel(tt.in); got != tt.want { + t.Errorf("azureScopeLabel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAzureDevOpsScopePayload_FullNameFallback(t *testing.T) { + raw := map[string]any{ + "id": "", + "name": "", + "fullName": "", + } + data, _ := json.Marshal(raw) + child := devlake.RemoteScopeChild{ + ID: "123", + Name: "repo", + FullName: "org/project/repo", + Data: data, + } + payload := azureDevOpsScopePayload(child, 42) + + if payload["id"] != "123" { + t.Fatalf("id = %v, want 123", payload["id"]) + } + if payload["name"] != "repo" { + t.Fatalf("name = %v, want repo", payload["name"]) + } + if payload["fullName"] != "org/project/repo" { + t.Fatalf("fullName = %v, want org/project/repo", payload["fullName"]) + } + if payload["connectionId"] != 42 { + t.Fatalf("connectionId = %v, want 42", payload["connectionId"]) + } +} + +func TestAzureDevOpsScopePayload_KeepsExistingFields(t *testing.T) { + raw := map[string]any{ + "id": "keep-id", + "name": "keep-name", + "fullName": "keep-full", + } + data, _ := json.Marshal(raw) + child := devlake.RemoteScopeChild{ + ID: "child-id", + Name: "child-name", + FullName: "child/full", + Data: data, + } + payload := azureDevOpsScopePayload(child, 7) + + if payload["id"] != "keep-id" { + t.Fatalf("id = %v, want keep-id", payload["id"]) + } + if payload["name"] != "keep-name" { + t.Fatalf("name = %v, want keep-name", payload["name"]) + } + if payload["fullName"] != "keep-full" { + t.Fatalf("fullName = %v, want keep-full", payload["fullName"]) + } + if payload["connectionId"] != 7 { + t.Fatalf("connectionId = %v, want 7", payload["connectionId"]) + } +} + func TestRunConfigureScopes_PluginFlag(t *testing.T) { makeCmd := func() (*cobra.Command, *ScopeOpts) { opts := &ScopeOpts{} diff --git a/cmd/connection_types.go b/cmd/connection_types.go index 5ca401b..98d9079 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -298,13 +298,24 @@ var connectionRegistry = []*ConnectionDef{ }, }, { - Plugin: "azure-devops", - DisplayName: "Azure DevOps", - Available: false, - TokenPrompt: "Azure DevOps PAT", - OrgPrompt: "Azure DevOps organization", - EnvVarNames: []string{"AZURE_DEVOPS_PAT"}, - EnvFileKeys: []string{"AZURE_DEVOPS_PAT"}, + Plugin: "azuredevops_go", + DisplayName: "Azure DevOps", + Available: true, + Endpoint: "", + NeedsOrg: true, + SupportsTest: true, + AuthMethod: "AccessToken", + RequiredScopes: []string{}, + ScopeHint: "", + TokenPrompt: "Azure DevOps PAT", + OrgPrompt: "Azure DevOps organization", + EnvVarNames: []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"}, + EnvFileKeys: []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"}, + ScopeFunc: scopeAzureDevOpsHandler, + ScopeIDField: "id", + HasRepoScopes: true, + ConnectionFlags: nil, + ScopeFlags: nil, }, { Plugin: "jira", @@ -357,6 +368,10 @@ func AvailableConnections() []*ConnectionDef { // FindConnectionDef returns the def for the given plugin slug, or nil. func FindConnectionDef(plugin string) *ConnectionDef { + switch plugin { + case "azure-devops": + plugin = "azuredevops_go" + } for _, d := range connectionRegistry { if d.Plugin == plugin { return d diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index 497e9b6..54c31f8 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -217,6 +217,68 @@ func TestJiraConnectionDef(t *testing.T) { } } +// TestAzureDevOpsRegistryEntry verifies the Azure DevOps plugin registry entry. +func TestAzureDevOpsRegistryEntry(t *testing.T) { + def := FindConnectionDef("azuredevops_go") + if def == nil { + t.Fatal("azuredevops_go plugin not found in registry") + } + + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Plugin", def.Plugin, "azuredevops_go"}, + {"DisplayName", def.DisplayName, "Azure DevOps"}, + {"Available", def.Available, true}, + {"Endpoint", def.Endpoint, ""}, + {"NeedsOrg", def.NeedsOrg, true}, + {"SupportsTest", def.SupportsTest, true}, + {"AuthMethod", def.authMethod(), "AccessToken"}, + {"ScopeIDField", def.ScopeIDField, "id"}, + {"HasRepoScopes", def.HasRepoScopes, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.want) + } + }) + } + + if def.ScopeFunc == nil { + t.Error("ScopeFunc should not be nil") + } + if len(def.RequiredScopes) != 0 { + t.Errorf("RequiredScopes should be empty, got %v", def.RequiredScopes) + } + if def.ScopeHint != "" { + t.Errorf("ScopeHint should be empty, got %q", def.ScopeHint) + } + + expectedEnvVars := []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"} + if len(def.EnvVarNames) != len(expectedEnvVars) { + t.Fatalf("EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(expectedEnvVars)) + } + for i, v := range expectedEnvVars { + if def.EnvVarNames[i] != v { + t.Errorf("EnvVarNames[%d]: got %q, want %q", i, def.EnvVarNames[i], v) + } + } + + expectedEnvFileKeys := []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"} + if len(def.EnvFileKeys) != len(expectedEnvFileKeys) { + t.Fatalf("EnvFileKeys length: got %d, want %d", len(def.EnvFileKeys), len(expectedEnvFileKeys)) + } + for i, v := range expectedEnvFileKeys { + if def.EnvFileKeys[i] != v { + t.Errorf("EnvFileKeys[%d]: got %q, want %q", i, def.EnvFileKeys[i], v) + } + } +} + // TestBuildCreateRequest_AuthMethod verifies that AuthMethod defaults to "AccessToken" // when empty, and uses the configured value when set. func TestBuildCreateRequest_AuthMethod(t *testing.T) { @@ -456,7 +518,7 @@ func TestNeedsTokenExpiry(t *testing.T) { {"github", true}, {"gh-copilot", true}, {"gitlab", false}, - {"azure-devops", false}, + {"azuredevops_go", false}, } for _, tt := range tests { t.Run(tt.plugin, func(t *testing.T) { diff --git a/cmd/helpers.go b/cmd/helpers.go index 1828083..edfe91a 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -143,6 +143,15 @@ func pluginDisplayName(plugin string) string { return plugin } +// canonicalPluginSlug returns the canonical registry slug for a plugin, resolving aliases. +// If the slug is unknown, it returns the input unchanged. +func canonicalPluginSlug(slug string) string { + if def := FindConnectionDef(slug); def != nil { + return def.Plugin + } + return slug +} + // deduplicateResults removes duplicate (Plugin, ConnectionID) pairs, // keeping the first occurrence. Multiple connections of the same plugin // with different IDs are preserved. diff --git a/docs/token-handling.md b/docs/token-handling.md index eb83a2a..4206398 100644 --- a/docs/token-handling.md +++ b/docs/token-handling.md @@ -2,7 +2,7 @@ How the CLI resolves, uses, and secures Personal Access Tokens (PATs) for each plugin connection. -Supported today: **GitHub**, **GitHub Copilot**. Coming soon: **Azure DevOps**, **GitLab**. +Supported today: **GitHub**, **GitHub Copilot**, **GitLab**, **Azure DevOps**. ## Token Resolution Order @@ -33,8 +33,8 @@ The CLI checks **plugin-specific** keys (first match wins): |--------|-------------------------------|---------------------------------| | GitHub | `GITHUB_PAT`, `GITHUB_TOKEN`, `GH_TOKEN` | `GITHUB_PAT`, `GITHUB_TOKEN`, `GH_TOKEN` | | GitHub Copilot | `GITHUB_PAT`, `GITHUB_TOKEN`, `GH_TOKEN` | `GITHUB_PAT`, `GITHUB_TOKEN`, `GH_TOKEN` | -| GitLab (coming soon) | `GITLAB_TOKEN` | `GITLAB_TOKEN` | -| Azure DevOps (coming soon) | `AZURE_DEVOPS_PAT` | `AZURE_DEVOPS_PAT` | +| GitLab | `GITLAB_TOKEN`, `GITLAB_PAT` | `GITLAB_TOKEN`, `GITLAB_PAT` | +| Azure DevOps | `AZURE_DEVOPS_PAT`, `AZDO_PAT` | `AZURE_DEVOPS_PAT`, `AZDO_PAT` | By default, the CLI looks for `.devlake.env` in the current directory. Override the path with `--env-file`: @@ -57,8 +57,8 @@ As a final fallback, the CLI prompts you to paste the token at the terminal. Inp | GitHub | `repo`, `read:org`, `read:user` | | GitHub Copilot | `manage_billing:copilot`, `read:org` | | GitHub Copilot (enterprise) | + `read:enterprise` | - -GitLab and Azure DevOps scopes will be documented when those plugins ship. +| GitLab | `read_api`, `read_repository` | +| Azure DevOps | PAT with repo + pipeline access (no OAuth scopes) | The CLI displays required scopes as a reminder before prompting for the token. diff --git a/internal/token/resolve_test.go b/internal/token/resolve_test.go index b87beca..3e547b9 100644 --- a/internal/token/resolve_test.go +++ b/internal/token/resolve_test.go @@ -31,8 +31,8 @@ func adoOpts(flagValue, envFile string) ResolveOpts { return ResolveOpts{ FlagValue: flagValue, EnvFilePath: envFile, - EnvFileKeys: []string{"AZURE_DEVOPS_PAT"}, - EnvVarNames: []string{"AZURE_DEVOPS_PAT"}, + EnvFileKeys: []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"}, + EnvVarNames: []string{"AZURE_DEVOPS_PAT", "AZDO_PAT"}, DisplayName: "Azure DevOps", } } @@ -115,6 +115,18 @@ func TestResolve_AzureDevOps_EnvVar(t *testing.T) { } } +func TestResolve_AzureDevOps_AZDO_EnvVar(t *testing.T) { + t.Setenv("AZURE_DEVOPS_PAT", "") + t.Setenv("AZDO_PAT", "azdo_fallback") + result, err := Resolve(adoOpts("", "")) + if err != nil { + t.Fatal(err) + } + if result.Token != "azdo_fallback" || result.Source != "environment" { + t.Errorf("got token=%q source=%q, want token=%q source=%q", result.Token, result.Source, "azdo_fallback", "environment") + } +} + func TestResolve_EnvFile_GitHubPAT(t *testing.T) { dir := t.TempDir() envFile := filepath.Join(dir, ".devlake.env")