Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ For the full guide, see [Day-2 Operations](docs/day-2.md).
| 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 | ✅ Available | Repos, pipelines, deployments (DORA) | PAT with repo and pipeline access |
| ArgoCD | ✅ Available | GitOps deployments, deployment frequency (DORA) | ArgoCD auth token |

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

Expand Down
81 changes: 81 additions & 0 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1278,3 +1278,84 @@ func scopeSonarQubeHandler(client *devlake.Client, connID int, org, enterprise s
Scopes: blueprintScopes,
}, nil
}

// scopeArgoCDHandler is the ScopeHandler for the argocd plugin.
func scopeArgoCDHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) {
fmt.Println("\n📋 Fetching ArgoCD applications...")

// Aggregate all pages of remote scopes
var allChildren []devlake.RemoteScopeChild
pageToken := ""
for {
remoteScopes, err := client.ListRemoteScopes("argocd", connID, "", pageToken)
if err != nil {
return nil, fmt.Errorf("failed to list ArgoCD applications: %w", err)
}
allChildren = append(allChildren, remoteScopes.Children...)
pageToken = remoteScopes.NextPageToken
if pageToken == "" {
break
}
}

// Extract applications from remote-scope response
var appOptions []string
appMap := make(map[string]*devlake.RemoteScopeChild)
for i := range allChildren {
child := &allChildren[i]
if child.Type == "scope" {
// Skip applications without a valid name (child.ID)
if child.ID == "" {
continue
}
label := child.Name
if label == "" {
label = child.ID
}
appOptions = append(appOptions, label)
appMap[label] = child
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

appMap is keyed by the user-facing label (child.Name fallback to child.ID). If multiple ArgoCD applications share the same Name (or if Name is not guaranteed unique), later entries overwrite earlier ones and the selected label can resolve to the wrong scope. Consider making the option label unambiguous (e.g., include the ID in the label like the SonarQube handler does) or keep a label→ID map and key the child map by ID.

This issue also appears on line 1340 of the same file.

Copilot uses AI. Check for mistakes.
}

if len(appOptions) == 0 {
return nil, fmt.Errorf("no ArgoCD applications found for connection %d", connID)
}

fmt.Println()
selectedLabels := prompt.SelectMulti("Select ArgoCD applications to track", appOptions)
if len(selectedLabels) == 0 {
return nil, fmt.Errorf("at least one ArgoCD application must be selected")
}

// Build scope data for PUT
fmt.Println("\n📝 Adding ArgoCD application scopes...")
var scopeData []any
var blueprintScopes []devlake.BlueprintScope
for _, label := range selectedLabels {
child := appMap[label]
scopeData = append(scopeData, devlake.ArgoCDAppScope{
ConnectionID: connID,
Name: child.ID,
})
blueprintScopes = append(blueprintScopes, devlake.BlueprintScope{
ScopeID: child.ID,
ScopeName: child.Name,
})
}

if len(scopeData) == 0 {
return nil, fmt.Errorf("no valid applications to add")
}

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

return &devlake.BlueprintConnection{
PluginName: "argocd",
ConnectionID: connID,
Scopes: blueprintScopes,
}, nil
}
18 changes: 18 additions & 0 deletions cmd/connection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,24 @@ var connectionRegistry = []*ConnectionDef{
ScopeIDField: "projectKey",
HasRepoScopes: false,
},
{
Plugin: "argocd",
DisplayName: "ArgoCD",
Available: true,
Endpoint: "", // user must provide (e.g., https://argocd.example.com)
SupportsTest: true,
AuthMethod: "AccessToken",
RateLimitPerHour: 0, // uses default 4500
// ArgoCD uses auth tokens; permissions come from the user account.
RequiredScopes: []string{},
ScopeHint: "",
TokenPrompt: "ArgoCD auth token",
EnvVarNames: []string{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"},
EnvFileKeys: []string{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"},
ScopeFunc: scopeArgoCDHandler,
ScopeIDField: "name",
HasRepoScopes: false,
},
}

// AvailableConnections returns only available (non-coming-soon) connection defs.
Expand Down
65 changes: 65 additions & 0 deletions cmd/connection_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -792,3 +792,68 @@ func TestConnectionRegistry_SonarQube(t *testing.T) {
}
}
}

// TestConnectionRegistry_ArgoCD verifies the ArgoCD plugin registry entry.
func TestConnectionRegistry_ArgoCD(t *testing.T) {
def := FindConnectionDef("argocd")
if def == nil {
t.Fatal("argocd plugin not found in registry")
}

tests := []struct {
name string
got interface{}
want interface{}
}{
{"Plugin", def.Plugin, "argocd"},
{"DisplayName", def.DisplayName, "ArgoCD"},
{"Available", def.Available, true},
{"Endpoint", def.Endpoint, ""},
{"SupportsTest", def.SupportsTest, true},
{"AuthMethod", def.AuthMethod, "AccessToken"},
{"ScopeIDField", def.ScopeIDField, "name"},
{"HasRepoScopes", def.HasRepoScopes, false},
}

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")
}

