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
111 changes: 111 additions & 0 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,117 @@ func scopeCircleCIHandler(client *devlake.Client, connID int, org, enterprise st
}, nil
}

// scopePagerDutyHandler is the ScopeHandler for the pagerduty plugin.
func scopePagerDutyHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) {
fmt.Println("\n🔍 Listing PagerDuty services...")
var (
allChildren []devlake.RemoteScopeChild
pageToken string
)
for {
resp, err := client.ListRemoteScopes("pagerduty", connID, "", pageToken)
if err != nil {
return nil, fmt.Errorf("listing PagerDuty services: %w", err)
}
allChildren = append(allChildren, resp.Children...)
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}

var (
serviceLabels []string
serviceByLabel = make(map[string]*devlake.RemoteScopeChild)
)
for i := range allChildren {
child := &allChildren[i]
if child.Type != "scope" || child.ID == "" {
continue
}
label := child.Name
if label == "" {
label = child.FullName
}
if label == "" {
label = child.ID
}
if label != child.ID {
label = fmt.Sprintf("%s (ID: %s)", label, child.ID)
}
serviceLabels = append(serviceLabels, label)
serviceByLabel[label] = child
}

if len(serviceLabels) == 0 {
return nil, fmt.Errorf("no PagerDuty services found — verify your API key has access")
}

fmt.Println()
selected := prompt.SelectMulti("Select PagerDuty services to track", serviceLabels)
if len(selected) == 0 {
return nil, fmt.Errorf("at least one PagerDuty service must be selected")
}

fmt.Println("\n📝 Adding PagerDuty service scopes...")
var (
scopeData []any
blueprintScopes []devlake.BlueprintScope
)
for _, label := range selected {
child := serviceByLabel[label]
scope := pagerDutyServiceFromChild(child, connID)
if scope.ID == "" || scope.Name == "" {
continue
}
scopeData = append(scopeData, scope)
blueprintScopes = append(blueprintScopes, devlake.BlueprintScope{
ScopeID: scope.ID,
ScopeName: scope.Name,
})
}

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

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

return &devlake.BlueprintConnection{
PluginName: "pagerduty",
ConnectionID: connID,
Scopes: blueprintScopes,
}, nil
}

// pagerDutyServiceFromChild builds a PagerDuty service scope from a remote-scope child.
func pagerDutyServiceFromChild(child *devlake.RemoteScopeChild, connID int) devlake.PagerDutyServiceScope {
scope := devlake.PagerDutyServiceScope{ConnectionID: connID}
if child != nil && len(child.Data) > 0 {
if err := json.Unmarshal(child.Data, &scope); err != nil {
scope = devlake.PagerDutyServiceScope{ConnectionID: connID}
}
}
scope.ConnectionID = connID
if scope.ID == "" && child != nil {
scope.ID = child.ID
}
if scope.Name == "" && child != nil {
switch {
case child.Name != "":
scope.Name = child.Name
case child.FullName != "":
scope.Name = child.FullName
default:
scope.Name = child.ID
}
}
return scope
}

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

func TestPagerDutyServiceFromChild_UsesData(t *testing.T) {
data, _ := json.Marshal(map[string]any{
"id": "SVC123",
"name": "Checkout",
"url": "https://api.pagerduty.com/services/SVC123",
})
child := &devlake.RemoteScopeChild{
ID: "fallback-id",
Name: "fallback-name",
FullName: "fallback/full",
Data: data,
}

scope := pagerDutyServiceFromChild(child, 101)
if scope.ID != "SVC123" {
t.Fatalf("ID = %q, want SVC123", scope.ID)
}
if scope.Name != "Checkout" {
t.Fatalf("Name = %q, want Checkout", scope.Name)
}
if scope.URL != "https://api.pagerduty.com/services/SVC123" {
t.Fatalf("URL = %q, want https://api.pagerduty.com/services/SVC123", scope.URL)
}
if scope.ConnectionID != 101 {
t.Fatalf("ConnectionID = %d, want 101", scope.ConnectionID)
}
}

