From b41ada751dc35e7775abbc9c53ba74cd38032793 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:30:35 +0000 Subject: [PATCH 1/4] Initial plan From 45cfad46518daee219b7d0b755a0bd8f7c6911ab Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:35:54 +0000 Subject: [PATCH 2/4] feat: add circleci plugin support --- cmd/configure_scopes.go | 107 ++++++++ cmd/configure_scopes_test.go | 88 ++++++ cmd/connection_types.go | 12 + cmd/connection_types_test.go | 40 +++ docs/configure-connection.md | 41 +-- docs/configure-scope.md | 35 ++- internal/devlake/types.go | 517 ++++++++++++++++++----------------- 7 files changed, 557 insertions(+), 283 deletions(-) diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 4a29805..3a88900 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -1200,6 +1200,113 @@ 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 + } +} + +// 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) + for _, child := range children { + if child.Type != "scope" || child.ID == "" { + continue + } + project := circleCIProjectFromChild(child, connID) + label := circleCIProjectLabel(project) + 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...") diff --git a/cmd/configure_scopes_test.go b/cmd/configure_scopes_test.go index 897067a..b9ab8a6 100644 --- a/cmd/configure_scopes_test.go +++ b/cmd/configure_scopes_test.go @@ -125,6 +125,94 @@ 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 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 98d9079..22a30fc 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -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", diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index 54c31f8..1a3fc5e 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -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) { @@ -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") diff --git a/docs/configure-connection.md b/docs/configure-connection.md index b32459c..5cd5bd6 100644 --- a/docs/configure-connection.md +++ b/docs/configure-connection.md @@ -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 | @@ -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` @@ -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 diff --git a/docs/configure-scope.md b/docs/configure-scope.md index df6de19..8b4e376 100644 --- a/docs/configure-scope.md +++ b/docs/configure-scope.md @@ -28,11 +28,11 @@ Add repository, job, or organization scopes to an existing DevLake connection. gh devlake configure scope add [flags] ``` -### Flags - +### Flags + | Flag | Default | Description | |------|---------|-------------| -| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gh-copilot`, `jenkins`) | +| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gh-copilot`, `gitlab`, `bitbucket`, `azuredevops_go`, `jenkins`, `jira`, `sonarqube`, `circleci`) | | `--connection-id` | *(auto-detected)* | Override the connection ID to scope | | `--org` | *(required)* | GitHub organization slug | | `--enterprise` | | Enterprise slug (enables enterprise-level Copilot metrics) | @@ -94,10 +94,13 @@ gh devlake configure scope add --plugin jenkins --org my-org --jobs "team/job1,t # Jenkins jobs (interactive remote-scope picker) gh devlake configure scope add --plugin jenkins --org my-org - -# Interactive (omit all flags) -gh devlake configure scope add -``` + +# CircleCI projects (interactive) +gh devlake configure scope add --plugin circleci --connection-id 4 + +# Interactive (omit all flags) +gh devlake configure scope add +``` ### What It Does (GitHub) @@ -116,12 +119,18 @@ gh devlake configure scope add 1. Lists Jenkins jobs via the remote-scope API (interactive picker) 2. Uses `--jobs` when provided instead of prompting 3. Calls `PUT /plugins/jenkins/connections/{id}/scopes` with the selected jobs - ---- - -## configure scope list - -List all scopes configured on a DevLake plugin connection. + +### What It Does (CircleCI) + +1. Lists followed projects via the DevLake remote-scope API +2. Prompts for one or more projects to track +3. Calls `PUT /plugins/circleci/connections/{id}/scopes` to add the selected projects + +--- + +## configure scope list + +List all scopes configured on a DevLake plugin connection. ### Usage diff --git a/internal/devlake/types.go b/internal/devlake/types.go index 3edaa9e..13e36b1 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -1,254 +1,263 @@ -package devlake - -import ( - "encoding/json" - "strconv" -) - -// ScopeConfig represents a DevLake scope configuration (e.g., DORA settings). -type ScopeConfig struct { - ID int `json:"id,omitempty"` - Name string `json:"name"` - ConnectionID int `json:"connectionId"` - DeploymentPattern string `json:"deploymentPattern,omitempty"` - ProductionPattern string `json:"productionPattern,omitempty"` - IssueTypeIncident string `json:"issueTypeIncident,omitempty"` - Refdiff *RefdiffConfig `json:"refdiff,omitempty"` -} - -// RefdiffConfig holds refdiff tag-matching settings. -type RefdiffConfig struct { - TagsPattern string `json:"tagsPattern"` - TagsLimit int `json:"tagsLimit"` - TagsOrder string `json:"tagsOrder"` -} - -// GitHubRepoScope represents a GitHub repository scope entry for PUT /scopes. -type GitHubRepoScope struct { - GithubID int `json:"githubId"` - ConnectionID int `json:"connectionId"` - Name string `json:"name"` - FullName string `json:"fullName"` - HTMLURL string `json:"htmlUrl"` - CloneURL string `json:"cloneUrl"` - ScopeConfigID int `json:"scopeConfigId,omitempty"` -} - -// CopilotScope represents a Copilot organization or enterprise scope entry. -type CopilotScope struct { - ID string `json:"id"` - ConnectionID int `json:"connectionId"` - Organization string `json:"organization"` - Enterprise string `json:"enterprise,omitempty"` - Name string `json:"name"` - FullName string `json:"fullName"` -} - -// GitLabProjectScope represents a GitLab project scope entry for PUT /scopes. -type GitLabProjectScope struct { - GitlabID int `json:"gitlabId"` - ConnectionID int `json:"connectionId"` - Name string `json:"name"` - PathWithNamespace string `json:"pathWithNamespace"` - HTTPURLToRepo string `json:"httpUrlToRepo,omitempty"` - SSHURLToRepo string `json:"sshUrlToRepo,omitempty"` - ScopeConfigID int `json:"scopeConfigId,omitempty"` -} - -// JenkinsJobScope represents a Jenkins job scope entry. -type JenkinsJobScope struct { - ConnectionID int `json:"connectionId"` - FullName string `json:"fullName"` - Name string `json:"name"` -} - -// JiraBoardScope represents a Jira board scope entry for PUT /scopes. -type JiraBoardScope struct { - BoardID uint64 `json:"boardId"` - ConnectionID int `json:"connectionId"` - Name string `json:"name"` -} - -// BitbucketRepoScope represents a Bitbucket Cloud repository scope entry for PUT /scopes. -// BitbucketID holds the repository full name (workspace/repo-slug), which is the -// canonical scope identifier used by the DevLake Bitbucket plugin. -type BitbucketRepoScope struct { - BitbucketID string `json:"bitbucketId"` - ConnectionID int `json:"connectionId"` - Name string `json:"name"` - FullName string `json:"fullName"` - CloneURL string `json:"cloneUrl,omitempty"` - 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"` -} - -// ScopeListWrapper wraps a scope object as returned by the DevLake GET scopes API. -// The API nests each scope inside a "scope" key: { "scope": { ... } }. -// RawScope preserves the full plugin-specific payload for generic ID extraction. -type ScopeListWrapper struct { - RawScope json.RawMessage `json:"scope"` - parsed map[string]json.RawMessage // lazily populated by parseScope -} - -// parseScope unmarshals RawScope into a map exactly once per wrapper instance, -// caching the result so callers that invoke both ScopeName and ScopeFullName on -// the same item do not unmarshal the same JSON twice. -func (w *ScopeListWrapper) parseScope() map[string]json.RawMessage { - if w.parsed == nil { - var m map[string]json.RawMessage - if err := json.Unmarshal(w.RawScope, &m); err != nil || m == nil { - m = make(map[string]json.RawMessage) - } - w.parsed = m - } - return w.parsed -} - -// ScopeName returns the display name from the raw scope JSON (checks "fullName" then "name"). -// Empty string values are skipped so the next candidate key is tried. -// Parsing is cached via parseScope() so calling ScopeName and ScopeFullName on the -// same instance only unmarshals the JSON once. -func (w *ScopeListWrapper) ScopeName() string { - m := w.parseScope() - for _, key := range []string{"fullName", "name"} { - if v, ok := m[key]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil && s != "" { - return s - } - } - } - return "" -} - -// ScopeFullName returns the "fullName" field from the raw scope JSON, or "". -// An empty string value is treated as absent (returns ""). -func (w *ScopeListWrapper) ScopeFullName() string { - m := w.parseScope() - if v, ok := m["fullName"]; ok { - var s string - if err := json.Unmarshal(v, &s); err == nil && s != "" { - return s - } - } - return "" -} - -// ExtractScopeID extracts the scope ID from a raw JSON scope object using the -// given field name. It tries to decode the value as a string first, then as -// an integer (converted to its decimal string representation). -func ExtractScopeID(raw json.RawMessage, fieldName string) string { - if fieldName == "" { - return "" - } - var m map[string]json.RawMessage - if err := json.Unmarshal(raw, &m); err != nil { - return "" - } - v, ok := m[fieldName] - if !ok { - return "" - } - var s string - if err := json.Unmarshal(v, &s); err == nil && s != "" { - return s - } - var n int64 - if err := json.Unmarshal(v, &n); err == nil && n != 0 { - return strconv.FormatInt(n, 10) - } - return "" -} - -// ScopeListResponse is the response from GET /plugins/{plugin}/connections/{id}/scopes. -type ScopeListResponse struct { - Scopes []ScopeListWrapper `json:"scopes"` - Count int `json:"count"` -} - -// RemoteScopeChild represents one item (group or scope) from the remote-scope API. -type RemoteScopeChild struct { - Type string `json:"type"` // "group" or "scope" - ID string `json:"id"` - ParentID string `json:"parentId"` - Name string `json:"name"` - FullName string `json:"fullName"` - Data json.RawMessage `json:"data"` -} - -// RemoteScopeResponse is the response from GET /plugins/{plugin}/connections/{id}/remote-scopes. -type RemoteScopeResponse struct { - Children []RemoteScopeChild `json:"children"` - NextPageToken string `json:"nextPageToken"` -} - -// Project represents a DevLake project. -type Project struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Metrics []ProjectMetric `json:"metrics,omitempty"` - Blueprint *Blueprint `json:"blueprint,omitempty"` -} - -// ProjectListResponse is the response from GET /projects. -type ProjectListResponse struct { - Count int `json:"count"` - Projects []Project `json:"projects"` -} - -// ProjectMetric enables a metric plugin for a project. -type ProjectMetric struct { - PluginName string `json:"pluginName"` - Enable bool `json:"enable"` -} - -// Blueprint represents a DevLake blueprint (returned from project creation or GET). -type Blueprint struct { - ID int `json:"id"` - Name string `json:"name,omitempty"` - Enable bool `json:"enable,omitempty"` - CronConfig string `json:"cronConfig,omitempty"` - TimeAfter string `json:"timeAfter,omitempty"` - Connections []BlueprintConnection `json:"connections,omitempty"` -} - -// BlueprintPatch is the payload for PATCH /blueprints/:id. -type BlueprintPatch struct { - Enable *bool `json:"enable,omitempty"` - Mode string `json:"mode,omitempty"` - CronConfig string `json:"cronConfig,omitempty"` - TimeAfter string `json:"timeAfter,omitempty"` - Connections []BlueprintConnection `json:"connections,omitempty"` -} - -// BlueprintConnection associates a plugin connection with scopes in a blueprint. -type BlueprintConnection struct { - PluginName string `json:"pluginName"` - ConnectionID int `json:"connectionId"` - Scopes []BlueprintScope `json:"scopes"` -} - -// BlueprintScope identifies a single scope within a blueprint connection. -type BlueprintScope struct { - ScopeID string `json:"scopeId"` - ScopeName string `json:"scopeName"` -} - -// Pipeline represents a DevLake pipeline (returned from trigger or GET). -type Pipeline struct { - ID int `json:"id"` - Status string `json:"status"` - FinishedTasks int `json:"finishedTasks"` - TotalTasks int `json:"totalTasks"` -} +package devlake + +import ( + "encoding/json" + "strconv" +) + +// ScopeConfig represents a DevLake scope configuration (e.g., DORA settings). +type ScopeConfig struct { + ID int `json:"id,omitempty"` + Name string `json:"name"` + ConnectionID int `json:"connectionId"` + DeploymentPattern string `json:"deploymentPattern,omitempty"` + ProductionPattern string `json:"productionPattern,omitempty"` + IssueTypeIncident string `json:"issueTypeIncident,omitempty"` + Refdiff *RefdiffConfig `json:"refdiff,omitempty"` +} + +// RefdiffConfig holds refdiff tag-matching settings. +type RefdiffConfig struct { + TagsPattern string `json:"tagsPattern"` + TagsLimit int `json:"tagsLimit"` + TagsOrder string `json:"tagsOrder"` +} + +// GitHubRepoScope represents a GitHub repository scope entry for PUT /scopes. +type GitHubRepoScope struct { + GithubID int `json:"githubId"` + ConnectionID int `json:"connectionId"` + Name string `json:"name"` + FullName string `json:"fullName"` + HTMLURL string `json:"htmlUrl"` + CloneURL string `json:"cloneUrl"` + ScopeConfigID int `json:"scopeConfigId,omitempty"` +} + +// CopilotScope represents a Copilot organization or enterprise scope entry. +type CopilotScope struct { + ID string `json:"id"` + ConnectionID int `json:"connectionId"` + Organization string `json:"organization"` + Enterprise string `json:"enterprise,omitempty"` + Name string `json:"name"` + FullName string `json:"fullName"` +} + +// GitLabProjectScope represents a GitLab project scope entry for PUT /scopes. +type GitLabProjectScope struct { + GitlabID int `json:"gitlabId"` + ConnectionID int `json:"connectionId"` + Name string `json:"name"` + PathWithNamespace string `json:"pathWithNamespace"` + HTTPURLToRepo string `json:"httpUrlToRepo,omitempty"` + SSHURLToRepo string `json:"sshUrlToRepo,omitempty"` + ScopeConfigID int `json:"scopeConfigId,omitempty"` +} + +// JenkinsJobScope represents a Jenkins job scope entry. +type JenkinsJobScope struct { + ConnectionID int `json:"connectionId"` + FullName string `json:"fullName"` + Name string `json:"name"` +} + +// JiraBoardScope represents a Jira board scope entry for PUT /scopes. +type JiraBoardScope struct { + BoardID uint64 `json:"boardId"` + ConnectionID int `json:"connectionId"` + Name string `json:"name"` +} + +// BitbucketRepoScope represents a Bitbucket Cloud repository scope entry for PUT /scopes. +// BitbucketID holds the repository full name (workspace/repo-slug), which is the +// canonical scope identifier used by the DevLake Bitbucket plugin. +type BitbucketRepoScope struct { + BitbucketID string `json:"bitbucketId"` + ConnectionID int `json:"connectionId"` + Name string `json:"name"` + FullName string `json:"fullName"` + CloneURL string `json:"cloneUrl,omitempty"` + 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"` +} + +// CircleCIProjectScope represents a CircleCI project scope entry for PUT /scopes. +type CircleCIProjectScope struct { + ConnectionID int `json:"connectionId"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + OrganizationID string `json:"organizationId"` +} + +// ScopeBatchRequest is the payload for PUT /scopes (batch upsert). +type ScopeBatchRequest struct { + Data []any `json:"data"` +} + +// ScopeListWrapper wraps a scope object as returned by the DevLake GET scopes API. +// The API nests each scope inside a "scope" key: { "scope": { ... } }. +// RawScope preserves the full plugin-specific payload for generic ID extraction. +type ScopeListWrapper struct { + RawScope json.RawMessage `json:"scope"` + parsed map[string]json.RawMessage // lazily populated by parseScope +} + +// parseScope unmarshals RawScope into a map exactly once per wrapper instance, +// caching the result so callers that invoke both ScopeName and ScopeFullName on +// the same item do not unmarshal the same JSON twice. +func (w *ScopeListWrapper) parseScope() map[string]json.RawMessage { + if w.parsed == nil { + var m map[string]json.RawMessage + if err := json.Unmarshal(w.RawScope, &m); err != nil || m == nil { + m = make(map[string]json.RawMessage) + } + w.parsed = m + } + return w.parsed +} + +// ScopeName returns the display name from the raw scope JSON (checks "fullName" then "name"). +// Empty string values are skipped so the next candidate key is tried. +// Parsing is cached via parseScope() so calling ScopeName and ScopeFullName on the +// same instance only unmarshals the JSON once. +func (w *ScopeListWrapper) ScopeName() string { + m := w.parseScope() + for _, key := range []string{"fullName", "name"} { + if v, ok := m[key]; ok { + var s string + if err := json.Unmarshal(v, &s); err == nil && s != "" { + return s + } + } + } + return "" +} + +// ScopeFullName returns the "fullName" field from the raw scope JSON, or "". +// An empty string value is treated as absent (returns ""). +func (w *ScopeListWrapper) ScopeFullName() string { + m := w.parseScope() + if v, ok := m["fullName"]; ok { + var s string + if err := json.Unmarshal(v, &s); err == nil && s != "" { + return s + } + } + return "" +} + +// ExtractScopeID extracts the scope ID from a raw JSON scope object using the +// given field name. It tries to decode the value as a string first, then as +// an integer (converted to its decimal string representation). +func ExtractScopeID(raw json.RawMessage, fieldName string) string { + if fieldName == "" { + return "" + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + v, ok := m[fieldName] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(v, &s); err == nil && s != "" { + return s + } + var n int64 + if err := json.Unmarshal(v, &n); err == nil && n != 0 { + return strconv.FormatInt(n, 10) + } + return "" +} + +// ScopeListResponse is the response from GET /plugins/{plugin}/connections/{id}/scopes. +type ScopeListResponse struct { + Scopes []ScopeListWrapper `json:"scopes"` + Count int `json:"count"` +} + +// RemoteScopeChild represents one item (group or scope) from the remote-scope API. +type RemoteScopeChild struct { + Type string `json:"type"` // "group" or "scope" + ID string `json:"id"` + ParentID string `json:"parentId"` + Name string `json:"name"` + FullName string `json:"fullName"` + Data json.RawMessage `json:"data"` +} + +// RemoteScopeResponse is the response from GET /plugins/{plugin}/connections/{id}/remote-scopes. +type RemoteScopeResponse struct { + Children []RemoteScopeChild `json:"children"` + NextPageToken string `json:"nextPageToken"` +} + +// Project represents a DevLake project. +type Project struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metrics []ProjectMetric `json:"metrics,omitempty"` + Blueprint *Blueprint `json:"blueprint,omitempty"` +} + +// ProjectListResponse is the response from GET /projects. +type ProjectListResponse struct { + Count int `json:"count"` + Projects []Project `json:"projects"` +} + +// ProjectMetric enables a metric plugin for a project. +type ProjectMetric struct { + PluginName string `json:"pluginName"` + Enable bool `json:"enable"` +} + +// Blueprint represents a DevLake blueprint (returned from project creation or GET). +type Blueprint struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + Enable bool `json:"enable,omitempty"` + CronConfig string `json:"cronConfig,omitempty"` + TimeAfter string `json:"timeAfter,omitempty"` + Connections []BlueprintConnection `json:"connections,omitempty"` +} + +// BlueprintPatch is the payload for PATCH /blueprints/:id. +type BlueprintPatch struct { + Enable *bool `json:"enable,omitempty"` + Mode string `json:"mode,omitempty"` + CronConfig string `json:"cronConfig,omitempty"` + TimeAfter string `json:"timeAfter,omitempty"` + Connections []BlueprintConnection `json:"connections,omitempty"` +} + +// BlueprintConnection associates a plugin connection with scopes in a blueprint. +type BlueprintConnection struct { + PluginName string `json:"pluginName"` + ConnectionID int `json:"connectionId"` + Scopes []BlueprintScope `json:"scopes"` +} + +// BlueprintScope identifies a single scope within a blueprint connection. +type BlueprintScope struct { + ScopeID string `json:"scopeId"` + ScopeName string `json:"scopeName"` +} + +// Pipeline represents a DevLake pipeline (returned from trigger or GET). +type Pipeline struct { + ID int `json:"id"` + Status string `json:"status"` + FinishedTasks int `json:"finishedTasks"` + TotalTasks int `json:"totalTasks"` +} From a2d743654eed82f4a8d0ad0ee8910b3649efcffd Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:06:14 +0000 Subject: [PATCH 3/4] fix: disambiguate circleci labels and clarify org flag --- cmd/configure_scopes.go | 20 +++++++++++++++++++- cmd/configure_scopes_test.go | 20 ++++++++++++++++++++ docs/configure-scope.md | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 3a88900..fee693e 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -1233,6 +1233,14 @@ func circleCIProjectLabel(project devlake.CircleCIProjectScope) string { } } +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...") @@ -1253,12 +1261,22 @@ func scopeCircleCIHandler(client *devlake.Client, connID int, org, enterprise st 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 := circleCIProjectLabel(project) + label := circleCIUniqueLabel(project, labelCounts) projectOptions = append(projectOptions, label) projectMap[label] = project } diff --git a/cmd/configure_scopes_test.go b/cmd/configure_scopes_test.go index b9ab8a6..abc348c 100644 --- a/cmd/configure_scopes_test.go +++ b/cmd/configure_scopes_test.go @@ -213,6 +213,26 @@ func TestCircleCIProjectLabel(t *testing.T) { } } +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 TestRunConfigureScopes_PluginFlag(t *testing.T) { makeCmd := func() (*cobra.Command, *ScopeOpts) { opts := &ScopeOpts{} diff --git a/docs/configure-scope.md b/docs/configure-scope.md index 8b4e376..1ceac0f 100644 --- a/docs/configure-scope.md +++ b/docs/configure-scope.md @@ -34,7 +34,7 @@ gh devlake configure scope add [flags] |------|---------|-------------| | `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gh-copilot`, `gitlab`, `bitbucket`, `azuredevops_go`, `jenkins`, `jira`, `sonarqube`, `circleci`) | | `--connection-id` | *(auto-detected)* | Override the connection ID to scope | -| `--org` | *(required)* | GitHub organization slug | +| `--org` | *(plugin-dependent)* | Organization/workspace slug when the plugin requires it (e.g., GitHub, Copilot, GitLab, Bitbucket, Azure DevOps) | | `--enterprise` | | Enterprise slug (enables enterprise-level Copilot metrics) | | `--repos` | | Comma-separated repos to add (`owner/repo,owner/repo2`) | | `--repos-file` | | Path to a file with repos (one `owner/repo` per line) | From 951cb225f4bbb7cb9a1f63b680944bd8bdf278fa Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:15:22 +0000 Subject: [PATCH 4/4] docs: clarify org flag requirement by plugin --- docs/configure-scope.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/configure-scope.md b/docs/configure-scope.md index 1ceac0f..5ddccc0 100644 --- a/docs/configure-scope.md +++ b/docs/configure-scope.md @@ -42,8 +42,10 @@ gh devlake configure scope add [flags] | `--deployment-pattern` | `(?i)deploy` | Regex matching CI/CD workflow names for deployments | | `--production-pattern` | `(?i)prod` | Regex matching environment names for production | | `--incident-label` | `incident` | GitHub issue label that marks incidents | - -> **Note:** `--plugin` is required when using any other flag. Without flags, the CLI enters interactive mode and prompts for everything. + +> **Org requirement:** `--org` is required for plugins that scope by organization/workspace (GitHub, Copilot, GitLab, Bitbucket, Azure DevOps). It is **not** required for CircleCI, Jenkins, Jira, or SonarQube. + +> **Note:** `--plugin` is required when using any other flag. Without flags, the CLI enters interactive mode and prompts for everything. ### Repo Resolution