// ArgoCD uses auth tokens, not OAuth/PAT scopes
if len(def.RequiredScopes) != 0 {
t.Errorf("RequiredScopes should be empty for ArgoCD auth tokens, got %v", def.RequiredScopes)
}
if def.ScopeHint != "" {
t.Errorf("ScopeHint should be empty for ArgoCD auth tokens, got %q", def.ScopeHint)
}

expectedEnvVars := []string{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvVarNames) != len(expectedEnvVars) {
t.Errorf("EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(expectedEnvVars))
} else {
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{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvFileKeys) != len(expectedEnvFileKeys) {
t.Errorf("EnvFileKeys length: got %d, want %d", len(def.EnvFileKeys), len(expectedEnvFileKeys))
} else {
for i, v := range expectedEnvFileKeys {
if def.EnvFileKeys[i] != v {
t.Errorf("EnvFileKeys[%d]: got %q, want %q", i, def.EnvFileKeys[i], v)
}
}
}
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

This new test block is not gofmt-formatted (indentation/tabs), unlike the rest of the file. Please run gofmt on this file (or at least this new function) to keep formatting consistent and avoid noisy diffs later.

Suggested change
}{
{"Plugin", def.Plugin, "argocd"},
{"DisplayName", def.DisplayName, "ArgoCD"},
{"Available", def.Available, true},
{"Endpoint", def.Endpoint, ""},
{"SupportsTest", def.SupportsTest, true},
{"AuthMethod", def.AuthMethod, "AccessToken"},
{"ScopeIDField", def.ScopeIDField, "name"},
{"HasRepoScopes", def.HasRepoScopes, false},
}
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")
}
// ArgoCD uses auth tokens, not OAuth/PAT scopes
if len(def.RequiredScopes) != 0 {
t.Errorf("RequiredScopes should be empty for ArgoCD auth tokens, got %v", def.RequiredScopes)
}
if def.ScopeHint != "" {
t.Errorf("ScopeHint should be empty for ArgoCD auth tokens, got %q", def.ScopeHint)
}
expectedEnvVars := []string{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvVarNames) != len(expectedEnvVars) {
t.Errorf("EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(expectedEnvVars))
} else {
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{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvFileKeys) != len(expectedEnvFileKeys) {
t.Errorf("EnvFileKeys length: got %d, want %d", len(def.EnvFileKeys), len(expectedEnvFileKeys))
} else {
for i, v := range expectedEnvFileKeys {
if def.EnvFileKeys[i] != v {
t.Errorf("EnvFileKeys[%d]: got %q, want %q", i, def.EnvFileKeys[i], v)
}
}
}
}
}{
{"Plugin", def.Plugin, "argocd"},
{"DisplayName", def.DisplayName, "ArgoCD"},
{"Available", def.Available, true},
{"Endpoint", def.Endpoint, ""},
{"SupportsTest", def.SupportsTest, true},
{"AuthMethod", def.AuthMethod, "AccessToken"},
{"ScopeIDField", def.ScopeIDField, "name"},
{"HasRepoScopes", def.HasRepoScopes, false},
}
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")
}
// ArgoCD uses auth tokens, not OAuth/PAT scopes
if len(def.RequiredScopes) != 0 {
t.Errorf("RequiredScopes should be empty for ArgoCD auth tokens, got %v", def.RequiredScopes)
}
if def.ScopeHint != "" {
t.Errorf("ScopeHint should be empty for ArgoCD auth tokens, got %q", def.ScopeHint)
}
expectedEnvVars := []string{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvVarNames) != len(expectedEnvVars) {
t.Errorf("EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(expectedEnvVars))
} else {
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{"ARGOCD_TOKEN", "ARGOCD_AUTH_TOKEN"}
if len(def.EnvFileKeys) != len(expectedEnvFileKeys) {
t.Errorf("EnvFileKeys length: got %d, want %d", len(def.EnvFileKeys), len(expectedEnvFileKeys))
} else {
for i, v := range expectedEnvFileKeys {
if def.EnvFileKeys[i] != v {
t.Errorf("EnvFileKeys[%d]: got %q, want %q", i, def.EnvFileKeys[i], v)
}
}
}
}

Copilot uses AI. Check for mistakes.
6 changes: 6 additions & 0 deletions internal/devlake/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ type SonarQubeProjectScope struct {
Name string `json:"name"`
}

// ArgoCDAppScope represents an ArgoCD application scope entry for PUT /scopes.
type ArgoCDAppScope struct {
ConnectionID int `json:"connectionId"`
Name string `json:"name"`
}

// ScopeBatchRequest is the payload for PUT /scopes (batch upsert).
type ScopeBatchRequest struct {
Data []any `json:"data"`
Expand Down
Loading