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
125 changes: 125 additions & 0 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,131 @@ func putBitbucketScopes(client *devlake.Client, connID int, repos []*devlake.Bit
return client.PutScopes("bitbucket", connID, &devlake.ScopeBatchRequest{Data: data})
}

func circleCIProjectFromChild(child devlake.RemoteScopeChild, connID int) devlake.CircleCIProjectScope {
var project devlake.CircleCIProjectScope
if len(child.Data) > 0 {
if err := json.Unmarshal(child.Data, &project); err != nil {
fmt.Printf("\n⚠️ Could not decode CircleCI project data for %s: %v\n", child.ID, err)
}
}
if project.ID == "" {
project.ID = child.ID
}
if project.Name == "" {
project.Name = child.Name
}
if project.Slug == "" {
project.Slug = child.FullName
}
project.ConnectionID = connID
return project
}

func circleCIProjectLabel(project devlake.CircleCIProjectScope) string {
switch {
case project.Name != "" && project.Slug != "" && project.Name != project.Slug:
return fmt.Sprintf("%s (slug: %s)", project.Name, project.Slug)
case project.Name != "":
return project.Name
case project.Slug != "":
return project.Slug
default:
return project.ID
}
}

func circleCIUniqueLabel(project devlake.CircleCIProjectScope, counts map[string]int) string {
base := circleCIProjectLabel(project)
if counts[base] > 1 && project.ID != "" {
return fmt.Sprintf("%s (ID: %s)", base, project.ID)
}
return base
}

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

var children []devlake.RemoteScopeChild
pageToken := ""
for {
resp, err := client.ListRemoteScopes("circleci", connID, "", pageToken)
if err != nil {
return nil, fmt.Errorf("failed to list CircleCI projects: %w", err)
}
children = append(children, resp.Children...)
pageToken = resp.NextPageToken
if pageToken == "" {
break
}
}

projectOptions := make([]string, 0, len(children))
projectMap := make(map[string]devlake.CircleCIProjectScope)
labelCounts := make(map[string]int, len(children))
for _, child := range children {
if child.Type != "scope" || child.ID == "" {
continue
}
project := circleCIProjectFromChild(child, connID)
baseLabel := circleCIProjectLabel(project)
labelCounts[baseLabel]++
}

for _, child := range children {
if child.Type != "scope" || child.ID == "" {
continue
}
project := circleCIProjectFromChild(child, connID)
label := circleCIUniqueLabel(project, labelCounts)
projectOptions = append(projectOptions, label)
projectMap[label] = project
}

if len(projectOptions) == 0 {
return nil, fmt.Errorf("no CircleCI projects found for connection %d", connID)
}

fmt.Println()
selected := prompt.SelectMulti("Select CircleCI projects to track", projectOptions)
if len(selected) == 0 {
return nil, fmt.Errorf("at least one CircleCI project must be selected")
}

fmt.Println("\n📝 Adding CircleCI project scopes...")
var (
scopeData []any
bpScopes []devlake.BlueprintScope
)
for _, label := range selected {
project := projectMap[label]
scopeData = append(scopeData, project)

scopeName := project.Name
if scopeName == "" {
scopeName = project.Slug
}
if scopeName == "" {
scopeName = project.ID
}
bpScopes = append(bpScopes, devlake.BlueprintScope{
ScopeID: project.ID,
ScopeName: scopeName,
})
}

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

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

// 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...")
Expand Down
108 changes: 108 additions & 0 deletions cmd/configure_scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,114 @@ func TestAzureDevOpsScopePayload_KeepsExistingFields(t *testing.T) {
}
}

func TestCircleCIProjectFromChild(t *testing.T) {
raw := map[string]any{
"id": "proj-1",
"name": "api",
"slug": "gh/org/api",
"organizationId": "org-1",
}
data, _ := json.Marshal(raw)
child := devlake.RemoteScopeChild{
ID: "child-id",
Name: "child-name",
FullName: "child/full",
Data: data,
}
project := circleCIProjectFromChild(child, 9)

if project.ID != "proj-1" {
t.Fatalf("ID = %q, want proj-1", project.ID)
}
if project.Name != "api" {
t.Fatalf("Name = %q, want api", project.Name)
}
if project.Slug != "gh/org/api" {
t.Fatalf("Slug = %q, want gh/org/api", project.Slug)
}
if project.OrganizationID != "org-1" {
t.Fatalf("OrganizationID = %q, want org-1", project.OrganizationID)
}
if project.ConnectionID != 9 {
t.Fatalf("ConnectionID = %d, want 9", project.ConnectionID)
}
}