func TestPagerDutyServiceFromChild_Fallbacks(t *testing.T) {
child := &devlake.RemoteScopeChild{
ID: "SVC999",
FullName: "Platform/Incident",
}

scope := pagerDutyServiceFromChild(child, 7)
if scope.ID != "SVC999" {
t.Fatalf("ID = %q, want SVC999", scope.ID)
}
if scope.Name != "Platform/Incident" {
t.Fatalf("Name = %q, want Platform/Incident", scope.Name)
}
if scope.URL != "" {
t.Fatalf("URL = %q, want empty", scope.URL)
}
if scope.ConnectionID != 7 {
t.Fatalf("ConnectionID = %d, want 7", scope.ConnectionID)
}
}

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 @@ -347,6 +347,18 @@ var connectionRegistry = []*ConnectionDef{
ScopeIDField: "boardId",
HasRepoScopes: false,
},
{
Plugin: "pagerduty",
DisplayName: "PagerDuty",
Available: true,
Endpoint: "https://api.pagerduty.com/",
SupportsTest: true,
TokenPrompt: "PagerDuty API key",
EnvVarNames: []string{"PAGERDUTY_TOKEN", "PAGERDUTY_API_KEY"},
EnvFileKeys: []string{"PAGERDUTY_TOKEN", "PAGERDUTY_API_KEY"},
ScopeFunc: scopePagerDutyHandler,
ScopeIDField: "id",
},
{
Plugin: "sonarqube",
DisplayName: "SonarQube",
Expand Down
52 changes: 52 additions & 0 deletions cmd/connection_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,58 @@ func TestJiraConnectionDef(t *testing.T) {
}
}

func TestConnectionRegistry_PagerDuty(t *testing.T) {
def := FindConnectionDef("pagerduty")
if def == nil {
t.Fatal("pagerduty plugin not found in registry")
}

tests := []struct {
name string
got any
want any
}{
{"Plugin", def.Plugin, "pagerduty"},
{"DisplayName", def.DisplayName, "PagerDuty"},
{"Available", def.Available, true},
{"Endpoint", def.Endpoint, "https://api.pagerduty.com/"},
{"SupportsTest", def.SupportsTest, true},
{"ScopeIDField", def.ScopeIDField, "id"},
}
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.Fatal("ScopeFunc should not be nil for PagerDuty")
}
if def.TokenPrompt != "PagerDuty API key" {
t.Errorf("TokenPrompt = %q, want %q", def.TokenPrompt, "PagerDuty API key")
}

expectedEnv := []string{"PAGERDUTY_TOKEN", "PAGERDUTY_API_KEY"}
if len(def.EnvVarNames) != len(expectedEnv) {
t.Fatalf("EnvVarNames length = %d, want %d", len(def.EnvVarNames), len(expectedEnv))
}
for i, v := range expectedEnv {
if def.EnvVarNames[i] != v {
t.Errorf("EnvVarNames[%d] = %q, want %q", i, def.EnvVarNames[i], v)
}
}
if len(def.EnvFileKeys) != len(expectedEnv) {
t.Fatalf("EnvFileKeys length = %d, want %d", len(def.EnvFileKeys), len(expectedEnv))
}
for i, v := range expectedEnv {
if def.EnvFileKeys[i] != v {
t.Errorf("EnvFileKeys[%d] = %q, want %q", i, def.EnvFileKeys[i], v)
}
}
}

