diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index d0d1d98..1641ca3 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -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...") diff --git a/cmd/configure_scopes_test.go b/cmd/configure_scopes_test.go index 8c1c2df..ba19de4 100644 --- a/cmd/configure_scopes_test.go +++ b/cmd/configure_scopes_test.go @@ -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{ diff --git a/cmd/connection_types.go b/cmd/connection_types.go index 02677c0..ba4b834 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -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", diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index 14708ac..b544dc0 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -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") diff --git a/docs/configure-connection.md b/docs/configure-connection.md index 5cd5bd6..6d3bc7b 100644 --- a/docs/configure-connection.md +++ b/docs/configure-connection.md @@ -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=`) | | `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 @@ -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 @@ -137,7 +146,7 @@ gh devlake configure connection list [--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 @@ -164,7 +173,7 @@ gh devlake configure connection test [--plugin ] [--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. @@ -196,7 +205,7 @@ gh devlake configure connection update [--plugin ] [--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 | @@ -236,10 +245,10 @@ gh devlake configure connection delete [--plugin ] [--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. diff --git a/docs/configure-scope.md b/docs/configure-scope.md index 9fddcc0..ad35755 100644 --- a/docs/configure-scope.md +++ b/docs/configure-scope.md @@ -32,20 +32,23 @@ gh devlake configure scope add [flags] | Flag | Default | Description | |------|---------|-------------| -| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gitlab`, `bitbucket`, `gh-copilot`, `jenkins`, `azuredevops_go` / `azure-devops`, `jira`, `sonarqube`, `argocd`, `circleci`) | +| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) | | `--connection-id` | *(auto-detected)* | Override the connection ID to scope | | `--org` | *(plugin-dependent)* | Org/workspace slug (`github`, GitLab group path, Bitbucket workspace, Azure DevOps org). Required for plugins whose connection definition needs an org (for example, Azure DevOps) or when running non-interactively; optional in interactive mode for plugins that support workspace discovery (for example, Bitbucket). | | `--enterprise` | | Enterprise slug (enables enterprise-level Copilot metrics) | | `--repos` | | Comma-separated repos to add (`owner/repo` for GitHub, `group/project` for GitLab, `workspace/repo-slug` for Bitbucket) | | `--repos-file` | | Path to a file with repos (one per line: `owner/repo` for GitHub, `group/project` for GitLab, `workspace/repo-slug` for Bitbucket) | | `--jobs` | | Comma-separated Jenkins job full names | +| `--projects` | | Comma-separated SonarQube project keys | | `--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 | -> **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, SonarQube, or ArgoCD. +> **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, PagerDuty, SonarQube, or ArgoCD. > **Note:** `--plugin` is required when using any other flag. Without flags, the CLI enters interactive mode and prompts for everything. + +> **Alias:** `azure-devops` is accepted as an alias for `azuredevops_go`. ### Repo Resolution @@ -103,6 +106,9 @@ gh devlake configure scope add --plugin jenkins --org my-org # CircleCI projects (interactive) gh devlake configure scope add --plugin circleci --connection-id 4 +# PagerDuty services (interactive) +gh devlake configure scope add --plugin pagerduty --connection-id 5 + # Interactive (omit all flags) gh devlake configure scope add ``` @@ -137,6 +143,12 @@ gh devlake configure scope add 2. Prompts for one or more projects to track 3. Calls `PUT /plugins/circleci/connections/{id}/scopes` to add the selected projects +### What It Does (PagerDuty) + +1. Lists PagerDuty services via the DevLake remote-scope API +2. Prompts for one or more services to track +3. Calls `PUT /plugins/pagerduty/connections/{id}/scopes` with the selected services + --- ## configure scope list @@ -153,7 +165,7 @@ gh devlake configure scope list [--plugin ] [--connection-id ] | Flag | Default | Description | |------|---------|-------------| -| `--plugin` | *(interactive)* | Plugin to query (`github`, `gh-copilot`, `jenkins`) | +| `--plugin` | *(interactive)* | Plugin to query (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) | | `--connection-id` | *(interactive)* | Connection ID to list scopes for | **Flag mode:** both `--plugin` and `--connection-id` are required. @@ -201,7 +213,7 @@ gh devlake configure scope delete [--plugin ] [--connection-id ] [-- | Flag | Default | Description | |------|---------|-------------| -| `--plugin` | *(interactive)* | Plugin of the connection (`github`, `gh-copilot`, `jenkins`) | +| `--plugin` | *(interactive)* | Plugin of the connection (`github`, `gh-copilot`, `jenkins`, `circleci`, `gitlab`, `bitbucket`, `azuredevops_go`, `jira`, `pagerduty`, `sonarqube`, `argocd`) | | `--connection-id` | *(interactive)* | Connection ID | | `--scope-id` | *(interactive)* | Scope ID to delete | | `--force` | `false` | Skip confirmation prompt | diff --git a/internal/devlake/types.go b/internal/devlake/types.go index 75bae2a..9e60540 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -97,6 +97,14 @@ type CircleCIProjectScope struct { OrganizationID string `json:"organizationId"` } +// PagerDutyServiceScope represents a PagerDuty service scope entry for PUT /scopes. +type PagerDutyServiceScope struct { + ConnectionID int `json:"connectionId"` + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url,omitempty"` +} + // ArgoCDAppScope represents an ArgoCD application scope entry for PUT /scopes. type ArgoCDAppScope struct { ConnectionID int `json:"connectionId"`