func TestCircleCIProjectFromChild_Fallbacks(t *testing.T) {
child := devlake.RemoteScopeChild{
ID: "child-id",
Name: "child-name",
FullName: "child/full",
}
project := circleCIProjectFromChild(child, 2)

if project.ID != "child-id" {
t.Fatalf("fallback ID = %q, want child-id", project.ID)
}
if project.Name != "child-name" {
t.Fatalf("fallback Name = %q, want child-name", project.Name)
}
if project.Slug != "child/full" {
t.Fatalf("fallback Slug = %q, want child/full", project.Slug)
}
}

func TestCircleCIProjectLabel(t *testing.T) {
tests := []struct {
name string
in devlake.CircleCIProjectScope
want string
}{
{
name: "name and slug",
in: devlake.CircleCIProjectScope{Name: "API", Slug: "gh/org/api"},
want: "API (slug: gh/org/api)",
},
{
name: "name only",
in: devlake.CircleCIProjectScope{Name: "API"},
want: "API",
},
{
name: "slug only",
in: devlake.CircleCIProjectScope{Slug: "gh/org/api"},
want: "gh/org/api",
},
{
name: "id fallback",
in: devlake.CircleCIProjectScope{ID: "proj-1"},
want: "proj-1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := circleCIProjectLabel(tt.in); got != tt.want {
t.Errorf("circleCIProjectLabel() = %q, want %q", got, tt.want)
}
})
}
}

func TestCircleCIUniqueLabel(t *testing.T) {
projectA := devlake.CircleCIProjectScope{ID: "a1", Name: "api", Slug: "gh/org/api"}
projectB := devlake.CircleCIProjectScope{ID: "b2", Name: "api", Slug: "gh/org/api"}
counts := map[string]int{
circleCIProjectLabel(projectA): 2,
}

labelA := circleCIUniqueLabel(projectA, counts)
labelB := circleCIUniqueLabel(projectB, counts)
if labelA == labelB {
t.Fatalf("expected unique labels, got identical %q", labelA)
}
if labelA == "api (slug: gh/org/api)" {
t.Fatalf("labelA should be disambiguated, got %q", labelA)
}
if labelB == "api (slug: gh/org/api)" {
t.Fatalf("labelB should be disambiguated, got %q", labelB)
}
}

