From 655dd8786ac9d690736ef97340bf3afbf626953f Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:18:52 +0000 Subject: [PATCH 1/4] Initial plan From 73921dee448207d0d43026872621c5ad49d8899d Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:22:37 +0000 Subject: [PATCH 2/4] Add SonarQube plugin support - Add SonarQube ConnectionDef to connectionRegistry with AccessToken auth - Implement scopeSonarQubeHandler for interactive project selection - Add SonarQubeProjectScope type to internal/devlake/types.go - Add comprehensive test coverage in TestConnectionRegistry_SonarQube - Update README with SonarQube in Supported Plugins table - All tests pass (go test ./..., go vet ./...) Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- README.md | 39 ++++++++--------- cmd/configure_scopes.go | 83 ++++++++++++++++++++++++++++++++++++ cmd/connection_types.go | 18 ++++++++ cmd/connection_types_test.go | 66 +++++++++++++++++++++++++++- internal/devlake/types.go | 11 ++++- 5 files changed, 195 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9152940..3554d55 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,12 @@ After setup, open Grafana at **http://localhost:3002** (admin / admin). DORA and Four concepts to understand — then every command makes sense: -| Concept | What It Is | -|---------|-----------| -| **Connection** | An authenticated link to a data source (GitHub, Copilot, Jenkins). Each gets its own PAT/credentials. | -| **Scope** | *What* to collect — specific repos for GitHub, an org/enterprise for Copilot, jobs for Jenkins. | -| **Project** | Groups connections + scopes into a single view with DORA metrics enabled. | -| **Blueprint** | The sync schedule (cron). Created automatically with the project. | +| Concept | What It Is | +|---------|-----------| +| **Connection** | An authenticated link to a data source (GitHub, Copilot, Jenkins). Each gets its own PAT/credentials. | +| **Scope** | *What* to collect — specific repos for GitHub, an org/enterprise for Copilot, jobs for Jenkins. | +| **Project** | Groups connections + scopes into a single view with DORA metrics enabled. | +| **Blueprint** | The sync schedule (cron). Created automatically with the project. | @@ -117,12 +117,12 @@ The CLI will prompt you for your PAT. You can also pass `--token`, use an `--env # GitHub (repos, PRs, workflows, deployments) gh devlake configure connection add --plugin github --org my-org -# Copilot (usage metrics, seats, acceptance rates) -gh devlake configure connection add --plugin gh-copilot --org my-org - -# Jenkins (jobs and build data for DORA) -gh devlake configure connection add --plugin jenkins --endpoint https://jenkins.example.com --username admin --token myapitoken -``` +# Copilot (usage metrics, seats, acceptance rates) +gh devlake configure connection add --plugin gh-copilot --org my-org + +# Jenkins (jobs and build data for DORA) +gh devlake configure connection add --plugin jenkins --endpoint https://jenkins.example.com --username admin --token myapitoken +``` The CLI tests each connection before saving. On success: @@ -142,12 +142,12 @@ Tell DevLake which repos or orgs to collect from: # GitHub — pick repos interactively, or pass --repos explicitly gh devlake configure scope add --plugin github --org my-org -# Copilot — org-level metrics -gh devlake configure scope add --plugin gh-copilot --org my-org - -# Jenkins — pick jobs interactively (or pass --jobs) -gh devlake configure scope add --plugin jenkins --org my-org -``` +# Copilot — org-level metrics +gh devlake configure scope add --plugin gh-copilot --org my-org + +# Jenkins — pick jobs interactively (or pass --jobs) +gh devlake configure scope add --plugin jenkins --org my-org +``` DORA patterns (deployment workflow, production environment, incident label) use sensible defaults. See [docs/configure-scope.md](docs/configure-scope.md) for overrides. @@ -194,9 +194,10 @@ For the full guide, see [Day-2 Operations](docs/day-2.md). | GitHub Copilot | ✅ Available | Usage metrics, seats, acceptance rates | `manage_billing:copilot`, `read:org` (+ `read:enterprise` for enterprise metrics) | | Jenkins | ✅ Available | Jobs, builds, deployments (DORA) | Username + API token/password | | Jira | ✅ Available | Boards, issues, sprints (change lead time, cycle time) | API token (permissions from user account) | -| Azure DevOps | 🔜 Coming soon | Repos, pipelines, deployments (DORA) | (TBD) | | 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) | See [Token Handling](docs/token-handling.md) for env key names and multi-plugin `.devlake.env` examples. diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 90645da..ff5f562 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -1021,3 +1021,86 @@ func putBitbucketScopes(client *devlake.Client, connID int, repos []*devlake.Bit } return client.PutScopes("bitbucket", connID, &devlake.ScopeBatchRequest{Data: data}) } + +// scopeSonarQubeHandler is the ScopeHandler for the sonarqube plugin. +func scopeSonarQubeHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) { + fmt.Println("\n📋 Fetching SonarQube projects...") + + // Aggregate all pages of remote scopes + var allChildren []devlake.RemoteScopeChild + pageToken := "" + for { + remoteScopes, err := client.ListRemoteScopes("sonarqube", connID, "", pageToken) + if err != nil { + return nil, fmt.Errorf("failed to list SonarQube projects: %w", err) + } + allChildren = append(allChildren, remoteScopes.Children...) + pageToken = remoteScopes.NextPageToken + if pageToken == "" { + break + } + } + + // Extract projects from remote-scope response + var projectOptions []string + projectMap := make(map[string]*devlake.RemoteScopeChild) + for i := range allChildren { + child := &allChildren[i] + if child.Type == "scope" { + label := child.Name + if child.ID != "" { + label = fmt.Sprintf("%s (key: %s)", child.Name, child.ID) + } + projectOptions = append(projectOptions, label) + projectMap[label] = child + } + } + + if len(projectOptions) == 0 { + return nil, fmt.Errorf("no SonarQube projects found for connection %d", connID) + } + + fmt.Println() + selectedLabels := prompt.SelectMulti("Select SonarQube projects to track", projectOptions) + if len(selectedLabels) == 0 { + return nil, fmt.Errorf("at least one project must be selected") + } + + // Build scope data for PUT + fmt.Println("\n📝 Adding SonarQube project scopes...") + var scopeData []any + var blueprintScopes []devlake.BlueprintScope + for _, label := range selectedLabels { + child := projectMap[label] + // Use child.ID as projectKey + if child.ID == "" { + fmt.Printf(" ⚠️ Skipping project %q: empty project key\n", child.Name) + continue + } + scopeData = append(scopeData, devlake.SonarQubeProjectScope{ + ConnectionID: connID, + ProjectKey: child.ID, + Name: child.Name, + }) + blueprintScopes = append(blueprintScopes, devlake.BlueprintScope{ + ScopeID: child.ID, + ScopeName: child.Name, + }) + } + + if len(scopeData) == 0 { + return nil, fmt.Errorf("no valid projects to add") + } + + err := client.PutScopes("sonarqube", connID, &devlake.ScopeBatchRequest{Data: scopeData}) + if err != nil { + return nil, fmt.Errorf("failed to add SonarQube project scopes: %w", err) + } + fmt.Printf(" ✅ Added %d project scope(s)\n", len(scopeData)) + + return &devlake.BlueprintConnection{ + PluginName: "sonarqube", + ConnectionID: connID, + Scopes: blueprintScopes, + }, nil +} diff --git a/cmd/connection_types.go b/cmd/connection_types.go index 4058d03..5ca401b 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -324,6 +324,24 @@ var connectionRegistry = []*ConnectionDef{ ScopeIDField: "boardId", HasRepoScopes: false, }, + { + Plugin: "sonarqube", + DisplayName: "SonarQube", + Available: true, + Endpoint: "", // user must provide (e.g., https://sonar.example.com/) + SupportsTest: true, + AuthMethod: "AccessToken", + RateLimitPerHour: 0, // uses default 4500 + // SonarQube uses API tokens; permissions come from the user account. + RequiredScopes: []string{}, + ScopeHint: "", + TokenPrompt: "SonarQube token", + EnvVarNames: []string{"SONARQUBE_TOKEN", "SONAR_TOKEN"}, + EnvFileKeys: []string{"SONARQUBE_TOKEN", "SONAR_TOKEN"}, + ScopeFunc: scopeSonarQubeHandler, + ScopeIDField: "projectKey", + HasRepoScopes: false, + }, } // AvailableConnections returns only available (non-coming-soon) connection defs. diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index e728372..ebf2c21 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -664,4 +664,68 @@ func TestConnectionRegistry_Jenkins(t *testing.T) { if !foundJobs { t.Errorf("jenkins ScopeFlags should include jobs flag") } -} \ No newline at end of file +} +// TestConnectionRegistry_SonarQube verifies the SonarQube plugin registry entry. +func TestConnectionRegistry_SonarQube(t *testing.T) { + def := FindConnectionDef("sonarqube") + if def == nil { + t.Fatal("sonarqube plugin not found in registry") + } + + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Plugin", def.Plugin, "sonarqube"}, + {"DisplayName", def.DisplayName, "SonarQube"}, + {"Available", def.Available, true}, + {"Endpoint", def.Endpoint, ""}, + {"SupportsTest", def.SupportsTest, true}, + {"AuthMethod", def.AuthMethod, "AccessToken"}, + {"ScopeIDField", def.ScopeIDField, "projectKey"}, + {"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") + } + + // SonarQube uses API tokens, not OAuth/PAT scopes + if len(def.RequiredScopes) != 0 { + t.Errorf("RequiredScopes should be empty for SonarQube API tokens, got %v", def.RequiredScopes) + } + if def.ScopeHint != "" { + t.Errorf("ScopeHint should be empty for SonarQube API tokens, got %q", def.ScopeHint) + } + + expectedEnvVars := []string{"SONARQUBE_TOKEN", "SONAR_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{"SONARQUBE_TOKEN", "SONAR_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) + } + } + } +} diff --git a/internal/devlake/types.go b/internal/devlake/types.go index f8a80f9..3edaa9e 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -81,6 +81,13 @@ type BitbucketRepoScope struct { HTMLURL string `json:"htmlUrl,omitempty"` } +// SonarQubeProjectScope represents a SonarQube project scope entry for PUT /scopes. +type SonarQubeProjectScope struct { + ConnectionID int `json:"connectionId"` + ProjectKey string `json:"projectKey"` + Name string `json:"name"` +} + // ScopeBatchRequest is the payload for PUT /scopes (batch upsert). type ScopeBatchRequest struct { Data []any `json:"data"` @@ -172,7 +179,7 @@ type ScopeListResponse struct { // RemoteScopeChild represents one item (group or scope) from the remote-scope API. type RemoteScopeChild struct { - Type string `json:"type"` // "group" or "scope" + Type string `json:"type"` // "group" or "scope" ID string `json:"id"` ParentID string `json:"parentId"` Name string `json:"name"` @@ -244,4 +251,4 @@ type Pipeline struct { Status string `json:"status"` FinishedTasks int `json:"finishedTasks"` TotalTasks int `json:"totalTasks"` -} +} From cb5927491f95d8c9d1e7dd5b283308dd1632f4bd Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:42:28 +0000 Subject: [PATCH 3/4] Fix test formatting: add blank line between test functions Add blank line between TestConnectionRegistry_Jenkins and TestConnectionRegistry_SonarQube for consistency with other test functions in the file. Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/connection_types_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index ebf2c21..497e9b6 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -665,6 +665,7 @@ func TestConnectionRegistry_Jenkins(t *testing.T) { t.Errorf("jenkins ScopeFlags should include jobs flag") } } + // TestConnectionRegistry_SonarQube verifies the SonarQube plugin registry entry. func TestConnectionRegistry_SonarQube(t *testing.T) { def := FindConnectionDef("sonarqube") From 8657cd6e030c4d0f304e6d2381e7f2f46db84928 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:08:52 +0000 Subject: [PATCH 4/4] Fix SonarQube scope handler UX issues - Filter out projects with empty project keys from picker to prevent confusing UX where users select items that get silently skipped - Improve error message: "at least one SonarQube project must be selected" for clarity in multi-plugin flows - Remove now-unnecessary empty ID check in scope data loop since filtering happens earlier Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_scopes.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index ff5f562..2f0b8ed 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -1047,10 +1047,11 @@ func scopeSonarQubeHandler(client *devlake.Client, connID int, org, enterprise s for i := range allChildren { child := &allChildren[i] if child.Type == "scope" { - label := child.Name - if child.ID != "" { - label = fmt.Sprintf("%s (key: %s)", child.Name, child.ID) + // Skip projects without a valid project key (child.ID) + if child.ID == "" { + continue } + label := fmt.Sprintf("%s (key: %s)", child.Name, child.ID) projectOptions = append(projectOptions, label) projectMap[label] = child } @@ -1063,7 +1064,7 @@ func scopeSonarQubeHandler(client *devlake.Client, connID int, org, enterprise s fmt.Println() selectedLabels := prompt.SelectMulti("Select SonarQube projects to track", projectOptions) if len(selectedLabels) == 0 { - return nil, fmt.Errorf("at least one project must be selected") + return nil, fmt.Errorf("at least one SonarQube project must be selected") } // Build scope data for PUT @@ -1072,11 +1073,6 @@ func scopeSonarQubeHandler(client *devlake.Client, connID int, org, enterprise s var blueprintScopes []devlake.BlueprintScope for _, label := range selectedLabels { child := projectMap[label] - // Use child.ID as projectKey - if child.ID == "" { - fmt.Printf(" ⚠️ Skipping project %q: empty project key\n", child.Name) - continue - } scopeData = append(scopeData, devlake.SonarQubeProjectScope{ ConnectionID: connID, ProjectKey: child.ID,