// TestAzureDevOpsRegistryEntry verifies the Azure DevOps plugin registry entry.
func TestAzureDevOpsRegistryEntry(t *testing.T) {
def := FindConnectionDef("azuredevops_go")
Expand Down
41 changes: 25 additions & 16 deletions docs/configure-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,37 @@ Aliases: `connections`

| Flag | Default | Description |
|------|---------|-------------|
| `--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) |
| `--plugin` | *(interactive)* | Plugin to configure (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) |
| `--org` | *(plugin-dependent)* | Organization/group/workspace slug (required for GitHub, GitLab, and Azure DevOps; optional for Copilot when `--enterprise` is provided) |
| `--enterprise` | | GitHub enterprise slug (Copilot only) |
| `--name` | `Plugin - org` | Connection display name |
| `--endpoint` | `https://api.github.com/` (GitHub/Copilot) | API endpoint (override for GitHub Enterprise Server; Jenkins has no default and must supply a URL) |
| `--endpoint` | *(plugin default when available)* | API endpoint override (required for Jenkins, Azure DevOps, Jira, SonarQube, and ArgoCD because they have no default endpoint) |
| `--proxy` | | HTTP proxy URL |
| `--token` | | GitHub PAT (highest priority source). For BasicAuth plugins (Jenkins, Bitbucket, Jira), this is the password. |
| `--username` | | Username for BasicAuth plugins (Jenkins, Bitbucket, Jira). Not used by GitHub or Copilot. |
| `--token` | | Plugin PAT or API token (highest priority source). For BasicAuth plugins (Jenkins, Bitbucket), this is the password/app token. |
| `--username` | | Username for BasicAuth plugins (Jenkins, Bitbucket). Ignored for token-based plugins. |
| `--env-file` | `.devlake.env` | Path to env file containing PAT |
| `--skip-cleanup` | `false` | Don't delete `.devlake.env` after setup |

### Required PAT Scopes
### Required PAT Scopes

| Plugin | Required Scopes |
|--------|----------------|
| `github` | `repo`, `read:org`, `read:user` |
| `gh-copilot` | `manage_billing:copilot`, `read:org` |
| `gh-copilot` (enterprise metrics) | + `read:enterprise` |
| `jenkins` | Username + API token/password (BasicAuth) |
| `circleci` | Personal API token (Circle-Token header) |
| `gitlab` | `read_api`, `read_repository` |
| `bitbucket` | App password (BasicAuth; no scopes) |
| `azuredevops_go` | Azure DevOps PAT (no scopes) |
| `jenkins` | Username + API token/password (BasicAuth) |
| `jira` | API token (no scopes) |
| `pagerduty` | API key (sent as `Token token=<key>`) |
| `sonarqube` | API token (no scopes) |
| `circleci` | Personal API token (Circle-Token header) |
| `argocd` | Auth token (no scopes) |

> **Alias:** `azure-devops` is accepted as an alias for `azuredevops_go`.

> **Org requirement:** `--org` is required for GitHub, GitLab, and Azure DevOps connections. Copilot accepts either `--org`, `--enterprise`, or both. CircleCI, Bitbucket, Jenkins, Jira, PagerDuty, SonarQube, and ArgoCD do not require `--org` at connection-creation time.

### Token Resolution Order

Expand Down Expand Up @@ -96,6 +102,9 @@ gh devlake configure connection --plugin jenkins --endpoint https://jenkins.exam
# CircleCI connection
gh devlake configure connection --plugin circleci --name "CircleCI - backend"

# PagerDuty connection
gh devlake configure connection --plugin pagerduty --name "PagerDuty - oncall"

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

Expand Down Expand Up @@ -137,7 +146,7 @@ gh devlake configure connection list [--plugin <plugin>]

| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(all plugins)* | Filter output to one plugin (`github`, `gh-copilot`, `jenkins`) |
| `--plugin` | *(all plugins)* | Filter output to one plugin (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) |

### Output

Expand All @@ -164,7 +173,7 @@ gh devlake configure connection test [--plugin <plugin>] [--id <id>]

| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(interactive)* | Plugin to test (`github`, `gh-copilot`, `jenkins`) |
| `--plugin` | *(interactive)* | Plugin to test (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) |
| `--id` | `0` | Connection ID to test |

Both flags are required for non-interactive mode. If either is omitted, the CLI prompts interactively.
Expand Down Expand Up @@ -196,7 +205,7 @@ gh devlake configure connection update [--plugin <plugin>] [--id <id>] [update f

| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(interactive)* | Plugin slug (`github`, `gh-copilot`, `jenkins`) |
| `--plugin` | *(interactive)* | Plugin slug (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) |
| `--id` | *(interactive)* | Connection ID to update |
| `--token` | | New PAT for token rotation |
| `--org` | | New organization slug |
Expand Down Expand Up @@ -236,10 +245,10 @@ gh devlake configure connection delete [--plugin <plugin>] [--id <id>]

### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(interactive)* | Plugin of the connection to delete |
| `--id` | *(interactive)* | ID of the connection to delete |
| Flag | Default | Description |
|------|---------|-------------|
| `--plugin` | *(interactive)* | Plugin of the connection to delete (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) |
| `--id` | *(interactive)* | ID of the connection to delete |
| `--force` | `false` | Skip confirmation prompt |

**Flag mode:** both `--plugin` and `--id` are required.
Expand Down
Loading
Loading