func TestParseBitbucketRepo(t *testing.T) {
t.Run("uses payload fields when present", func(t *testing.T) {
data, _ := json.Marshal(map[string]any{
Expand Down
12 changes: 12 additions & 0 deletions cmd/connection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ var connectionRegistry = []*ConnectionDef{
{Name: "jobs", Description: "Comma-separated Jenkins job full names"},
},
},
{
Plugin: "circleci",
DisplayName: "CircleCI",
Available: true,
Endpoint: "https://circleci.com/api/v2/",
SupportsTest: true,
TokenPrompt: "CircleCI personal API token",
EnvVarNames: []string{"CIRCLECI_TOKEN", "CIRCLE_TOKEN"},
EnvFileKeys: []string{"CIRCLECI_TOKEN", "CIRCLE_TOKEN"},
ScopeFunc: scopeCircleCIHandler,
ScopeIDField: "id",
},
{
Plugin: "gitlab",
DisplayName: "GitLab",
Expand Down
40 changes: 40 additions & 0 deletions cmd/connection_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ func TestNeedsTokenExpiry(t *testing.T) {
{"gh-copilot", true},
{"gitlab", false},
{"azuredevops_go", false},
{"circleci", false},
}
for _, tt := range tests {
t.Run(tt.plugin, func(t *testing.T) {
Expand Down Expand Up @@ -728,6 +729,45 @@ func TestConnectionRegistry_Jenkins(t *testing.T) {
}
}

func TestConnectionRegistry_CircleCI(t *testing.T) {
def := FindConnectionDef("circleci")
if def == nil {
t.Fatal("circleci connection def not found")
}
if !def.Available {
t.Errorf("circleci should be available")
}
if def.Endpoint != "https://circleci.com/api/v2/" {
t.Errorf("circleci Endpoint = %q, want https://circleci.com/api/v2/", def.Endpoint)
}
if !def.SupportsTest {
t.Errorf("circleci SupportsTest should be true")
}
if def.ScopeIDField != "id" {
t.Errorf("circleci ScopeIDField = %q, want %q", def.ScopeIDField, "id")
}
if def.ScopeFunc == nil {
t.Errorf("circleci ScopeFunc should be set")
}
wantEnvVars := []string{"CIRCLECI_TOKEN", "CIRCLE_TOKEN"}
if len(def.EnvVarNames) != len(wantEnvVars) {
t.Fatalf("circleci EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(wantEnvVars))
}
for i, v := range wantEnvVars {
if def.EnvVarNames[i] != v {
t.Errorf("circleci EnvVarNames[%d]: got %q, want %q", i, def.EnvVarNames[i], v)
}
}
if len(def.EnvFileKeys) != len(wantEnvVars) {
t.Fatalf("circleci EnvFileKeys length: got %d, want %d", len(def.EnvFileKeys), len(wantEnvVars))
}
for i, v := range wantEnvVars {
if def.EnvFileKeys[i] != v {
t.Errorf("circleci EnvFileKeys[%d]: got %q, want %q", i, def.EnvFileKeys[i], v)
}
}
}

// TestConnectionRegistry_SonarQube verifies the SonarQube plugin registry entry.
func TestConnectionRegistry_SonarQube(t *testing.T) {
def := FindConnectionDef("sonarqube")
Expand Down
41 changes: 25 additions & 16 deletions docs/configure-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ gh devlake configure connection [flags]

Aliases: `connections`

### Flags
### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(interactive)* | Plugin to configure (`github`, `gh-copilot`, `jenkins`) |
| `--plugin` | *(interactive)* | Plugin to configure (`github`, `gh-copilot`, `gitlab`, `bitbucket`, `azuredevops_go`, `jenkins`, `jira`, `sonarqube`, `circleci`) |
| `--org` | *(required for Copilot)* | GitHub organization slug |
| `--enterprise` | | GitHub enterprise slug (for enterprise-level Copilot metrics) |
| `--name` | `Plugin - org` | Connection display name |
Expand All @@ -34,18 +34,24 @@ Aliases: `connections`
| `--skip-cleanup` | `false` | Don't delete `.devlake.env` after setup |

### Required PAT Scopes
| Plugin | Required Scopes |

| Plugin | Required Scopes |
|--------|----------------|
| `github` | `repo`, `read:org`, `read:user` |
| `gh-copilot` | `manage_billing:copilot`, `read:org` |
| `gh-copilot` (enterprise metrics) | + `read:enterprise` |
| `gitlab` | `read_api`, `read_repository` |
| `bitbucket` | App password (BasicAuth; no scopes) |
| `azuredevops_go` | Azure DevOps PAT (no scopes) |
| `jenkins` | Username + API token/password (BasicAuth) |

### Token Resolution Order

For each plugin, the CLI resolves the PAT in this order (see [token-handling.md](token-handling.md) for the full guide):

| `jira` | API token (no scopes) |
| `sonarqube` | API token (no scopes) |
| `circleci` | Personal API token (Circle-Token header) |

### Token Resolution Order

For each plugin, the CLI resolves the PAT in this order (see [token-handling.md](token-handling.md) for the full guide):

1. `--token` flag
2. `.devlake.env` file — checked for plugin-specific keys:
- GitHub / Copilot: `GITHUB_PAT`, `GITHUB_TOKEN`, or `GH_TOKEN`
Expand Down Expand Up @@ -86,12 +92,15 @@ gh devlake configure connection --plugin gh-copilot --org my-org

# Jenkins connection (endpoint required)
gh devlake configure connection --plugin jenkins --endpoint https://jenkins.example.com --username admin

# Enterprise Copilot metrics
gh devlake configure connection --plugin gh-copilot --org my-org --enterprise my-enterprise

# GitHub Enterprise Server
gh devlake configure connection --plugin github --org my-org \

# CircleCI connection
gh devlake configure connection --plugin circleci --name "CircleCI - backend"

# Enterprise Copilot metrics
gh devlake configure connection --plugin gh-copilot --org my-org --enterprise my-enterprise

# GitHub Enterprise Server
gh devlake configure connection --plugin github --org my-org \
--endpoint https://github.example.com/api/v3/

# With proxy
Expand Down
Loading
Loading