diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 1641ca3..f63361f 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -1632,3 +1632,117 @@ func scopeArgoCDHandler(client *devlake.Client, connID int, org, enterprise stri Scopes: blueprintScopes, }, nil } + +// scopeQDevHandler configures S3-based scopes for Amazon Q Developer. +// Scopes are time-sliced S3 prefixes (year/month) for Q Developer data collection. +func scopeQDevHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) { + // Interactive mode: prompt for scope parameters + if opts == nil { + opts = &ScopeOpts{} + } + + // Determine scope creation mode + var accountID, prefix, basePath string + var year, month int + + fmt.Println("\nšŸ“ Configuring Amazon Q Developer scope...") + fmt.Println(" Scopes represent S3 data slices for Q Developer metrics.") + fmt.Println() + + // Prompt for account ID (recommended) or explicit prefix (legacy) + accountID = prompt.ReadLine("AWS Account ID (leave empty for explicit prefix)") + if accountID != "" { + // Account-based scope (new style) + basePath = prompt.ReadLine("S3 base path [leave empty for default]") + yearStr := prompt.ReadLine("Year for data collection (e.g., 2024)") + if yearStr == "" { + return nil, fmt.Errorf("year is required") + } + var err error + year, err = strconv.Atoi(yearStr) + if err != nil || year < 2020 || year > 2100 { + return nil, fmt.Errorf("invalid year: %s", yearStr) + } + + monthStr := prompt.ReadLine("Month (1-12, leave empty for full year)") + if monthStr != "" { + month, err = strconv.Atoi(monthStr) + if err != nil || month < 1 || month > 12 { + return nil, fmt.Errorf("invalid month: %s (must be 1-12)", monthStr) + } + } + } else { + // Explicit prefix (legacy style) + prefix = prompt.ReadLine("S3 prefix (full path)") + if prefix == "" { + return nil, fmt.Errorf("either account ID or explicit prefix is required") + } + yearStr := prompt.ReadLine("Year for data collection (e.g., 2024)") + if yearStr != "" { + var err error + year, err = strconv.Atoi(yearStr) + if err != nil { + return nil, fmt.Errorf("invalid year: %s", yearStr) + } + } + } + + // Build scope ID + var scopeID, scopeName, scopeFullName string + if accountID != "" { + if month > 0 { + scopeID = fmt.Sprintf("%s_%d_%02d", accountID, year, month) + scopeName = fmt.Sprintf("Q Developer %s %d-%02d", accountID, year, month) + scopeFullName = fmt.Sprintf("%s/%d/%02d", accountID, year, month) + } else { + scopeID = fmt.Sprintf("%s_%d", accountID, year) + scopeName = fmt.Sprintf("Q Developer %s %d", accountID, year) + scopeFullName = fmt.Sprintf("%s/%d", accountID, year) + } + } else { + // Legacy prefix-based scope + scopeID = prefix + if year > 0 { + scopeID = fmt.Sprintf("%s_%d", prefix, year) + } + scopeName = fmt.Sprintf("Q Developer %s", prefix) + scopeFullName = prefix + } + + // Build scope data for PUT + fmt.Println("\nšŸ“ Adding Q Developer S3 slice scope...") + var monthPtr *int + if month > 0 { + monthPtr = &month + } + + scope := devlake.QDevS3Slice{ + ID: scopeID, + ConnectionID: connID, + Prefix: prefix, + BasePath: basePath, + AccountID: accountID, + Year: year, + Month: monthPtr, + Name: scopeName, + FullName: scopeFullName, + } + + scopeData := []any{scope} + err := client.PutScopes("q_dev", connID, &devlake.ScopeBatchRequest{Data: scopeData}) + if err != nil { + return nil, fmt.Errorf("failed to add Q Developer scope: %w", err) + } + fmt.Printf(" āœ… Added S3 slice scope: %s\n", scopeFullName) + + return &devlake.BlueprintConnection{ + PluginName: "q_dev", + ConnectionID: connID, + Scopes: []devlake.BlueprintScope{ + { + ScopeID: scopeID, + ScopeName: scopeName, + }, + }, + }, nil +} diff --git a/cmd/connection_types.go b/cmd/connection_types.go index ba4b834..5b44068 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -97,6 +97,14 @@ type ConnectionParams struct { Name string // override default connection name Proxy string // HTTP proxy URL Endpoint string // override default endpoint (e.g. GitHub Enterprise Server) + + // AWS-specific fields (for Amazon Q Developer) + AccessKeyID string + SecretAccessKey string + Region string + Bucket string + IdentityStoreID string + IdentityStoreRegion string } // rateLimitOrDefault returns the configured rate limit or a sensible default. @@ -143,6 +151,25 @@ func (d *ConnectionDef) BuildCreateRequest(name string, params ConnectionParams) if (d.NeedsEnterprise || d.NeedsOrgOrEnt) && params.Enterprise != "" { req.Enterprise = params.Enterprise } + // AWS-specific fields (for Amazon Q Developer) + if params.AccessKeyID != "" { + req.AccessKeyID = params.AccessKeyID + } + if params.SecretAccessKey != "" { + req.SecretAccessKey = params.SecretAccessKey + } + if params.Region != "" { + req.Region = params.Region + } + if params.Bucket != "" { + req.Bucket = params.Bucket + } + if params.IdentityStoreID != "" { + req.IdentityStoreID = params.IdentityStoreID + } + if params.IdentityStoreRegion != "" { + req.IdentityStoreRegion = params.IdentityStoreRegion + } return req } @@ -174,6 +201,25 @@ func (d *ConnectionDef) BuildTestRequest(name string, params ConnectionParams) * if (d.NeedsEnterprise || d.NeedsOrgOrEnt) && params.Enterprise != "" { req.Enterprise = params.Enterprise } + // AWS-specific fields (for Amazon Q Developer) + if params.AccessKeyID != "" { + req.AccessKeyID = params.AccessKeyID + } + if params.SecretAccessKey != "" { + req.SecretAccessKey = params.SecretAccessKey + } + if params.Region != "" { + req.Region = params.Region + } + if params.Bucket != "" { + req.Bucket = params.Bucket + } + if params.IdentityStoreID != "" { + req.IdentityStoreID = params.IdentityStoreID + } + if params.IdentityStoreRegion != "" { + req.IdentityStoreRegion = params.IdentityStoreRegion + } return req } @@ -398,6 +444,38 @@ var connectionRegistry = []*ConnectionDef{ ScopeIDField: "name", HasRepoScopes: false, }, + { + Plugin: "q_dev", + DisplayName: "Amazon Q Developer", + Available: true, + Endpoint: "", // S3-based, no REST endpoint + SupportsTest: false, + AuthMethod: "AwsCredentials", + RateLimitPerHour: 20000, + RequiredScopes: []string{}, + ScopeHint: "", + TokenPrompt: "AWS Secret Access Key", + EnvVarNames: []string{"AWS_SECRET_ACCESS_KEY"}, + EnvFileKeys: []string{"AWS_SECRET_ACCESS_KEY"}, + ScopeFunc: scopeQDevHandler, + ScopeIDField: "id", + HasRepoScopes: false, + ConnectionFlags: []FlagDef{ + {Name: "access-key-id", Description: "AWS Access Key ID"}, + {Name: "secret-access-key", Description: "AWS Secret Access Key"}, + {Name: "region", Description: "AWS region (e.g., us-east-1)"}, + {Name: "bucket", Description: "S3 bucket name containing Q Developer data"}, + {Name: "identity-store-id", Description: "IAM Identity Center store ID (optional)"}, + {Name: "identity-store-region", Description: "IAM Identity Center region (optional)"}, + }, + ScopeFlags: []FlagDef{ + {Name: "account-id", Description: "AWS account ID for scope generation"}, + {Name: "year", Description: "Year for data collection (e.g., 2024)"}, + {Name: "month", Description: "Month for data collection (1-12, optional)"}, + {Name: "base-path", Description: "S3 base path prefix (optional)"}, + {Name: "prefix", Description: "Explicit S3 prefix (alternative to account-id)"}, + }, + }, } // AvailableConnections returns only available (non-coming-soon) connection defs. diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index b544dc0..d8bd981 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -331,6 +331,199 @@ func TestAzureDevOpsRegistryEntry(t *testing.T) { } } +// TestConnectionRegistry_QDev verifies the Amazon Q Developer plugin registry entry. +func TestConnectionRegistry_QDev(t *testing.T) { + def := FindConnectionDef("q_dev") + if def == nil { + t.Fatal("q_dev plugin not found in registry") + } + + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Plugin", def.Plugin, "q_dev"}, + {"DisplayName", def.DisplayName, "Amazon Q Developer"}, + {"Available", def.Available, true}, + {"Endpoint", def.Endpoint, ""}, + {"SupportsTest", def.SupportsTest, false}, // S3-based, no test endpoint + {"AuthMethod", def.AuthMethod, "AwsCredentials"}, + {"ScopeIDField", def.ScopeIDField, "id"}, + {"HasRepoScopes", def.HasRepoScopes, false}, + {"RateLimitPerHour", def.RateLimitPerHour, 20000}, + } + + 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") + } + + // Amazon Q Developer uses AWS credentials, not OAuth/PAT scopes + if len(def.RequiredScopes) != 0 { + t.Errorf("RequiredScopes should be empty for AWS credentials, got %v", def.RequiredScopes) + } + if def.ScopeHint != "" { + t.Errorf("ScopeHint should be empty for AWS credentials, got %q", def.ScopeHint) + } + + expectedEnvVars := []string{"AWS_SECRET_ACCESS_KEY"} + 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{"AWS_SECRET_ACCESS_KEY"} + 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) + } + } + } + + // Check ConnectionFlags + expectedConnFlags := []string{"access-key-id", "secret-access-key", "region", "bucket", "identity-store-id", "identity-store-region"} + if len(def.ConnectionFlags) != len(expectedConnFlags) { + t.Errorf("ConnectionFlags length: got %d, want %d", len(def.ConnectionFlags), len(expectedConnFlags)) + } else { + for i, want := range expectedConnFlags { + if def.ConnectionFlags[i].Name != want { + t.Errorf("ConnectionFlags[%d].Name: got %q, want %q", i, def.ConnectionFlags[i].Name, want) + } + } + } + + // Check ScopeFlags + expectedScopeFlags := []string{"account-id", "year", "month", "base-path", "prefix"} + if len(def.ScopeFlags) != len(expectedScopeFlags) { + t.Errorf("ScopeFlags length: got %d, want %d", len(def.ScopeFlags), len(expectedScopeFlags)) + } else { + for i, want := range expectedScopeFlags { + if def.ScopeFlags[i].Name != want { + t.Errorf("ScopeFlags[%d].Name: got %q, want %q", i, def.ScopeFlags[i].Name, want) + } + } + } +} + +// TestBuildCreateRequest_AwsCredentials verifies that AWS-specific fields are populated +// in the connection create request for Amazon Q Developer. +func TestBuildCreateRequest_AwsCredentials(t *testing.T) { + def := &ConnectionDef{ + Plugin: "q_dev", + DisplayName: "Amazon Q Developer", + Endpoint: "", + AuthMethod: "AwsCredentials", + RateLimitPerHour: 20000, + } + + t.Run("AWS fields populated correctly", func(t *testing.T) { + req := def.BuildCreateRequest("test-q-dev", ConnectionParams{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Region: "us-east-1", + Bucket: "my-q-developer-bucket", + IdentityStoreID: "d-1234567890", + IdentityStoreRegion: "us-east-1", + }) + if req.AccessKeyID != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("got AccessKeyID %q, want %q", req.AccessKeyID, "AKIAIOSFODNN7EXAMPLE") + } + if req.SecretAccessKey != "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" { + t.Errorf("got SecretAccessKey %q, want %q", req.SecretAccessKey, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + } + if req.Region != "us-east-1" { + t.Errorf("got Region %q, want %q", req.Region, "us-east-1") + } + if req.Bucket != "my-q-developer-bucket" { + t.Errorf("got Bucket %q, want %q", req.Bucket, "my-q-developer-bucket") + } + if req.IdentityStoreID != "d-1234567890" { + t.Errorf("got IdentityStoreID %q, want %q", req.IdentityStoreID, "d-1234567890") + } + if req.IdentityStoreRegion != "us-east-1" { + t.Errorf("got IdentityStoreRegion %q, want %q", req.IdentityStoreRegion, "us-east-1") + } + if req.AuthMethod != "AwsCredentials" { + t.Errorf("got AuthMethod %q, want %q", req.AuthMethod, "AwsCredentials") + } + if req.RateLimitPerHour != 20000 { + t.Errorf("got RateLimitPerHour %d, want %d", req.RateLimitPerHour, 20000) + } + }) + + t.Run("optional Identity Store fields omitted when empty", func(t *testing.T) { + req := def.BuildCreateRequest("test-q-dev-minimal", ConnectionParams{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Region: "us-west-2", + Bucket: "my-bucket", + }) + if req.IdentityStoreID != "" { + t.Errorf("expected empty IdentityStoreID, got %q", req.IdentityStoreID) + } + if req.IdentityStoreRegion != "" { + t.Errorf("expected empty IdentityStoreRegion, got %q", req.IdentityStoreRegion) + } + if req.AccessKeyID != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("got AccessKeyID %q, want %q", req.AccessKeyID, "AKIAIOSFODNN7EXAMPLE") + } + }) +} + +// TestBuildTestRequest_AwsCredentials verifies that AWS-specific fields are populated +// in the connection test request for Amazon Q Developer. +func TestBuildTestRequest_AwsCredentials(t *testing.T) { + def := &ConnectionDef{ + Plugin: "q_dev", + DisplayName: "Amazon Q Developer", + Endpoint: "", + AuthMethod: "AwsCredentials", + RateLimitPerHour: 20000, + } + + t.Run("AWS fields populated correctly in test request", func(t *testing.T) { + req := def.BuildTestRequest("test-q-dev", ConnectionParams{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Region: "us-east-1", + Bucket: "my-q-developer-bucket", + IdentityStoreID: "d-1234567890", + IdentityStoreRegion: "us-east-1", + }) + if req.AccessKeyID != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("got AccessKeyID %q, want %q", req.AccessKeyID, "AKIAIOSFODNN7EXAMPLE") + } + if req.SecretAccessKey != "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" { + t.Errorf("got SecretAccessKey %q, want %q", req.SecretAccessKey, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + } + if req.Region != "us-east-1" { + t.Errorf("got Region %q, want %q", req.Region, "us-east-1") + } + if req.Bucket != "my-q-developer-bucket" { + t.Errorf("got Bucket %q, want %q", req.Bucket, "my-q-developer-bucket") + } + if req.AuthMethod != "AwsCredentials" { + t.Errorf("got AuthMethod %q, want %q", req.AuthMethod, "AwsCredentials") + } + }) +} + // TestBuildCreateRequest_AuthMethod verifies that AuthMethod defaults to "AccessToken" // when empty, and uses the configured value when set. func TestBuildCreateRequest_AuthMethod(t *testing.T) { diff --git a/internal/devlake/client.go b/internal/devlake/client.go index 9831e3a..82ac65f 100644 --- a/internal/devlake/client.go +++ b/internal/devlake/client.go @@ -79,6 +79,14 @@ type ConnectionCreateRequest struct { Enterprise string `json:"enterprise,omitempty"` TokenExpiresAt string `json:"tokenExpiresAt,omitempty"` RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt,omitempty"` + + // AWS-specific fields (for Amazon Q Developer) + AccessKeyID string `json:"accessKeyId,omitempty"` + SecretAccessKey string `json:"secretAccessKey,omitempty"` + Region string `json:"region,omitempty"` + Bucket string `json:"bucket,omitempty"` + IdentityStoreID string `json:"identityStoreId,omitempty"` + IdentityStoreRegion string `json:"identityStoreRegion,omitempty"` } // ConnectionTestRequest is the payload for testing a connection before creating. @@ -94,6 +102,14 @@ type ConnectionTestRequest struct { Proxy string `json:"proxy"` Organization string `json:"organization,omitempty"` Enterprise string `json:"enterprise,omitempty"` + + // AWS-specific fields (for Amazon Q Developer) + AccessKeyID string `json:"accessKeyId,omitempty"` + SecretAccessKey string `json:"secretAccessKey,omitempty"` + Region string `json:"region,omitempty"` + Bucket string `json:"bucket,omitempty"` + IdentityStoreID string `json:"identityStoreId,omitempty"` + IdentityStoreRegion string `json:"identityStoreRegion,omitempty"` } // ConnectionTestResult is the response from testing a connection. diff --git a/internal/devlake/types.go b/internal/devlake/types.go index feaa397..1680730 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -111,6 +111,20 @@ type ArgoCDAppScope struct { Name string `json:"name"` } +// QDevS3Slice represents an Amazon Q Developer S3 slice scope entry for PUT /scopes. +// Scopes represent time-sliced S3 prefixes for Q Developer data collection. +type QDevS3Slice struct { + ID string `json:"id"` + ConnectionID int `json:"connectionId"` + Prefix string `json:"prefix,omitempty"` + BasePath string `json:"basePath,omitempty"` + AccountID string `json:"accountId,omitempty"` + Year int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + Name string `json:"name"` + FullName string `json:"fullName"` +} + // ScopeBatchRequest is the payload for PUT /scopes (batch upsert). type ScopeBatchRequest struct { Data []any `json:"data"`